diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000000..88a87436aa5f0 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# If you would like `git blame` to ignore commits from this file, run... +# git config blame.ignoreRevsFile .git-blame-ignore-revs + +# chore: format code with semicolons when using prettier (#9555) +988c9af0153561397686c119da9d1336d2433fdd diff --git a/.github/actions/setup-go/action.yaml b/.github/actions/setup-go/action.yaml index 4d696ef298b12..d699ba4ea1f1c 100644 --- a/.github/actions/setup-go/action.yaml +++ b/.github/actions/setup-go/action.yaml @@ -4,7 +4,7 @@ description: | inputs: version: description: "The Go version to use." - default: "1.20.6" + default: "1.20.10" runs: using: "composite" steps: diff --git a/.github/actions/setup-sqlc/action.yaml b/.github/actions/setup-sqlc/action.yaml index 354e55e8213f6..054bc78e202be 100644 --- a/.github/actions/setup-sqlc/action.yaml +++ b/.github/actions/setup-sqlc/action.yaml @@ -5,6 +5,6 @@ runs: using: "composite" steps: - name: Setup sqlc - uses: sqlc-dev/setup-sqlc@v3 + uses: sqlc-dev/setup-sqlc@v4 with: - sqlc-version: "1.19.1" + sqlc-version: "1.20.0" diff --git a/.github/actions/setup-tf/action.yaml b/.github/actions/setup-tf/action.yaml index 16472c9fafd6e..63a539a3fd922 100644 --- a/.github/actions/setup-tf/action.yaml +++ b/.github/actions/setup-tf/action.yaml @@ -7,5 +7,5 @@ runs: - name: Install Terraform uses: hashicorp/setup-terraform@v2 with: - terraform_version: ~1.5 + terraform_version: 1.5.5 terraform_wrapper: false diff --git a/.github/actions/upload-datadog/action.yaml b/.github/actions/upload-datadog/action.yaml index 252329b16024d..8201b1a76d08a 100644 --- a/.github/actions/upload-datadog/action.yaml +++ b/.github/actions/upload-datadog/action.yaml @@ -20,7 +20,7 @@ runs: echo "No API key provided, skipping..." exit 0 fi - npm install -g @datadog/datadog-ci@2.10.0 + npm install -g @datadog/datadog-ci@2.21.0 datadog-ci junit upload --service coder ./gotests.xml \ --tags os:${{runner.os}} --tags runner_name:${{runner.name}} env: diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 5b9f7a9c6597a..d0d75f78a7b99 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -8,7 +8,7 @@ updates: timezone: "America/Chicago" labels: [] commit-message: - prefix: "chore" + prefix: "ci" ignore: # These actions deliver the latest versions by updating the major # release tag, so ignore minor and patch versions @@ -92,6 +92,7 @@ updates: - dependency-name: "@types/node" update-types: - version-update:semver-major + open-pull-requests-limit: 15 groups: react: patterns: @@ -117,11 +118,6 @@ updates: - "@eslint*" - "@typescript-eslint/eslint-plugin" - "@typescript-eslint/parser" - jest: - patterns: - - "jest*" - - "@swc/jest" - - "@types/jest" - package-ecosystem: "npm" directory: "/offlinedocs/" @@ -146,20 +142,6 @@ updates: - version-update:semver-major # Update dogfood. - - package-ecosystem: "docker" - directory: "/dogfood/" - schedule: - interval: "weekly" - time: "06:00" - timezone: "America/Chicago" - commit-message: - prefix: "chore" - labels: [] - groups: - dogfood-docker: - patterns: - - "*" - - package-ecosystem: "terraform" directory: "/dogfood/" schedule: diff --git a/.github/pr-deployments/certificate.yaml b/.github/pr-deployments/certificate.yaml new file mode 100644 index 0000000000000..cf441a98bbc88 --- /dev/null +++ b/.github/pr-deployments/certificate.yaml @@ -0,0 +1,13 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: pr${PR_NUMBER}-tls + namespace: pr-deployment-certs +spec: + secretName: pr${PR_NUMBER}-tls + issuerRef: + name: letsencrypt + kind: ClusterIssuer + dnsNames: + - "${PR_HOSTNAME}" + - "*.${PR_HOSTNAME}" diff --git a/.github/pr-deployments/rbac.yaml b/.github/pr-deployments/rbac.yaml new file mode 100644 index 0000000000000..0d37cae7daebe --- /dev/null +++ b/.github/pr-deployments/rbac.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: coder-workspace-pr${PR_NUMBER} + namespace: pr${PR_NUMBER} + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-workspace-pr${PR_NUMBER} + namespace: pr${PR_NUMBER} +rules: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["*"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: coder-workspace-pr${PR_NUMBER} + namespace: pr${PR_NUMBER} +subjects: + - kind: ServiceAccount + name: coder-workspace-pr${PR_NUMBER} + namespace: pr${PR_NUMBER} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-workspace-pr${PR_NUMBER} diff --git a/.github/pr-deployments/template/main.tf b/.github/pr-deployments/template/main.tf new file mode 100644 index 0000000000000..f914089900729 --- /dev/null +++ b/.github/pr-deployments/template/main.tf @@ -0,0 +1,314 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + kubernetes = { + source = "hashicorp/kubernetes" + } + } +} + +provider "coder" { +} + +variable "namespace" { + type = string + description = "The Kubernetes namespace to create workspaces in (must exist prior to creating workspaces)" +} + +data "coder_parameter" "cpu" { + name = "cpu" + display_name = "CPU" + description = "The number of CPU cores" + default = "2" + icon = "/icon/memory.svg" + mutable = true + option { + name = "2 Cores" + value = "2" + } + option { + name = "4 Cores" + value = "4" + } + option { + name = "6 Cores" + value = "6" + } + option { + name = "8 Cores" + value = "8" + } +} + +data "coder_parameter" "memory" { + name = "memory" + display_name = "Memory" + description = "The amount of memory in GB" + default = "2" + icon = "/icon/memory.svg" + mutable = true + option { + name = "2 GB" + value = "2" + } + option { + name = "4 GB" + value = "4" + } + option { + name = "6 GB" + value = "6" + } + option { + name = "8 GB" + value = "8" + } +} + +data "coder_parameter" "home_disk_size" { + name = "home_disk_size" + display_name = "Home disk size" + description = "The size of the home disk in GB" + default = "10" + type = "number" + icon = "/emojis/1f4be.png" + mutable = false + validation { + min = 1 + max = 99999 + } +} + +provider "kubernetes" { + config_path = null +} + +data "coder_workspace" "me" {} + +resource "coder_agent" "main" { + os = "linux" + arch = "amd64" + startup_script_timeout = 180 + startup_script = <<-EOT + set -e + + # install and start code-server + curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/tmp/code-server + /tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 & + + EOT + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + # For basic resources, you can use the `coder stat` command. + # If you need more control, you can write your own script. + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "4_cpu_usage_host" + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Memory Usage (Host)" + key = "5_mem_usage_host" + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Load Average (Host)" + key = "6_load_host" + # get load avg scaled by number of cores + script = < $protoc_path - chmod +x $protoc_path - protoc --version + mkdir -p /tmp/proto + pushd /tmp/proto + curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.3/protoc-23.3-linux-x86_64.zip + unzip protoc.zip + cp -r ./bin/* /usr/local/bin + cp -r ./include /usr/local/bin/include + popd - name: make gen run: "make --output-sync -j -B gen" @@ -212,7 +208,7 @@ jobs: timeout-minutes: 7 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 1 @@ -224,10 +220,10 @@ jobs: with: # This doesn't need caching. It's super fast anyways! cache: false - go-version: 1.20.6 + go-version: 1.20.10 - name: Install shfmt - run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.5.0 + run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0 - name: make fmt run: | @@ -238,7 +234,7 @@ jobs: run: ./scripts/check_unstaged.sh test-go: - runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'buildjet-4vcpu-ubuntu-2204' || matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'macos-latest-xl' || matrix.os == 'windows-2019' && github.repository_owner == 'coder' && 'windows-latest-8-cores' || matrix.os }} + runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'buildjet-4vcpu-ubuntu-2204' || matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'macos-latest-xlarge' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-16-cores' || matrix.os }} needs: changes if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main' timeout-minutes: 20 @@ -248,10 +244,10 @@ jobs: os: - ubuntu-latest - macos-latest - - windows-2019 + - windows-2022 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 1 @@ -275,22 +271,29 @@ jobs: echo "cover=false" >> $GITHUB_OUTPUT fi + # if macOS, install google-chrome for scaletests. As another concern, + # should we really have this kind of external dependency requirement + # on standard CI? + if [ "${{ matrix.os }}" == "macos-latest" ]; then + brew install google-chrome + fi + # By default Go will use the number of logical CPUs, which # is a fine default. PARALLEL_FLAG="" + # macOS will output "The default interactive shell is now zsh" + # intermittently in CI... + if [ "${{ matrix.os }}" == "macos-latest" ]; then + touch ~/.bash_profile && echo "export BASH_SILENCE_DEPRECATION_WARNING=1" >> ~/.bash_profile + fi export TS_DEBUG_DISCO=true gotestsum --junitfile="gotests.xml" --jsonfile="gotests.json" \ --packages="./..." -- $PARALLEL_FLAG -short -failfast $COVERAGE_FLAGS - - name: Print test stats - if: success() || failure() - run: | - # Artifacts are not available after rerunning a job, - # so we need to print the test stats to the log. - go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json - - name: Upload test stats to Datadog + timeout-minutes: 1 + continue-on-error: true uses: ./.github/actions/upload-datadog if: success() || failure() with: @@ -320,7 +323,7 @@ jobs: timeout-minutes: 25 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 1 @@ -335,14 +338,9 @@ jobs: export TS_DEBUG_DISCO=true make test-postgres - - name: Print test stats - if: success() || failure() - run: | - # Artifacts are not available after rerunning a job, - # so we need to print the test stats to the log. - go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json - - name: Upload test stats to Datadog + timeout-minutes: 1 + continue-on-error: true uses: ./.github/actions/upload-datadog if: success() || failure() with: @@ -368,7 +366,7 @@ jobs: timeout-minutes: 25 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 1 @@ -383,6 +381,8 @@ jobs: gotestsum --junitfile="gotests.xml" -- -race ./... - name: Upload test stats to Datadog + timeout-minutes: 1 + continue-on-error: true uses: ./.github/actions/upload-datadog if: always() with: @@ -390,7 +390,7 @@ jobs: deploy: name: "deploy" - runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }} + runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-16vcpu-ubuntu-2204' || 'ubuntu-latest' }} timeout-minutes: 30 needs: changes if: | @@ -401,7 +401,7 @@ jobs: id-token: write steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -489,7 +489,7 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 1 @@ -513,13 +513,13 @@ jobs: flags: unittest-js test-e2e: - runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }} + runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-16vcpu-ubuntu-2204' || 'ubuntu-latest' }} needs: changes if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main' timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 1 @@ -532,6 +532,24 @@ jobs: - name: Setup Terraform uses: ./.github/actions/setup-tf + - name: go install tools + run: | + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 + go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.33 + go install golang.org/x/tools/cmd/goimports@latest + go install github.com/mikefarah/yq/v4@v4.30.6 + go install github.com/golang/mock/mockgen@v1.6.0 + + - name: Install Protoc + run: | + mkdir -p /tmp/proto + pushd /tmp/proto + curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.3/protoc-23.3-linux-x86_64.zip + unzip protoc.zip + cp -r ./bin/* /usr/local/bin + cp -r ./include /usr/local/bin/include + popd + - name: Build run: | make -B site/out/index.html @@ -539,7 +557,7 @@ jobs: - run: pnpm playwright:install working-directory: site - - run: pnpm playwright:test + - run: pnpm playwright:test --workers 1 env: DEBUG: pw:api working-directory: site @@ -552,6 +570,14 @@ jobs: path: ./site/test-results/**/*.webm retention-days: 7 + - name: Upload pprof dumps + if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork + uses: actions/upload-artifact@v3 + with: + name: debug-pprof-dumps + path: ./site/test-results/**/debug-pprof-*.txt + retention-days: 7 + chromatic: # REMARK: this is only used to build storybook and deploy it to Chromatic. runs-on: ubuntu-latest @@ -559,7 +585,7 @@ jobs: if: needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true' steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: # Required by Chromatic for build-over-build history, otherwise we # only get 1 commit on shallow checkout. @@ -586,6 +612,7 @@ jobs: # https://www.chromatic.com/docs/github-actions#forked-repositories projectToken: 695c25b6cb65 workingDir: "./site" + storybookBaseDir: "./site" # Prevent excessive build runs on minor version changes skip: "@(renovate/**|dependabot/**)" # Run TurboSnap to trace file dependencies to related stories @@ -611,6 +638,7 @@ jobs: buildScriptName: "storybook:build" projectToken: 695c25b6cb65 workingDir: "./site" + storybookBaseDir: "./site" # Run TurboSnap to trace file dependencies to related stories # and tell chromatic to only take snapshots of relevent stories onlyChanged: true @@ -622,7 +650,7 @@ jobs: if: needs.changes.outputs.offlinedocs == 'true' || needs.changes.outputs.ci == 'true' steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: # 0 is required here for version.sh to work. fetch-depth: 0 @@ -640,9 +668,7 @@ jobs: go install github.com/golang/mock/mockgen@v1.6.0 - name: Setup sqlc - uses: sqlc-dev/setup-sqlc@v3 - with: - sqlc-version: "1.19.1" + uses: ./.github/actions/setup-sqlc - name: Format run: | @@ -668,6 +694,7 @@ jobs: - test-go-pg - test-go-race - test-js + - test-e2e - offlinedocs # Allow this job to run even if the needed jobs fail, are skipped or # cancelled. @@ -703,7 +730,7 @@ jobs: DOCKER_CLI_EXPERIMENTAL: "enabled" steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -717,13 +744,14 @@ jobs: uses: ./.github/actions/setup-sqlc - name: GHCR Login - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Linux amd64 Docker image + id: build_and_push run: | set -euxo pipefail go mod download @@ -738,3 +766,19 @@ jobs: --version $version \ --push \ build/coder_linux_amd64 + + # Tag image with new package tag and push + tag=$(echo "$version" | sed 's/+/-/g') + docker tag ghcr.io/coder/coder-preview:main ghcr.io/coder/coder-preview:main-$tag + docker push ghcr.io/coder/coder-preview:main-$tag + + - name: Prune old images + uses: vlaurin/action-ghcr-prune@v0.5.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + organization: coder + container: coder-preview + keep-younger-than: 7 # days + keep-tags-regexes: ^pr + prune-tags-regexes: ^main- + prune-untagged: true diff --git a/.github/workflows/contrib.yaml b/.github/workflows/contrib.yaml index e3365d1abe24c..d7efb4274e14a 100644 --- a/.github/workflows/contrib.yaml +++ b/.github/workflows/contrib.yaml @@ -34,7 +34,7 @@ jobs: steps: - name: cla if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' - uses: contributor-assistant/github-action@v2.3.0 + uses: contributor-assistant/github-action@v2.3.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # the below token should have repo scope and must be manually added by you in the repository's secret @@ -46,7 +46,8 @@ jobs: path-to-document: "https://github.com/coder/cla/blob/main/README.md" # branch should not be protected branch: "main" - allowlist: dependabot* + # Some users have signed a corporate CLA with Coder so are exempt from signing our community one. + allowlist: "coryb,aaronlehmann,dependabot*" release-labels: runs-on: ubuntu-latest diff --git a/.github/workflows/docker-base.yaml b/.github/workflows/docker-base.yaml index 7eb4a01c8e806..c88bea3ef182a 100644 --- a/.github/workflows/docker-base.yaml +++ b/.github/workflows/docker-base.yaml @@ -32,10 +32,10 @@ jobs: if: github.repository_owner == 'coder' steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Docker login - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml index f1a6c2e712fd0..b265504c0c73f 100644 --- a/.github/workflows/dogfood.yaml +++ b/.github/workflows/dogfood.yaml @@ -5,11 +5,15 @@ on: branches: - main paths: + - "flake.nix" + - "flake.lock" - "dogfood/**" - ".github/workflows/dogfood.yaml" # Uncomment these lines when testing with CI. # pull_request: # paths: + # - "flake.nix" + # - "flake.lock" # - "dogfood/**" # - ".github/workflows/dogfood.yaml" workflow_dispatch: @@ -18,6 +22,9 @@ jobs: deploy_image: runs-on: buildjet-4vcpu-ubuntu-2204 steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Get branch name id: branch-name uses: tj-actions/branch-names@v6.5 @@ -30,34 +37,31 @@ jobs: tag=${tag//\//--} echo "tag=${tag}" >> $GITHUB_OUTPUT - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v6 + + - name: Run the Magic Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + - run: nix build .#devEnvImage && ./result | docker load - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - - name: Build and push - uses: docker/build-push-action@v4 - with: - context: "{{defaultContext}}:dogfood" - pull: true - push: true - tags: "codercom/oss-dogfood:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood:latest" - cache-from: type=registry,ref=codercom/oss-dogfood:latest - cache-to: type=inline + - name: Tag and Push + run: | + docker tag codercom/oss-dogfood:latest codercom/oss-dogfood:${{ steps.docker-tag-name.outputs.tag }} + docker push codercom/oss-dogfood -a deploy_template: needs: deploy_image runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Get short commit SHA id: vars diff --git a/.github/workflows/nightly-gauntlet.yaml b/.github/workflows/nightly-gauntlet.yaml index 3e18a1a85058b..592abe921c013 100644 --- a/.github/workflows/nightly-gauntlet.yaml +++ b/.github/workflows/nightly-gauntlet.yaml @@ -17,7 +17,7 @@ jobs: timeout-minutes: 240 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Go uses: ./.github/actions/setup-go @@ -44,7 +44,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Go uses: ./.github/actions/setup-go diff --git a/.github/workflows/pr-auto-assign.yaml b/.github/workflows/pr-auto-assign.yaml index 94afac8290931..ba6ad2fa05314 100644 --- a/.github/workflows/pr-auto-assign.yaml +++ b/.github/workflows/pr-auto-assign.yaml @@ -14,4 +14,4 @@ jobs: runs-on: ubuntu-latest steps: - name: Assign author - uses: toshimaru/auto-author-assign@v1.6.2 + uses: toshimaru/auto-author-assign@v2.0.1 diff --git a/.github/workflows/pr-cleanup.yaml b/.github/workflows/pr-cleanup.yaml index 510c8f4299361..d32ea2f5d49b7 100644 --- a/.github/workflows/pr-cleanup.yaml +++ b/.github/workflows/pr-cleanup.yaml @@ -1,4 +1,4 @@ -name: Cleanup PR deployment and image +name: pr-cleanup on: pull_request: types: closed @@ -35,14 +35,14 @@ jobs: - name: Set up kubeconfig run: | - set -euxo pipefail + set -euo pipefail mkdir -p ~/.kube echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config export KUBECONFIG=~/.kube/config - name: Delete helm release run: | - set -euxo pipefail + set -euo pipefail helm delete --namespace "pr${{ steps.pr_number.outputs.PR_NUMBER }}" "pr${{ steps.pr_number.outputs.PR_NUMBER }}" || echo "helm release not found" - name: "Remove PR namespace" @@ -51,7 +51,7 @@ jobs: - name: "Remove DNS records" run: | - set -euxo pipefail + set -euo pipefail # Get identifier for the record record_id=$(curl -X GET "https://api.cloudflare.com/client/v4/zones/${{ secrets.PR_DEPLOYMENTS_ZONE_ID }}/dns_records?name=%2A.pr${{ steps.pr_number.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}" \ -H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \ diff --git a/.github/workflows/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml index adc0e2d25c376..858821c08a6cc 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -4,24 +4,30 @@ # 3. when a PR is updated name: Deploy PR on: - pull_request: - types: synchronize + push: + branches-ignore: + - main workflow_dispatch: inputs: pr_number: description: "PR number" type: number required: true - skip_build: - description: "Skip build job" - required: false - type: boolean - default: false experiments: description: "Experiments to enable" required: false type: string default: "*" + build: + description: "Force new build" + required: false + type: boolean + default: false + deploy: + description: "Force new deployment" + required: false + type: boolean + default: false env: REPO: ghcr.io/coder/coder-preview @@ -29,43 +35,66 @@ env: permissions: contents: read packages: write - pull-requests: write - -concurrency: - group: ${{ github.workflow }}-PR-${{ github.event.pull_request.number || github.event.inputs.pr_number }} - cancel-in-progress: true + pull-requests: write # needed for commenting on PRs jobs: + check_pr: + runs-on: ubuntu-latest + outputs: + PR_OPEN: ${{ steps.check_pr.outputs.pr_open }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check if PR is open + id: check_pr + run: | + set -euo pipefail + pr_open=true + if [[ "$(gh pr view --json state | jq -r '.state')" != "OPEN" ]]; then + echo "PR doesn't exist or is closed." + pr_open=false + fi + echo "pr_open=$pr_open" >> $GITHUB_OUTPUT + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + get_info: - if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' + needs: check_pr + if: ${{ needs.check_pr.outputs.PR_OPEN == 'true' }} outputs: PR_NUMBER: ${{ steps.pr_info.outputs.PR_NUMBER }} PR_TITLE: ${{ steps.pr_info.outputs.PR_TITLE }} PR_URL: ${{ steps.pr_info.outputs.PR_URL }} - PR_BRANCH: ${{ steps.pr_info.outputs.PR_BRANCH }} CODER_BASE_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_BASE_IMAGE_TAG }} CODER_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_IMAGE_TAG }} - NEW: ${{ steps.check_deployment.outputs.new }} - BUILD: ${{ steps.filter.outputs.all_count > steps.filter.outputs.ignored_count || steps.check_deployment.outputs.new }} + NEW: ${{ steps.check_deployment.outputs.NEW }} + BUILD: ${{ steps.build_conditionals.outputs.first_or_force_build == 'true' || steps.build_conditionals.outputs.automatic_rebuild == 'true' }} runs-on: "ubuntu-latest" steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Get PR number, title, and branch name id: pr_info run: | - set -euxo pipefail - PR_NUMBER=${{ github.event.inputs.pr_number || github.event.pull_request.number }} - PR_TITLE=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/coder/coder/pulls/$PR_NUMBER | jq -r '.title') - PR_BRANCH=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/coder/coder/pulls/$PR_NUMBER | jq -r '.head.ref') - echo "PR_URL=https://github.com/coder/coder/pull/$PR_NUMBER" >> $GITHUB_OUTPUT + set -euo pipefail + PR_NUMBER=$(gh pr view --json number | jq -r '.number') + PR_TITLE=$(gh pr view --json title | jq -r '.title') + PR_URL=$(gh pr view --json url | jq -r '.url') + echo "PR_URL=$PR_URL" >> $GITHUB_OUTPUT echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_OUTPUT echo "PR_TITLE=$PR_TITLE" >> $GITHUB_OUTPUT - echo "PR_BRANCH=$PR_BRANCH" >> $GITHUB_OUTPUT + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set required tags id: set_tags run: | - set -euxo pipefail + set -euo pipefail echo "CODER_BASE_IMAGE_TAG=$CODER_BASE_IMAGE_TAG" >> $GITHUB_OUTPUT echo "CODER_IMAGE_TAG=$CODER_IMAGE_TAG" >> $GITHUB_OUTPUT env: @@ -74,61 +103,30 @@ jobs: - name: Set up kubeconfig run: | - set -euxo pipefail + set -euo pipefail mkdir -p ~/.kube echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config + chmod 644 ~/.kube/config export KUBECONFIG=~/.kube/config - name: Check if the helm deployment already exists id: check_deployment run: | - set -euxo pipefail + set -euo pipefail if helm status "pr${{ steps.pr_info.outputs.PR_NUMBER }}" --namespace "pr${{ steps.pr_info.outputs.PR_NUMBER }}" > /dev/null 2>&1; then echo "Deployment already exists. Skipping deployment." - new=false + NEW=false else echo "Deployment doesn't exist." - new=true + NEW=true fi - echo "new=$new" >> $GITHUB_OUTPUT - - - name: Find Comment - uses: peter-evans/find-comment@v2 - if: github.event_name == 'workflow_dispatch' || steps.check_deployment.outputs.NEW == 'false' - id: fc - with: - issue-number: ${{ steps.pr_info.outputs.PR_NUMBER }} - comment-author: "github-actions[bot]" - body-includes: ":rocket:" - direction: last - - - name: Comment on PR - id: comment_id - if: github.event_name == 'workflow_dispatch' || steps.check_deployment.outputs.NEW == 'false' - uses: peter-evans/create-or-update-comment@v3 - with: - comment-id: ${{ steps.fc.outputs.comment-id }} - issue-number: ${{ steps.pr_info.outputs.PR_NUMBER }} - edit-mode: replace - body: | - --- - :rocket: Deploying PR ${{ steps.pr_info.outputs.PR_NUMBER }} ... - --- - reactions: eyes - reactions-edit-mode: replace - - - name: Checkout - if: github.event_name == 'workflow_dispatch' || steps.check_deployment.outputs.NEW == 'false' - uses: actions/checkout@v3 - with: - ref: ${{ steps.pr_info.outputs.PR_BRANCH }} - fetch-depth: 0 + echo "NEW=$NEW" >> $GITHUB_OUTPUT - name: Check changed files - if: github.event_name == 'workflow_dispatch' || steps.check_deployment.outputs.NEW == 'false' uses: dorny/paths-filter@v2 id: filter with: + base: ${{ github.ref }} filters: | all: - "**" @@ -149,57 +147,85 @@ jobs: - "scripts/**/*[^D][^o][^c][^k][^e][^r][^f][^i][^l][^e][.][b][^a][^s][^e]*" - name: Print number of changed files - if: github.event_name == 'workflow_dispatch' || steps.check_deployment.outputs.NEW == 'false' run: | - set -euxo pipefail + set -euo pipefail echo "Total number of changed files: ${{ steps.filter.outputs.all_count }}" echo "Number of ignored files: ${{ steps.filter.outputs.ignored_count }}" + - name: Build conditionals + id: build_conditionals + run: | + set -euo pipefail + # build if the workflow is manually triggered and the deployment doesn't exist (first build or force rebuild) + echo "first_or_force_build=${{ (github.event_name == 'workflow_dispatch' && steps.check_deployment.outputs.NEW == 'true') || github.event.inputs.build == 'true' }}" >> $GITHUB_OUTPUT + # build if the deployment alreday exist and there are changes in the files that we care about (automatic updates) + echo "automatic_rebuild=${{ steps.check_deployment.outputs.NEW == 'false' && steps.filter.outputs.all_count > steps.filter.outputs.ignored_count }}" >> $GITHUB_OUTPUT + + comment-pr: + needs: get_info + if: needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true' + runs-on: "ubuntu-latest" + steps: + - name: Find Comment + uses: peter-evans/find-comment@v2 + id: fc + with: + issue-number: ${{ needs.get_info.outputs.PR_NUMBER }} + comment-author: "github-actions[bot]" + body-includes: ":rocket:" + direction: last + + - name: Comment on PR + id: comment_id + uses: peter-evans/create-or-update-comment@v3 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + issue-number: ${{ needs.get_info.outputs.PR_NUMBER }} + edit-mode: replace + body: | + --- + :rocket: Deploying PR ${{ needs.get_info.outputs.PR_NUMBER }} ... + --- + reactions: eyes + reactions-edit-mode: replace + build: needs: get_info - # Skips the build job if the workflow was triggered by a workflow_dispatch event and the skip_build input is set to true - # or if the workflow was triggered by an issue_comment event and the comment body contains --skip-build - # always run the build job if a pull_request event triggered the workflow - if: | - (github.event_name == 'workflow_dispatch' && github.event.inputs.skip_build == 'false') || - (github.event_name == 'pull_request' && needs.get_info.result == 'success' && needs.get_info.outputs.NEW == 'false') + # Run build job only if there are changes in the files that we care about or if the workflow is manually triggered with --build flag + if: needs.get_info.outputs.BUILD == 'true' runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }} + # This concurrency only cancels build jobs if a new build is triggred. It will avoid cancelling the current deployemtn in case of docs chnages. + concurrency: + group: build-${{ github.workflow }}-${{ github.ref }}-${{ needs.get_info.outputs.BUILD }} + cancel-in-progress: true env: DOCKER_CLI_EXPERIMENTAL: "enabled" CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }} - PR_NUMBER: ${{ needs.get_info.outputs.PR_NUMBER }} - PR_BRANCH: ${{ needs.get_info.outputs.PR_BRANCH }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: - ref: ${{ env.PR_BRANCH }} fetch-depth: 0 - name: Setup Node - if: needs.get_info.outputs.BUILD == 'true' uses: ./.github/actions/setup-node - name: Setup Go - if: needs.get_info.outputs.BUILD == 'true' uses: ./.github/actions/setup-go - name: Setup sqlc - if: needs.get_info.outputs.BUILD == 'true' uses: ./.github/actions/setup-sqlc - name: GHCR Login - if: needs.get_info.outputs.BUILD == 'true' - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Linux amd64 Docker image - if: needs.get_info.outputs.BUILD == 'true' run: | - set -euxo pipefail + set -euo pipefail go mod download make gen/mark-fresh export DOCKER_IMAGE_NO_PREREQUISITES=true @@ -217,35 +243,43 @@ jobs: needs: [build, get_info] # Run deploy job only if build job was successful or skipped if: | - always() && (needs.build.result == 'success' || needs.build.result == 'skipped') && - (github.event_name == 'workflow_dispatch' || needs.get_info.outputs.NEW == 'false') + always() && (needs.build.result == 'success' || needs.build.result == 'skipped') && + (needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true') runs-on: "ubuntu-latest" env: CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }} PR_NUMBER: ${{ needs.get_info.outputs.PR_NUMBER }} PR_TITLE: ${{ needs.get_info.outputs.PR_TITLE }} PR_URL: ${{ needs.get_info.outputs.PR_URL }} - PR_BRANCH: ${{ needs.get_info.outputs.PR_BRANCH }} - PR_DEPLOYMENT_ACCESS_URL: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}" + PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}" steps: - name: Set up kubeconfig run: | - set -euxo pipefail + set -euo pipefail mkdir -p ~/.kube echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config + chmod 644 ~/.kube/config export KUBECONFIG=~/.kube/config - name: Check if image exists - if: needs.get_info.outputs.NEW == 'true' run: | - set -euxo pipefail - foundTag=$(curl -fsSL https://github.com/coder/coder/pkgs/container/coder-preview | grep -o ${{ env.CODER_IMAGE_TAG }} | head -n 1) + set -euo pipefail + foundTag=$( + gh api /orgs/coder/packages/container/coder-preview/versions | + jq -r --arg tag "pr${{ env.PR_NUMBER }}" '.[] | + select(.metadata.container.tags == [$tag]) | + .metadata.container.tags[0]' + ) if [ -z "$foundTag" ]; then echo "Image not found" echo "${{ env.CODER_IMAGE_TAG }} not found in ghcr.io/coder/coder-preview" - echo "Please remove --skip-build from the comment and try again" exit 1 + else + echo "Image found" + echo "$foundTag tag found in ghcr.io/coder/coder-preview" fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Add DNS record to Cloudflare if: needs.get_info.outputs.NEW == 'true' @@ -253,43 +287,27 @@ jobs: curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.PR_DEPLOYMENTS_ZONE_ID }}/dns_records" \ -H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \ -H "Content-Type:application/json" \ - --data '{"type":"CNAME","name":"*.${{ env.PR_DEPLOYMENT_ACCESS_URL }}","content":"${{ env.PR_DEPLOYMENT_ACCESS_URL }}","ttl":1,"proxied":false}' - - - name: Checkout - uses: actions/checkout@v3 - with: - ref: ${{ env.PR_BRANCH }} + --data '{"type":"CNAME","name":"*.${{ env.PR_HOSTNAME }}","content":"${{ env.PR_HOSTNAME }}","ttl":1,"proxied":false}' - name: Create PR namespace - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | - set -euxo pipefail + set -euo pipefail # try to delete the namespace, but don't fail if it doesn't exist kubectl delete namespace "pr${{ env.PR_NUMBER }}" || true kubectl create namespace "pr${{ env.PR_NUMBER }}" + - name: Checkout + uses: actions/checkout@v4 + - name: Check and Create Certificate - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | # Using kubectl to check if a Certificate resource already exists # we are doing this to avoid letsenrypt rate limits if ! kubectl get certificate pr${{ env.PR_NUMBER }}-tls -n pr-deployment-certs > /dev/null 2>&1; then echo "Certificate doesn't exist. Creating a new one." - cat < pr-deploy-values.yaml - coder: - image: - repo: ${{ env.REPO }} - tag: pr${{ env.PR_NUMBER }} - pullPolicy: Always - service: - type: ClusterIP - ingress: - enable: true - className: traefik - host: ${{ env.PR_DEPLOYMENT_ACCESS_URL }} - wildcardHost: "*.${{ env.PR_DEPLOYMENT_ACCESS_URL }}" - tls: - enable: true - secretName: pr${{ env.PR_NUMBER }}-tls - wildcardSecretName: pr${{ env.PR_NUMBER }}-tls - env: - - name: "CODER_ACCESS_URL" - value: "https://${{ env.PR_DEPLOYMENT_ACCESS_URL }}" - - name: "CODER_WILDCARD_ACCESS_URL" - value: "*.${{ env.PR_DEPLOYMENT_ACCESS_URL }}" - - name: "CODER_EXPERIMENTS" - value: "${{ github.event.inputs.experiments }}" - - name: CODER_PG_CONNECTION_URL - valueFrom: - secretKeyRef: - name: coder-db-url - key: url - - name: "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS" - value: "true" - - name: "CODER_OAUTH2_GITHUB_CLIENT_ID" - value: "${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID }}" - - name: "CODER_OAUTH2_GITHUB_CLIENT_SECRET" - value: "${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET }}" - - name: "CODER_OAUTH2_GITHUB_ALLOWED_ORGS" - value: "coder" - EOF + set -euo pipefail + envsubst < ./.github/pr-deployments/values.yaml > ./pr-deploy-values.yaml - name: Install/Upgrade Helm chart run: | - set -euxo pipefail - if [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then - helm upgrade --install "pr${{ env.PR_NUMBER }}" ./helm \ - --namespace "pr${{ env.PR_NUMBER }}" \ - --values ./pr-deploy-values.yaml \ - --force - else - if [[ ${{ needs.get_info.outputs.BUILD }} == "true" ]]; then - helm upgrade --install "pr${{ env.PR_NUMBER }}" ./helm \ - --namespace "pr${{ env.PR_NUMBER }}" \ - --reuse-values \ - --force - else - echo "Skipping helm upgrade, as there is no new image to deploy" - fi - fi + set -euo pipefail + helm upgrade --install "pr${{ env.PR_NUMBER }}" ./helm/coder \ + --namespace "pr${{ env.PR_NUMBER }}" \ + --values ./pr-deploy-values.yaml \ + --force - name: Install coder-logstream-kube - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | helm repo add coder-logstream-kube https://helm.coder.com/logstream-kube helm upgrade --install coder-logstream-kube coder-logstream-kube/coder-logstream-kube \ --namespace "pr${{ env.PR_NUMBER }}" \ - --set url="https://pr${{ env.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}" + --set url="https://${{ env.PR_HOSTNAME }}" - name: Get Coder binary - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | - set -euxo pipefail + set -euo pipefail DEST="${HOME}/coder" - URL="https://${{ env.PR_DEPLOYMENT_ACCESS_URL }}/bin/coder-linux-amd64" + URL="https://${{ env.PR_HOSTNAME }}/bin/coder-linux-amd64" mkdir -p "$(dirname ${DEST})" @@ -414,10 +395,10 @@ jobs: mv "${DEST}" /usr/local/bin/coder - name: Create first user, template and workspace - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' id: setup_deployment run: | - set -euxo pipefail + set -euo pipefail # Create first user @@ -429,28 +410,23 @@ jobs: echo "password=$password" >> $GITHUB_OUTPUT coder login \ - --first-user-username test \ + --first-user-username coder \ --first-user-email pr${{ env.PR_NUMBER }}@coder.com \ --first-user-password $password \ --first-user-trial \ --use-token-as-session \ - https://${{ env.PR_DEPLOYMENT_ACCESS_URL }} + https://${{ env.PR_HOSTNAME }} # Create template - coder templates init --id kubernetes && cd ./kubernetes/ && coder templates create -y --variable namespace=pr${{ env.PR_NUMBER }} + cd ./.github/pr-deployments/template + coder templates create -y --variable namespace=pr${{ env.PR_NUMBER }} kubernetes # Create workspace - cat < workspace.yaml - cpu: "2" - memory: "4" - home_disk_size: "2" - EOF - - coder create --template="kubernetes" test --rich-parameter-file ./workspace.yaml -y - coder stop test -y + coder create --template="kubernetes" kube --parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y + coder stop kube -y - name: Send Slack notification - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | curl -s -o /dev/null -X POST -H 'Content-type: application/json' \ -d \ @@ -458,7 +434,7 @@ jobs: "pr_number": "'"${{ env.PR_NUMBER }}"'", "pr_url": "'"${{ env.PR_URL }}"'", "pr_title": "'"${{ env.PR_TITLE }}"'", - "pr_access_url": "'"https://${{ env.PR_DEPLOYMENT_ACCESS_URL }}"'", + "pr_access_url": "'"https://${{ env.PR_HOSTNAME }}"'", "pr_username": "'"test"'", "pr_email": "'"pr${{ env.PR_NUMBER }}@coder.com"'", "pr_password": "'"${{ steps.setup_deployment.outputs.password }}"'", diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 95f07feda29ca..f0b1f2d631379 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -28,10 +28,6 @@ env: # https://github.blog/changelog/2022-06-10-github-actions-inputs-unified-across-manual-and-reusable-workflows/ CODER_RELEASE: ${{ !inputs.dry_run }} CODER_DRY_RUN: ${{ inputs.dry_run }} - # For some reason, setup-go won't actually pick up a new patch version if - # it has an old one cached. We need to manually specify the versions so we - # can get the latest release. Never use "~1.xx" here! - CODER_GO_VERSION: "1.20.6" jobs: release: @@ -44,7 +40,7 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -89,7 +85,7 @@ jobs: cat "$CODER_RELEASE_NOTES_FILE" - name: Docker Login - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -98,16 +94,8 @@ jobs: - name: Setup Go uses: ./.github/actions/setup-go - - name: Cache Node - id: cache-node - uses: buildjet/cache@v3 - with: - path: | - **/node_modules - .eslintcache - key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - js-${{ runner.os }}- + - name: Setup Node + uses: ./.github/actions/setup-node - name: Install nsis and zstd run: sudo apt-get install -y nsis zstd @@ -153,7 +141,8 @@ jobs: build/coder_"$version"_linux_{amd64,armv7,arm64}.{tar.gz,apk,deb,rpm} \ build/coder_"$version"_{darwin,windows}_{amd64,arm64}.zip \ build/coder_"$version"_windows_amd64_installer.exe \ - build/coder_helm_"$version".tgz + build/coder_helm_"$version".tgz \ + build/provisioner_helm_"$version".tgz env: CODER_SIGN_DARWIN: "1" AC_CERTIFICATE_FILE: /tmp/apple_cert.p12 @@ -307,9 +296,11 @@ jobs: version="$(./scripts/version.sh)" mkdir -p build/helm cp "build/coder_helm_${version}.tgz" build/helm + cp "build/provisioner_helm_${version}.tgz" build/helm gsutil cp gs://helm.coder.com/v2/index.yaml build/helm/index.yaml helm repo index build/helm --url https://helm.coder.com/v2 --merge build/helm/index.yaml gsutil -h "Cache-Control:no-cache,max-age=0" cp build/helm/coder_helm_${version}.tgz gs://helm.coder.com/v2 + gsutil -h "Cache-Control:no-cache,max-age=0" cp build/helm/provisioner_helm_${version}.tgz gs://helm.coder.com/v2 gsutil -h "Cache-Control:no-cache,max-age=0" cp build/helm/index.yaml gs://helm.coder.com/v2 gsutil -h "Cache-Control:no-cache,max-age=0" cp helm/artifacthub-repo.yml gs://helm.coder.com/v2 @@ -337,13 +328,88 @@ jobs: event-type: coder-release client-payload: '{"coder_version": "${{ steps.version.outputs.version }}"}' + publish-homebrew: + name: Publish to Homebrew tap + runs-on: ubuntu-latest + needs: release + if: ${{ !inputs.dry_run }} + + steps: + # TODO: skip this if it's not a new release (i.e. a backport). This is + # fine right now because it just makes a PR that we can close. + - name: Update homebrew + env: + # Variables used by the `gh` command + GH_REPO: coder/homebrew-coder + GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + run: | + # Keep version number around for reference, removing any potential leading v + coder_version="$(echo "${{ needs.release.outputs.version }}" | tr -d v)" + + set -euxo pipefail + + # Setup Git + git config --global user.email "ci@coder.com" + git config --global user.name "Coder CI" + git config --global credential.helper "store" + + temp_dir="$(mktemp -d)" + cd "$temp_dir" + + # Download checksums + checksums_url="$(gh release view --repo coder/coder "v$coder_version" --json assets \ + | jq -r ".assets | map(.url) | .[]" \ + | grep -e ".checksums.txt\$")" + wget "$checksums_url" -O checksums.txt + + # Get the SHAs + darwin_arm_sha="$(cat checksums.txt | grep "darwin_arm64.zip" | awk '{ print $1 }')" + darwin_intel_sha="$(cat checksums.txt | grep "darwin_amd64.zip" | awk '{ print $1 }')" + linux_sha="$(cat checksums.txt | grep "linux_amd64.tar.gz" | awk '{ print $1 }')" + + echo "macOS arm64: $darwin_arm_sha" + echo "macOS amd64: $darwin_intel_sha" + echo "Linux amd64: $linux_sha" + + # Check out the homebrew repo + git clone "https://github.com/$GH_REPO" homebrew-coder + brew_branch="auto-release/$coder_version" + cd homebrew-coder + + # Check if a PR already exists. + pr_count="$(gh pr list --search "head:$brew_branch" --json id,closed | jq -r ".[] | select(.closed == false) | .id" | wc -l)" + if [[ "$pr_count" > 0 ]]; then + echo "Bailing out as PR already exists" 2>&1 + exit 0 + fi + + # Set up cdrci credentials for pushing to homebrew-coder + echo "https://x-access-token:$GH_TOKEN@github.com" >> ~/.git-credentials + # Update the formulae and push + git checkout -b "$brew_branch" + ./scripts/update-v2.sh "$coder_version" "$darwin_arm_sha" "$darwin_intel_sha" "$linux_sha" + git add . + git commit -m "coder $coder_version" + git push -u origin -f "$brew_branch" + + # Create PR + gh pr create \ + -B master -H "$brew_branch" \ + -t "coder $coder_version" \ + -b "" \ + -r "${{ github.actor }}" \ + -a "${{ github.actor }}" \ + -b "This automatic PR was triggered by the release of Coder v$coder_version" + publish-winget: name: Publish to winget-pkgs runs-on: windows-latest needs: release + if: ${{ !inputs.dry_run }} + steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -376,12 +442,6 @@ jobs: echo "Installer URL: ${installer_url}" echo "Package version: ${version}" - # Bail if dry-run. - if ($env:CODER_DRY_RUN -match "t") { - echo "Skipping submission due to dry-run." - exit 0 - } - # The URL "|X64" suffix forces the architecture as it cannot be # sniffed properly from the URL. wingetcreate checks both the URL and # binary magic bytes for the architecture and they need to both match, @@ -405,7 +465,6 @@ jobs: WINGET_GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} - name: Comment on PR - if: ${{ !inputs.dry_run }} run: | # wait 30 seconds Start-Sleep -Seconds 30.0 @@ -421,3 +480,66 @@ jobs: # For gh CLI. We need a real token since we're commenting on a PR in a # different repo. GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + + publish-chocolatey: + name: Publish to Chocolatey + runs-on: windows-latest + needs: release + if: ${{ !inputs.dry_run }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Same reason as for release. + - name: Fetch git tags + run: git fetch --tags --force + + # From https://chocolatey.org + - name: Install Chocolatey + run: | + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + + iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + + - name: Build chocolatey package + run: | + cd scripts/chocolatey + + # The package version is the same as the tag minus the leading "v". + # The version in this output already has the leading "v" removed but + # we do it again to be safe. + $version = "${{ needs.release.outputs.version }}".Trim('v') + + $release_assets = gh release view --repo coder/coder "v${version}" --json assets | ` + ConvertFrom-Json + + # Get the URL for the Windows ZIP from the release assets. + $zip_url = $release_assets.assets | ` + Where-Object name -Match ".*_windows_amd64.zip$" | ` + Select -ExpandProperty url + + echo "ZIP URL: ${zip_url}" + echo "Package version: ${version}" + + echo "Downloading ZIP..." + Invoke-WebRequest $zip_url -OutFile assets.zip + + echo "Extracting ZIP..." + Expand-Archive assets.zip -DestinationPath assets/ + + # No need to specify nuspec if there's only one in the directory. + choco pack --version=$version binary_path=assets/coder.exe + + choco apikey --api-key $env:CHOCO_API_KEY --source https://push.chocolatey.org/ + + # No need to specify nupkg if there's only one in the directory. + choco push --source https://push.chocolatey.org/ + + env: + CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} + # We need a GitHub token for the gh CLI to function under GitHub Actions + GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index daed19ee5dff5..0f26a126da995 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -21,15 +21,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }}-security cancel-in-progress: ${{ github.event_name == 'pull_request' }} -env: - CODER_GO_VERSION: "1.20.6" - jobs: codeql: runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v2 @@ -62,23 +59,15 @@ jobs: runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Go uses: ./.github/actions/setup-go - - name: Cache Node - id: cache-node - uses: buildjet/cache@v3 - with: - path: | - **/node_modules - .eslintcache - key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - js-${{ runner.os }}- + - name: Setup Node + uses: ./.github/actions/setup-node - name: Setup sqlc uses: ./.github/actions/setup-sqlc @@ -133,7 +122,7 @@ jobs: image_name: ${{ steps.build.outputs.image }} - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@41f05d9ecffa2ed3f1580af306000f734b733e54 + uses: aquasecurity/trivy-action@b77b85c0254bba6789e787844f0585cde1e56320 with: image-ref: ${{ steps.build.outputs.image }} format: sarif diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 3490763f0be10..ed6d1d0127bc0 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Run delete-old-branches-action uses: beatlabs/delete-old-branches-action@v0.0.10 with: @@ -52,8 +52,8 @@ jobs: with: token: ${{ github.token }} repository: ${{ github.repository }} - retain_days: 1 - keep_minimum_runs: 1 + retain_days: 30 + keep_minimum_runs: 30 delete_workflow_pattern: pr-cleanup.yaml - name: Delete PR Deploy workflow skipped runs @@ -61,7 +61,6 @@ jobs: with: token: ${{ github.token }} repository: ${{ github.repository }} - retain_days: 0 - keep_minimum_runs: 0 - delete_run_by_conclusion_pattern: skipped + retain_days: 30 + keep_minimum_runs: 30 delete_workflow_pattern: pr-deploy.yaml diff --git a/.gitignore b/.gitignore index b22db03c2089e..2ccd459b811b9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,15 +30,14 @@ site/e2e/states/*.json site/e2e/.auth.json site/playwright-report/* site/.swc -site/dist/ # Make target for updating golden files (any dir). .gen-golden # Build -/build/ -/dist/ -site/out/ +build/ +dist/ +out/ # Bundle analysis site/stats/ @@ -61,3 +60,12 @@ site/stats/ ./scaletest/terraform/.terraform.lock.hcl scaletest/terraform/secrets.tfvars .terraform.tfstate.* + +# Nix +result + +# Data dumps from unit tests +**/*.test.sql + +# Filebrowser.db +**/filebrowser.db diff --git a/.golangci.yaml b/.golangci.yaml index e3f3797d06b81..f2ecce63da607 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -2,12 +2,19 @@ # Over time we should try tightening some of these. linters-settings: + dupl: + # goal: 100 + threshold: 412 + exhaustruct: include: # Gradually extend to cover more of the codebase. - 'httpmw\.\w+' + # We want to enforce all values are specified when inserting or updating + # a database row. Ref: #9936 + - 'github.com/coder/coder/v2/coderd/database\.[^G][^e][^t]\w+Params' gocognit: - min-complexity: 46 # Min code complexity (def 30). + min-complexity: 300 goconst: min-len: 4 # Min length of string consts (def 3). @@ -118,10 +125,6 @@ linters-settings: goimports: local-prefixes: coder.com,cdr.dev,go.coder.com,github.com/cdr,github.com/coder - gocyclo: - # goal: 30 - min-complexity: 47 - importas: no-unaliased: true @@ -131,7 +134,8 @@ linters-settings: - trialer nestif: - min-complexity: 4 # Min complexity of if statements (def 5, goal 4) + # goal: 10 + min-complexity: 20 revive: # see https://github.com/mgechev/revive#available-rules for details. @@ -211,6 +215,7 @@ issues: run: skip-dirs: - node_modules + - .git skip-files: - scripts/rules.go timeout: 10m @@ -231,7 +236,12 @@ linters: - exportloopref - forcetypeassert - gocritic - - gocyclo + # gocyclo is may be useful in the future when we start caring + # about testing complexity, but for the time being we should + # create a good culture around cognitive complexity. + # - gocyclo + - gocognit + - nestif - goimports - gomodguard - gosec @@ -267,3 +277,4 @@ linters: - typecheck - unconvert - unused + - dupl diff --git a/.prettierignore b/.prettierignore index 9296d15d8802e..c7882767e85af 100644 --- a/.prettierignore +++ b/.prettierignore @@ -33,15 +33,14 @@ site/e2e/states/*.json site/e2e/.auth.json site/playwright-report/* site/.swc -site/dist/ # Make target for updating golden files (any dir). .gen-golden # Build -/build/ -/dist/ -site/out/ +build/ +dist/ +out/ # Bundle analysis site/stats/ @@ -64,10 +63,19 @@ site/stats/ ./scaletest/terraform/.terraform.lock.hcl scaletest/terraform/secrets.tfvars .terraform.tfstate.* + +# Nix +result + +# Data dumps from unit tests +**/*.test.sql + +# Filebrowser.db +**/filebrowser.db # .prettierignore.include: # Helm templates contain variables that are invalid YAML and can't be formatted # by Prettier. -helm/templates/*.yaml +helm/**/templates/*.yaml # Terraform state files used in tests, these are automatically generated. # Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json @@ -80,3 +88,6 @@ scripts/apitypings/testdata/**/*.ts site/e2e/provisionerGenerated.ts **/pnpm-lock.yaml + +# Ignore generated JSON (e.g. examples/examples.gen.json). +**/*.gen.json diff --git a/.prettierignore.include b/.prettierignore.include index 1f60eda9c54a7..3a42bc75ecf9f 100644 --- a/.prettierignore.include +++ b/.prettierignore.include @@ -1,6 +1,6 @@ # Helm templates contain variables that are invalid YAML and can't be formatted # by Prettier. -helm/templates/*.yaml +helm/**/templates/*.yaml # Terraform state files used in tests, these are automatically generated. # Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json @@ -13,3 +13,6 @@ scripts/apitypings/testdata/**/*.ts site/e2e/provisionerGenerated.ts **/pnpm-lock.yaml + +# Ignore generated JSON (e.g. examples/examples.gen.json). +**/*.gen.json diff --git a/.prettierrc.yaml b/.prettierrc.yaml index 9ba1d2ca9db7a..189b2580f6244 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -1,18 +1,18 @@ # This config file is used in conjunction with `.editorconfig` to specify # formatting for prettier-supported files. See `.editorconfig` and -# `site/.editorconfig`for whitespace formatting options. +# `site/.editorconfig` for whitespace formatting options. printWidth: 80 -semi: false +proseWrap: always trailingComma: all useTabs: false tabWidth: 2 overrides: - files: - README.md + - docs/api/**/*.md + - docs/cli/**/*.md + - docs/changelogs/*.md + - .github/**/*.{yaml,yml,toml} + - scripts/**/*.{yaml,yml,toml} options: proseWrap: preserve - - files: - - "site/**/*.yaml" - - "site/**/*.yml" - options: - proseWrap: always diff --git a/.swaggo b/.swaggo index e4b76f3ed82d9..bf8a6bad030c2 100644 --- a/.swaggo +++ b/.swaggo @@ -1,8 +1,8 @@ // Replace all NullTime with string -replace github.com/coder/coder/codersdk.NullTime string +replace github.com/coder/coder/v2/codersdk.NullTime string // Prevent swaggo from rendering enums for time.Duration replace time.Duration int64 // Do not expose "echo" provider -replace github.com/coder/coder/codersdk.ProvisionerType string +replace github.com/coder/coder/v2/codersdk.ProvisionerType string // Do not render netip.Addr replace netip.Addr string diff --git a/.vscode/settings.json b/.vscode/settings.json index 1ff3ea0883482..6f726162d260a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,7 +20,7 @@ "codersdk", "cronstrue", "databasefake", - "dbfake", + "dbmem", "dbgen", "dbtype", "DERP", @@ -39,6 +39,7 @@ "enterprisemeta", "errgroup", "eventsourcemock", + "externalauth", "Failf", "fatih", "Formik", @@ -186,12 +187,20 @@ ] }, "eslint.workingDirectories": ["./site"], - "files.exclude": { - "**/node_modules": true - }, "search.exclude": { + "**.pb.go": true, + "**/*.gen.json": true, + "**/testdata/*": true, + "**Generated.ts": true, + "coderd/apidoc/**": true, + "docs/api/*.md": true, + "docs/templates/*.md": true, + "LICENSE": true, "scripts/metricsdocgen/metrics": true, - "docs/api/*.md": true + "site/out/**": true, + "site/storybook-static/**": true, + "**.map": true, + "pnpm-lock.yaml": true }, // Ensure files always have a newline. "files.insertFinalNewline": true, diff --git a/Makefile b/Makefile index c9089a9d4e452..72e44308c6f03 100644 --- a/Makefile +++ b/Makefile @@ -107,9 +107,9 @@ endif clean: - rm -rf build site/out - mkdir -p build site/out/bin - git restore site/out + rm -rf build/ site/build/ site/out/ + mkdir -p build/ site/out/bin/ + git restore site/out/ .PHONY: clean build-slim: $(CODER_SLIM_BINARIES) @@ -344,15 +344,19 @@ push/$(CODER_MAIN_IMAGE): $(CODER_MAIN_IMAGE) docker manifest push "$$image_tag" .PHONY: push/$(CODER_MAIN_IMAGE) +# Helm charts that are available +charts = coder provisioner + # Shortcut for Helm chart package. -build/coder_helm.tgz: build/coder_helm_$(VERSION).tgz +$(foreach chart,$(charts),build/$(chart)_helm.tgz): build/%_helm.tgz: build/%_helm_$(VERSION).tgz rm -f "$@" ln "$<" "$@" # Helm chart package. -build/coder_helm_$(VERSION).tgz: +$(foreach chart,$(charts),build/$(chart)_helm_$(VERSION).tgz): build/%_helm_$(VERSION).tgz: ./scripts/helm.sh \ --version "$(VERSION)" \ + --chart $* \ --output "$@" site/out/index.html: site/package.json $(shell find ./site $(FIND_EXCLUSIONS) -type f \( -name '*.ts' -o -name '*.tsx' \)) @@ -415,7 +419,6 @@ lint: lint/shellcheck lint/go lint/ts lint/helm lint/site-icons lint/site-icons: ./scripts/check_site_icons.sh - .PHONY: lint/site-icons lint/ts: @@ -445,17 +448,19 @@ lint/helm: DB_GEN_FILES := \ coderd/database/querier.go \ coderd/database/unique_constraint.go \ - coderd/database/dbfake/dbfake.go \ + coderd/database/dbmem/dbmem.go \ coderd/database/dbmetrics/dbmetrics.go \ coderd/database/dbauthz/dbauthz.go \ coderd/database/dbmock/dbmock.go # all gen targets should be added here and to gen/mark-fresh gen: \ - coderd/database/dump.sql \ - $(DB_GEN_FILES) \ + tailnet/proto/tailnet.pb.go \ + agent/proto/agent.pb.go \ provisionersdk/proto/provisioner.pb.go \ provisionerd/proto/provisionerd.pb.go \ + coderd/database/dump.sql \ + $(DB_GEN_FILES) \ site/src/api/typesGenerated.ts \ coderd/rbac/object_gen.go \ docs/admin/prometheus.md \ @@ -466,17 +471,22 @@ gen: \ .prettierignore \ site/.prettierrc.yaml \ site/.prettierignore \ - site/.eslintignore + site/.eslintignore \ + site/e2e/provisionerGenerated.ts \ + site/src/theme/icons.json \ + examples/examples.gen.json .PHONY: gen # Mark all generated files as fresh so make thinks they're up-to-date. This is # used during releases so we don't run generation scripts. gen/mark-fresh: files="\ - coderd/database/dump.sql \ - $(DB_GEN_FILES) \ + tailnet/proto/tailnet.pb.go \ + agent/proto/agent.pb.go \ provisionersdk/proto/provisioner.pb.go \ provisionerd/proto/provisionerd.pb.go \ + coderd/database/dump.sql \ + $(DB_GEN_FILES) \ site/src/api/typesGenerated.ts \ coderd/rbac/object_gen.go \ docs/admin/prometheus.md \ @@ -488,6 +498,9 @@ gen/mark-fresh: site/.prettierrc.yaml \ site/.prettierignore \ site/.eslintignore \ + site/e2e/provisionerGenerated.ts \ + site/src/theme/icons.json \ + examples/examples.gen.json \ " for file in $$files; do echo "$$file" @@ -507,12 +520,30 @@ coderd/database/dump.sql: coderd/database/gen/dump/main.go $(wildcard coderd/dat go run ./coderd/database/gen/dump/main.go # Generates Go code for querying the database. +# coderd/database/queries.sql.go +# coderd/database/models.go coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql) ./coderd/database/generate.sh coderd/database/dbmock/dbmock.go: coderd/database/db.go coderd/database/querier.go go generate ./coderd/database/dbmock/ +tailnet/proto/tailnet.pb.go: tailnet/proto/tailnet.proto + protoc \ + --go_out=. \ + --go_opt=paths=source_relative \ + --go-drpc_out=. \ + --go-drpc_opt=paths=source_relative \ + ./tailnet/proto/tailnet.proto + +agent/proto/agent.pb.go: agent/proto/agent.proto + protoc \ + --go_out=. \ + --go_opt=paths=source_relative \ + --go-drpc_out=. \ + --go-drpc_opt=paths=source_relative \ + ./agent/proto/agent.proto + provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto protoc \ --go_out=. \ @@ -529,10 +560,21 @@ provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto --go-drpc_opt=paths=source_relative \ ./provisionerd/proto/provisionerd.proto -site/src/api/typesGenerated.ts: scripts/apitypings/main.go $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go') - go run scripts/apitypings/main.go > site/src/api/typesGenerated.ts +site/src/api/typesGenerated.ts: $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go') + go run ./scripts/apitypings/ > $@ + pnpm run format:write:only "$@" + +site/e2e/provisionerGenerated.ts: provisionerd/proto/provisionerd.pb.go provisionersdk/proto/provisioner.pb.go cd site - pnpm run format:types + ../scripts/pnpm_install.sh + pnpm run gen:provisioner + +site/src/theme/icons.json: $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*) + go run ./scripts/gensite/ -icons "$@" + pnpm run format:write:only "$@" + +examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates) + go run ./scripts/examplegen/main.go > examples/examples.gen.json coderd/rbac/object_gen.go: scripts/rbacgen/main.go coderd/rbac/object.go go run scripts/rbacgen/main.go ./coderd/rbac > coderd/rbac/object_gen.go @@ -541,8 +583,8 @@ docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/me go run scripts/metricsdocgen/main.go pnpm run format:write:only ./docs/admin/prometheus.md -docs/cli.md: scripts/clidocgen/main.go $(GO_SRC_FILES) - BASE_PATH="." go run ./scripts/clidocgen +docs/cli.md: scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES) + CI=true BASE_PATH="." go run ./scripts/clidocgen pnpm run format:write:only ./docs/cli.md ./docs/cli/*.md ./docs/manifest.json docs/admin/audit-logs.md: scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go @@ -553,7 +595,7 @@ coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS) ./scripts/apidocgen/generate.sh pnpm run format:write:only ./docs/api ./docs/manifest.json ./coderd/apidoc/swagger.json -update-golden-files: cli/testdata/.gen-golden helm/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden enterprise/cli/testdata/.gen-golden +update-golden-files: cli/testdata/.gen-golden helm/coder/tests/testdata/.gen-golden helm/provisioner/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden enterprise/cli/testdata/.gen-golden coderd/.gen-golden provisioner/terraform/testdata/.gen-golden .PHONY: update-golden-files cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go) @@ -564,8 +606,20 @@ enterprise/cli/testdata/.gen-golden: $(wildcard enterprise/cli/testdata/*.golden go test ./enterprise/cli -run="TestEnterpriseCommandHelp" -update touch "$@" -helm/tests/testdata/.gen-golden: $(wildcard helm/tests/testdata/*.yaml) $(wildcard helm/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/tests/*_test.go) - go test ./helm/tests -run=TestUpdateGoldenFiles -update +helm/coder/tests/testdata/.gen-golden: $(wildcard helm/coder/tests/testdata/*.yaml) $(wildcard helm/coder/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/coder/tests/*_test.go) + go test ./helm/coder/tests -run=TestUpdateGoldenFiles -update + touch "$@" + +helm/provisioner/tests/testdata/.gen-golden: $(wildcard helm/provisioner/tests/testdata/*.yaml) $(wildcard helm/provisioner/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/provisioner/tests/*_test.go) + go test ./helm/provisioner/tests -run=TestUpdateGoldenFiles -update + touch "$@" + +coderd/.gen-golden: $(wildcard coderd/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard coderd/*_test.go) + go test ./coderd -run="Test.*Golden$$" -update + touch "$@" + +provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard provisioner/terraform/*_test.go) + go test ./provisioner/terraform -run="Test.*Golden$$" -update touch "$@" scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go) @@ -586,7 +640,7 @@ site/.prettierrc.yaml: .prettierrc.yaml # - ./ -> ../ # - ./site -> ./ yq \ - '.overrides[].files |= map(. | sub("^./"; "") | sub("^"; "../") | sub("../site/"; "./"))' \ + '.overrides[].files |= map(. | sub("^./"; "") | sub("^"; "../") | sub("../site/"; "./") | sub("../!"; "!../"))' \ "$<" >> "$@" # Combine .gitignore with .prettierignore.include to generate .prettierignore. diff --git a/README.md b/README.md index 9443eb6b701fd..27634813adf34 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,11 @@ curl -L https://coder.com/install.sh | sh You can run the install script with `--dry-run` to see the commands that will be used to install without executing them. You can modify the installation process by including flags. Run the install script with `--help` for reference. -> See [install](docs/install) for additional methods. +> See [install](https://coder.com/docs/v2/latest/install) for additional methods. Once installed, you can start a production deployment1 with a single command: -```console +```shell # Automatically sets up an external access URL on *.try.coder.app coder server diff --git a/SECURITY.md b/SECURITY.md index 46986c9d3aadf..ee5ac8075eaf9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,7 @@ # Coder Security -Coder welcomes feedback from security researchers and the general public -to help improve our security. If you believe you have discovered a vulnerability, +Coder welcomes feedback from security researchers and the general public to help +improve our security. If you believe you have discovered a vulnerability, privacy issue, exposed data, or other security issues in any of our assets, we want to hear from you. This policy outlines steps for reporting vulnerabilities to us, what we expect, what you can expect from us. @@ -10,64 +10,72 @@ You can see the pretty version [here](https://coder.com/security/policy) # Why Coder's security matters -If an attacker could fully compromise a Coder installation, they could spin -up expensive workstations, steal valuable credentials, or steal proprietary -source code. We take this risk very seriously and employ routine pen testing, -vulnerability scanning, and code reviews. We also welcome the contributions -from the community that helped make this product possible. +If an attacker could fully compromise a Coder installation, they could spin up +expensive workstations, steal valuable credentials, or steal proprietary source +code. We take this risk very seriously and employ routine pen testing, +vulnerability scanning, and code reviews. We also welcome the contributions from +the community that helped make this product possible. # Where should I report security issues? -Please report security issues to security@coder.com, providing -all relevant information. The more details you provide, the easier it will be -for us to triage and fix the issue. +Please report security issues to security@coder.com, providing all relevant +information. The more details you provide, the easier it will be for us to +triage and fix the issue. # Out of Scope -Our primary concern is around an abuse of the Coder application that allows -an attacker to gain access to another users workspace, or spin up unwanted +Our primary concern is around an abuse of the Coder application that allows an +attacker to gain access to another users workspace, or spin up unwanted workspaces. - DOS/DDOS attacks affecting availability --> While we do support rate limiting - of requests, we primarily leave this to the owner of the Coder installation. Our - rationale is that a DOS attack only affecting availability is not a valuable - target for attackers. + of requests, we primarily leave this to the owner of the Coder installation. + Our rationale is that a DOS attack only affecting availability is not a + valuable target for attackers. - Abuse of a compromised user credential --> If a user credential is compromised - outside of the Coder ecosystem, then we consider it beyond the scope of our application. - However, if an unprivileged user could escalate their permissions or gain access - to another workspace, that is a cause for concern. + outside of the Coder ecosystem, then we consider it beyond the scope of our + application. However, if an unprivileged user could escalate their permissions + or gain access to another workspace, that is a cause for concern. - Vulnerabilities in third party systems --> Vulnerabilities discovered in - out-of-scope systems should be reported to the appropriate vendor or applicable authority. + out-of-scope systems should be reported to the appropriate vendor or + applicable authority. # Our Commitments When working with us, according to this policy, you can expect us to: -- Respond to your report promptly, and work with you to understand and validate your report; -- Strive to keep you informed about the progress of a vulnerability as it is processed; -- Work to remediate discovered vulnerabilities in a timely manner, within our operational constraints; and -- Extend Safe Harbor for your vulnerability research that is related to this policy. +- Respond to your report promptly, and work with you to understand and validate + your report; +- Strive to keep you informed about the progress of a vulnerability as it is + processed; +- Work to remediate discovered vulnerabilities in a timely manner, within our + operational constraints; and +- Extend Safe Harbor for your vulnerability research that is related to this + policy. # Our Expectations -In participating in our vulnerability disclosure program in good faith, we ask that you: +In participating in our vulnerability disclosure program in good faith, we ask +that you: -- Play by the rules, including following this policy and any other relevant agreements. - If there is any inconsistency between this policy and any other applicable terms, the - terms of this policy will prevail; +- Play by the rules, including following this policy and any other relevant + agreements. If there is any inconsistency between this policy and any other + applicable terms, the terms of this policy will prevail; - Report any vulnerability you’ve discovered promptly; -- Avoid violating the privacy of others, disrupting our systems, destroying data, and/or - harming user experience; +- Avoid violating the privacy of others, disrupting our systems, destroying + data, and/or harming user experience; - Use only the Official Channels to discuss vulnerability information with us; -- Provide us a reasonable amount of time (at least 90 days from the initial report) to - resolve the issue before you disclose it publicly; -- Perform testing only on in-scope systems, and respect systems and activities which - are out-of-scope; -- If a vulnerability provides unintended access to data: Limit the amount of data you - access to the minimum required for effectively demonstrating a Proof of Concept; and - cease testing and submit a report immediately if you encounter any user data during testing, - such as Personally Identifiable Information (PII), Personal Healthcare Information (PHI), - credit card data, or proprietary information; -- You should only interact with test accounts you own or with explicit permission from +- Provide us a reasonable amount of time (at least 90 days from the initial + report) to resolve the issue before you disclose it publicly; +- Perform testing only on in-scope systems, and respect systems and activities + which are out-of-scope; +- If a vulnerability provides unintended access to data: Limit the amount of + data you access to the minimum required for effectively demonstrating a Proof + of Concept; and cease testing and submit a report immediately if you encounter + any user data during testing, such as Personally Identifiable Information + (PII), Personal Healthcare Information (PHI), credit card data, or proprietary + information; +- You should only interact with test accounts you own or with explicit + permission from - the account holder; and - Do not engage in extortion. diff --git a/agent/agent.go b/agent/agent.go index 52c423787fb44..0d2326d2ab9d3 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -12,16 +12,17 @@ import ( "net/http" "net/netip" "os" - "os/exec" "os/user" "path/filepath" + "runtime" + "runtime/debug" "sort" "strconv" "strings" "sync" "time" - "github.com/armon/circbuf" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/spf13/afero" @@ -34,14 +35,16 @@ import ( "tailscale.com/types/netlogtype" "cdr.dev/slog" - "github.com/coder/coder/agent/agentssh" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/pty" - "github.com/coder/coder/tailnet" + "github.com/coder/coder/v2/agent/agentproc" + "github.com/coder/coder/v2/agent/agentscripts" + "github.com/coder/coder/v2/agent/agentssh" + "github.com/coder/coder/v2/agent/reconnectingpty" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/cli/gitauth" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/tailnet" "github.com/coder/retry" ) @@ -51,6 +54,10 @@ const ( ProtocolDial = "dial" ) +// EnvProcPrioMgmt determines whether we attempt to manage +// process CPU and OOM Killer priority. +const EnvProcPrioMgmt = "CODER_PROC_PRIO_MGMT" + type Options struct { Filesystem afero.Fs LogDir string @@ -63,11 +70,16 @@ type Options struct { IgnorePorts map[int]string SSHMaxTimeout time.Duration TailnetListenPort uint16 - Subsystem codersdk.AgentSubsystem + Subsystems []codersdk.AgentSubsystem Addresses []netip.Prefix PrometheusRegistry *prometheus.Registry ReportMetadataInterval time.Duration ServiceBannerRefreshInterval time.Duration + Syscaller agentproc.Syscaller + // ModifiedProcesses is used for testing process priority management. + ModifiedProcesses chan []*agentproc.Process + // ProcessManagementTick is used for testing process priority management. + ProcessManagementTick <-chan time.Time } type Client interface { @@ -78,7 +90,7 @@ type Client interface { PostLifecycle(ctx context.Context, state agentsdk.PostLifecycleRequest) error PostAppHealth(ctx context.Context, req agentsdk.PostAppHealthsRequest) error PostStartup(ctx context.Context, req agentsdk.PostStartupRequest) error - PostMetadata(ctx context.Context, key string, req agentsdk.PostMetadataRequest) error + PostMetadata(ctx context.Context, req agentsdk.PostMetadataRequest) error PatchLogs(ctx context.Context, req agentsdk.PatchLogs) error GetServiceBanner(ctx context.Context) (codersdk.ServiceBannerConfig, error) } @@ -91,9 +103,6 @@ type Agent interface { } func New(options Options) Agent { - if options.ReconnectingPTYTimeout == 0 { - options.ReconnectingPTYTimeout = 5 * time.Minute - } if options.Filesystem == nil { options.Filesystem = afero.NewOsFs() } @@ -123,6 +132,10 @@ func New(options Options) Agent { prometheusRegistry = prometheus.NewRegistry() } + if options.Syscaller == nil { + options.Syscaller = agentproc.NewSyscaller() + } + ctx, cancelFunc := context.WithCancel(context.Background()) a := &agent{ tailnetListenPort: options.TailnetListenPort, @@ -144,8 +157,11 @@ func New(options Options) Agent { reportMetadataInterval: options.ReportMetadataInterval, serviceBannerRefreshInterval: options.ServiceBannerRefreshInterval, sshMaxTimeout: options.SSHMaxTimeout, - subsystem: options.Subsystem, + subsystems: options.Subsystems, addresses: options.Addresses, + syscaller: options.Syscaller, + modifiedProcs: options.ModifiedProcesses, + processManagementTick: options.ProcessManagementTick, prometheusRegistry: prometheusRegistry, metrics: newAgentMetrics(prometheusRegistry), @@ -166,7 +182,7 @@ type agent struct { // listing all listening ports. This is helpful to hide ports that // are used by the agent, that the user does not care about. ignorePorts map[int]string - subsystem codersdk.AgentSubsystem + subsystems []codersdk.AgentSubsystem reconnectingPTYs sync.Map reconnectingPTYTimeout time.Duration @@ -180,6 +196,7 @@ type agent struct { manifest atomic.Pointer[agentsdk.Manifest] // manifest is atomic because values can change after reconnection. reportMetadataInterval time.Duration + scriptRunner *agentscripts.Runner serviceBanner atomic.Pointer[codersdk.ServiceBannerConfig] // serviceBanner is atomic because it is periodically updated. serviceBannerRefreshInterval time.Duration sessionToken atomic.Pointer[string] @@ -200,6 +217,12 @@ type agent struct { prometheusRegistry *prometheus.Registry metrics *agentMetrics + syscaller agentproc.Syscaller + + // modifiedProcs is used for testing process priority management. + modifiedProcs chan []*agentproc.Process + // processManagementTick is used for testing process priority management. + processManagementTick <-chan time.Time } func (a *agent) TailnetConn() *tailnet.Conn { @@ -216,7 +239,13 @@ func (a *agent) init(ctx context.Context) { sshSrv.Manifest = &a.manifest sshSrv.ServiceBanner = &a.serviceBanner a.sshServer = sshSrv - + a.scriptRunner = agentscripts.New(agentscripts.Options{ + LogDir: a.logDir, + Logger: a.logger, + SSHServer: sshSrv, + Filesystem: a.filesystem, + PatchLogs: a.client.PatchLogs, + }) go a.runLoop(ctx) } @@ -228,6 +257,7 @@ func (a *agent) runLoop(ctx context.Context) { go a.reportLifecycleLoop(ctx) go a.reportMetadataLoop(ctx) go a.fetchServiceBannerLoop(ctx) + go a.manageProcessPriorityLoop(ctx) for retrier := retry.New(100*time.Millisecond, 10*time.Second); retrier.Wait(ctx); { a.logger.Info(ctx, "connecting to coderd") @@ -332,140 +362,210 @@ func (t *trySingleflight) Do(key string, fn func()) { } func (a *agent) reportMetadataLoop(ctx context.Context) { - const metadataLimit = 128 + tickerDone := make(chan struct{}) + collectDone := make(chan struct{}) + ctx, cancel := context.WithCancel(ctx) + defer func() { + cancel() + <-collectDone + <-tickerDone + }() var ( - baseTicker = time.NewTicker(a.reportMetadataInterval) - lastCollectedAtMu sync.RWMutex - lastCollectedAts = make(map[string]time.Time) - metadataResults = make(chan metadataResultAndKey, metadataLimit) - logger = a.logger.Named("metadata") + logger = a.logger.Named("metadata") + report = make(chan struct{}, 1) + collect = make(chan struct{}, 1) + metadataResults = make(chan metadataResultAndKey, 1) ) - defer baseTicker.Stop() - - // We use a custom singleflight that immediately returns if there is already - // a goroutine running for a given key. This is to prevent a build-up of - // goroutines waiting on Do when the script takes many multiples of - // baseInterval to run. - flight := trySingleflight{m: map[string]struct{}{}} - - postMetadata := func(mr metadataResultAndKey) { - err := a.client.PostMetadata(ctx, mr.key, *mr.result) - if err != nil { - a.logger.Error(ctx, "agent failed to report metadata", slog.Error(err)) - } - } - for { - select { - case <-ctx.Done(): - return - case mr := <-metadataResults: - postMetadata(mr) - continue - case <-baseTicker.C: + // Set up collect and report as a single ticker with two channels, + // this is to allow collection and reporting to be triggered + // independently of each other. + go func() { + t := time.NewTicker(a.reportMetadataInterval) + defer func() { + t.Stop() + close(report) + close(collect) + close(tickerDone) + }() + wake := func(c chan<- struct{}) { + select { + case c <- struct{}{}: + default: + } } + wake(collect) // Start immediately. - if len(metadataResults) > 0 { - // The inner collection loop expects the channel is empty before spinning up - // all the collection goroutines. - logger.Debug(ctx, "metadata collection backpressured", - slog.F("queue_len", len(metadataResults)), - ) - continue + for { + select { + case <-ctx.Done(): + return + case <-t.C: + wake(report) + wake(collect) + } } + }() - manifest := a.manifest.Load() - if manifest == nil { - continue - } + go func() { + defer close(collectDone) + + var ( + // We use a custom singleflight that immediately returns if there is already + // a goroutine running for a given key. This is to prevent a build-up of + // goroutines waiting on Do when the script takes many multiples of + // baseInterval to run. + flight = trySingleflight{m: map[string]struct{}{}} + lastCollectedAtMu sync.RWMutex + lastCollectedAts = make(map[string]time.Time) + ) + for { + select { + case <-ctx.Done(): + return + case <-collect: + } - if len(manifest.Metadata) > metadataLimit { - logger.Error( - ctx, "metadata limit exceeded", - slog.F("limit", metadataLimit), slog.F("got", len(manifest.Metadata)), - ) - continue - } + manifest := a.manifest.Load() + if manifest == nil { + continue + } - // If the manifest changes (e.g. on agent reconnect) we need to - // purge old cache values to prevent lastCollectedAt from growing - // boundlessly. - lastCollectedAtMu.Lock() - for key := range lastCollectedAts { - if slices.IndexFunc(manifest.Metadata, func(md codersdk.WorkspaceAgentMetadataDescription) bool { - return md.Key == key - }) < 0 { - logger.Debug(ctx, "deleting lastCollected key, missing from manifest", - slog.F("key", key), - ) - delete(lastCollectedAts, key) + // If the manifest changes (e.g. on agent reconnect) we need to + // purge old cache values to prevent lastCollectedAt from growing + // boundlessly. + lastCollectedAtMu.Lock() + for key := range lastCollectedAts { + if slices.IndexFunc(manifest.Metadata, func(md codersdk.WorkspaceAgentMetadataDescription) bool { + return md.Key == key + }) < 0 { + logger.Debug(ctx, "deleting lastCollected key, missing from manifest", + slog.F("key", key), + ) + delete(lastCollectedAts, key) + } } - } - lastCollectedAtMu.Unlock() - - // Spawn a goroutine for each metadata collection, and use a - // channel to synchronize the results and avoid both messy - // mutex logic and overloading the API. - for _, md := range manifest.Metadata { - md := md - // We send the result to the channel in the goroutine to avoid - // sending the same result multiple times. So, we don't care about - // the return values. - go flight.Do(md.Key, func() { - ctx := slog.With(ctx, slog.F("key", md.Key)) - lastCollectedAtMu.RLock() - collectedAt, ok := lastCollectedAts[md.Key] - lastCollectedAtMu.RUnlock() - if ok { - // If the interval is zero, we assume the user just wants - // a single collection at startup, not a spinning loop. - if md.Interval == 0 { - return + lastCollectedAtMu.Unlock() + + // Spawn a goroutine for each metadata collection, and use a + // channel to synchronize the results and avoid both messy + // mutex logic and overloading the API. + for _, md := range manifest.Metadata { + md := md + // We send the result to the channel in the goroutine to avoid + // sending the same result multiple times. So, we don't care about + // the return values. + go flight.Do(md.Key, func() { + ctx := slog.With(ctx, slog.F("key", md.Key)) + lastCollectedAtMu.RLock() + collectedAt, ok := lastCollectedAts[md.Key] + lastCollectedAtMu.RUnlock() + if ok { + // If the interval is zero, we assume the user just wants + // a single collection at startup, not a spinning loop. + if md.Interval == 0 { + return + } + intervalUnit := time.Second + // reportMetadataInterval is only less than a second in tests, + // so adjust the interval unit for them. + if a.reportMetadataInterval < time.Second { + intervalUnit = 100 * time.Millisecond + } + // The last collected value isn't quite stale yet, so we skip it. + if collectedAt.Add(time.Duration(md.Interval) * intervalUnit).After(time.Now()) { + return + } } - intervalUnit := time.Second - // reportMetadataInterval is only less than a second in tests, - // so adjust the interval unit for them. - if a.reportMetadataInterval < time.Second { - intervalUnit = 100 * time.Millisecond + + timeout := md.Timeout + if timeout == 0 { + if md.Interval != 0 { + timeout = md.Interval + } else if interval := int64(a.reportMetadataInterval.Seconds()); interval != 0 { + // Fallback to the report interval + timeout = interval * 3 + } else { + // If the interval is still 0 (possible if the interval + // is less than a second), default to 5. This was + // randomly picked. + timeout = 5 + } } - // The last collected value isn't quite stale yet, so we skip it. - if collectedAt.Add(time.Duration(md.Interval) * intervalUnit).After(time.Now()) { - return + ctxTimeout := time.Duration(timeout) * time.Second + ctx, cancel := context.WithTimeout(ctx, ctxTimeout) + defer cancel() + + now := time.Now() + select { + case <-ctx.Done(): + logger.Warn(ctx, "metadata collection timed out", slog.F("timeout", ctxTimeout)) + case metadataResults <- metadataResultAndKey{ + key: md.Key, + result: a.collectMetadata(ctx, md, now), + }: + lastCollectedAtMu.Lock() + lastCollectedAts[md.Key] = now + lastCollectedAtMu.Unlock() } - } + }) + } + } + }() - timeout := md.Timeout - if timeout == 0 { - if md.Interval != 0 { - timeout = md.Interval - } else if interval := int64(a.reportMetadataInterval.Seconds()); interval != 0 { - // Fallback to the report interval - timeout = interval * 3 - } else { - // If the interval is still 0 (possible if the interval - // is less than a second), default to 5. This was - // randomly picked. - timeout = 5 - } - } - ctxTimeout := time.Duration(timeout) * time.Second - ctx, cancel := context.WithTimeout(ctx, ctxTimeout) - defer cancel() + // Gather metadata updates and report them once every interval. If a + // previous report is in flight, wait for it to complete before + // sending a new one. If the network conditions are bad, we won't + // benefit from canceling the previous send and starting a new one. + var ( + updatedMetadata = make(map[string]*codersdk.WorkspaceAgentMetadataResult) + reportTimeout = 30 * time.Second + reportSemaphore = make(chan struct{}, 1) + ) + reportSemaphore <- struct{}{} - now := time.Now() + for { + select { + case <-ctx.Done(): + return + case mr := <-metadataResults: + // This can overwrite unsent values, but that's fine because + // we're only interested about up-to-date values. + updatedMetadata[mr.key] = mr.result + continue + case <-report: + if len(updatedMetadata) > 0 { select { - case <-ctx.Done(): - logger.Warn(ctx, "metadata collection timed out", slog.F("timeout", ctxTimeout)) - case metadataResults <- metadataResultAndKey{ - key: md.Key, - result: a.collectMetadata(ctx, md, now), - }: - lastCollectedAtMu.Lock() - lastCollectedAts[md.Key] = now - lastCollectedAtMu.Unlock() + case <-reportSemaphore: + default: + // If there's already a report in flight, don't send + // another one, wait for next tick instead. + continue } - }) + + metadata := make([]agentsdk.Metadata, 0, len(updatedMetadata)) + for key, result := range updatedMetadata { + metadata = append(metadata, agentsdk.Metadata{ + Key: key, + WorkspaceAgentMetadataResult: *result, + }) + delete(updatedMetadata, key) + } + + go func() { + ctx, cancel := context.WithTimeout(ctx, reportTimeout) + defer func() { + cancel() + reportSemaphore <- struct{}{} + }() + + err := a.client.PostMetadata(ctx, agentsdk.PostMetadataRequest{Metadata: metadata}) + if err != nil { + a.logger.Error(ctx, "agent failed to report metadata", slog.Error(err)) + } + }() + } } } } @@ -526,7 +626,7 @@ func (a *agent) reportLifecycleLoop(ctx context.Context) { func (a *agent) setLifecycle(ctx context.Context, state codersdk.WorkspaceAgentLifecycle) { report := agentsdk.PostLifecycleRequest{ State: state, - ChangedAt: database.Now(), + ChangedAt: dbtime.Now(), } a.lifecycleMu.Lock() @@ -608,7 +708,7 @@ func (a *agent) run(ctx context.Context) error { err = a.client.PostStartup(ctx, agentsdk.PostStartupRequest{ Version: buildinfo.Version(), ExpandedDirectory: manifest.Directory, - Subsystem: a.subsystem, + Subsystems: a.subsystems, }) if err != nil { return xerrors.Errorf("update workspace agent version: %w", err) @@ -634,41 +734,29 @@ func (a *agent) run(ctx context.Context) error { } } - lifecycleState := codersdk.WorkspaceAgentLifecycleReady - scriptDone := make(chan error, 1) - err = a.trackConnGoroutine(func() { - defer close(scriptDone) - scriptDone <- a.runStartupScript(ctx, manifest.StartupScript) - }) + err = a.scriptRunner.Init(manifest.Scripts) if err != nil { - return xerrors.Errorf("track startup script: %w", err) + return xerrors.Errorf("init script runner: %w", err) } - go func() { - var timeout <-chan time.Time - // If timeout is zero, an older version of the coder - // provider was used. Otherwise a timeout is always > 0. - if manifest.StartupScriptTimeout > 0 { - t := time.NewTimer(manifest.StartupScriptTimeout) - defer t.Stop() - timeout = t.C - } - - var err error - select { - case err = <-scriptDone: - case <-timeout: - a.logger.Warn(ctx, "script timed out", slog.F("lifecycle", "startup"), slog.F("timeout", manifest.ShutdownScriptTimeout)) - a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleStartTimeout) - err = <-scriptDone // The script can still complete after a timeout. - } + err = a.trackConnGoroutine(func() { + err := a.scriptRunner.Execute(ctx, func(script codersdk.WorkspaceAgentScript) bool { + return script.RunOnStart + }) if err != nil { - if errors.Is(err, context.Canceled) { - return + a.logger.Warn(ctx, "startup script(s) failed", slog.Error(err)) + if errors.Is(err, agentscripts.ErrTimeout) { + a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleStartTimeout) + } else { + a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleStartError) } - lifecycleState = codersdk.WorkspaceAgentLifecycleStartError + } else { + a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleReady) } - a.setLifecycle(ctx, lifecycleState) - }() + a.scriptRunner.StartCron() + }) + if err != nil { + return xerrors.Errorf("track conn goroutine: %w", err) + } } // This automatically closes when the context ends! @@ -681,7 +769,7 @@ func (a *agent) run(ctx context.Context) error { network := a.network a.closeMutex.Unlock() if network == nil { - network, err = a.createTailnet(ctx, manifest.AgentID, manifest.DERPMap, manifest.DisableDirectConnections) + network, err = a.createTailnet(ctx, manifest.AgentID, manifest.DERPMap, manifest.DERPForceWebSockets, manifest.DisableDirectConnections) if err != nil { return xerrors.Errorf("create tailnet: %w", err) } @@ -704,8 +792,10 @@ func (a *agent) run(ctx context.Context) error { if err != nil { a.logger.Error(ctx, "update tailnet addresses", slog.Error(err)) } - // Update the DERP map and allow/disallow direct connections. + // Update the DERP map, force WebSocket setting and allow/disallow + // direct connections. network.SetDERPMap(manifest.DERPMap) + network.SetDERPForceWebSockets(manifest.DERPForceWebSockets) network.SetBlockEndpoints(manifest.DisableDirectConnections) } @@ -759,13 +849,15 @@ func (a *agent) trackConnGoroutine(fn func()) error { return nil } -func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *tailcfg.DERPMap, disableDirectConnections bool) (_ *tailnet.Conn, err error) { +func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *tailcfg.DERPMap, derpForceWebSockets, disableDirectConnections bool) (_ *tailnet.Conn, err error) { network, err := tailnet.NewConn(&tailnet.Options{ - Addresses: a.wireguardAddresses(agentID), - DERPMap: derpMap, - Logger: a.logger.Named("net.tailnet"), - ListenPort: a.tailnetListenPort, - BlockEndpoints: disableDirectConnections, + ID: agentID, + Addresses: a.wireguardAddresses(agentID), + DERPMap: derpMap, + DERPForceWebSockets: derpForceWebSockets, + Logger: a.logger.Named("net.tailnet"), + ListenPort: a.tailnetListenPort, + BlockEndpoints: disableDirectConnections, }) if err != nil { return nil, xerrors.Errorf("create tailnet: %w", err) @@ -811,7 +903,10 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t } break } - logger.Debug(ctx, "accepted conn", slog.F("remote", conn.RemoteAddr().String())) + clog := logger.With( + slog.F("remote", conn.RemoteAddr().String()), + slog.F("local", conn.LocalAddr().String())) + clog.Info(ctx, "accepted conn") wg.Add(1) closed := make(chan struct{}) go func() { @@ -843,7 +938,7 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t logger.Warn(ctx, "failed to unmarshal init", slog.F("raw", data)) return } - _ = a.handleReconnectingPTY(ctx, logger, msg, conn) + _ = a.handleReconnectingPTY(ctx, clog, msg, conn) }() } wg.Wait() @@ -870,6 +965,10 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t } break } + clog := a.logger.Named("speedtest").With( + slog.F("remote", conn.RemoteAddr().String()), + slog.F("local", conn.LocalAddr().String())) + clog.Info(ctx, "accepted conn") wg.Add(1) closed := make(chan struct{}) go func() { @@ -882,7 +981,12 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t }() go func() { defer close(closed) - _ = speedtest.ServeConn(conn) + sErr := speedtest.ServeConn(conn) + if sErr != nil { + clog.Error(ctx, "test ended with error", slog.Error(sErr)) + return + } + clog.Info(ctx, "test ended") }() } wg.Wait() @@ -979,93 +1083,6 @@ func (a *agent) runDERPMapSubscriber(ctx context.Context, network *tailnet.Conn) } } -func (a *agent) runStartupScript(ctx context.Context, script string) error { - return a.runScript(ctx, "startup", script) -} - -func (a *agent) runShutdownScript(ctx context.Context, script string) error { - return a.runScript(ctx, "shutdown", script) -} - -func (a *agent) runScript(ctx context.Context, lifecycle, script string) (err error) { - if script == "" { - return nil - } - - logger := a.logger.With(slog.F("lifecycle", lifecycle)) - - logger.Info(ctx, fmt.Sprintf("running %s script", lifecycle), slog.F("script", script)) - fileWriter, err := a.filesystem.OpenFile(filepath.Join(a.logDir, fmt.Sprintf("coder-%s-script.log", lifecycle)), os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return xerrors.Errorf("open %s script log file: %w", lifecycle, err) - } - defer func() { - err := fileWriter.Close() - if err != nil { - logger.Warn(ctx, fmt.Sprintf("close %s script log file", lifecycle), slog.Error(err)) - } - }() - - cmdPty, err := a.sshServer.CreateCommand(ctx, script, nil) - if err != nil { - return xerrors.Errorf("%s script: create command: %w", lifecycle, err) - } - cmd := cmdPty.AsExec() - - var stdout, stderr io.Writer = fileWriter, fileWriter - if lifecycle == "startup" { - send, flushAndClose := agentsdk.LogsSender(a.client.PatchLogs, logger) - // If ctx is canceled here (or in a writer below), we may be - // discarding logs, but that's okay because we're shutting down - // anyway. We could consider creating a new context here if we - // want better control over flush during shutdown. - defer func() { - if err := flushAndClose(ctx); err != nil { - logger.Warn(ctx, "flush startup logs failed", slog.Error(err)) - } - }() - - infoW := agentsdk.StartupLogsWriter(ctx, send, codersdk.WorkspaceAgentLogSourceStartupScript, codersdk.LogLevelInfo) - defer infoW.Close() - errW := agentsdk.StartupLogsWriter(ctx, send, codersdk.WorkspaceAgentLogSourceStartupScript, codersdk.LogLevelError) - defer errW.Close() - - stdout = io.MultiWriter(fileWriter, infoW) - stderr = io.MultiWriter(fileWriter, errW) - } - - cmd.Stdout = stdout - cmd.Stderr = stderr - - start := time.Now() - defer func() { - end := time.Now() - execTime := end.Sub(start) - exitCode := 0 - if err != nil { - exitCode = 255 // Unknown status. - var exitError *exec.ExitError - if xerrors.As(err, &exitError) { - exitCode = exitError.ExitCode() - } - logger.Warn(ctx, fmt.Sprintf("%s script failed", lifecycle), slog.F("execution_time", execTime), slog.F("exit_code", exitCode), slog.Error(err)) - } else { - logger.Info(ctx, fmt.Sprintf("%s script completed", lifecycle), slog.F("execution_time", execTime), slog.F("exit_code", exitCode)) - } - }() - - err = cmd.Run() - if err != nil { - // cmd.Run does not return a context canceled error, it returns "signal: killed". - if ctx.Err() != nil { - return ctx.Err() - } - - return xerrors.Errorf("%s script: run: %w", lifecycle, err) - } - return nil -} - func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, msg codersdk.WorkspaceAgentReconnectingPTYInit, conn net.Conn) (retErr error) { defer conn.Close() a.metrics.connectionsTotal.Add(1) @@ -1074,8 +1091,8 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, m defer a.connCountReconnectingPTY.Add(-1) connectionID := uuid.NewString() - logger = logger.With(slog.F("message_id", msg.ID), slog.F("connection_id", connectionID)) - logger.Debug(ctx, "starting handler") + connLogger := logger.With(slog.F("message_id", msg.ID), slog.F("connection_id", connectionID)) + connLogger.Debug(ctx, "starting handler") defer func() { if err := retErr; err != nil { @@ -1086,22 +1103,22 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, m // If the agent is closed, we don't want to // log this as an error since it's expected. if closed { - logger.Debug(ctx, "reconnecting PTY failed with session error (agent closed)", slog.Error(err)) + connLogger.Info(ctx, "reconnecting pty failed with attach error (agent closed)", slog.Error(err)) } else { - logger.Error(ctx, "reconnecting PTY failed with session error", slog.Error(err)) + connLogger.Error(ctx, "reconnecting pty failed with attach error", slog.Error(err)) } } - logger.Debug(ctx, "session closed") + connLogger.Info(ctx, "reconnecting pty connection closed") }() - var rpty *reconnectingPTY - sendConnected := make(chan *reconnectingPTY, 1) + var rpty reconnectingpty.ReconnectingPTY + sendConnected := make(chan reconnectingpty.ReconnectingPTY, 1) // On store, reserve this ID to prevent multiple concurrent new connections. waitReady, ok := a.reconnectingPTYs.LoadOrStore(msg.ID, sendConnected) if ok { close(sendConnected) // Unused. - logger.Debug(ctx, "connecting to existing session") - c, ok := waitReady.(chan *reconnectingPTY) + connLogger.Debug(ctx, "connecting to existing reconnecting pty") + c, ok := waitReady.(chan reconnectingpty.ReconnectingPTY) if !ok { return xerrors.Errorf("found invalid type in reconnecting pty map: %T", waitReady) } @@ -1111,7 +1128,7 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, m } c <- rpty // Put it back for the next reconnect. } else { - logger.Debug(ctx, "creating new session") + connLogger.Debug(ctx, "creating new reconnecting pty") connected := false defer func() { @@ -1127,169 +1144,24 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, m a.metrics.reconnectingPTYErrors.WithLabelValues("create_command").Add(1) return xerrors.Errorf("create command: %w", err) } - cmd.Env = append(cmd.Env, "TERM=xterm-256color") - - // Default to buffer 64KiB. - circularBuffer, err := circbuf.NewBuffer(64 << 10) - if err != nil { - return xerrors.Errorf("create circular buffer: %w", err) - } - ptty, process, err := pty.Start(cmd) - if err != nil { - a.metrics.reconnectingPTYErrors.WithLabelValues("start_command").Add(1) - return xerrors.Errorf("start command: %w", err) - } + rpty = reconnectingpty.New(ctx, cmd, &reconnectingpty.Options{ + Timeout: a.reconnectingPTYTimeout, + Metrics: a.metrics.reconnectingPTYErrors, + }, logger.With(slog.F("message_id", msg.ID))) - ctx, cancel := context.WithCancel(ctx) - rpty = &reconnectingPTY{ - activeConns: map[string]net.Conn{ - // We have to put the connection in the map instantly otherwise - // the connection won't be closed if the process instantly dies. - connectionID: conn, - }, - ptty: ptty, - // Timeouts created with an after func can be reset! - timeout: time.AfterFunc(a.reconnectingPTYTimeout, cancel), - circularBuffer: circularBuffer, - } - // We don't need to separately monitor for the process exiting. - // When it exits, our ptty.OutputReader() will return EOF after - // reading all process output. if err = a.trackConnGoroutine(func() { - buffer := make([]byte, 1024) - for { - read, err := rpty.ptty.OutputReader().Read(buffer) - if err != nil { - // When the PTY is closed, this is triggered. - // Error is typically a benign EOF, so only log for debugging. - if errors.Is(err, io.EOF) { - logger.Debug(ctx, "unable to read pty output, command might have exited", slog.Error(err)) - } else { - logger.Warn(ctx, "unable to read pty output, command might have exited", slog.Error(err)) - a.metrics.reconnectingPTYErrors.WithLabelValues("output_reader").Add(1) - } - break - } - part := buffer[:read] - rpty.circularBufferMutex.Lock() - _, err = rpty.circularBuffer.Write(part) - rpty.circularBufferMutex.Unlock() - if err != nil { - logger.Error(ctx, "write to circular buffer", slog.Error(err)) - break - } - rpty.activeConnsMutex.Lock() - for cid, conn := range rpty.activeConns { - _, err = conn.Write(part) - if err != nil { - logger.Warn(ctx, - "error writing to active conn", - slog.F("other_conn_id", cid), - slog.Error(err), - ) - a.metrics.reconnectingPTYErrors.WithLabelValues("write").Add(1) - } - } - rpty.activeConnsMutex.Unlock() - } - - // Cleanup the process, PTY, and delete it's - // ID from memory. - _ = process.Kill() - rpty.Close() + rpty.Wait() a.reconnectingPTYs.Delete(msg.ID) }); err != nil { - _ = process.Kill() - _ = ptty.Close() + rpty.Close(err) return xerrors.Errorf("start routine: %w", err) } + connected = true sendConnected <- rpty } - // Resize the PTY to initial height + width. - err := rpty.ptty.Resize(msg.Height, msg.Width) - if err != nil { - // We can continue after this, it's not fatal! - logger.Error(ctx, "reconnecting PTY initial resize failed, but will continue", slog.Error(err)) - a.metrics.reconnectingPTYErrors.WithLabelValues("resize").Add(1) - } - // Write any previously stored data for the TTY. - rpty.circularBufferMutex.RLock() - prevBuf := slices.Clone(rpty.circularBuffer.Bytes()) - rpty.circularBufferMutex.RUnlock() - // Note that there is a small race here between writing buffered - // data and storing conn in activeConns. This is likely a very minor - // edge case, but we should look into ways to avoid it. Holding - // activeConnsMutex would be one option, but holding this mutex - // while also holding circularBufferMutex seems dangerous. - _, err = conn.Write(prevBuf) - if err != nil { - a.metrics.reconnectingPTYErrors.WithLabelValues("write").Add(1) - return xerrors.Errorf("write buffer to conn: %w", err) - } - // Multiple connections to the same TTY are permitted. - // This could easily be used for terminal sharing, but - // we do it because it's a nice user experience to - // copy/paste a terminal URL and have it _just work_. - rpty.activeConnsMutex.Lock() - rpty.activeConns[connectionID] = conn - rpty.activeConnsMutex.Unlock() - // Resetting this timeout prevents the PTY from exiting. - rpty.timeout.Reset(a.reconnectingPTYTimeout) - - ctx, cancelFunc := context.WithCancel(ctx) - defer cancelFunc() - heartbeat := time.NewTicker(a.reconnectingPTYTimeout / 2) - defer heartbeat.Stop() - go func() { - // Keep updating the activity while this - // connection is alive! - for { - select { - case <-ctx.Done(): - return - case <-heartbeat.C: - } - rpty.timeout.Reset(a.reconnectingPTYTimeout) - } - }() - defer func() { - // After this connection ends, remove it from - // the PTYs active connections. If it isn't - // removed, all PTY data will be sent to it. - rpty.activeConnsMutex.Lock() - delete(rpty.activeConns, connectionID) - rpty.activeConnsMutex.Unlock() - }() - decoder := json.NewDecoder(conn) - var req codersdk.ReconnectingPTYRequest - for { - err = decoder.Decode(&req) - if xerrors.Is(err, io.EOF) { - return nil - } - if err != nil { - logger.Warn(ctx, "reconnecting PTY failed with read error", slog.Error(err)) - return nil - } - _, err = rpty.ptty.InputWriter().Write([]byte(req.Data)) - if err != nil { - logger.Warn(ctx, "reconnecting PTY failed with write error", slog.Error(err)) - a.metrics.reconnectingPTYErrors.WithLabelValues("input_writer").Add(1) - return nil - } - // Check if a resize needs to happen! - if req.Height == 0 || req.Width == 0 { - continue - } - err = rpty.ptty.Resize(req.Height, req.Width) - if err != nil { - // We can continue after this, it's not fatal! - logger.Error(ctx, "reconnecting PTY resize failed, but will continue", slog.Error(err)) - a.metrics.reconnectingPTYErrors.WithLabelValues("resize").Add(1) - } - } + return rpty.Attach(ctx, connectionID, conn, msg.Height, msg.Width, connLogger) } // startReportingConnectionStats runs the connection stats reporting goroutine. @@ -1397,6 +1269,115 @@ func (a *agent) startReportingConnectionStats(ctx context.Context) { } } +var prioritizedProcs = []string{"coder agent"} + +func (a *agent) manageProcessPriorityLoop(ctx context.Context) { + defer func() { + if r := recover(); r != nil { + a.logger.Critical(ctx, "recovered from panic", + slog.F("panic", r), + slog.F("stack", string(debug.Stack())), + ) + } + }() + + if val := a.envVars[EnvProcPrioMgmt]; val == "" || runtime.GOOS != "linux" { + a.logger.Debug(ctx, "process priority not enabled, agent will not manage process niceness/oom_score_adj ", + slog.F("env_var", EnvProcPrioMgmt), + slog.F("value", val), + slog.F("goos", runtime.GOOS), + ) + return + } + + if a.processManagementTick == nil { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + a.processManagementTick = ticker.C + } + + for { + procs, err := a.manageProcessPriority(ctx) + if err != nil { + a.logger.Error(ctx, "manage process priority", + slog.Error(err), + ) + } + if a.modifiedProcs != nil { + a.modifiedProcs <- procs + } + + select { + case <-a.processManagementTick: + case <-ctx.Done(): + return + } + } +} + +func (a *agent) manageProcessPriority(ctx context.Context) ([]*agentproc.Process, error) { + const ( + niceness = 10 + ) + + procs, err := agentproc.List(a.filesystem, a.syscaller) + if err != nil { + return nil, xerrors.Errorf("list: %w", err) + } + + var ( + modProcs = []*agentproc.Process{} + logger slog.Logger + ) + + for _, proc := range procs { + logger = a.logger.With( + slog.F("cmd", proc.Cmd()), + slog.F("pid", proc.PID), + ) + + containsFn := func(e string) bool { + contains := strings.Contains(proc.Cmd(), e) + return contains + } + + // If the process is prioritized we should adjust + // it's oom_score_adj and avoid lowering its niceness. + if slices.ContainsFunc[[]string, string](prioritizedProcs, containsFn) { + continue + } + + score, err := proc.Niceness(a.syscaller) + if err != nil { + logger.Warn(ctx, "unable to get proc niceness", + slog.Error(err), + ) + continue + } + + // We only want processes that don't have a nice value set + // so we don't override user nice values. + // Getpriority actually returns priority for the nice value + // which is niceness + 20, so here 20 = a niceness of 0 (aka unset). + if score != 20 { + // We don't log here since it can get spammy + continue + } + + err = proc.SetNiceness(a.syscaller, niceness) + if err != nil { + logger.Warn(ctx, "unable to set proc niceness", + slog.F("niceness", niceness), + slog.Error(err), + ) + continue + } + + modProcs = append(modProcs, proc) + } + return modProcs, nil +} + // isClosed returns whether the API is closed or not. func (a *agent) isClosed() bool { select { @@ -1408,24 +1389,57 @@ func (a *agent) isClosed() bool { } func (a *agent) HTTPDebug() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r := chi.NewRouter() + + requireNetwork := func(w http.ResponseWriter) (*tailnet.Conn, bool) { a.closeMutex.Lock() network := a.network a.closeMutex.Unlock() if network == nil { - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte("network is not ready yet")) + return nil, false + } + + return network, true + } + + r.Get("/debug/magicsock", func(w http.ResponseWriter, r *http.Request) { + network, ok := requireNetwork(w) + if !ok { return } + network.MagicsockServeHTTPDebug(w, r) + }) - if r.URL.Path == "/debug/magicsock" { - network.MagicsockServeHTTPDebug(w, r) - } else { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte("404 not found")) + r.Get("/debug/magicsock/debug-logging/{state}", func(w http.ResponseWriter, r *http.Request) { + state := chi.URLParam(r, "state") + stateBool, err := strconv.ParseBool(state) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(w, "invalid state %q, must be a boolean", state) + return + } + + network, ok := requireNetwork(w) + if !ok { + return } + + network.MagicsockSetDebugLoggingEnabled(stateBool) + a.logger.Info(r.Context(), "updated magicsock debug logging due to debug request", slog.F("new_state", stateBool)) + + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, "updated magicsock debug logging to %v", stateBool) + }) + + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("404 not found")) }) + + return r } func (a *agent) Close() error { @@ -1447,39 +1461,24 @@ func (a *agent) Close() error { } lifecycleState := codersdk.WorkspaceAgentLifecycleOff - if manifest := a.manifest.Load(); manifest != nil && manifest.ShutdownScript != "" { - scriptDone := make(chan error, 1) - go func() { - defer close(scriptDone) - scriptDone <- a.runShutdownScript(ctx, manifest.ShutdownScript) - }() - - var timeout <-chan time.Time - // If timeout is zero, an older version of the coder - // provider was used. Otherwise a timeout is always > 0. - if manifest.ShutdownScriptTimeout > 0 { - t := time.NewTimer(manifest.ShutdownScriptTimeout) - defer t.Stop() - timeout = t.C - } - - var err error - select { - case err = <-scriptDone: - case <-timeout: - a.logger.Warn(ctx, "script timed out", slog.F("lifecycle", "shutdown"), slog.F("timeout", manifest.ShutdownScriptTimeout)) - a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleShutdownTimeout) - err = <-scriptDone // The script can still complete after a timeout. - } - if err != nil { + err = a.scriptRunner.Execute(ctx, func(script codersdk.WorkspaceAgentScript) bool { + return script.RunOnStop + }) + if err != nil { + a.logger.Warn(ctx, "shutdown script(s) failed", slog.Error(err)) + if errors.Is(err, agentscripts.ErrTimeout) { + lifecycleState = codersdk.WorkspaceAgentLifecycleShutdownTimeout + } else { lifecycleState = codersdk.WorkspaceAgentLifecycleShutdownError } } - - // Set final state and wait for it to be reported because context - // cancellation will stop the report loop. a.setLifecycle(ctx, lifecycleState) + err = a.scriptRunner.Close() + if err != nil { + a.logger.Error(ctx, "script runner close", slog.Error(err)) + } + // Wait for the lifecycle to be reported, but don't wait forever so // that we don't break user expectations. ctx, cancel := context.WithTimeout(ctx, 5*time.Second) @@ -1507,31 +1506,6 @@ lifecycleWaitLoop: return nil } -type reconnectingPTY struct { - activeConnsMutex sync.Mutex - activeConns map[string]net.Conn - - circularBuffer *circbuf.Buffer - circularBufferMutex sync.RWMutex - timeout *time.Timer - ptty pty.PTYCmd -} - -// Close ends all connections to the reconnecting -// PTY and clear the circular buffer. -func (r *reconnectingPTY) Close() { - r.activeConnsMutex.Lock() - defer r.activeConnsMutex.Unlock() - for _, conn := range r.activeConns { - _ = conn.Close() - } - _ = r.ptty.Close() - r.circularBufferMutex.Lock() - r.circularBuffer.Reset() - r.circularBufferMutex.Unlock() - r.timeout.Stop() -} - // userHomeDir returns the home directory of the current user, giving // priority to the $HOME environment variable. func userHomeDir() (string, error) { diff --git a/agent/agent_test.go b/agent/agent_test.go index 34637992536b7..b54f877fcdab9 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -1,7 +1,6 @@ package agent_test import ( - "bufio" "bytes" "context" "encoding/json" @@ -12,6 +11,7 @@ import ( "net/http/httptest" "net/netip" "os" + "os/exec" "os/user" "path" "path/filepath" @@ -21,10 +21,12 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "testing" "time" scp "github.com/bramvdbogaerde/go-scp" + "github.com/golang/mock/gomock" "github.com/google/uuid" "github.com/pion/udp" "github.com/pkg/sftp" @@ -41,18 +43,20 @@ import ( "tailscale.com/tailcfg" "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "github.com/coder/coder/agent/agentssh" - "github.com/coder/coder/agent/agenttest" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/pty" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/tailnet" - "github.com/coder/coder/tailnet/tailnettest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agentproc" + "github.com/coder/coder/v2/agent/agentproc/agentproctest" + "github.com/coder/coder/v2/agent/agentssh" + "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/pty" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/tailnettest" + "github.com/coder/coder/v2/testutil" ) func TestMain(m *testing.M) { @@ -102,7 +106,7 @@ func TestAgent_Stats_ReconnectingPTY(t *testing.T) { //nolint:dogsled conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) - ptyConn, err := conn.ReconnectingPTY(ctx, uuid.New(), 128, 128, "/bin/bash") + ptyConn, err := conn.ReconnectingPTY(ctx, uuid.New(), 128, 128, "bash") require.NoError(t, err) defer ptyConn.Close() @@ -346,13 +350,18 @@ func TestAgent_Session_TTY_MOTD(t *testing.T) { unexpected: []string{}, }, { - name: "Trim", - manifest: agentsdk.Manifest{}, + name: "Trim", + // Enable motd since it will be printed after the banner, + // this ensures that we can test for an exact mount of + // newlines. + manifest: agentsdk.Manifest{ + MOTDFile: name, + }, banner: codersdk.ServiceBannerConfig{ Enabled: true, Message: "\n\n\n\n\n\nbanner\n\n\n\n\n\n", }, - expectedRe: regexp.MustCompile("([^\n\r]|^)banner\r\n\r\n[^\r\n]"), + expectedRe: regexp.MustCompile(`([^\n\r]|^)banner\r\n\r\n[^\r\n]`), }, } @@ -371,6 +380,7 @@ func TestAgent_Session_TTY_MOTD(t *testing.T) { } } +//nolint:tparallel // Sub tests need to run sequentially. func TestAgent_Session_TTY_MOTD_Update(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { @@ -430,33 +440,38 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) { } //nolint:dogsled // Allow the blank identifiers. conn, client, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, setSBInterval) - for _, test := range tests { + + sshClient, err := conn.SSHClient(ctx) + require.NoError(t, err) + t.Cleanup(func() { + _ = sshClient.Close() + }) + + //nolint:paralleltest // These tests need to swap the banner func. + for i, test := range tests { test := test - // Set new banner func and wait for the agent to call it to update the - // banner. - ready := make(chan struct{}, 2) - client.SetServiceBannerFunc(func() (codersdk.ServiceBannerConfig, error) { - select { - case ready <- struct{}{}: - default: - } - return test.banner, nil - }) - <-ready - <-ready // Wait for two updates to ensure the value has propagated. + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + // Set new banner func and wait for the agent to call it to update the + // banner. + ready := make(chan struct{}, 2) + client.SetServiceBannerFunc(func() (codersdk.ServiceBannerConfig, error) { + select { + case ready <- struct{}{}: + default: + } + return test.banner, nil + }) + <-ready + <-ready // Wait for two updates to ensure the value has propagated. - sshClient, err := conn.SSHClient(ctx) - require.NoError(t, err) - t.Cleanup(func() { - _ = sshClient.Close() - }) - session, err := sshClient.NewSession() - require.NoError(t, err) - t.Cleanup(func() { - _ = session.Close() - }) + session, err := sshClient.NewSession() + require.NoError(t, err) + t.Cleanup(func() { + _ = session.Close() + }) - testSessionOutput(t, session, test.expected, test.unexpected, nil) + testSessionOutput(t, session, test.expected, test.unexpected, nil) + }) } } @@ -1055,84 +1070,6 @@ func TestAgent_SSHConnectionEnvVars(t *testing.T) { } } -func TestAgent_StartupScript(t *testing.T) { - t.Parallel() - output := "something" - command := "sh -c 'echo " + output + "'" - if runtime.GOOS == "windows" { - command = "cmd.exe /c echo " + output - } - t.Run("Success", func(t *testing.T) { - t.Parallel() - logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - client := agenttest.NewClient(t, - logger, - uuid.New(), - agentsdk.Manifest{ - StartupScript: command, - DERPMap: &tailcfg.DERPMap{}, - }, - make(chan *agentsdk.Stats), - tailnet.NewCoordinator(logger), - ) - closer := agent.New(agent.Options{ - Client: client, - Filesystem: afero.NewMemMapFs(), - Logger: logger.Named("agent"), - ReconnectingPTYTimeout: 0, - }) - t.Cleanup(func() { - _ = closer.Close() - }) - assert.Eventually(t, func() bool { - got := client.GetLifecycleStates() - return len(got) > 0 && got[len(got)-1] == codersdk.WorkspaceAgentLifecycleReady - }, testutil.WaitShort, testutil.IntervalMedium) - - require.Len(t, client.GetStartupLogs(), 1) - require.Equal(t, output, client.GetStartupLogs()[0].Output) - }) - // This ensures that even when coderd sends back that the startup - // script has written too many lines it will still succeed! - t.Run("OverflowsAndSkips", func(t *testing.T) { - t.Parallel() - logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - client := agenttest.NewClient(t, - logger, - uuid.New(), - agentsdk.Manifest{ - StartupScript: command, - DERPMap: &tailcfg.DERPMap{}, - }, - make(chan *agentsdk.Stats, 50), - tailnet.NewCoordinator(logger), - ) - client.PatchWorkspaceLogs = func() error { - resp := httptest.NewRecorder() - httpapi.Write(context.Background(), resp, http.StatusRequestEntityTooLarge, codersdk.Response{ - Message: "Too many lines!", - }) - res := resp.Result() - defer res.Body.Close() - return codersdk.ReadBodyAsError(res) - } - closer := agent.New(agent.Options{ - Client: client, - Filesystem: afero.NewMemMapFs(), - Logger: logger.Named("agent"), - ReconnectingPTYTimeout: 0, - }) - t.Cleanup(func() { - _ = closer.Close() - }) - assert.Eventually(t, func() bool { - got := client.GetLifecycleStates() - return len(got) > 0 && got[len(got)-1] == codersdk.WorkspaceAgentLifecycleReady - }, testutil.WaitShort, testutil.IntervalMedium) - require.Len(t, client.GetStartupLogs(), 0) - }) -} - func TestAgent_Metadata(t *testing.T) { t.Parallel() @@ -1140,34 +1077,43 @@ func TestAgent_Metadata(t *testing.T) { t.Run("Once", func(t *testing.T) { t.Parallel() + //nolint:dogsled _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ Metadata: []codersdk.WorkspaceAgentMetadataDescription{ { - Key: "greeting", + Key: "greeting1", Interval: 0, Script: echoHello, }, + { + Key: "greeting2", + Interval: 1, + Script: echoHello, + }, }, }, 0, func(_ *agenttest.Client, opts *agent.Options) { - opts.ReportMetadataInterval = 100 * time.Millisecond + opts.ReportMetadataInterval = testutil.IntervalFast }) - var gotMd map[string]agentsdk.PostMetadataRequest + var gotMd map[string]agentsdk.Metadata require.Eventually(t, func() bool { gotMd = client.GetMetadata() - return len(gotMd) == 1 - }, testutil.WaitShort, testutil.IntervalMedium) + return len(gotMd) == 2 + }, testutil.WaitShort, testutil.IntervalFast/2) - collectedAt := gotMd["greeting"].CollectedAt + collectedAt1 := gotMd["greeting1"].CollectedAt + collectedAt2 := gotMd["greeting2"].CollectedAt - require.Never(t, func() bool { + require.Eventually(t, func() bool { gotMd = client.GetMetadata() - if len(gotMd) != 1 { + if len(gotMd) != 2 { panic("unexpected number of metadata") } - return !gotMd["greeting"].CollectedAt.Equal(collectedAt) - }, testutil.WaitShort, testutil.IntervalMedium) + return !gotMd["greeting2"].CollectedAt.Equal(collectedAt2) + }, testutil.WaitShort, testutil.IntervalFast/2) + + require.Equal(t, gotMd["greeting1"].CollectedAt, collectedAt1, "metadata should not be collected again") }) t.Run("Many", func(t *testing.T) { @@ -1186,7 +1132,7 @@ func TestAgent_Metadata(t *testing.T) { opts.ReportMetadataInterval = testutil.IntervalFast }) - var gotMd map[string]agentsdk.PostMetadataRequest + var gotMd map[string]agentsdk.Metadata require.Eventually(t, func() bool { gotMd = client.GetMetadata() return len(gotMd) == 1 @@ -1287,8 +1233,11 @@ func TestAgent_Lifecycle(t *testing.T) { t.Parallel() _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ - StartupScript: "sleep 3", - StartupScriptTimeout: time.Nanosecond, + Scripts: []codersdk.WorkspaceAgentScript{{ + Script: "sleep 3", + Timeout: time.Millisecond, + RunOnStart: true, + }}, }, 0) want := []codersdk.WorkspaceAgentLifecycle{ @@ -1309,8 +1258,11 @@ func TestAgent_Lifecycle(t *testing.T) { t.Parallel() _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ - StartupScript: "false", - StartupScriptTimeout: 30 * time.Second, + Scripts: []codersdk.WorkspaceAgentScript{{ + Script: "false", + Timeout: 30 * time.Second, + RunOnStart: true, + }}, }, 0) want := []codersdk.WorkspaceAgentLifecycle{ @@ -1331,8 +1283,11 @@ func TestAgent_Lifecycle(t *testing.T) { t.Parallel() _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ - StartupScript: "true", - StartupScriptTimeout: 30 * time.Second, + Scripts: []codersdk.WorkspaceAgentScript{{ + Script: "true", + Timeout: 30 * time.Second, + RunOnStart: true, + }}, }, 0) want := []codersdk.WorkspaceAgentLifecycle{ @@ -1353,8 +1308,11 @@ func TestAgent_Lifecycle(t *testing.T) { t.Parallel() _, client, _, _, closer := setupAgent(t, agentsdk.Manifest{ - ShutdownScript: "sleep 3", - StartupScriptTimeout: 30 * time.Second, + Scripts: []codersdk.WorkspaceAgentScript{{ + Script: "sleep 3", + Timeout: 30 * time.Second, + RunOnStop: true, + }}, }, 0) assert.Eventually(t, func() bool { @@ -1391,8 +1349,11 @@ func TestAgent_Lifecycle(t *testing.T) { t.Parallel() _, client, _, _, closer := setupAgent(t, agentsdk.Manifest{ - ShutdownScript: "sleep 3", - ShutdownScriptTimeout: time.Nanosecond, + Scripts: []codersdk.WorkspaceAgentScript{{ + Script: "sleep 3", + Timeout: time.Millisecond, + RunOnStop: true, + }}, }, 0) assert.Eventually(t, func() bool { @@ -1430,8 +1391,11 @@ func TestAgent_Lifecycle(t *testing.T) { t.Parallel() _, client, _, _, closer := setupAgent(t, agentsdk.Manifest{ - ShutdownScript: "false", - ShutdownScriptTimeout: 30 * time.Second, + Scripts: []codersdk.WorkspaceAgentScript{{ + Script: "false", + Timeout: 30 * time.Second, + RunOnStop: true, + }}, }, 0) assert.Eventually(t, func() bool { @@ -1475,9 +1439,16 @@ func TestAgent_Lifecycle(t *testing.T) { logger, uuid.New(), agentsdk.Manifest{ - DERPMap: derpMap, - StartupScript: "echo 1", - ShutdownScript: "echo " + expected, + DERPMap: derpMap, + Scripts: []codersdk.WorkspaceAgentScript{{ + LogPath: "coder-startup-script.log", + Script: "echo 1", + RunOnStart: true, + }, { + LogPath: "coder-shutdown-script.log", + Script: "echo " + expected, + RunOnStop: true, + }}, }, make(chan *agentsdk.Stats, 50), tailnet.NewCoordinator(logger), @@ -1528,9 +1499,7 @@ func TestAgent_Startup(t *testing.T) { t.Parallel() _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ - StartupScript: "true", - StartupScriptTimeout: 30 * time.Second, - Directory: "", + Directory: "", }, 0) assert.Eventually(t, func() bool { return client.GetStartup().Version != "" @@ -1542,9 +1511,7 @@ func TestAgent_Startup(t *testing.T) { t.Parallel() _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ - StartupScript: "true", - StartupScriptTimeout: 30 * time.Second, - Directory: "~", + Directory: "~", }, 0) assert.Eventually(t, func() bool { return client.GetStartup().Version != "" @@ -1558,9 +1525,7 @@ func TestAgent_Startup(t *testing.T) { t.Parallel() _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ - StartupScript: "true", - StartupScriptTimeout: 30 * time.Second, - Directory: "coder/coder", + Directory: "coder/coder", }, 0) assert.Eventually(t, func() bool { return client.GetStartup().Version != "" @@ -1574,9 +1539,7 @@ func TestAgent_Startup(t *testing.T) { t.Parallel() _, client, _, _, _ := setupAgent(t, agentsdk.Manifest{ - StartupScript: "true", - StartupScriptTimeout: 30 * time.Second, - Directory: "$HOME", + Directory: "$HOME", }, 0) assert.Eventually(t, func() bool { return client.GetStartup().Version != "" @@ -1587,8 +1550,8 @@ func TestAgent_Startup(t *testing.T) { }) } +//nolint:paralleltest // This test sets an environment variable. func TestAgent_ReconnectingPTY(t *testing.T) { - t.Parallel() if runtime.GOOS == "windows" { // This might be our implementation, or ConPTY itself. // It's difficult to find extensive tests for it, so @@ -1596,61 +1559,136 @@ func TestAgent_ReconnectingPTY(t *testing.T) { t.Skip("ConPTY appears to be inconsistent on Windows.") } - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + backends := []string{"Buffered", "Screen"} - //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) - id := uuid.New() - netConn, err := conn.ReconnectingPTY(ctx, id, 100, 100, "/bin/bash") - require.NoError(t, err) - defer netConn.Close() + _, err := exec.LookPath("screen") + hasScreen := err == nil - bufRead := bufio.NewReader(netConn) + // Make sure UTF-8 works even with LANG set to something like C. + t.Setenv("LANG", "C") - // Brief pause to reduce the likelihood that we send keystrokes while - // the shell is simultaneously sending a prompt. - time.Sleep(100 * time.Millisecond) + for _, backendType := range backends { + backendType := backendType + t.Run(backendType, func(t *testing.T) { + if backendType == "Screen" { + if runtime.GOOS != "linux" { + t.Skipf("`screen` is not supported on %s", runtime.GOOS) + } else if !hasScreen { + t.Skip("`screen` not found") + } + } else if hasScreen && runtime.GOOS == "linux" { + // Set up a PATH that does not have screen in it. + bashPath, err := exec.LookPath("bash") + require.NoError(t, err) + dir, err := os.MkdirTemp("/tmp", "coder-test-reconnecting-pty-PATH") + require.NoError(t, err, "create temp dir for reconnecting pty PATH") + err = os.Symlink(bashPath, filepath.Join(dir, "bash")) + require.NoError(t, err, "symlink bash into reconnecting pty PATH") + t.Setenv("PATH", dir) + } - data, err := json.Marshal(codersdk.ReconnectingPTYRequest{ - Data: "echo test\r\n", - }) - require.NoError(t, err) - _, err = netConn.Write(data) - require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() - expectLine := func(matcher func(string) bool) { - for { - line, err := bufRead.ReadString('\n') + //nolint:dogsled + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) + id := uuid.New() + // --norc disables executing .bashrc, which is often used to customize the bash prompt + netConn1, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash --norc") + require.NoError(t, err) + defer netConn1.Close() + tr1 := testutil.NewTerminalReader(t, netConn1) + + // A second simultaneous connection. + netConn2, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash --norc") require.NoError(t, err) - if matcher(line) { - break + defer netConn2.Close() + tr2 := testutil.NewTerminalReader(t, netConn2) + + matchPrompt := func(line string) bool { + return strings.Contains(line, "$ ") || strings.Contains(line, "# ") + } + matchEchoCommand := func(line string) bool { + return strings.Contains(line, "echo test") + } + matchEchoOutput := func(line string) bool { + return strings.Contains(line, "test") && !strings.Contains(line, "echo") + } + matchExitCommand := func(line string) bool { + return strings.Contains(line, "exit") + } + matchExitOutput := func(line string) bool { + return strings.Contains(line, "exit") || strings.Contains(line, "logout") } - } - } - matchEchoCommand := func(line string) bool { - return strings.Contains(line, "echo test") - } - matchEchoOutput := func(line string) bool { - return strings.Contains(line, "test") && !strings.Contains(line, "echo") - } + // Wait for the prompt before writing commands. If the command arrives before the prompt is written, screen + // will sometimes put the command output on the same line as the command and the test will flake + require.NoError(t, tr1.ReadUntil(ctx, matchPrompt), "find prompt") + require.NoError(t, tr2.ReadUntil(ctx, matchPrompt), "find prompt") + + data, err := json.Marshal(codersdk.ReconnectingPTYRequest{ + Data: "echo test\r", + }) + require.NoError(t, err) + _, err = netConn1.Write(data) + require.NoError(t, err) - // Once for typing the command... - expectLine(matchEchoCommand) - // And another time for the actual output. - expectLine(matchEchoOutput) + // Once for typing the command... + require.NoError(t, tr1.ReadUntil(ctx, matchEchoCommand), "find echo command") + // And another time for the actual output. + require.NoError(t, tr1.ReadUntil(ctx, matchEchoOutput), "find echo output") - _ = netConn.Close() - netConn, err = conn.ReconnectingPTY(ctx, id, 100, 100, "/bin/bash") - require.NoError(t, err) - defer netConn.Close() + // Same for the other connection. + require.NoError(t, tr2.ReadUntil(ctx, matchEchoCommand), "find echo command") + require.NoError(t, tr2.ReadUntil(ctx, matchEchoOutput), "find echo output") + + _ = netConn1.Close() + _ = netConn2.Close() + netConn3, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash --norc") + require.NoError(t, err) + defer netConn3.Close() + tr3 := testutil.NewTerminalReader(t, netConn3) - bufRead = bufio.NewReader(netConn) + // Same output again! + require.NoError(t, tr3.ReadUntil(ctx, matchEchoCommand), "find echo command") + require.NoError(t, tr3.ReadUntil(ctx, matchEchoOutput), "find echo output") - // Same output again! - expectLine(matchEchoCommand) - expectLine(matchEchoOutput) + // Exit should cause the connection to close. + data, err = json.Marshal(codersdk.ReconnectingPTYRequest{ + Data: "exit\r", + }) + require.NoError(t, err) + _, err = netConn3.Write(data) + require.NoError(t, err) + + // Once for the input and again for the output. + require.NoError(t, tr3.ReadUntil(ctx, matchExitCommand), "find exit command") + require.NoError(t, tr3.ReadUntil(ctx, matchExitOutput), "find exit output") + + // Wait for the connection to close. + require.ErrorIs(t, tr3.ReadUntil(ctx, nil), io.EOF) + + // Try a non-shell command. It should output then immediately exit. + netConn4, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "echo test") + require.NoError(t, err) + defer netConn4.Close() + + tr4 := testutil.NewTerminalReader(t, netConn4) + require.NoError(t, tr4.ReadUntil(ctx, matchEchoOutput), "find echo output") + require.ErrorIs(t, tr4.ReadUntil(ctx, nil), io.EOF) + + // Ensure that UTF-8 is supported. Avoid the terminal emulator because it + // does not appear to support UTF-8, just make sure the bytes that come + // back have the character in it. + netConn5, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "echo ❯") + require.NoError(t, err) + defer netConn5.Close() + + bytes, err := io.ReadAll(netConn5) + require.NoError(t, err) + require.Contains(t, string(bytes), "❯") + }) + } } func TestAgent_Dial(t *testing.T) { @@ -1843,13 +1881,16 @@ func TestAgent_UpdatedDERP(t *testing.T) { func TestAgent_Speedtest(t *testing.T) { t.Parallel() t.Skip("This test is relatively flakey because of Tailscale's speedtest code...") + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() derpMap, _ := tailnettest.RunDERPAndSTUN(t) //nolint:dogsled conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{ DERPMap: derpMap, - }, 0) + }, 0, func(client *agenttest.Client, options *agent.Options) { + options.Logger = logger.Named("agent") + }) defer conn.Close() res, err := conn.Speedtest(ctx, speedtest.Upload, 250*time.Millisecond) require.NoError(t, err) @@ -1932,6 +1973,96 @@ func TestAgent_WriteVSCodeConfigs(t *testing.T) { }, testutil.WaitShort, testutil.IntervalFast) } +func TestAgent_DebugServer(t *testing.T) { + t.Parallel() + + derpMap, _ := tailnettest.RunDERPAndSTUN(t) + //nolint:dogsled + conn, _, _, _, agnt := setupAgent(t, agentsdk.Manifest{ + DERPMap: derpMap, + }, 0) + + awaitReachableCtx := testutil.Context(t, testutil.WaitLong) + ok := conn.AwaitReachable(awaitReachableCtx) + require.True(t, ok) + _ = conn.Close() + + srv := httptest.NewServer(agnt.HTTPDebug()) + t.Cleanup(srv.Close) + + t.Run("MagicsockDebug", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/magicsock", nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Contains(t, string(resBody), "

magicsock

") + }) + + t.Run("MagicsockDebugLogging", func(t *testing.T) { + t.Parallel() + + t.Run("Enable", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/magicsock/debug-logging/t", nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Contains(t, string(resBody), "updated magicsock debug logging to true") + }) + + t.Run("Disable", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/magicsock/debug-logging/0", nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Contains(t, string(resBody), "updated magicsock debug logging to false") + }) + + t.Run("Invalid", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/magicsock/debug-logging/blah", nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Contains(t, string(resBody), `invalid state "blah", must be a boolean`) + }) + }) +} + func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) (*ptytest.PTYCmd, pty.Process) { //nolint:dogsled agentConn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) @@ -2013,7 +2144,7 @@ func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Durati *agenttest.Client, <-chan *agentsdk.Stats, afero.Fs, - io.Closer, + agent.Agent, ) { logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) if metadata.DERPMap == nil { @@ -2250,6 +2381,173 @@ func TestAgent_Metrics_SSH(t *testing.T) { require.NoError(t, err) } +func TestAgent_ManageProcessPriority(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "linux" { + t.Skip("Skipping non-linux environment") + } + + var ( + expectedProcs = map[int32]agentproc.Process{} + fs = afero.NewMemMapFs() + syscaller = agentproctest.NewMockSyscaller(gomock.NewController(t)) + ticker = make(chan time.Time) + modProcs = make(chan []*agentproc.Process) + logger = slog.Make(sloghuman.Sink(io.Discard)) + ) + + // Create some processes. + for i := 0; i < 4; i++ { + // Create a prioritized process. This process should + // have it's oom_score_adj set to -500 and its nice + // score should be untouched. + var proc agentproc.Process + if i == 0 { + proc = agentproctest.GenerateProcess(t, fs, + func(p *agentproc.Process) { + p.CmdLine = "./coder\x00agent\x00--no-reap" + p.PID = int32(i) + }, + ) + } else { + proc = agentproctest.GenerateProcess(t, fs, + func(p *agentproc.Process) { + // Make the cmd something similar to a prioritized + // process but differentiate the arguments. + p.CmdLine = "./coder\x00stat" + }, + ) + + syscaller.EXPECT().SetPriority(proc.PID, 10).Return(nil) + syscaller.EXPECT().GetPriority(proc.PID).Return(20, nil) + } + syscaller.EXPECT(). + Kill(proc.PID, syscall.Signal(0)). + Return(nil) + + expectedProcs[proc.PID] = proc + } + + _, _, _, _, _ = setupAgent(t, agentsdk.Manifest{}, 0, func(c *agenttest.Client, o *agent.Options) { + o.Syscaller = syscaller + o.ModifiedProcesses = modProcs + o.EnvironmentVariables = map[string]string{agent.EnvProcPrioMgmt: "1"} + o.Filesystem = fs + o.Logger = logger + o.ProcessManagementTick = ticker + }) + actualProcs := <-modProcs + require.Len(t, actualProcs, len(expectedProcs)-1) + }) + + t.Run("IgnoreCustomNice", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "linux" { + t.Skip("Skipping non-linux environment") + } + + var ( + expectedProcs = map[int32]agentproc.Process{} + fs = afero.NewMemMapFs() + ticker = make(chan time.Time) + syscaller = agentproctest.NewMockSyscaller(gomock.NewController(t)) + modProcs = make(chan []*agentproc.Process) + logger = slog.Make(sloghuman.Sink(io.Discard)) + ) + + // Create some processes. + for i := 0; i < 2; i++ { + proc := agentproctest.GenerateProcess(t, fs) + syscaller.EXPECT(). + Kill(proc.PID, syscall.Signal(0)). + Return(nil) + + if i == 0 { + // Set a random nice score. This one should not be adjusted by + // our management loop. + syscaller.EXPECT().GetPriority(proc.PID).Return(25, nil) + } else { + syscaller.EXPECT().GetPriority(proc.PID).Return(20, nil) + syscaller.EXPECT().SetPriority(proc.PID, 10).Return(nil) + } + + expectedProcs[proc.PID] = proc + } + + _, _, _, _, _ = setupAgent(t, agentsdk.Manifest{}, 0, func(c *agenttest.Client, o *agent.Options) { + o.Syscaller = syscaller + o.ModifiedProcesses = modProcs + o.EnvironmentVariables = map[string]string{agent.EnvProcPrioMgmt: "1"} + o.Filesystem = fs + o.Logger = logger + o.ProcessManagementTick = ticker + }) + actualProcs := <-modProcs + // We should ignore the process with a custom nice score. + require.Len(t, actualProcs, 1) + }) + + t.Run("DisabledByDefault", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "linux" { + t.Skip("Skipping non-linux environment") + } + + var ( + buf bytes.Buffer + wr = &syncWriter{ + w: &buf, + } + ) + log := slog.Make(sloghuman.Sink(wr)).Leveled(slog.LevelDebug) + + _, _, _, _, _ = setupAgent(t, agentsdk.Manifest{}, 0, func(c *agenttest.Client, o *agent.Options) { + o.Logger = log + }) + + require.Eventually(t, func() bool { + wr.mu.Lock() + defer wr.mu.Unlock() + return strings.Contains(buf.String(), "process priority not enabled") + }, testutil.WaitLong, testutil.IntervalFast) + }) + + t.Run("DisabledForNonLinux", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "linux" { + t.Skip("Skipping linux environment") + } + + var ( + buf bytes.Buffer + wr = &syncWriter{ + w: &buf, + } + ) + log := slog.Make(sloghuman.Sink(wr)).Leveled(slog.LevelDebug) + + _, _, _, _, _ = setupAgent(t, agentsdk.Manifest{}, 0, func(c *agenttest.Client, o *agent.Options) { + o.Logger = log + // Try to enable it so that we can assert that non-linux + // environments are truly disabled. + o.EnvironmentVariables = map[string]string{agent.EnvProcPrioMgmt: "1"} + }) + require.Eventually(t, func() bool { + wr.mu.Lock() + defer wr.mu.Unlock() + + return strings.Contains(buf.String(), "process priority not enabled") + }, testutil.WaitLong, testutil.IntervalFast) + }) +} + func verifyCollectedMetrics(t *testing.T, expected []agentsdk.AgentMetric, actual []*promgo.MetricFamily) bool { t.Helper() @@ -2271,3 +2569,14 @@ func verifyCollectedMetrics(t *testing.T, expected []agentsdk.AgentMetric, actua } return true } + +type syncWriter struct { + mu sync.Mutex + w io.Writer +} + +func (s *syncWriter) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.w.Write(p) +} diff --git a/agent/agentproc/agentproctest/doc.go b/agent/agentproc/agentproctest/doc.go new file mode 100644 index 0000000000000..5007b36268f76 --- /dev/null +++ b/agent/agentproc/agentproctest/doc.go @@ -0,0 +1,5 @@ +// Package agentproctest contains utility functions +// for testing process management in the agent. +package agentproctest + +//go:generate mockgen -destination ./syscallermock.go -package agentproctest github.com/coder/coder/v2/agent/agentproc Syscaller diff --git a/agent/agentproc/agentproctest/proc.go b/agent/agentproc/agentproctest/proc.go new file mode 100644 index 0000000000000..c36e04ec1cdc3 --- /dev/null +++ b/agent/agentproc/agentproctest/proc.go @@ -0,0 +1,49 @@ +package agentproctest + +import ( + "fmt" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/agent/agentproc" + "github.com/coder/coder/v2/cryptorand" +) + +func GenerateProcess(t *testing.T, fs afero.Fs, muts ...func(*agentproc.Process)) agentproc.Process { + t.Helper() + + pid, err := cryptorand.Intn(1<<31 - 1) + require.NoError(t, err) + + arg1, err := cryptorand.String(5) + require.NoError(t, err) + + arg2, err := cryptorand.String(5) + require.NoError(t, err) + + arg3, err := cryptorand.String(5) + require.NoError(t, err) + + cmdline := fmt.Sprintf("%s\x00%s\x00%s", arg1, arg2, arg3) + + process := agentproc.Process{ + CmdLine: cmdline, + PID: int32(pid), + } + + for _, mut := range muts { + mut(&process) + } + + process.Dir = fmt.Sprintf("%s/%d", "/proc", process.PID) + + err = fs.MkdirAll(process.Dir, 0o555) + require.NoError(t, err) + + err = afero.WriteFile(fs, fmt.Sprintf("%s/cmdline", process.Dir), []byte(process.CmdLine), 0o444) + require.NoError(t, err) + + return process +} diff --git a/agent/agentproc/agentproctest/syscallermock.go b/agent/agentproc/agentproctest/syscallermock.go new file mode 100644 index 0000000000000..8d9697bc559ef --- /dev/null +++ b/agent/agentproc/agentproctest/syscallermock.go @@ -0,0 +1,78 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coder/coder/v2/agent/agentproc (interfaces: Syscaller) + +// Package agentproctest is a generated GoMock package. +package agentproctest + +import ( + reflect "reflect" + syscall "syscall" + + gomock "github.com/golang/mock/gomock" +) + +// MockSyscaller is a mock of Syscaller interface. +type MockSyscaller struct { + ctrl *gomock.Controller + recorder *MockSyscallerMockRecorder +} + +// MockSyscallerMockRecorder is the mock recorder for MockSyscaller. +type MockSyscallerMockRecorder struct { + mock *MockSyscaller +} + +// NewMockSyscaller creates a new mock instance. +func NewMockSyscaller(ctrl *gomock.Controller) *MockSyscaller { + mock := &MockSyscaller{ctrl: ctrl} + mock.recorder = &MockSyscallerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSyscaller) EXPECT() *MockSyscallerMockRecorder { + return m.recorder +} + +// GetPriority mocks base method. +func (m *MockSyscaller) GetPriority(arg0 int32) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPriority", arg0) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPriority indicates an expected call of GetPriority. +func (mr *MockSyscallerMockRecorder) GetPriority(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriority", reflect.TypeOf((*MockSyscaller)(nil).GetPriority), arg0) +} + +// Kill mocks base method. +func (m *MockSyscaller) Kill(arg0 int32, arg1 syscall.Signal) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Kill", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Kill indicates an expected call of Kill. +func (mr *MockSyscallerMockRecorder) Kill(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kill", reflect.TypeOf((*MockSyscaller)(nil).Kill), arg0, arg1) +} + +// SetPriority mocks base method. +func (m *MockSyscaller) SetPriority(arg0 int32, arg1 int) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetPriority", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetPriority indicates an expected call of SetPriority. +func (mr *MockSyscallerMockRecorder) SetPriority(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriority", reflect.TypeOf((*MockSyscaller)(nil).SetPriority), arg0, arg1) +} diff --git a/agent/agentproc/doc.go b/agent/agentproc/doc.go new file mode 100644 index 0000000000000..8b15c52c5f9fb --- /dev/null +++ b/agent/agentproc/doc.go @@ -0,0 +1,3 @@ +// Package agentproc contains logic for interfacing with local +// processes running in the same context as the agent. +package agentproc diff --git a/agent/agentproc/proc_other.go b/agent/agentproc/proc_other.go new file mode 100644 index 0000000000000..c0c4e2a25ce32 --- /dev/null +++ b/agent/agentproc/proc_other.go @@ -0,0 +1,24 @@ +//go:build !linux +// +build !linux + +package agentproc + +import ( + "github.com/spf13/afero" +) + +func (p *Process) Niceness(sc Syscaller) (int, error) { + return 0, errUnimplemented +} + +func (p *Process) SetNiceness(sc Syscaller, score int) error { + return errUnimplemented +} + +func (p *Process) Cmd() string { + return "" +} + +func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) { + return nil, errUnimplemented +} diff --git a/agent/agentproc/proc_test.go b/agent/agentproc/proc_test.go new file mode 100644 index 0000000000000..37991679503c6 --- /dev/null +++ b/agent/agentproc/proc_test.go @@ -0,0 +1,166 @@ +package agentproc_test + +import ( + "runtime" + "syscall" + "testing" + + "github.com/golang/mock/gomock" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/agent/agentproc" + "github.com/coder/coder/v2/agent/agentproc/agentproctest" +) + +func TestList(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "linux" { + t.Skipf("skipping non-linux environment") + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var ( + fs = afero.NewMemMapFs() + sc = agentproctest.NewMockSyscaller(gomock.NewController(t)) + expectedProcs = make(map[int32]agentproc.Process) + ) + + for i := 0; i < 4; i++ { + proc := agentproctest.GenerateProcess(t, fs) + expectedProcs[proc.PID] = proc + + sc.EXPECT(). + Kill(proc.PID, syscall.Signal(0)). + Return(nil) + } + + actualProcs, err := agentproc.List(fs, sc) + require.NoError(t, err) + require.Len(t, actualProcs, len(expectedProcs)) + for _, proc := range actualProcs { + expected, ok := expectedProcs[proc.PID] + require.True(t, ok) + require.Equal(t, expected.PID, proc.PID) + require.Equal(t, expected.CmdLine, proc.CmdLine) + require.Equal(t, expected.Dir, proc.Dir) + } + }) + + t.Run("FinishedProcess", func(t *testing.T) { + t.Parallel() + + var ( + fs = afero.NewMemMapFs() + sc = agentproctest.NewMockSyscaller(gomock.NewController(t)) + expectedProcs = make(map[int32]agentproc.Process) + ) + + for i := 0; i < 3; i++ { + proc := agentproctest.GenerateProcess(t, fs) + expectedProcs[proc.PID] = proc + + sc.EXPECT(). + Kill(proc.PID, syscall.Signal(0)). + Return(nil) + } + + // Create a process that's already finished. We're not adding + // it to the map because it should be skipped over. + proc := agentproctest.GenerateProcess(t, fs) + sc.EXPECT(). + Kill(proc.PID, syscall.Signal(0)). + Return(xerrors.New("os: process already finished")) + + actualProcs, err := agentproc.List(fs, sc) + require.NoError(t, err) + require.Len(t, actualProcs, len(expectedProcs)) + for _, proc := range actualProcs { + expected, ok := expectedProcs[proc.PID] + require.True(t, ok) + require.Equal(t, expected.PID, proc.PID) + require.Equal(t, expected.CmdLine, proc.CmdLine) + require.Equal(t, expected.Dir, proc.Dir) + } + }) + + t.Run("NoSuchProcess", func(t *testing.T) { + t.Parallel() + + var ( + fs = afero.NewMemMapFs() + sc = agentproctest.NewMockSyscaller(gomock.NewController(t)) + expectedProcs = make(map[int32]agentproc.Process) + ) + + for i := 0; i < 3; i++ { + proc := agentproctest.GenerateProcess(t, fs) + expectedProcs[proc.PID] = proc + + sc.EXPECT(). + Kill(proc.PID, syscall.Signal(0)). + Return(nil) + } + + // Create a process that doesn't exist. We're not adding + // it to the map because it should be skipped over. + proc := agentproctest.GenerateProcess(t, fs) + sc.EXPECT(). + Kill(proc.PID, syscall.Signal(0)). + Return(syscall.ESRCH) + + actualProcs, err := agentproc.List(fs, sc) + require.NoError(t, err) + require.Len(t, actualProcs, len(expectedProcs)) + for _, proc := range actualProcs { + expected, ok := expectedProcs[proc.PID] + require.True(t, ok) + require.Equal(t, expected.PID, proc.PID) + require.Equal(t, expected.CmdLine, proc.CmdLine) + require.Equal(t, expected.Dir, proc.Dir) + } + }) +} + +// These tests are not very interesting but they provide some modicum of +// confidence. +func TestProcess(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "linux" { + t.Skipf("skipping non-linux environment") + } + + t.Run("SetNiceness", func(t *testing.T) { + t.Parallel() + + var ( + sc = agentproctest.NewMockSyscaller(gomock.NewController(t)) + proc = &agentproc.Process{ + PID: 32, + } + score = 20 + ) + + sc.EXPECT().SetPriority(proc.PID, score).Return(nil) + err := proc.SetNiceness(sc, score) + require.NoError(t, err) + }) + + t.Run("Cmd", func(t *testing.T) { + t.Parallel() + + var ( + proc = &agentproc.Process{ + CmdLine: "helloworld\x00--arg1\x00--arg2", + } + expectedName = "helloworld --arg1 --arg2" + ) + + require.Equal(t, expectedName, proc.Cmd()) + }) +} diff --git a/agent/agentproc/proc_unix.go b/agent/agentproc/proc_unix.go new file mode 100644 index 0000000000000..f52caed52ee33 --- /dev/null +++ b/agent/agentproc/proc_unix.go @@ -0,0 +1,109 @@ +//go:build linux +// +build linux + +package agentproc + +import ( + "errors" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/spf13/afero" + "golang.org/x/xerrors" +) + +func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) { + d, err := fs.Open(defaultProcDir) + if err != nil { + return nil, xerrors.Errorf("open dir %q: %w", defaultProcDir, err) + } + defer d.Close() + + entries, err := d.Readdirnames(0) + if err != nil { + return nil, xerrors.Errorf("readdirnames: %w", err) + } + + processes := make([]*Process, 0, len(entries)) + for _, entry := range entries { + pid, err := strconv.ParseInt(entry, 10, 32) + if err != nil { + continue + } + + // Check that the process still exists. + exists, err := isProcessExist(syscaller, int32(pid)) + if err != nil { + return nil, xerrors.Errorf("check process exists: %w", err) + } + if !exists { + continue + } + + cmdline, err := afero.ReadFile(fs, filepath.Join(defaultProcDir, entry, "cmdline")) + if err != nil { + var errNo syscall.Errno + if xerrors.As(err, &errNo) && errNo == syscall.EPERM { + continue + } + return nil, xerrors.Errorf("read cmdline: %w", err) + } + processes = append(processes, &Process{ + PID: int32(pid), + CmdLine: string(cmdline), + Dir: filepath.Join(defaultProcDir, entry), + }) + } + + return processes, nil +} + +func isProcessExist(syscaller Syscaller, pid int32) (bool, error) { + err := syscaller.Kill(pid, syscall.Signal(0)) + if err == nil { + return true, nil + } + if err.Error() == "os: process already finished" { + return false, nil + } + + var errno syscall.Errno + if !errors.As(err, &errno) { + return false, err + } + + switch errno { + case syscall.ESRCH: + return false, nil + case syscall.EPERM: + return true, nil + } + + return false, xerrors.Errorf("kill: %w", err) +} + +func (p *Process) Niceness(sc Syscaller) (int, error) { + nice, err := sc.GetPriority(p.PID) + if err != nil { + return 0, xerrors.Errorf("get priority for %q: %w", p.CmdLine, err) + } + return nice, nil +} + +func (p *Process) SetNiceness(sc Syscaller, score int) error { + err := sc.SetPriority(p.PID, score) + if err != nil { + return xerrors.Errorf("set priority for %q: %w", p.CmdLine, err) + } + return nil +} + +func (p *Process) Cmd() string { + return strings.Join(p.cmdLine(), " ") +} + +func (p *Process) cmdLine() []string { + return strings.Split(p.CmdLine, "\x00") +} diff --git a/agent/agentproc/syscaller.go b/agent/agentproc/syscaller.go new file mode 100644 index 0000000000000..1cd6640e36b43 --- /dev/null +++ b/agent/agentproc/syscaller.go @@ -0,0 +1,19 @@ +package agentproc + +import ( + "syscall" +) + +type Syscaller interface { + SetPriority(pid int32, priority int) error + GetPriority(pid int32) (int, error) + Kill(pid int32, sig syscall.Signal) error +} + +const defaultProcDir = "/proc" + +type Process struct { + Dir string + CmdLine string + PID int32 +} diff --git a/agent/agentproc/syscaller_other.go b/agent/agentproc/syscaller_other.go new file mode 100644 index 0000000000000..114c553e43da2 --- /dev/null +++ b/agent/agentproc/syscaller_other.go @@ -0,0 +1,30 @@ +//go:build !linux +// +build !linux + +package agentproc + +import ( + "syscall" + + "golang.org/x/xerrors" +) + +func NewSyscaller() Syscaller { + return nopSyscaller{} +} + +var errUnimplemented = xerrors.New("unimplemented") + +type nopSyscaller struct{} + +func (nopSyscaller) SetPriority(pid int32, priority int) error { + return errUnimplemented +} + +func (nopSyscaller) GetPriority(pid int32) (int, error) { + return 0, errUnimplemented +} + +func (nopSyscaller) Kill(pid int32, sig syscall.Signal) error { + return errUnimplemented +} diff --git a/agent/agentproc/syscaller_unix.go b/agent/agentproc/syscaller_unix.go new file mode 100644 index 0000000000000..e63e56b50f724 --- /dev/null +++ b/agent/agentproc/syscaller_unix.go @@ -0,0 +1,42 @@ +//go:build linux +// +build linux + +package agentproc + +import ( + "syscall" + + "golang.org/x/sys/unix" + "golang.org/x/xerrors" +) + +func NewSyscaller() Syscaller { + return UnixSyscaller{} +} + +type UnixSyscaller struct{} + +func (UnixSyscaller) SetPriority(pid int32, nice int) error { + err := unix.Setpriority(unix.PRIO_PROCESS, int(pid), nice) + if err != nil { + return xerrors.Errorf("set priority: %w", err) + } + return nil +} + +func (UnixSyscaller) GetPriority(pid int32) (int, error) { + nice, err := unix.Getpriority(0, int(pid)) + if err != nil { + return 0, xerrors.Errorf("get priority: %w", err) + } + return nice, nil +} + +func (UnixSyscaller) Kill(pid int32, sig syscall.Signal) error { + err := syscall.Kill(int(pid), sig) + if err != nil { + return xerrors.Errorf("kill: %w", err) + } + + return nil +} diff --git a/agent/agentscripts/agentscripts.go b/agent/agentscripts/agentscripts.go new file mode 100644 index 0000000000000..3acc48b0a140c --- /dev/null +++ b/agent/agentscripts/agentscripts.go @@ -0,0 +1,314 @@ +package agentscripts + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "path/filepath" + "sync" + "sync/atomic" + "time" + + "github.com/robfig/cron/v3" + "github.com/spf13/afero" + "golang.org/x/sync/errgroup" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/v2/agent/agentssh" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" +) + +var ( + // ErrTimeout is returned when a script times out. + ErrTimeout = xerrors.New("script timed out") + // ErrOutputPipesOpen is returned when a script exits leaving the output + // pipe(s) (stdout, stderr) open. This happens because we set WaitDelay on + // the command, which gives us two things: + // + // 1. The ability to ensure that a script exits (this is important for e.g. + // blocking login, and avoiding doing so indefinitely) + // 2. Improved command cancellation on timeout + ErrOutputPipesOpen = xerrors.New("script exited without closing output pipes") + + parser = cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.DowOptional) +) + +// Options are a set of options for the runner. +type Options struct { + LogDir string + Logger slog.Logger + SSHServer *agentssh.Server + Filesystem afero.Fs + PatchLogs func(ctx context.Context, req agentsdk.PatchLogs) error +} + +// New creates a runner for the provided scripts. +func New(opts Options) *Runner { + cronCtx, cronCtxCancel := context.WithCancel(context.Background()) + return &Runner{ + Options: opts, + cronCtx: cronCtx, + cronCtxCancel: cronCtxCancel, + cron: cron.New(cron.WithParser(parser)), + closed: make(chan struct{}), + } +} + +type Runner struct { + Options + + cronCtx context.Context + cronCtxCancel context.CancelFunc + cmdCloseWait sync.WaitGroup + closed chan struct{} + closeMutex sync.Mutex + cron *cron.Cron + initialized atomic.Bool + scripts []codersdk.WorkspaceAgentScript +} + +// Init initializes the runner with the provided scripts. +// It also schedules any scripts that have a schedule. +// This function must be called before Execute. +func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript) error { + if r.initialized.Load() { + return xerrors.New("init: already initialized") + } + r.initialized.Store(true) + r.scripts = scripts + r.Logger.Info(r.cronCtx, "initializing agent scripts", slog.F("script_count", len(scripts)), slog.F("log_dir", r.LogDir)) + + for _, script := range scripts { + if script.Cron == "" { + continue + } + script := script + _, err := r.cron.AddFunc(script.Cron, func() { + err := r.run(r.cronCtx, script) + if err != nil { + r.Logger.Warn(context.Background(), "run agent script on schedule", slog.Error(err)) + } + }) + if err != nil { + return xerrors.Errorf("add schedule: %w", err) + } + } + return nil +} + +// StartCron starts the cron scheduler. +// This is done async to allow for the caller to execute scripts prior. +func (r *Runner) StartCron() { + // cron.Start() and cron.Stop() does not guarantee that the cron goroutine + // has exited by the time the `cron.Stop()` context returns, so we need to + // track it manually. + err := r.trackCommandGoroutine(func() { + r.cron.Run() + }) + if err != nil { + r.Logger.Warn(context.Background(), "start cron failed", slog.Error(err)) + } +} + +// Execute runs a set of scripts according to a filter. +func (r *Runner) Execute(ctx context.Context, filter func(script codersdk.WorkspaceAgentScript) bool) error { + if filter == nil { + // Execute em' all! + filter = func(script codersdk.WorkspaceAgentScript) bool { + return true + } + } + var eg errgroup.Group + for _, script := range r.scripts { + if !filter(script) { + continue + } + script := script + eg.Go(func() error { + err := r.run(ctx, script) + if err != nil { + return xerrors.Errorf("run agent script %q: %w", script.LogSourceID, err) + } + return nil + }) + } + return eg.Wait() +} + +// run executes the provided script with the timeout. +// If the timeout is exceeded, the process is sent an interrupt signal. +// If the process does not exit after a few seconds, it is forcefully killed. +// This function immediately returns after a timeout, and does not wait for the process to exit. +func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript) error { + logPath := script.LogPath + if logPath == "" { + logPath = fmt.Sprintf("coder-script-%s.log", script.LogSourceID) + } + if logPath[0] == '~' { + // First we check the environment. + homeDir, err := os.UserHomeDir() + if err != nil { + u, err := user.Current() + if err != nil { + return xerrors.Errorf("current user: %w", err) + } + homeDir = u.HomeDir + } + logPath = filepath.Join(homeDir, logPath[1:]) + } + logPath = os.ExpandEnv(logPath) + if !filepath.IsAbs(logPath) { + logPath = filepath.Join(r.LogDir, logPath) + } + logger := r.Logger.With(slog.F("log_path", logPath)) + logger.Info(ctx, "running agent script", slog.F("script", script.Script)) + + fileWriter, err := r.Filesystem.OpenFile(logPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return xerrors.Errorf("open %s script log file: %w", logPath, err) + } + defer func() { + err := fileWriter.Close() + if err != nil { + logger.Warn(ctx, fmt.Sprintf("close %s script log file", logPath), slog.Error(err)) + } + }() + + var cmd *exec.Cmd + cmdCtx := ctx + if script.Timeout > 0 { + var ctxCancel context.CancelFunc + cmdCtx, ctxCancel = context.WithTimeout(ctx, script.Timeout) + defer ctxCancel() + } + cmdPty, err := r.SSHServer.CreateCommand(cmdCtx, script.Script, nil) + if err != nil { + return xerrors.Errorf("%s script: create command: %w", logPath, err) + } + cmd = cmdPty.AsExec() + cmd.SysProcAttr = cmdSysProcAttr() + cmd.WaitDelay = 10 * time.Second + cmd.Cancel = cmdCancel(cmd) + + send, flushAndClose := agentsdk.LogsSender(script.LogSourceID, r.PatchLogs, logger) + // If ctx is canceled here (or in a writer below), we may be + // discarding logs, but that's okay because we're shutting down + // anyway. We could consider creating a new context here if we + // want better control over flush during shutdown. + defer func() { + if err := flushAndClose(ctx); err != nil { + logger.Warn(ctx, "flush startup logs failed", slog.Error(err)) + } + }() + + infoW := agentsdk.LogsWriter(ctx, send, script.LogSourceID, codersdk.LogLevelInfo) + defer infoW.Close() + errW := agentsdk.LogsWriter(ctx, send, script.LogSourceID, codersdk.LogLevelError) + defer errW.Close() + cmd.Stdout = io.MultiWriter(fileWriter, infoW) + cmd.Stderr = io.MultiWriter(fileWriter, errW) + + start := time.Now() + defer func() { + end := time.Now() + execTime := end.Sub(start) + exitCode := 0 + if err != nil { + exitCode = 255 // Unknown status. + var exitError *exec.ExitError + if xerrors.As(err, &exitError) { + exitCode = exitError.ExitCode() + } + logger.Warn(ctx, fmt.Sprintf("%s script failed", logPath), slog.F("execution_time", execTime), slog.F("exit_code", exitCode), slog.Error(err)) + } else { + logger.Info(ctx, fmt.Sprintf("%s script completed", logPath), slog.F("execution_time", execTime), slog.F("exit_code", exitCode)) + } + }() + + err = cmd.Start() + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return ErrTimeout + } + return xerrors.Errorf("%s script: start command: %w", logPath, err) + } + + cmdDone := make(chan error, 1) + err = r.trackCommandGoroutine(func() { + cmdDone <- cmd.Wait() + }) + if err != nil { + return xerrors.Errorf("%s script: track command goroutine: %w", logPath, err) + } + select { + case <-cmdCtx.Done(): + // Wait for the command to drain! + select { + case <-cmdDone: + case <-time.After(10 * time.Second): + } + err = cmdCtx.Err() + case err = <-cmdDone: + } + switch { + case errors.Is(err, exec.ErrWaitDelay): + err = ErrOutputPipesOpen + message := fmt.Sprintf("script exited successfully, but output pipes were not closed after %s", cmd.WaitDelay) + details := fmt.Sprint( + "This usually means a child process was started with references to stdout or stderr. As a result, this " + + "process may now have been terminated. Consider redirecting the output or using a separate " + + "\"coder_script\" for the process, see " + + "https://coder.com/docs/v2/latest/templates/troubleshooting#startup-script-issues for more information.", + ) + // Inform the user by propagating the message via log writers. + _, _ = fmt.Fprintf(cmd.Stderr, "WARNING: %s. %s\n", message, details) + // Also log to agent logs for ease of debugging. + r.Logger.Warn(ctx, message, slog.F("details", details), slog.Error(err)) + + case errors.Is(err, context.DeadlineExceeded): + err = ErrTimeout + } + return err +} + +func (r *Runner) Close() error { + r.closeMutex.Lock() + defer r.closeMutex.Unlock() + if r.isClosed() { + return nil + } + close(r.closed) + r.cronCtxCancel() + <-r.cron.Stop().Done() + r.cmdCloseWait.Wait() + return nil +} + +func (r *Runner) trackCommandGoroutine(fn func()) error { + r.closeMutex.Lock() + defer r.closeMutex.Unlock() + if r.isClosed() { + return xerrors.New("track command goroutine: closed") + } + r.cmdCloseWait.Add(1) + go func() { + defer r.cmdCloseWait.Done() + fn() + }() + return nil +} + +func (r *Runner) isClosed() bool { + select { + case <-r.closed: + return true + default: + return false + } +} diff --git a/agent/agentscripts/agentscripts_other.go b/agent/agentscripts/agentscripts_other.go new file mode 100644 index 0000000000000..a7ab83276e67d --- /dev/null +++ b/agent/agentscripts/agentscripts_other.go @@ -0,0 +1,20 @@ +//go:build !windows + +package agentscripts + +import ( + "os/exec" + "syscall" +) + +func cmdSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + Setsid: true, + } +} + +func cmdCancel(cmd *exec.Cmd) func() error { + return func() error { + return syscall.Kill(-cmd.Process.Pid, syscall.SIGHUP) + } +} diff --git a/agent/agentscripts/agentscripts_test.go b/agent/agentscripts/agentscripts_test.go new file mode 100644 index 0000000000000..1570e35d59b31 --- /dev/null +++ b/agent/agentscripts/agentscripts_test.go @@ -0,0 +1,80 @@ +package agentscripts_test + +import ( + "context" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + "go.uber.org/atomic" + "go.uber.org/goleak" + + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/agent/agentscripts" + "github.com/coder/coder/v2/agent/agentssh" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestExecuteBasic(t *testing.T) { + t.Parallel() + logs := make(chan agentsdk.PatchLogs, 1) + runner := setup(t, func(ctx context.Context, req agentsdk.PatchLogs) error { + logs <- req + return nil + }) + defer runner.Close() + err := runner.Init([]codersdk.WorkspaceAgentScript{{ + Script: "echo hello", + }}) + require.NoError(t, err) + require.NoError(t, runner.Execute(context.Background(), func(script codersdk.WorkspaceAgentScript) bool { + return true + })) + log := <-logs + require.Equal(t, "hello", log.Logs[0].Output) +} + +func TestTimeout(t *testing.T) { + t.Parallel() + runner := setup(t, nil) + defer runner.Close() + err := runner.Init([]codersdk.WorkspaceAgentScript{{ + Script: "sleep infinity", + Timeout: time.Millisecond, + }}) + require.NoError(t, err) + require.ErrorIs(t, runner.Execute(context.Background(), nil), agentscripts.ErrTimeout) +} + +func setup(t *testing.T, patchLogs func(ctx context.Context, req agentsdk.PatchLogs) error) *agentscripts.Runner { + t.Helper() + if patchLogs == nil { + // noop + patchLogs = func(ctx context.Context, req agentsdk.PatchLogs) error { + return nil + } + } + fs := afero.NewMemMapFs() + logger := slogtest.Make(t, nil) + s, err := agentssh.NewServer(context.Background(), logger, prometheus.NewRegistry(), fs, 0, "") + require.NoError(t, err) + s.AgentToken = func() string { return "" } + s.Manifest = atomic.NewPointer(&agentsdk.Manifest{}) + t.Cleanup(func() { + _ = s.Close() + }) + return agentscripts.New(agentscripts.Options{ + LogDir: t.TempDir(), + Logger: logger, + SSHServer: s, + Filesystem: fs, + PatchLogs: patchLogs, + }) +} diff --git a/agent/agentscripts/agentscripts_windows.go b/agent/agentscripts/agentscripts_windows.go new file mode 100644 index 0000000000000..cda1b3fcc39e1 --- /dev/null +++ b/agent/agentscripts/agentscripts_windows.go @@ -0,0 +1,17 @@ +package agentscripts + +import ( + "os" + "os/exec" + "syscall" +) + +func cmdSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{} +} + +func cmdCancel(cmd *exec.Cmd) func() error { + return func() error { + return cmd.Process.Signal(os.Interrupt) + } +} diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index 729cadd423ce2..19831c0d7caa8 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -19,6 +19,7 @@ import ( "time" "github.com/gliderlabs/ssh" + "github.com/kballard/go-shellquote" "github.com/pkg/sftp" "github.com/prometheus/client_golang/prometheus" "github.com/spf13/afero" @@ -28,10 +29,10 @@ import ( "cdr.dev/slog" - "github.com/coder/coder/agent/usershell" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/pty" + "github.com/coder/coder/v2/agent/usershell" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/pty" ) const ( @@ -254,11 +255,13 @@ func (s *Server) sessionStart(session ssh.Session, extraEnv []string) (retErr er magicType = strings.TrimPrefix(kv, MagicSessionTypeEnvironmentVariable+"=") env = append(env[:index], env[index+1:]...) } - switch magicType { - case MagicSessionTypeVSCode: + + // Always force lowercase checking to be case-insensitive. + switch strings.ToLower(magicType) { + case strings.ToLower(MagicSessionTypeVSCode): s.connCountVSCode.Add(1) defer s.connCountVSCode.Add(-1) - case MagicSessionTypeJetBrains: + case strings.ToLower(MagicSessionTypeJetBrains): s.connCountJetBrains.Add(1) defer s.connCountJetBrains.Add(-1) case "": @@ -513,8 +516,32 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string) if runtime.GOOS == "windows" { caller = "/c" } + name := shell args := []string{caller, script} + // A preceding space is generally not idiomatic for a shebang, + // but in Terraform it's quite standard to use < 1 { + args = words[1:] + } else { + args = []string{} + } + args = append(args, caller, script) + } + // gliderlabs/ssh returns a command slice of zero // when a shell is requested. if len(script) == 0 { @@ -526,7 +553,7 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string) } } - cmd := pty.CommandContext(ctx, shell, args...) + cmd := pty.CommandContext(ctx, name, args...) cmd.Dir = manifest.Directory // If the metadata directory doesn't exist, we run the command diff --git a/agent/agentssh/agentssh_internal_test.go b/agent/agentssh/agentssh_internal_test.go index ba4295bdbc149..aa4cfe0236261 100644 --- a/agent/agentssh/agentssh_internal_test.go +++ b/agent/agentssh/agentssh_internal_test.go @@ -15,8 +15,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/pty" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/pty" + "github.com/coder/coder/v2/testutil" "cdr.dev/slog/sloggers/slogtest" ) diff --git a/agent/agentssh/agentssh_test.go b/agent/agentssh/agentssh_test.go index 2467f1f221d6d..b72da96e4ce43 100644 --- a/agent/agentssh/agentssh_test.go +++ b/agent/agentssh/agentssh_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "net" + "runtime" "strings" "sync" "testing" @@ -20,9 +21,9 @@ import ( "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent/agentssh" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/agent/agentssh" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/pty/ptytest" ) func TestMain(m *testing.M) { @@ -71,6 +72,42 @@ func TestNewServer_ServeClient(t *testing.T) { <-done } +func TestNewServer_ExecuteShebang(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("bash doesn't exist on Windows") + } + + ctx := context.Background() + logger := slogtest.Make(t, nil) + s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), 0, "") + require.NoError(t, err) + t.Cleanup(func() { + _ = s.Close() + }) + s.AgentToken = func() string { return "" } + s.Manifest = atomic.NewPointer(&agentsdk.Manifest{}) + + t.Run("Basic", func(t *testing.T) { + t.Parallel() + cmd, err := s.CreateCommand(ctx, `#!/bin/bash + echo test`, nil) + require.NoError(t, err) + output, err := cmd.AsExec().CombinedOutput() + require.NoError(t, err) + require.Equal(t, "test\n", string(output)) + }) + t.Run("Args", func(t *testing.T) { + t.Parallel() + cmd, err := s.CreateCommand(ctx, `#!/usr/bin/env bash + echo test`, nil) + require.NoError(t, err) + output, err := cmd.AsExec().CombinedOutput() + require.NoError(t, err) + require.Equal(t, "test\n", string(output)) + }) +} + func TestNewServer_CloseActiveConnections(t *testing.T) { t.Parallel() diff --git a/agent/agentssh/metrics.go b/agent/agentssh/metrics.go index 88ee100d65d9a..9c6f2fbb3c5d5 100644 --- a/agent/agentssh/metrics.go +++ b/agent/agentssh/metrics.go @@ -1,6 +1,8 @@ package agentssh import ( + "strings" + "github.com/prometheus/client_golang/prometheus" ) @@ -78,5 +80,6 @@ func magicTypeMetricLabel(magicType string) string { default: magicType = "unknown" } - return magicType + // Always be case insensitive + return strings.ToLower(magicType) } diff --git a/agent/agentssh/x11_test.go b/agent/agentssh/x11_test.go index 1fce885bab780..e5f3f62ddce74 100644 --- a/agent/agentssh/x11_test.go +++ b/agent/agentssh/x11_test.go @@ -19,9 +19,9 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent/agentssh" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent/agentssh" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/testutil" ) func TestServer_X11(t *testing.T) { diff --git a/agent/agenttest/agent.go b/agent/agenttest/agent.go new file mode 100644 index 0000000000000..77b7c6e368822 --- /dev/null +++ b/agent/agenttest/agent.go @@ -0,0 +1,57 @@ +package agenttest + +import ( + "context" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/codersdk/agentsdk" +) + +// New starts a new agent for use in tests. +// The agent will use the provided coder URL and session token. +// The options passed to agent.New() can be modified by passing an optional +// variadic func(*agent.Options). +// Returns the agent. Closing the agent is handled by the test cleanup. +// It is the responsibility of the caller to call coderdtest.AwaitWorkspaceAgents +// to ensure agent is connected. +func New(t testing.TB, coderURL *url.URL, agentToken string, opts ...func(*agent.Options)) agent.Agent { + t.Helper() + + var o agent.Options + log := slogtest.Make(t, nil).Leveled(slog.LevelDebug).Named("agent") + o.Logger = log + + for _, opt := range opts { + opt(&o) + } + + if o.Client == nil { + agentClient := agentsdk.New(coderURL) + agentClient.SetSessionToken(agentToken) + agentClient.SDK.SetLogger(log) + o.Client = agentClient + } + + if o.ExchangeToken == nil { + o.ExchangeToken = func(_ context.Context) (string, error) { + return agentToken, nil + } + } + + if o.LogDir == "" { + o.LogDir = t.TempDir() + } + + agt := agent.New(o) + t.Cleanup(func() { + assert.NoError(t, agt.Close(), "failed to close agent during cleanup") + }) + + return agt +} diff --git a/agent/agenttest/client.go b/agent/agenttest/client.go index cc9d365b5331d..22933f4fd2aa7 100644 --- a/agent/agenttest/client.go +++ b/agent/agenttest/client.go @@ -13,10 +13,10 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/tailnet" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/testutil" ) func NewClient(t testing.TB, @@ -45,7 +45,7 @@ type Client struct { logger slog.Logger agentID uuid.UUID manifest agentsdk.Manifest - metadata map[string]agentsdk.PostMetadataRequest + metadata map[string]agentsdk.Metadata statsChan chan *agentsdk.Stats coordinator tailnet.Coordinator LastWorkspaceAgent func() @@ -136,20 +136,22 @@ func (c *Client) GetStartup() agentsdk.PostStartupRequest { return c.startup } -func (c *Client) GetMetadata() map[string]agentsdk.PostMetadataRequest { +func (c *Client) GetMetadata() map[string]agentsdk.Metadata { c.mu.Lock() defer c.mu.Unlock() return maps.Clone(c.metadata) } -func (c *Client) PostMetadata(ctx context.Context, key string, req agentsdk.PostMetadataRequest) error { +func (c *Client) PostMetadata(ctx context.Context, req agentsdk.PostMetadataRequest) error { c.mu.Lock() defer c.mu.Unlock() if c.metadata == nil { - c.metadata = make(map[string]agentsdk.PostMetadataRequest) + c.metadata = make(map[string]agentsdk.Metadata) + } + for _, md := range req.Metadata { + c.metadata[md.Key] = md + c.logger.Debug(ctx, "post metadata", slog.F("key", md.Key), slog.F("md", md)) } - c.metadata[key] = req - c.logger.Debug(ctx, "post metadata", slog.F("key", key), slog.F("req", req)) return nil } diff --git a/agent/api.go b/agent/api.go index c2cea963fbe66..0886b35bc0db1 100644 --- a/agent/api.go +++ b/agent/api.go @@ -5,10 +5,10 @@ import ( "sync" "time" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) func (a *agent) apiHandler() http.Handler { diff --git a/agent/apphealth.go b/agent/apphealth.go index 3d93b6c85ac26..c32a9a6339668 100644 --- a/agent/apphealth.go +++ b/agent/apphealth.go @@ -10,8 +10,8 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/retry" ) diff --git a/agent/apphealth_test.go b/agent/apphealth_test.go index 20c0d152760fc..748a88356e2aa 100644 --- a/agent/apphealth_test.go +++ b/agent/apphealth_test.go @@ -13,11 +13,11 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/testutil" ) func TestAppHealth_Healthy(t *testing.T) { diff --git a/agent/metrics.go b/agent/metrics.go index dc5fb6c018474..ddbe6f49beed1 100644 --- a/agent/metrics.go +++ b/agent/metrics.go @@ -11,7 +11,7 @@ import ( "cdr.dev/slog" - "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/v2/codersdk/agentsdk" ) type agentMetrics struct { diff --git a/agent/ports_supported.go b/agent/ports_supported.go index 7f9d30f3e9d05..81d177ee63de9 100644 --- a/agent/ports_supported.go +++ b/agent/ports_supported.go @@ -8,7 +8,7 @@ import ( "github.com/cakturk/go-netstat/netstat" "golang.org/x/xerrors" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" ) func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) { diff --git a/agent/ports_unsupported.go b/agent/ports_unsupported.go index 0ab26ac299736..0af99d1dc79b4 100644 --- a/agent/ports_unsupported.go +++ b/agent/ports_unsupported.go @@ -2,7 +2,7 @@ package agent -import "github.com/coder/coder/codersdk" +import "github.com/coder/coder/v2/codersdk" func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) { // Can't scan for ports on non-linux or non-windows_amd64 systems at the diff --git a/agent/proto/agent.pb.go b/agent/proto/agent.pb.go new file mode 100644 index 0000000000000..fb75710f1cd56 --- /dev/null +++ b/agent/proto/agent.pb.go @@ -0,0 +1,2453 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.30.0 +// protoc v4.23.3 +// source: agent/proto/agent.proto + +package proto + +import ( + proto "github.com/coder/coder/v2/tailnet/proto" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AppHealth int32 + +const ( + AppHealth_APP_HEALTH_UNSPECIFIED AppHealth = 0 + AppHealth_DISABLED AppHealth = 1 + AppHealth_INITIALIZING AppHealth = 2 + AppHealth_HEALTHY AppHealth = 3 + AppHealth_UNHEALTHY AppHealth = 4 +) + +// Enum value maps for AppHealth. +var ( + AppHealth_name = map[int32]string{ + 0: "APP_HEALTH_UNSPECIFIED", + 1: "DISABLED", + 2: "INITIALIZING", + 3: "HEALTHY", + 4: "UNHEALTHY", + } + AppHealth_value = map[string]int32{ + "APP_HEALTH_UNSPECIFIED": 0, + "DISABLED": 1, + "INITIALIZING": 2, + "HEALTHY": 3, + "UNHEALTHY": 4, + } +) + +func (x AppHealth) Enum() *AppHealth { + p := new(AppHealth) + *p = x + return p +} + +func (x AppHealth) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AppHealth) Descriptor() protoreflect.EnumDescriptor { + return file_agent_proto_agent_proto_enumTypes[0].Descriptor() +} + +func (AppHealth) Type() protoreflect.EnumType { + return &file_agent_proto_agent_proto_enumTypes[0] +} + +func (x AppHealth) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AppHealth.Descriptor instead. +func (AppHealth) EnumDescriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{0} +} + +type WorkspaceApp_SharingLevel int32 + +const ( + WorkspaceApp_SHARING_LEVEL_UNSPECIFIED WorkspaceApp_SharingLevel = 0 + WorkspaceApp_OWNER WorkspaceApp_SharingLevel = 1 + WorkspaceApp_AUTHENTICATED WorkspaceApp_SharingLevel = 2 + WorkspaceApp_PUBLIC WorkspaceApp_SharingLevel = 3 +) + +// Enum value maps for WorkspaceApp_SharingLevel. +var ( + WorkspaceApp_SharingLevel_name = map[int32]string{ + 0: "SHARING_LEVEL_UNSPECIFIED", + 1: "OWNER", + 2: "AUTHENTICATED", + 3: "PUBLIC", + } + WorkspaceApp_SharingLevel_value = map[string]int32{ + "SHARING_LEVEL_UNSPECIFIED": 0, + "OWNER": 1, + "AUTHENTICATED": 2, + "PUBLIC": 3, + } +) + +func (x WorkspaceApp_SharingLevel) Enum() *WorkspaceApp_SharingLevel { + p := new(WorkspaceApp_SharingLevel) + *p = x + return p +} + +func (x WorkspaceApp_SharingLevel) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WorkspaceApp_SharingLevel) Descriptor() protoreflect.EnumDescriptor { + return file_agent_proto_agent_proto_enumTypes[1].Descriptor() +} + +func (WorkspaceApp_SharingLevel) Type() protoreflect.EnumType { + return &file_agent_proto_agent_proto_enumTypes[1] +} + +func (x WorkspaceApp_SharingLevel) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WorkspaceApp_SharingLevel.Descriptor instead. +func (WorkspaceApp_SharingLevel) EnumDescriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{0, 0} +} + +type WorkspaceApp_Health int32 + +const ( + WorkspaceApp_HEALTH_UNSPECIFIED WorkspaceApp_Health = 0 + WorkspaceApp_DISABLED WorkspaceApp_Health = 1 + WorkspaceApp_INITIALIZING WorkspaceApp_Health = 2 + WorkspaceApp_HEALTHY WorkspaceApp_Health = 3 + WorkspaceApp_UNHEALTHY WorkspaceApp_Health = 4 +) + +// Enum value maps for WorkspaceApp_Health. +var ( + WorkspaceApp_Health_name = map[int32]string{ + 0: "HEALTH_UNSPECIFIED", + 1: "DISABLED", + 2: "INITIALIZING", + 3: "HEALTHY", + 4: "UNHEALTHY", + } + WorkspaceApp_Health_value = map[string]int32{ + "HEALTH_UNSPECIFIED": 0, + "DISABLED": 1, + "INITIALIZING": 2, + "HEALTHY": 3, + "UNHEALTHY": 4, + } +) + +func (x WorkspaceApp_Health) Enum() *WorkspaceApp_Health { + p := new(WorkspaceApp_Health) + *p = x + return p +} + +func (x WorkspaceApp_Health) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WorkspaceApp_Health) Descriptor() protoreflect.EnumDescriptor { + return file_agent_proto_agent_proto_enumTypes[2].Descriptor() +} + +func (WorkspaceApp_Health) Type() protoreflect.EnumType { + return &file_agent_proto_agent_proto_enumTypes[2] +} + +func (x WorkspaceApp_Health) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WorkspaceApp_Health.Descriptor instead. +func (WorkspaceApp_Health) EnumDescriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{0, 1} +} + +type Stats_Metric_Type int32 + +const ( + Stats_Metric_TYPE_UNSPECIFIED Stats_Metric_Type = 0 + Stats_Metric_COUNTER Stats_Metric_Type = 1 + Stats_Metric_GAUGE Stats_Metric_Type = 2 +) + +// Enum value maps for Stats_Metric_Type. +var ( + Stats_Metric_Type_name = map[int32]string{ + 0: "TYPE_UNSPECIFIED", + 1: "COUNTER", + 2: "GAUGE", + } + Stats_Metric_Type_value = map[string]int32{ + "TYPE_UNSPECIFIED": 0, + "COUNTER": 1, + "GAUGE": 2, + } +) + +func (x Stats_Metric_Type) Enum() *Stats_Metric_Type { + p := new(Stats_Metric_Type) + *p = x + return p +} + +func (x Stats_Metric_Type) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Stats_Metric_Type) Descriptor() protoreflect.EnumDescriptor { + return file_agent_proto_agent_proto_enumTypes[3].Descriptor() +} + +func (Stats_Metric_Type) Type() protoreflect.EnumType { + return &file_agent_proto_agent_proto_enumTypes[3] +} + +func (x Stats_Metric_Type) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Stats_Metric_Type.Descriptor instead. +func (Stats_Metric_Type) EnumDescriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{5, 1, 0} +} + +type Lifecycle_State int32 + +const ( + Lifecycle_STATE_UNSPECIFIED Lifecycle_State = 0 + Lifecycle_CREATED Lifecycle_State = 1 + Lifecycle_STARTED Lifecycle_State = 2 + Lifecycle_START_TIMEOUT Lifecycle_State = 3 + Lifecycle_START_ERROR Lifecycle_State = 4 + Lifecycle_READY Lifecycle_State = 5 + Lifecycle_SHUTTING_DOWN Lifecycle_State = 6 + Lifecycle_SHUTDOWN_TIMEOUT Lifecycle_State = 7 + Lifecycle_SHUTDOWN_ERROR Lifecycle_State = 8 + Lifecycle_OFF Lifecycle_State = 9 +) + +// Enum value maps for Lifecycle_State. +var ( + Lifecycle_State_name = map[int32]string{ + 0: "STATE_UNSPECIFIED", + 1: "CREATED", + 2: "STARTED", + 3: "START_TIMEOUT", + 4: "START_ERROR", + 5: "READY", + 6: "SHUTTING_DOWN", + 7: "SHUTDOWN_TIMEOUT", + 8: "SHUTDOWN_ERROR", + 9: "OFF", + } + Lifecycle_State_value = map[string]int32{ + "STATE_UNSPECIFIED": 0, + "CREATED": 1, + "STARTED": 2, + "START_TIMEOUT": 3, + "START_ERROR": 4, + "READY": 5, + "SHUTTING_DOWN": 6, + "SHUTDOWN_TIMEOUT": 7, + "SHUTDOWN_ERROR": 8, + "OFF": 9, + } +) + +func (x Lifecycle_State) Enum() *Lifecycle_State { + p := new(Lifecycle_State) + *p = x + return p +} + +func (x Lifecycle_State) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Lifecycle_State) Descriptor() protoreflect.EnumDescriptor { + return file_agent_proto_agent_proto_enumTypes[4].Descriptor() +} + +func (Lifecycle_State) Type() protoreflect.EnumType { + return &file_agent_proto_agent_proto_enumTypes[4] +} + +func (x Lifecycle_State) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Lifecycle_State.Descriptor instead. +func (Lifecycle_State) EnumDescriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{8, 0} +} + +type Log_Level int32 + +const ( + Log_LEVEL_UNSPECIFIED Log_Level = 0 + Log_TRACE Log_Level = 1 + Log_DEBUG Log_Level = 2 + Log_INFO Log_Level = 3 + Log_WARN Log_Level = 4 + Log_ERROR Log_Level = 5 +) + +// Enum value maps for Log_Level. +var ( + Log_Level_name = map[int32]string{ + 0: "LEVEL_UNSPECIFIED", + 1: "TRACE", + 2: "DEBUG", + 3: "INFO", + 4: "WARN", + 5: "ERROR", + } + Log_Level_value = map[string]int32{ + "LEVEL_UNSPECIFIED": 0, + "TRACE": 1, + "DEBUG": 2, + "INFO": 3, + "WARN": 4, + "ERROR": 5, + } +) + +func (x Log_Level) Enum() *Log_Level { + p := new(Log_Level) + *p = x + return p +} + +func (x Log_Level) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Log_Level) Descriptor() protoreflect.EnumDescriptor { + return file_agent_proto_agent_proto_enumTypes[5].Descriptor() +} + +func (Log_Level) Type() protoreflect.EnumType { + return &file_agent_proto_agent_proto_enumTypes[5] +} + +func (x Log_Level) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Log_Level.Descriptor instead. +func (Log_Level) EnumDescriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{17, 0} +} + +type WorkspaceApp struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Uuid []byte `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,omitempty"` + Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` + External bool `protobuf:"varint,3,opt,name=external,proto3" json:"external,omitempty"` + Slug string `protobuf:"bytes,4,opt,name=slug,proto3" json:"slug,omitempty"` + DisplayName string `protobuf:"bytes,5,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + Command string `protobuf:"bytes,6,opt,name=command,proto3" json:"command,omitempty"` + Icon string `protobuf:"bytes,7,opt,name=icon,proto3" json:"icon,omitempty"` + Subdomain bool `protobuf:"varint,8,opt,name=subdomain,proto3" json:"subdomain,omitempty"` + SubdomainName string `protobuf:"bytes,9,opt,name=subdomain_name,json=subdomainName,proto3" json:"subdomain_name,omitempty"` + SharingLevel WorkspaceApp_SharingLevel `protobuf:"varint,10,opt,name=sharing_level,json=sharingLevel,proto3,enum=coder.agent.v2.WorkspaceApp_SharingLevel" json:"sharing_level,omitempty"` + Healthcheck *WorkspaceApp_HealthCheck `protobuf:"bytes,11,opt,name=healthcheck,proto3" json:"healthcheck,omitempty"` + Health WorkspaceApp_Health `protobuf:"varint,12,opt,name=health,proto3,enum=coder.agent.v2.WorkspaceApp_Health" json:"health,omitempty"` +} + +func (x *WorkspaceApp) Reset() { + *x = WorkspaceApp{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WorkspaceApp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkspaceApp) ProtoMessage() {} + +func (x *WorkspaceApp) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkspaceApp.ProtoReflect.Descriptor instead. +func (*WorkspaceApp) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *WorkspaceApp) GetUuid() []byte { + if x != nil { + return x.Uuid + } + return nil +} + +func (x *WorkspaceApp) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *WorkspaceApp) GetExternal() bool { + if x != nil { + return x.External + } + return false +} + +func (x *WorkspaceApp) GetSlug() string { + if x != nil { + return x.Slug + } + return "" +} + +func (x *WorkspaceApp) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *WorkspaceApp) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *WorkspaceApp) GetIcon() string { + if x != nil { + return x.Icon + } + return "" +} + +func (x *WorkspaceApp) GetSubdomain() bool { + if x != nil { + return x.Subdomain + } + return false +} + +func (x *WorkspaceApp) GetSubdomainName() string { + if x != nil { + return x.SubdomainName + } + return "" +} + +func (x *WorkspaceApp) GetSharingLevel() WorkspaceApp_SharingLevel { + if x != nil { + return x.SharingLevel + } + return WorkspaceApp_SHARING_LEVEL_UNSPECIFIED +} + +func (x *WorkspaceApp) GetHealthcheck() *WorkspaceApp_HealthCheck { + if x != nil { + return x.Healthcheck + } + return nil +} + +func (x *WorkspaceApp) GetHealth() WorkspaceApp_Health { + if x != nil { + return x.Health + } + return WorkspaceApp_HEALTH_UNSPECIFIED +} + +type Manifest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + GitAuthConfigs uint32 `protobuf:"varint,1,opt,name=git_auth_configs,json=gitAuthConfigs,proto3" json:"git_auth_configs,omitempty"` + VsCodePortProxyUri string `protobuf:"bytes,2,opt,name=vs_code_port_proxy_uri,json=vsCodePortProxyUri,proto3" json:"vs_code_port_proxy_uri,omitempty"` + Apps []*WorkspaceApp `protobuf:"bytes,3,rep,name=apps,proto3" json:"apps,omitempty"` + DerpMap *proto.DERPMap `protobuf:"bytes,4,opt,name=derp_map,json=derpMap,proto3" json:"derp_map,omitempty"` +} + +func (x *Manifest) Reset() { + *x = Manifest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Manifest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Manifest) ProtoMessage() {} + +func (x *Manifest) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Manifest.ProtoReflect.Descriptor instead. +func (*Manifest) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *Manifest) GetGitAuthConfigs() uint32 { + if x != nil { + return x.GitAuthConfigs + } + return 0 +} + +func (x *Manifest) GetVsCodePortProxyUri() string { + if x != nil { + return x.VsCodePortProxyUri + } + return "" +} + +func (x *Manifest) GetApps() []*WorkspaceApp { + if x != nil { + return x.Apps + } + return nil +} + +func (x *Manifest) GetDerpMap() *proto.DERPMap { + if x != nil { + return x.DerpMap + } + return nil +} + +type GetManifestRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *GetManifestRequest) Reset() { + *x = GetManifestRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetManifestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetManifestRequest) ProtoMessage() {} + +func (x *GetManifestRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetManifestRequest.ProtoReflect.Descriptor instead. +func (*GetManifestRequest) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{2} +} + +type ServiceBanner struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + BackgroundColor string `protobuf:"bytes,3,opt,name=background_color,json=backgroundColor,proto3" json:"background_color,omitempty"` +} + +func (x *ServiceBanner) Reset() { + *x = ServiceBanner{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ServiceBanner) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceBanner) ProtoMessage() {} + +func (x *ServiceBanner) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServiceBanner.ProtoReflect.Descriptor instead. +func (*ServiceBanner) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *ServiceBanner) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *ServiceBanner) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ServiceBanner) GetBackgroundColor() string { + if x != nil { + return x.BackgroundColor + } + return "" +} + +type GetServiceBannerRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *GetServiceBannerRequest) Reset() { + *x = GetServiceBannerRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetServiceBannerRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetServiceBannerRequest) ProtoMessage() {} + +func (x *GetServiceBannerRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetServiceBannerRequest.ProtoReflect.Descriptor instead. +func (*GetServiceBannerRequest) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{4} +} + +type Stats struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // ConnectionsByProto is a count of connections by protocol. + ConnectionsByProto map[string]int64 `protobuf:"bytes,1,rep,name=connections_by_proto,json=connectionsByProto,proto3" json:"connections_by_proto,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + // ConnectionCount is the number of connections received by an agent. + ConnectionCount int64 `protobuf:"varint,2,opt,name=connection_count,json=connectionCount,proto3" json:"connection_count,omitempty"` + // ConnectionMedianLatencyMS is the median latency of all connections in milliseconds. + ConnectionMedianLatencyMs float64 `protobuf:"fixed64,3,opt,name=connection_median_latency_ms,json=connectionMedianLatencyMs,proto3" json:"connection_median_latency_ms,omitempty"` + // RxPackets is the number of received packets. + RxPackets int64 `protobuf:"varint,4,opt,name=rx_packets,json=rxPackets,proto3" json:"rx_packets,omitempty"` + // RxBytes is the number of received bytes. + RxBytes int64 `protobuf:"varint,5,opt,name=rx_bytes,json=rxBytes,proto3" json:"rx_bytes,omitempty"` + // TxPackets is the number of transmitted bytes. + TxPackets int64 `protobuf:"varint,6,opt,name=tx_packets,json=txPackets,proto3" json:"tx_packets,omitempty"` + // TxBytes is the number of transmitted bytes. + TxBytes int64 `protobuf:"varint,7,opt,name=tx_bytes,json=txBytes,proto3" json:"tx_bytes,omitempty"` + // SessionCountVSCode is the number of connections received by an agent + // that are from our VS Code extension. + SessionCountVscode int64 `protobuf:"varint,8,opt,name=session_count_vscode,json=sessionCountVscode,proto3" json:"session_count_vscode,omitempty"` + // SessionCountJetBrains is the number of connections received by an agent + // that are from our JetBrains extension. + SessionCountJetbrains int64 `protobuf:"varint,9,opt,name=session_count_jetbrains,json=sessionCountJetbrains,proto3" json:"session_count_jetbrains,omitempty"` + // SessionCountReconnectingPTY is the number of connections received by an agent + // that are from the reconnecting web terminal. + SessionCountReconnectingPty int64 `protobuf:"varint,10,opt,name=session_count_reconnecting_pty,json=sessionCountReconnectingPty,proto3" json:"session_count_reconnecting_pty,omitempty"` + // SessionCountSSH is the number of connections received by an agent + // that are normal, non-tagged SSH sessions. + SessionCountSsh int64 `protobuf:"varint,11,opt,name=session_count_ssh,json=sessionCountSsh,proto3" json:"session_count_ssh,omitempty"` +} + +func (x *Stats) Reset() { + *x = Stats{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Stats) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Stats) ProtoMessage() {} + +func (x *Stats) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Stats.ProtoReflect.Descriptor instead. +func (*Stats) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *Stats) GetConnectionsByProto() map[string]int64 { + if x != nil { + return x.ConnectionsByProto + } + return nil +} + +func (x *Stats) GetConnectionCount() int64 { + if x != nil { + return x.ConnectionCount + } + return 0 +} + +func (x *Stats) GetConnectionMedianLatencyMs() float64 { + if x != nil { + return x.ConnectionMedianLatencyMs + } + return 0 +} + +func (x *Stats) GetRxPackets() int64 { + if x != nil { + return x.RxPackets + } + return 0 +} + +func (x *Stats) GetRxBytes() int64 { + if x != nil { + return x.RxBytes + } + return 0 +} + +func (x *Stats) GetTxPackets() int64 { + if x != nil { + return x.TxPackets + } + return 0 +} + +func (x *Stats) GetTxBytes() int64 { + if x != nil { + return x.TxBytes + } + return 0 +} + +func (x *Stats) GetSessionCountVscode() int64 { + if x != nil { + return x.SessionCountVscode + } + return 0 +} + +func (x *Stats) GetSessionCountJetbrains() int64 { + if x != nil { + return x.SessionCountJetbrains + } + return 0 +} + +func (x *Stats) GetSessionCountReconnectingPty() int64 { + if x != nil { + return x.SessionCountReconnectingPty + } + return 0 +} + +func (x *Stats) GetSessionCountSsh() int64 { + if x != nil { + return x.SessionCountSsh + } + return 0 +} + +type UpdateStatsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Stats *Stats `protobuf:"bytes,1,opt,name=stats,proto3" json:"stats,omitempty"` +} + +func (x *UpdateStatsRequest) Reset() { + *x = UpdateStatsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateStatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateStatsRequest) ProtoMessage() {} + +func (x *UpdateStatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateStatsRequest.ProtoReflect.Descriptor instead. +func (*UpdateStatsRequest) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{6} +} + +func (x *UpdateStatsRequest) GetStats() *Stats { + if x != nil { + return x.Stats + } + return nil +} + +type UpdateStatsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ReportIntervalNanoseconds *durationpb.Duration `protobuf:"bytes,1,opt,name=report_interval_nanoseconds,json=reportIntervalNanoseconds,proto3" json:"report_interval_nanoseconds,omitempty"` +} + +func (x *UpdateStatsResponse) Reset() { + *x = UpdateStatsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateStatsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateStatsResponse) ProtoMessage() {} + +func (x *UpdateStatsResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateStatsResponse.ProtoReflect.Descriptor instead. +func (*UpdateStatsResponse) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{7} +} + +func (x *UpdateStatsResponse) GetReportIntervalNanoseconds() *durationpb.Duration { + if x != nil { + return x.ReportIntervalNanoseconds + } + return nil +} + +type Lifecycle struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + State Lifecycle_State `protobuf:"varint,1,opt,name=state,proto3,enum=coder.agent.v2.Lifecycle_State" json:"state,omitempty"` +} + +func (x *Lifecycle) Reset() { + *x = Lifecycle{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Lifecycle) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Lifecycle) ProtoMessage() {} + +func (x *Lifecycle) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Lifecycle.ProtoReflect.Descriptor instead. +func (*Lifecycle) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{8} +} + +func (x *Lifecycle) GetState() Lifecycle_State { + if x != nil { + return x.State + } + return Lifecycle_STATE_UNSPECIFIED +} + +type UpdateLifecycleRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Lifecycle *Lifecycle `protobuf:"bytes,1,opt,name=lifecycle,proto3" json:"lifecycle,omitempty"` +} + +func (x *UpdateLifecycleRequest) Reset() { + *x = UpdateLifecycleRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateLifecycleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateLifecycleRequest) ProtoMessage() {} + +func (x *UpdateLifecycleRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateLifecycleRequest.ProtoReflect.Descriptor instead. +func (*UpdateLifecycleRequest) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{9} +} + +func (x *UpdateLifecycleRequest) GetLifecycle() *Lifecycle { + if x != nil { + return x.Lifecycle + } + return nil +} + +type BatchUpdateAppHealthRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Updates []*BatchUpdateAppHealthRequest_HealthUpdate `protobuf:"bytes,1,rep,name=updates,proto3" json:"updates,omitempty"` +} + +func (x *BatchUpdateAppHealthRequest) Reset() { + *x = BatchUpdateAppHealthRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BatchUpdateAppHealthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchUpdateAppHealthRequest) ProtoMessage() {} + +func (x *BatchUpdateAppHealthRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchUpdateAppHealthRequest.ProtoReflect.Descriptor instead. +func (*BatchUpdateAppHealthRequest) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{10} +} + +func (x *BatchUpdateAppHealthRequest) GetUpdates() []*BatchUpdateAppHealthRequest_HealthUpdate { + if x != nil { + return x.Updates + } + return nil +} + +type BatchUpdateAppHealthResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *BatchUpdateAppHealthResponse) Reset() { + *x = BatchUpdateAppHealthResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BatchUpdateAppHealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchUpdateAppHealthResponse) ProtoMessage() {} + +func (x *BatchUpdateAppHealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchUpdateAppHealthResponse.ProtoReflect.Descriptor instead. +func (*BatchUpdateAppHealthResponse) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{11} +} + +type Startup struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + ExpandedDirectory string `protobuf:"bytes,2,opt,name=expanded_directory,json=expandedDirectory,proto3" json:"expanded_directory,omitempty"` + Subsystems []string `protobuf:"bytes,3,rep,name=subsystems,proto3" json:"subsystems,omitempty"` +} + +func (x *Startup) Reset() { + *x = Startup{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Startup) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Startup) ProtoMessage() {} + +func (x *Startup) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Startup.ProtoReflect.Descriptor instead. +func (*Startup) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{12} +} + +func (x *Startup) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *Startup) GetExpandedDirectory() string { + if x != nil { + return x.ExpandedDirectory + } + return "" +} + +func (x *Startup) GetSubsystems() []string { + if x != nil { + return x.Subsystems + } + return nil +} + +type UpdateStartupRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Startup *Startup `protobuf:"bytes,1,opt,name=startup,proto3" json:"startup,omitempty"` +} + +func (x *UpdateStartupRequest) Reset() { + *x = UpdateStartupRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UpdateStartupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateStartupRequest) ProtoMessage() {} + +func (x *UpdateStartupRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateStartupRequest.ProtoReflect.Descriptor instead. +func (*UpdateStartupRequest) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{13} +} + +func (x *UpdateStartupRequest) GetStartup() *Startup { + if x != nil { + return x.Startup + } + return nil +} + +type Metadata struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + CollectedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=collected_at,json=collectedAt,proto3" json:"collected_at,omitempty"` + Age int64 `protobuf:"varint,3,opt,name=age,proto3" json:"age,omitempty"` + Value string `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"` + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *Metadata) Reset() { + *x = Metadata{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Metadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Metadata) ProtoMessage() {} + +func (x *Metadata) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Metadata.ProtoReflect.Descriptor instead. +func (*Metadata) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{14} +} + +func (x *Metadata) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *Metadata) GetCollectedAt() *timestamppb.Timestamp { + if x != nil { + return x.CollectedAt + } + return nil +} + +func (x *Metadata) GetAge() int64 { + if x != nil { + return x.Age + } + return 0 +} + +func (x *Metadata) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *Metadata) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type BatchUpdateMetadataRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Metadata []*Metadata `protobuf:"bytes,2,rep,name=metadata,proto3" json:"metadata,omitempty"` +} + +func (x *BatchUpdateMetadataRequest) Reset() { + *x = BatchUpdateMetadataRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BatchUpdateMetadataRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchUpdateMetadataRequest) ProtoMessage() {} + +func (x *BatchUpdateMetadataRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchUpdateMetadataRequest.ProtoReflect.Descriptor instead. +func (*BatchUpdateMetadataRequest) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{15} +} + +func (x *BatchUpdateMetadataRequest) GetMetadata() []*Metadata { + if x != nil { + return x.Metadata + } + return nil +} + +type BatchUpdateMetadataResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *BatchUpdateMetadataResponse) Reset() { + *x = BatchUpdateMetadataResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BatchUpdateMetadataResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchUpdateMetadataResponse) ProtoMessage() {} + +func (x *BatchUpdateMetadataResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchUpdateMetadataResponse.ProtoReflect.Descriptor instead. +func (*BatchUpdateMetadataResponse) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{16} +} + +type Log struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + Output string `protobuf:"bytes,2,opt,name=output,proto3" json:"output,omitempty"` + Level Log_Level `protobuf:"varint,3,opt,name=level,proto3,enum=coder.agent.v2.Log_Level" json:"level,omitempty"` +} + +func (x *Log) Reset() { + *x = Log{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Log) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Log) ProtoMessage() {} + +func (x *Log) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Log.ProtoReflect.Descriptor instead. +func (*Log) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{17} +} + +func (x *Log) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *Log) GetOutput() string { + if x != nil { + return x.Output + } + return "" +} + +func (x *Log) GetLevel() Log_Level { + if x != nil { + return x.Level + } + return Log_LEVEL_UNSPECIFIED +} + +type BatchCreateLogsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SourceId []byte `protobuf:"bytes,1,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"` + Logs []*Log `protobuf:"bytes,2,rep,name=logs,proto3" json:"logs,omitempty"` +} + +func (x *BatchCreateLogsRequest) Reset() { + *x = BatchCreateLogsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BatchCreateLogsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchCreateLogsRequest) ProtoMessage() {} + +func (x *BatchCreateLogsRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchCreateLogsRequest.ProtoReflect.Descriptor instead. +func (*BatchCreateLogsRequest) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{18} +} + +func (x *BatchCreateLogsRequest) GetSourceId() []byte { + if x != nil { + return x.SourceId + } + return nil +} + +func (x *BatchCreateLogsRequest) GetLogs() []*Log { + if x != nil { + return x.Logs + } + return nil +} + +type BatchCreateLogsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *BatchCreateLogsResponse) Reset() { + *x = BatchCreateLogsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BatchCreateLogsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchCreateLogsResponse) ProtoMessage() {} + +func (x *BatchCreateLogsResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[19] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchCreateLogsResponse.ProtoReflect.Descriptor instead. +func (*BatchCreateLogsResponse) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{19} +} + +type WorkspaceApp_HealthCheck struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Interval int32 `protobuf:"varint,2,opt,name=interval,proto3" json:"interval,omitempty"` + Threshold int32 `protobuf:"varint,3,opt,name=threshold,proto3" json:"threshold,omitempty"` +} + +func (x *WorkspaceApp_HealthCheck) Reset() { + *x = WorkspaceApp_HealthCheck{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *WorkspaceApp_HealthCheck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkspaceApp_HealthCheck) ProtoMessage() {} + +func (x *WorkspaceApp_HealthCheck) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[20] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkspaceApp_HealthCheck.ProtoReflect.Descriptor instead. +func (*WorkspaceApp_HealthCheck) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *WorkspaceApp_HealthCheck) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *WorkspaceApp_HealthCheck) GetInterval() int32 { + if x != nil { + return x.Interval + } + return 0 +} + +func (x *WorkspaceApp_HealthCheck) GetThreshold() int32 { + if x != nil { + return x.Threshold + } + return 0 +} + +type Stats_Metric struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type Stats_Metric_Type `protobuf:"varint,2,opt,name=type,proto3,enum=coder.agent.v2.Stats_Metric_Type" json:"type,omitempty"` + Value float64 `protobuf:"fixed64,3,opt,name=value,proto3" json:"value,omitempty"` + Labels map[string]string `protobuf:"bytes,4,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *Stats_Metric) Reset() { + *x = Stats_Metric{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Stats_Metric) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Stats_Metric) ProtoMessage() {} + +func (x *Stats_Metric) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[22] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Stats_Metric.ProtoReflect.Descriptor instead. +func (*Stats_Metric) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{5, 1} +} + +func (x *Stats_Metric) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Stats_Metric) GetType() Stats_Metric_Type { + if x != nil { + return x.Type + } + return Stats_Metric_TYPE_UNSPECIFIED +} + +func (x *Stats_Metric) GetValue() float64 { + if x != nil { + return x.Value + } + return 0 +} + +func (x *Stats_Metric) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +type BatchUpdateAppHealthRequest_HealthUpdate struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Uuid []byte `protobuf:"bytes,1,opt,name=uuid,proto3" json:"uuid,omitempty"` + Health AppHealth `protobuf:"varint,2,opt,name=health,proto3,enum=coder.agent.v2.AppHealth" json:"health,omitempty"` +} + +func (x *BatchUpdateAppHealthRequest_HealthUpdate) Reset() { + *x = BatchUpdateAppHealthRequest_HealthUpdate{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_proto_agent_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BatchUpdateAppHealthRequest_HealthUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchUpdateAppHealthRequest_HealthUpdate) ProtoMessage() {} + +func (x *BatchUpdateAppHealthRequest_HealthUpdate) ProtoReflect() protoreflect.Message { + mi := &file_agent_proto_agent_proto_msgTypes[24] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchUpdateAppHealthRequest_HealthUpdate.ProtoReflect.Descriptor instead. +func (*BatchUpdateAppHealthRequest_HealthUpdate) Descriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{10, 0} +} + +func (x *BatchUpdateAppHealthRequest_HealthUpdate) GetUuid() []byte { + if x != nil { + return x.Uuid + } + return nil +} + +func (x *BatchUpdateAppHealthRequest_HealthUpdate) GetHealth() AppHealth { + if x != nil { + return x.Health + } + return AppHealth_APP_HEALTH_UNSPECIFIED +} + +var File_agent_proto_agent_proto protoreflect.FileDescriptor + +var file_agent_proto_agent_proto_rawDesc = []byte{ + 0x0a, 0x17, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x1a, 0x1b, 0x74, 0x61, 0x69, 0x6c, 0x6e, + 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe5, 0x05, 0x0a, 0x0c, 0x57, 0x6f, 0x72, 0x6b, + 0x73, 0x70, 0x61, 0x63, 0x65, 0x41, 0x70, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x75, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x75, 0x75, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, + 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1a, + 0x0a, 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, + 0x75, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x12, 0x21, + 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, + 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x69, + 0x63, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x12, + 0x1c, 0x0a, 0x09, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x09, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, 0x0a, + 0x0e, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x4e, 0x0a, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x5f, + 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x29, 0x2e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, + 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x41, 0x70, 0x70, 0x2e, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, + 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x0c, 0x73, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, + 0x65, 0x76, 0x65, 0x6c, 0x12, 0x4a, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, + 0x65, 0x63, 0x6b, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x41, 0x70, 0x70, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, + 0x65, 0x63, 0x6b, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, + 0x12, 0x3b, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x41, 0x70, 0x70, 0x2e, 0x48, + 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x06, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x1a, 0x59, 0x0a, + 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, + 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1a, + 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, + 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, + 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, + 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, 0x57, 0x0a, 0x0c, 0x53, 0x68, 0x61, 0x72, + 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x19, 0x53, 0x48, 0x41, 0x52, + 0x49, 0x4e, 0x47, 0x5f, 0x4c, 0x45, 0x56, 0x45, 0x4c, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, + 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, + 0x10, 0x01, 0x12, 0x11, 0x0a, 0x0d, 0x41, 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, + 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, + 0x03, 0x22, 0x5c, 0x0a, 0x06, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x12, 0x48, + 0x45, 0x41, 0x4c, 0x54, 0x48, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, + 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, + 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x49, 0x4e, 0x49, 0x54, 0x49, 0x41, 0x4c, 0x49, 0x5a, 0x49, 0x4e, + 0x47, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x03, + 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x04, 0x22, + 0xd0, 0x01, 0x0a, 0x08, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x10, + 0x67, 0x69, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x67, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x32, 0x0a, 0x16, 0x76, 0x73, 0x5f, 0x63, 0x6f, 0x64, + 0x65, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x75, 0x72, 0x69, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x76, 0x73, 0x43, 0x6f, 0x64, 0x65, 0x50, 0x6f, + 0x72, 0x74, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x55, 0x72, 0x69, 0x12, 0x30, 0x0a, 0x04, 0x61, 0x70, + 0x70, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x41, 0x70, 0x70, 0x52, 0x04, 0x61, 0x70, 0x70, 0x73, 0x12, 0x34, 0x0a, 0x08, + 0x64, 0x65, 0x72, 0x70, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x44, 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, 0x52, 0x07, 0x64, 0x65, 0x72, 0x70, 0x4d, + 0x61, 0x70, 0x22, 0x14, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, + 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x6e, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, + 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x29, 0x0a, + 0x10, 0x62, 0x61, 0x63, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x63, 0x6f, 0x6c, 0x6f, + 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x62, 0x61, 0x63, 0x6b, 0x67, 0x72, 0x6f, + 0x75, 0x6e, 0x64, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x22, 0x19, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x22, 0x89, 0x07, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x5f, 0x0a, + 0x14, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x5f, 0x62, 0x79, 0x5f, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x61, + 0x74, 0x73, 0x2e, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, + 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x12, 0x63, 0x6f, 0x6e, 0x6e, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x29, + 0x0a, 0x10, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x3f, 0x0a, 0x1c, 0x63, 0x6f, 0x6e, + 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x6e, 0x5f, 0x6c, + 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x5f, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x52, + 0x19, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x65, 0x64, 0x69, 0x61, + 0x6e, 0x4c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x4d, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x78, + 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, + 0x72, 0x78, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x78, 0x5f, + 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x72, 0x78, 0x42, + 0x79, 0x74, 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x78, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x65, + 0x74, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x78, 0x50, 0x61, 0x63, 0x6b, + 0x65, 0x74, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x78, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x74, 0x78, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x30, + 0x0a, 0x14, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, + 0x76, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x56, 0x73, 0x63, 0x6f, 0x64, 0x65, + 0x12, 0x36, 0x0a, 0x17, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x5f, 0x6a, 0x65, 0x74, 0x62, 0x72, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x15, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4a, + 0x65, 0x74, 0x62, 0x72, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x43, 0x0a, 0x1e, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x6e, 0x6e, + 0x65, 0x63, 0x74, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x74, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x1b, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, + 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x74, 0x79, 0x12, 0x2a, 0x0a, + 0x11, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x73, + 0x73, 0x68, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x73, 0x68, 0x1a, 0x45, 0x0a, 0x17, 0x43, 0x6f, 0x6e, + 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x79, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x1a, 0x9c, 0x02, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x12, 0x0a, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, + 0x35, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x21, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, + 0x74, 0x61, 0x74, 0x73, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x2e, 0x54, 0x79, 0x70, 0x65, + 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x40, 0x0a, 0x06, + 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, + 0x61, 0x74, 0x73, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, + 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x1a, 0x39, + 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x34, 0x0a, 0x04, 0x54, 0x79, 0x70, + 0x65, 0x12, 0x14, 0x0a, 0x10, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, + 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x4f, 0x55, 0x4e, 0x54, + 0x45, 0x52, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x47, 0x41, 0x55, 0x47, 0x45, 0x10, 0x02, 0x22, + 0x41, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2b, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x05, 0x73, 0x74, 0x61, + 0x74, 0x73, 0x22, 0x70, 0x0a, 0x13, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x1b, 0x72, 0x65, 0x70, + 0x6f, 0x72, 0x74, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x5f, 0x6e, 0x61, 0x6e, + 0x6f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x19, 0x72, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x4e, 0x61, 0x6e, 0x6f, 0x73, 0x65, 0x63, + 0x6f, 0x6e, 0x64, 0x73, 0x22, 0xf2, 0x01, 0x0a, 0x09, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, + 0x6c, 0x65, 0x12, 0x35, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x1f, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, + 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x2e, 0x53, 0x74, 0x61, + 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0xad, 0x01, 0x0a, 0x05, 0x53, 0x74, + 0x61, 0x74, 0x65, 0x12, 0x15, 0x0a, 0x11, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, + 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x52, + 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x41, 0x52, 0x54, + 0x45, 0x44, 0x10, 0x02, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x54, 0x41, 0x52, 0x54, 0x5f, 0x54, 0x49, + 0x4d, 0x45, 0x4f, 0x55, 0x54, 0x10, 0x03, 0x12, 0x0f, 0x0a, 0x0b, 0x53, 0x54, 0x41, 0x52, 0x54, + 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x52, 0x45, 0x41, 0x44, + 0x59, 0x10, 0x05, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x48, 0x55, 0x54, 0x54, 0x49, 0x4e, 0x47, 0x5f, + 0x44, 0x4f, 0x57, 0x4e, 0x10, 0x06, 0x12, 0x14, 0x0a, 0x10, 0x53, 0x48, 0x55, 0x54, 0x44, 0x4f, + 0x57, 0x4e, 0x5f, 0x54, 0x49, 0x4d, 0x45, 0x4f, 0x55, 0x54, 0x10, 0x07, 0x12, 0x12, 0x0a, 0x0e, + 0x53, 0x48, 0x55, 0x54, 0x44, 0x4f, 0x57, 0x4e, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x08, + 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x46, 0x46, 0x10, 0x09, 0x22, 0x51, 0x0a, 0x16, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x37, 0x0a, 0x09, 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, + 0x65, 0x52, 0x09, 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x22, 0xc8, 0x01, 0x0a, + 0x1b, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, + 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x52, 0x0a, 0x07, + 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, + 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, + 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x07, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, + 0x1a, 0x55, 0x0a, 0x0c, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x12, 0x12, 0x0a, 0x04, 0x75, 0x75, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, + 0x75, 0x75, 0x69, 0x64, 0x12, 0x31, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, + 0x06, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x22, 0x1e, 0x0a, 0x1c, 0x42, 0x61, 0x74, 0x63, 0x68, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x72, 0x0a, 0x07, 0x53, 0x74, 0x61, 0x72, 0x74, + 0x75, 0x70, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d, 0x0a, 0x12, + 0x65, 0x78, 0x70, 0x61, 0x6e, 0x64, 0x65, 0x64, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, + 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x65, 0x78, 0x70, 0x61, 0x6e, 0x64, + 0x65, 0x64, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x73, + 0x75, 0x62, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x0a, 0x73, 0x75, 0x62, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x73, 0x22, 0x49, 0x0a, 0x14, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x07, 0x73, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x52, 0x07, 0x73, + 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x22, 0x99, 0x01, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x3d, 0x0a, 0x0c, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0b, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x41, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x03, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x14, 0x0a, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, + 0x6f, 0x72, 0x22, 0x52, 0x0a, 0x1a, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x34, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x2e, 0x76, 0x32, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0x1d, 0x0a, 0x1b, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xde, 0x01, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x39, 0x0a, + 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, + 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, + 0x12, 0x2f, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, + 0x2e, 0x4c, 0x6f, 0x67, 0x2e, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, + 0x6c, 0x22, 0x53, 0x0a, 0x05, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x15, 0x0a, 0x11, 0x4c, 0x45, + 0x56, 0x45, 0x4c, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, + 0x00, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, 0x43, 0x45, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, + 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, + 0x03, 0x12, 0x08, 0x0a, 0x04, 0x57, 0x41, 0x52, 0x4e, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x45, + 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x22, 0x5e, 0x0a, 0x16, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x12, 0x27, 0x0a, + 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x6f, 0x67, + 0x52, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x22, 0x19, 0x0a, 0x17, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x2a, 0x63, 0x0a, 0x09, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x12, 0x1a, + 0x0a, 0x16, 0x41, 0x50, 0x50, 0x5f, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x5f, 0x55, 0x4e, 0x53, + 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x49, + 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x49, 0x4e, 0x49, 0x54, + 0x49, 0x41, 0x4c, 0x49, 0x5a, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x45, + 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x03, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x48, 0x45, 0x41, + 0x4c, 0x54, 0x48, 0x59, 0x10, 0x04, 0x32, 0xb2, 0x07, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, + 0x12, 0x4b, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, + 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, + 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x5a, 0x0a, + 0x10, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, + 0x72, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, + 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, + 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x56, 0x0a, 0x0b, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x54, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, + 0x79, 0x63, 0x6c, 0x65, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, + 0x63, 0x79, 0x63, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, + 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x72, 0x0a, 0x15, 0x42, 0x61, 0x74, 0x63, 0x68, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x73, + 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, + 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, + 0x6c, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0d, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12, 0x24, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12, 0x6e, 0x0a, 0x13, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x12, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x26, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x56, 0x0a, 0x0e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x44, 0x45, 0x52, 0x50, 0x4d, 0x61, 0x70, + 0x73, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, + 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x44, 0x45, 0x52, 0x50, 0x4d, + 0x61, 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x45, + 0x52, 0x50, 0x4d, 0x61, 0x70, 0x30, 0x01, 0x12, 0x62, 0x0a, 0x11, 0x43, 0x6f, 0x6f, 0x72, 0x64, + 0x69, 0x6e, 0x61, 0x74, 0x65, 0x54, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x12, 0x23, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x32, 0x2e, + 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x74, 0x61, 0x69, 0x6c, 0x6e, 0x65, + 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x27, 0x5a, 0x25, 0x67, + 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_agent_proto_agent_proto_rawDescOnce sync.Once + file_agent_proto_agent_proto_rawDescData = file_agent_proto_agent_proto_rawDesc +) + +func file_agent_proto_agent_proto_rawDescGZIP() []byte { + file_agent_proto_agent_proto_rawDescOnce.Do(func() { + file_agent_proto_agent_proto_rawDescData = protoimpl.X.CompressGZIP(file_agent_proto_agent_proto_rawDescData) + }) + return file_agent_proto_agent_proto_rawDescData +} + +var file_agent_proto_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 6) +var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_agent_proto_agent_proto_goTypes = []interface{}{ + (AppHealth)(0), // 0: coder.agent.v2.AppHealth + (WorkspaceApp_SharingLevel)(0), // 1: coder.agent.v2.WorkspaceApp.SharingLevel + (WorkspaceApp_Health)(0), // 2: coder.agent.v2.WorkspaceApp.Health + (Stats_Metric_Type)(0), // 3: coder.agent.v2.Stats.Metric.Type + (Lifecycle_State)(0), // 4: coder.agent.v2.Lifecycle.State + (Log_Level)(0), // 5: coder.agent.v2.Log.Level + (*WorkspaceApp)(nil), // 6: coder.agent.v2.WorkspaceApp + (*Manifest)(nil), // 7: coder.agent.v2.Manifest + (*GetManifestRequest)(nil), // 8: coder.agent.v2.GetManifestRequest + (*ServiceBanner)(nil), // 9: coder.agent.v2.ServiceBanner + (*GetServiceBannerRequest)(nil), // 10: coder.agent.v2.GetServiceBannerRequest + (*Stats)(nil), // 11: coder.agent.v2.Stats + (*UpdateStatsRequest)(nil), // 12: coder.agent.v2.UpdateStatsRequest + (*UpdateStatsResponse)(nil), // 13: coder.agent.v2.UpdateStatsResponse + (*Lifecycle)(nil), // 14: coder.agent.v2.Lifecycle + (*UpdateLifecycleRequest)(nil), // 15: coder.agent.v2.UpdateLifecycleRequest + (*BatchUpdateAppHealthRequest)(nil), // 16: coder.agent.v2.BatchUpdateAppHealthRequest + (*BatchUpdateAppHealthResponse)(nil), // 17: coder.agent.v2.BatchUpdateAppHealthResponse + (*Startup)(nil), // 18: coder.agent.v2.Startup + (*UpdateStartupRequest)(nil), // 19: coder.agent.v2.UpdateStartupRequest + (*Metadata)(nil), // 20: coder.agent.v2.Metadata + (*BatchUpdateMetadataRequest)(nil), // 21: coder.agent.v2.BatchUpdateMetadataRequest + (*BatchUpdateMetadataResponse)(nil), // 22: coder.agent.v2.BatchUpdateMetadataResponse + (*Log)(nil), // 23: coder.agent.v2.Log + (*BatchCreateLogsRequest)(nil), // 24: coder.agent.v2.BatchCreateLogsRequest + (*BatchCreateLogsResponse)(nil), // 25: coder.agent.v2.BatchCreateLogsResponse + (*WorkspaceApp_HealthCheck)(nil), // 26: coder.agent.v2.WorkspaceApp.HealthCheck + nil, // 27: coder.agent.v2.Stats.ConnectionsByProtoEntry + (*Stats_Metric)(nil), // 28: coder.agent.v2.Stats.Metric + nil, // 29: coder.agent.v2.Stats.Metric.LabelsEntry + (*BatchUpdateAppHealthRequest_HealthUpdate)(nil), // 30: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate + (*proto.DERPMap)(nil), // 31: coder.tailnet.v2.DERPMap + (*durationpb.Duration)(nil), // 32: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 33: google.protobuf.Timestamp + (*proto.StreamDERPMapsRequest)(nil), // 34: coder.tailnet.v2.StreamDERPMapsRequest + (*proto.CoordinateRequest)(nil), // 35: coder.tailnet.v2.CoordinateRequest + (*proto.CoordinateResponse)(nil), // 36: coder.tailnet.v2.CoordinateResponse +} +var file_agent_proto_agent_proto_depIdxs = []int32{ + 1, // 0: coder.agent.v2.WorkspaceApp.sharing_level:type_name -> coder.agent.v2.WorkspaceApp.SharingLevel + 26, // 1: coder.agent.v2.WorkspaceApp.healthcheck:type_name -> coder.agent.v2.WorkspaceApp.HealthCheck + 2, // 2: coder.agent.v2.WorkspaceApp.health:type_name -> coder.agent.v2.WorkspaceApp.Health + 6, // 3: coder.agent.v2.Manifest.apps:type_name -> coder.agent.v2.WorkspaceApp + 31, // 4: coder.agent.v2.Manifest.derp_map:type_name -> coder.tailnet.v2.DERPMap + 27, // 5: coder.agent.v2.Stats.connections_by_proto:type_name -> coder.agent.v2.Stats.ConnectionsByProtoEntry + 11, // 6: coder.agent.v2.UpdateStatsRequest.stats:type_name -> coder.agent.v2.Stats + 32, // 7: coder.agent.v2.UpdateStatsResponse.report_interval_nanoseconds:type_name -> google.protobuf.Duration + 4, // 8: coder.agent.v2.Lifecycle.state:type_name -> coder.agent.v2.Lifecycle.State + 14, // 9: coder.agent.v2.UpdateLifecycleRequest.lifecycle:type_name -> coder.agent.v2.Lifecycle + 30, // 10: coder.agent.v2.BatchUpdateAppHealthRequest.updates:type_name -> coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate + 18, // 11: coder.agent.v2.UpdateStartupRequest.startup:type_name -> coder.agent.v2.Startup + 33, // 12: coder.agent.v2.Metadata.collected_at:type_name -> google.protobuf.Timestamp + 20, // 13: coder.agent.v2.BatchUpdateMetadataRequest.metadata:type_name -> coder.agent.v2.Metadata + 33, // 14: coder.agent.v2.Log.created_at:type_name -> google.protobuf.Timestamp + 5, // 15: coder.agent.v2.Log.level:type_name -> coder.agent.v2.Log.Level + 23, // 16: coder.agent.v2.BatchCreateLogsRequest.logs:type_name -> coder.agent.v2.Log + 3, // 17: coder.agent.v2.Stats.Metric.type:type_name -> coder.agent.v2.Stats.Metric.Type + 29, // 18: coder.agent.v2.Stats.Metric.labels:type_name -> coder.agent.v2.Stats.Metric.LabelsEntry + 0, // 19: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate.health:type_name -> coder.agent.v2.AppHealth + 8, // 20: coder.agent.v2.Agent.GetManifest:input_type -> coder.agent.v2.GetManifestRequest + 10, // 21: coder.agent.v2.Agent.GetServiceBanner:input_type -> coder.agent.v2.GetServiceBannerRequest + 12, // 22: coder.agent.v2.Agent.UpdateStats:input_type -> coder.agent.v2.UpdateStatsRequest + 15, // 23: coder.agent.v2.Agent.UpdateLifecycle:input_type -> coder.agent.v2.UpdateLifecycleRequest + 16, // 24: coder.agent.v2.Agent.BatchUpdateAppHealths:input_type -> coder.agent.v2.BatchUpdateAppHealthRequest + 19, // 25: coder.agent.v2.Agent.UpdateStartup:input_type -> coder.agent.v2.UpdateStartupRequest + 21, // 26: coder.agent.v2.Agent.BatchUpdateMetadata:input_type -> coder.agent.v2.BatchUpdateMetadataRequest + 24, // 27: coder.agent.v2.Agent.BatchCreateLogs:input_type -> coder.agent.v2.BatchCreateLogsRequest + 34, // 28: coder.agent.v2.Agent.StreamDERPMaps:input_type -> coder.tailnet.v2.StreamDERPMapsRequest + 35, // 29: coder.agent.v2.Agent.CoordinateTailnet:input_type -> coder.tailnet.v2.CoordinateRequest + 7, // 30: coder.agent.v2.Agent.GetManifest:output_type -> coder.agent.v2.Manifest + 9, // 31: coder.agent.v2.Agent.GetServiceBanner:output_type -> coder.agent.v2.ServiceBanner + 13, // 32: coder.agent.v2.Agent.UpdateStats:output_type -> coder.agent.v2.UpdateStatsResponse + 14, // 33: coder.agent.v2.Agent.UpdateLifecycle:output_type -> coder.agent.v2.Lifecycle + 17, // 34: coder.agent.v2.Agent.BatchUpdateAppHealths:output_type -> coder.agent.v2.BatchUpdateAppHealthResponse + 18, // 35: coder.agent.v2.Agent.UpdateStartup:output_type -> coder.agent.v2.Startup + 22, // 36: coder.agent.v2.Agent.BatchUpdateMetadata:output_type -> coder.agent.v2.BatchUpdateMetadataResponse + 25, // 37: coder.agent.v2.Agent.BatchCreateLogs:output_type -> coder.agent.v2.BatchCreateLogsResponse + 31, // 38: coder.agent.v2.Agent.StreamDERPMaps:output_type -> coder.tailnet.v2.DERPMap + 36, // 39: coder.agent.v2.Agent.CoordinateTailnet:output_type -> coder.tailnet.v2.CoordinateResponse + 30, // [30:40] is the sub-list for method output_type + 20, // [20:30] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name +} + +func init() { file_agent_proto_agent_proto_init() } +func file_agent_proto_agent_proto_init() { + if File_agent_proto_agent_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_agent_proto_agent_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WorkspaceApp); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Manifest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetManifestRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ServiceBanner); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetServiceBannerRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Stats); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UpdateStatsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UpdateStatsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Lifecycle); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UpdateLifecycleRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BatchUpdateAppHealthRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BatchUpdateAppHealthResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Startup); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UpdateStartupRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Metadata); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BatchUpdateMetadataRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BatchUpdateMetadataResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Log); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BatchCreateLogsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BatchCreateLogsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*WorkspaceApp_HealthCheck); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Stats_Metric); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_proto_agent_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BatchUpdateAppHealthRequest_HealthUpdate); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_agent_proto_agent_proto_rawDesc, + NumEnums: 6, + NumMessages: 25, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_agent_proto_agent_proto_goTypes, + DependencyIndexes: file_agent_proto_agent_proto_depIdxs, + EnumInfos: file_agent_proto_agent_proto_enumTypes, + MessageInfos: file_agent_proto_agent_proto_msgTypes, + }.Build() + File_agent_proto_agent_proto = out.File + file_agent_proto_agent_proto_rawDesc = nil + file_agent_proto_agent_proto_goTypes = nil + file_agent_proto_agent_proto_depIdxs = nil +} diff --git a/agent/proto/agent.proto b/agent/proto/agent.proto new file mode 100644 index 0000000000000..5d6c09c167db0 --- /dev/null +++ b/agent/proto/agent.proto @@ -0,0 +1,211 @@ +syntax = "proto3"; +option go_package = "github.com/coder/coder/v2/agent/proto"; + +package coder.agent.v2; + +import "tailnet/proto/tailnet.proto"; +import "google/protobuf/timestamp.proto"; +import "google/protobuf/duration.proto"; + +message WorkspaceApp { + bytes uuid = 1; + string url = 2; + bool external = 3; + string slug = 4; + string display_name = 5; + string command = 6; + string icon = 7; + bool subdomain = 8; + string subdomain_name = 9; + + enum SharingLevel { + SHARING_LEVEL_UNSPECIFIED = 0; + OWNER = 1; + AUTHENTICATED = 2; + PUBLIC = 3; + } + SharingLevel sharing_level = 10; + + message HealthCheck { + string url = 1; + int32 interval = 2; + int32 threshold = 3; + } + HealthCheck healthcheck = 11; + + enum Health { + HEALTH_UNSPECIFIED = 0; + DISABLED = 1; + INITIALIZING = 2; + HEALTHY = 3; + UNHEALTHY = 4; + } + Health health = 12; +} + +message Manifest { + uint32 git_auth_configs = 1; + string vs_code_port_proxy_uri = 2; + repeated WorkspaceApp apps = 3; + coder.tailnet.v2.DERPMap derp_map = 4; +} + +message GetManifestRequest {} + +message ServiceBanner { + bool enabled = 1; + string message = 2; + string background_color = 3; +} + +message GetServiceBannerRequest {} + +message Stats { + // ConnectionsByProto is a count of connections by protocol. + map connections_by_proto = 1; + // ConnectionCount is the number of connections received by an agent. + int64 connection_count = 2; + // ConnectionMedianLatencyMS is the median latency of all connections in milliseconds. + double connection_median_latency_ms = 3; + // RxPackets is the number of received packets. + int64 rx_packets = 4; + // RxBytes is the number of received bytes. + int64 rx_bytes = 5; + // TxPackets is the number of transmitted bytes. + int64 tx_packets = 6; + // TxBytes is the number of transmitted bytes. + int64 tx_bytes = 7; + + // SessionCountVSCode is the number of connections received by an agent + // that are from our VS Code extension. + int64 session_count_vscode = 8; + // SessionCountJetBrains is the number of connections received by an agent + // that are from our JetBrains extension. + int64 session_count_jetbrains = 9; + // SessionCountReconnectingPTY is the number of connections received by an agent + // that are from the reconnecting web terminal. + int64 session_count_reconnecting_pty = 10; + // SessionCountSSH is the number of connections received by an agent + // that are normal, non-tagged SSH sessions. + int64 session_count_ssh = 11; + + message Metric { + string name = 1; + + enum Type { + TYPE_UNSPECIFIED = 0; + COUNTER = 1; + GAUGE = 2; + } + Type type = 2; + + double value = 3; + map labels = 4; + } +} + +message UpdateStatsRequest{ + Stats stats = 1; +} + +message UpdateStatsResponse { + google.protobuf.Duration report_interval_nanoseconds = 1; +} + +message Lifecycle { + enum State { + STATE_UNSPECIFIED = 0; + CREATED = 1; + STARTED = 2; + START_TIMEOUT = 3; + START_ERROR = 4; + READY = 5; + SHUTTING_DOWN = 6; + SHUTDOWN_TIMEOUT = 7; + SHUTDOWN_ERROR = 8; + OFF = 9; + } + State state = 1; +} + +message UpdateLifecycleRequest { + Lifecycle lifecycle = 1; +} + +enum AppHealth { + APP_HEALTH_UNSPECIFIED = 0; + DISABLED = 1; + INITIALIZING = 2; + HEALTHY = 3; + UNHEALTHY = 4; +} + +message BatchUpdateAppHealthRequest { + message HealthUpdate { + bytes uuid = 1; + AppHealth health = 2; + } + repeated HealthUpdate updates = 1; +} + +message BatchUpdateAppHealthResponse {} + +message Startup { + string version = 1; + string expanded_directory = 2; + repeated string subsystems = 3; +} + +message UpdateStartupRequest{ + Startup startup = 1; +} + +message Metadata { + string key = 1; + google.protobuf.Timestamp collected_at = 2; + int64 age = 3; + string value = 4; + string error = 5; +} + +message BatchUpdateMetadataRequest { + repeated Metadata metadata = 2; +} + +message BatchUpdateMetadataResponse {} + +message Log { + google.protobuf.Timestamp created_at = 1; + string output = 2; + + enum Level { + LEVEL_UNSPECIFIED = 0; + TRACE = 1; + DEBUG = 2; + INFO = 3; + WARN = 4; + ERROR = 5; + } + Level level = 3; +} + +message BatchCreateLogsRequest { + bytes source_id = 1; + repeated Log logs = 2; +} + +message BatchCreateLogsResponse {} + +service Agent { + rpc GetManifest(GetManifestRequest) returns (Manifest); + rpc GetServiceBanner(GetServiceBannerRequest) returns (ServiceBanner); + rpc UpdateStats(UpdateStatsRequest) returns (UpdateStatsResponse); + rpc UpdateLifecycle(UpdateLifecycleRequest) returns (Lifecycle); + rpc BatchUpdateAppHealths(BatchUpdateAppHealthRequest) returns (BatchUpdateAppHealthResponse); + rpc UpdateStartup(UpdateStartupRequest) returns (Startup); + rpc BatchUpdateMetadata(BatchUpdateMetadataRequest) returns (BatchUpdateMetadataResponse); + rpc BatchCreateLogs(BatchCreateLogsRequest) returns (BatchCreateLogsResponse); + + rpc StreamDERPMaps(tailnet.v2.StreamDERPMapsRequest) returns (stream tailnet.v2.DERPMap); + rpc CoordinateTailnet(stream tailnet.v2.CoordinateRequest) returns (stream tailnet.v2.CoordinateResponse); +} diff --git a/agent/proto/agent_drpc.pb.go b/agent/proto/agent_drpc.pb.go new file mode 100644 index 0000000000000..b64ca2b4f2bc7 --- /dev/null +++ b/agent/proto/agent_drpc.pb.go @@ -0,0 +1,539 @@ +// Code generated by protoc-gen-go-drpc. DO NOT EDIT. +// protoc-gen-go-drpc version: v0.0.33 +// source: agent/proto/agent.proto + +package proto + +import ( + context "context" + errors "errors" + proto1 "github.com/coder/coder/v2/tailnet/proto" + protojson "google.golang.org/protobuf/encoding/protojson" + proto "google.golang.org/protobuf/proto" + drpc "storj.io/drpc" + drpcerr "storj.io/drpc/drpcerr" +) + +type drpcEncoding_File_agent_proto_agent_proto struct{} + +func (drpcEncoding_File_agent_proto_agent_proto) Marshal(msg drpc.Message) ([]byte, error) { + return proto.Marshal(msg.(proto.Message)) +} + +func (drpcEncoding_File_agent_proto_agent_proto) MarshalAppend(buf []byte, msg drpc.Message) ([]byte, error) { + return proto.MarshalOptions{}.MarshalAppend(buf, msg.(proto.Message)) +} + +func (drpcEncoding_File_agent_proto_agent_proto) Unmarshal(buf []byte, msg drpc.Message) error { + return proto.Unmarshal(buf, msg.(proto.Message)) +} + +func (drpcEncoding_File_agent_proto_agent_proto) JSONMarshal(msg drpc.Message) ([]byte, error) { + return protojson.Marshal(msg.(proto.Message)) +} + +func (drpcEncoding_File_agent_proto_agent_proto) JSONUnmarshal(buf []byte, msg drpc.Message) error { + return protojson.Unmarshal(buf, msg.(proto.Message)) +} + +type DRPCAgentClient interface { + DRPCConn() drpc.Conn + + GetManifest(ctx context.Context, in *GetManifestRequest) (*Manifest, error) + GetServiceBanner(ctx context.Context, in *GetServiceBannerRequest) (*ServiceBanner, error) + UpdateStats(ctx context.Context, in *UpdateStatsRequest) (*UpdateStatsResponse, error) + UpdateLifecycle(ctx context.Context, in *UpdateLifecycleRequest) (*Lifecycle, error) + BatchUpdateAppHealths(ctx context.Context, in *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error) + UpdateStartup(ctx context.Context, in *UpdateStartupRequest) (*Startup, error) + BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error) + BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error) + StreamDERPMaps(ctx context.Context, in *proto1.StreamDERPMapsRequest) (DRPCAgent_StreamDERPMapsClient, error) + CoordinateTailnet(ctx context.Context) (DRPCAgent_CoordinateTailnetClient, error) +} + +type drpcAgentClient struct { + cc drpc.Conn +} + +func NewDRPCAgentClient(cc drpc.Conn) DRPCAgentClient { + return &drpcAgentClient{cc} +} + +func (c *drpcAgentClient) DRPCConn() drpc.Conn { return c.cc } + +func (c *drpcAgentClient) GetManifest(ctx context.Context, in *GetManifestRequest) (*Manifest, error) { + out := new(Manifest) + err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/GetManifest", drpcEncoding_File_agent_proto_agent_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentClient) GetServiceBanner(ctx context.Context, in *GetServiceBannerRequest) (*ServiceBanner, error) { + out := new(ServiceBanner) + err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/GetServiceBanner", drpcEncoding_File_agent_proto_agent_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentClient) UpdateStats(ctx context.Context, in *UpdateStatsRequest) (*UpdateStatsResponse, error) { + out := new(UpdateStatsResponse) + err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/UpdateStats", drpcEncoding_File_agent_proto_agent_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentClient) UpdateLifecycle(ctx context.Context, in *UpdateLifecycleRequest) (*Lifecycle, error) { + out := new(Lifecycle) + err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/UpdateLifecycle", drpcEncoding_File_agent_proto_agent_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentClient) BatchUpdateAppHealths(ctx context.Context, in *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error) { + out := new(BatchUpdateAppHealthResponse) + err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/BatchUpdateAppHealths", drpcEncoding_File_agent_proto_agent_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentClient) UpdateStartup(ctx context.Context, in *UpdateStartupRequest) (*Startup, error) { + out := new(Startup) + err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/UpdateStartup", drpcEncoding_File_agent_proto_agent_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentClient) BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error) { + out := new(BatchUpdateMetadataResponse) + err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/BatchUpdateMetadata", drpcEncoding_File_agent_proto_agent_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentClient) BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error) { + out := new(BatchCreateLogsResponse) + err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/BatchCreateLogs", drpcEncoding_File_agent_proto_agent_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentClient) StreamDERPMaps(ctx context.Context, in *proto1.StreamDERPMapsRequest) (DRPCAgent_StreamDERPMapsClient, error) { + stream, err := c.cc.NewStream(ctx, "/coder.agent.v2.Agent/StreamDERPMaps", drpcEncoding_File_agent_proto_agent_proto{}) + if err != nil { + return nil, err + } + x := &drpcAgent_StreamDERPMapsClient{stream} + if err := x.MsgSend(in, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return nil, err + } + if err := x.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type DRPCAgent_StreamDERPMapsClient interface { + drpc.Stream + Recv() (*proto1.DERPMap, error) +} + +type drpcAgent_StreamDERPMapsClient struct { + drpc.Stream +} + +func (x *drpcAgent_StreamDERPMapsClient) GetStream() drpc.Stream { + return x.Stream +} + +func (x *drpcAgent_StreamDERPMapsClient) Recv() (*proto1.DERPMap, error) { + m := new(proto1.DERPMap) + if err := x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return nil, err + } + return m, nil +} + +func (x *drpcAgent_StreamDERPMapsClient) RecvMsg(m *proto1.DERPMap) error { + return x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{}) +} + +func (c *drpcAgentClient) CoordinateTailnet(ctx context.Context) (DRPCAgent_CoordinateTailnetClient, error) { + stream, err := c.cc.NewStream(ctx, "/coder.agent.v2.Agent/CoordinateTailnet", drpcEncoding_File_agent_proto_agent_proto{}) + if err != nil { + return nil, err + } + x := &drpcAgent_CoordinateTailnetClient{stream} + return x, nil +} + +type DRPCAgent_CoordinateTailnetClient interface { + drpc.Stream + Send(*proto1.CoordinateRequest) error + Recv() (*proto1.CoordinateResponse, error) +} + +type drpcAgent_CoordinateTailnetClient struct { + drpc.Stream +} + +func (x *drpcAgent_CoordinateTailnetClient) GetStream() drpc.Stream { + return x.Stream +} + +func (x *drpcAgent_CoordinateTailnetClient) Send(m *proto1.CoordinateRequest) error { + return x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}) +} + +func (x *drpcAgent_CoordinateTailnetClient) Recv() (*proto1.CoordinateResponse, error) { + m := new(proto1.CoordinateResponse) + if err := x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return nil, err + } + return m, nil +} + +func (x *drpcAgent_CoordinateTailnetClient) RecvMsg(m *proto1.CoordinateResponse) error { + return x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{}) +} + +type DRPCAgentServer interface { + GetManifest(context.Context, *GetManifestRequest) (*Manifest, error) + GetServiceBanner(context.Context, *GetServiceBannerRequest) (*ServiceBanner, error) + UpdateStats(context.Context, *UpdateStatsRequest) (*UpdateStatsResponse, error) + UpdateLifecycle(context.Context, *UpdateLifecycleRequest) (*Lifecycle, error) + BatchUpdateAppHealths(context.Context, *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error) + UpdateStartup(context.Context, *UpdateStartupRequest) (*Startup, error) + BatchUpdateMetadata(context.Context, *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error) + BatchCreateLogs(context.Context, *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error) + StreamDERPMaps(*proto1.StreamDERPMapsRequest, DRPCAgent_StreamDERPMapsStream) error + CoordinateTailnet(DRPCAgent_CoordinateTailnetStream) error +} + +type DRPCAgentUnimplementedServer struct{} + +func (s *DRPCAgentUnimplementedServer) GetManifest(context.Context, *GetManifestRequest) (*Manifest, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentUnimplementedServer) GetServiceBanner(context.Context, *GetServiceBannerRequest) (*ServiceBanner, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentUnimplementedServer) UpdateStats(context.Context, *UpdateStatsRequest) (*UpdateStatsResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentUnimplementedServer) UpdateLifecycle(context.Context, *UpdateLifecycleRequest) (*Lifecycle, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentUnimplementedServer) BatchUpdateAppHealths(context.Context, *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentUnimplementedServer) UpdateStartup(context.Context, *UpdateStartupRequest) (*Startup, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentUnimplementedServer) BatchUpdateMetadata(context.Context, *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentUnimplementedServer) BatchCreateLogs(context.Context, *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentUnimplementedServer) StreamDERPMaps(*proto1.StreamDERPMapsRequest, DRPCAgent_StreamDERPMapsStream) error { + return drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentUnimplementedServer) CoordinateTailnet(DRPCAgent_CoordinateTailnetStream) error { + return drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +type DRPCAgentDescription struct{} + +func (DRPCAgentDescription) NumMethods() int { return 10 } + +func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) { + switch n { + case 0: + return "/coder.agent.v2.Agent/GetManifest", drpcEncoding_File_agent_proto_agent_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentServer). + GetManifest( + ctx, + in1.(*GetManifestRequest), + ) + }, DRPCAgentServer.GetManifest, true + case 1: + return "/coder.agent.v2.Agent/GetServiceBanner", drpcEncoding_File_agent_proto_agent_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentServer). + GetServiceBanner( + ctx, + in1.(*GetServiceBannerRequest), + ) + }, DRPCAgentServer.GetServiceBanner, true + case 2: + return "/coder.agent.v2.Agent/UpdateStats", drpcEncoding_File_agent_proto_agent_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentServer). + UpdateStats( + ctx, + in1.(*UpdateStatsRequest), + ) + }, DRPCAgentServer.UpdateStats, true + case 3: + return "/coder.agent.v2.Agent/UpdateLifecycle", drpcEncoding_File_agent_proto_agent_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentServer). + UpdateLifecycle( + ctx, + in1.(*UpdateLifecycleRequest), + ) + }, DRPCAgentServer.UpdateLifecycle, true + case 4: + return "/coder.agent.v2.Agent/BatchUpdateAppHealths", drpcEncoding_File_agent_proto_agent_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentServer). + BatchUpdateAppHealths( + ctx, + in1.(*BatchUpdateAppHealthRequest), + ) + }, DRPCAgentServer.BatchUpdateAppHealths, true + case 5: + return "/coder.agent.v2.Agent/UpdateStartup", drpcEncoding_File_agent_proto_agent_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentServer). + UpdateStartup( + ctx, + in1.(*UpdateStartupRequest), + ) + }, DRPCAgentServer.UpdateStartup, true + case 6: + return "/coder.agent.v2.Agent/BatchUpdateMetadata", drpcEncoding_File_agent_proto_agent_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentServer). + BatchUpdateMetadata( + ctx, + in1.(*BatchUpdateMetadataRequest), + ) + }, DRPCAgentServer.BatchUpdateMetadata, true + case 7: + return "/coder.agent.v2.Agent/BatchCreateLogs", drpcEncoding_File_agent_proto_agent_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentServer). + BatchCreateLogs( + ctx, + in1.(*BatchCreateLogsRequest), + ) + }, DRPCAgentServer.BatchCreateLogs, true + case 8: + return "/coder.agent.v2.Agent/StreamDERPMaps", drpcEncoding_File_agent_proto_agent_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return nil, srv.(DRPCAgentServer). + StreamDERPMaps( + in1.(*proto1.StreamDERPMapsRequest), + &drpcAgent_StreamDERPMapsStream{in2.(drpc.Stream)}, + ) + }, DRPCAgentServer.StreamDERPMaps, true + case 9: + return "/coder.agent.v2.Agent/CoordinateTailnet", drpcEncoding_File_agent_proto_agent_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return nil, srv.(DRPCAgentServer). + CoordinateTailnet( + &drpcAgent_CoordinateTailnetStream{in1.(drpc.Stream)}, + ) + }, DRPCAgentServer.CoordinateTailnet, true + default: + return "", nil, nil, nil, false + } +} + +func DRPCRegisterAgent(mux drpc.Mux, impl DRPCAgentServer) error { + return mux.Register(impl, DRPCAgentDescription{}) +} + +type DRPCAgent_GetManifestStream interface { + drpc.Stream + SendAndClose(*Manifest) error +} + +type drpcAgent_GetManifestStream struct { + drpc.Stream +} + +func (x *drpcAgent_GetManifestStream) SendAndClose(m *Manifest) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgent_GetServiceBannerStream interface { + drpc.Stream + SendAndClose(*ServiceBanner) error +} + +type drpcAgent_GetServiceBannerStream struct { + drpc.Stream +} + +func (x *drpcAgent_GetServiceBannerStream) SendAndClose(m *ServiceBanner) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgent_UpdateStatsStream interface { + drpc.Stream + SendAndClose(*UpdateStatsResponse) error +} + +type drpcAgent_UpdateStatsStream struct { + drpc.Stream +} + +func (x *drpcAgent_UpdateStatsStream) SendAndClose(m *UpdateStatsResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgent_UpdateLifecycleStream interface { + drpc.Stream + SendAndClose(*Lifecycle) error +} + +type drpcAgent_UpdateLifecycleStream struct { + drpc.Stream +} + +func (x *drpcAgent_UpdateLifecycleStream) SendAndClose(m *Lifecycle) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgent_BatchUpdateAppHealthsStream interface { + drpc.Stream + SendAndClose(*BatchUpdateAppHealthResponse) error +} + +type drpcAgent_BatchUpdateAppHealthsStream struct { + drpc.Stream +} + +func (x *drpcAgent_BatchUpdateAppHealthsStream) SendAndClose(m *BatchUpdateAppHealthResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgent_UpdateStartupStream interface { + drpc.Stream + SendAndClose(*Startup) error +} + +type drpcAgent_UpdateStartupStream struct { + drpc.Stream +} + +func (x *drpcAgent_UpdateStartupStream) SendAndClose(m *Startup) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgent_BatchUpdateMetadataStream interface { + drpc.Stream + SendAndClose(*BatchUpdateMetadataResponse) error +} + +type drpcAgent_BatchUpdateMetadataStream struct { + drpc.Stream +} + +func (x *drpcAgent_BatchUpdateMetadataStream) SendAndClose(m *BatchUpdateMetadataResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgent_BatchCreateLogsStream interface { + drpc.Stream + SendAndClose(*BatchCreateLogsResponse) error +} + +type drpcAgent_BatchCreateLogsStream struct { + drpc.Stream +} + +func (x *drpcAgent_BatchCreateLogsStream) SendAndClose(m *BatchCreateLogsResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgent_StreamDERPMapsStream interface { + drpc.Stream + Send(*proto1.DERPMap) error +} + +type drpcAgent_StreamDERPMapsStream struct { + drpc.Stream +} + +func (x *drpcAgent_StreamDERPMapsStream) Send(m *proto1.DERPMap) error { + return x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}) +} + +type DRPCAgent_CoordinateTailnetStream interface { + drpc.Stream + Send(*proto1.CoordinateResponse) error + Recv() (*proto1.CoordinateRequest, error) +} + +type drpcAgent_CoordinateTailnetStream struct { + drpc.Stream +} + +func (x *drpcAgent_CoordinateTailnetStream) Send(m *proto1.CoordinateResponse) error { + return x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}) +} + +func (x *drpcAgent_CoordinateTailnetStream) Recv() (*proto1.CoordinateRequest, error) { + m := new(proto1.CoordinateRequest) + if err := x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil { + return nil, err + } + return m, nil +} + +func (x *drpcAgent_CoordinateTailnetStream) RecvMsg(m *proto1.CoordinateRequest) error { + return x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{}) +} diff --git a/agent/reaper/reaper_test.go b/agent/reaper/reaper_test.go index 0509edb382c6b..84246fba0619b 100644 --- a/agent/reaper/reaper_test.go +++ b/agent/reaper/reaper_test.go @@ -14,8 +14,8 @@ import ( "github.com/hashicorp/go-reap" "github.com/stretchr/testify/require" - "github.com/coder/coder/agent/reaper" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent/reaper" + "github.com/coder/coder/v2/testutil" ) // TestReap checks that's the reaper is successfully reaping diff --git a/agent/reconnectingpty/buffered.go b/agent/reconnectingpty/buffered.go new file mode 100644 index 0000000000000..d53b22ffe2153 --- /dev/null +++ b/agent/reconnectingpty/buffered.go @@ -0,0 +1,241 @@ +package reconnectingpty + +import ( + "context" + "errors" + "io" + "net" + "time" + + "github.com/armon/circbuf" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/exp/slices" + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/pty" +) + +// bufferedReconnectingPTY provides a reconnectable PTY by using a ring buffer to store +// scrollback. +type bufferedReconnectingPTY struct { + command *pty.Cmd + + activeConns map[string]net.Conn + circularBuffer *circbuf.Buffer + + ptty pty.PTYCmd + process pty.Process + + metrics *prometheus.CounterVec + + state *ptyState + // timer will close the reconnecting pty when it expires. The timer will be + // reset as long as there are active connections. + timer *time.Timer + timeout time.Duration +} + +// newBuffered starts the buffered pty. If the context ends the process will be +// killed. +func newBuffered(ctx context.Context, cmd *pty.Cmd, options *Options, logger slog.Logger) *bufferedReconnectingPTY { + rpty := &bufferedReconnectingPTY{ + activeConns: map[string]net.Conn{}, + command: cmd, + metrics: options.Metrics, + state: newState(), + timeout: options.Timeout, + } + + // Default to buffer 64KiB. + circularBuffer, err := circbuf.NewBuffer(64 << 10) + if err != nil { + rpty.state.setState(StateDone, xerrors.Errorf("create circular buffer: %w", err)) + return rpty + } + rpty.circularBuffer = circularBuffer + + // Add TERM then start the command with a pty. pty.Cmd duplicates Path as the + // first argument so remove it. + cmdWithEnv := pty.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) + cmdWithEnv.Env = append(rpty.command.Env, "TERM=xterm-256color") + cmdWithEnv.Dir = rpty.command.Dir + ptty, process, err := pty.Start(cmdWithEnv) + if err != nil { + rpty.state.setState(StateDone, xerrors.Errorf("start pty: %w", err)) + return rpty + } + rpty.ptty = ptty + rpty.process = process + + go rpty.lifecycle(ctx, logger) + + // Multiplex the output onto the circular buffer and each active connection. + // We do not need to separately monitor for the process exiting. When it + // exits, our ptty.OutputReader() will return EOF after reading all process + // output. + go func() { + buffer := make([]byte, 1024) + for { + read, err := ptty.OutputReader().Read(buffer) + if err != nil { + // When the PTY is closed, this is triggered. + // Error is typically a benign EOF, so only log for debugging. + if errors.Is(err, io.EOF) { + logger.Debug(ctx, "unable to read pty output, command might have exited", slog.Error(err)) + } else { + logger.Warn(ctx, "unable to read pty output, command might have exited", slog.Error(err)) + rpty.metrics.WithLabelValues("output_reader").Add(1) + } + // Could have been killed externally or failed to start at all (command + // not found for example). + // TODO: Should we check the process's exit code in case the command was + // invalid? + rpty.Close(nil) + break + } + part := buffer[:read] + rpty.state.cond.L.Lock() + _, err = rpty.circularBuffer.Write(part) + if err != nil { + logger.Error(ctx, "write to circular buffer", slog.Error(err)) + rpty.metrics.WithLabelValues("write_buffer").Add(1) + } + // TODO: Instead of ranging over a map, could we send the output to a + // channel and have each individual Attach read from that? + for cid, conn := range rpty.activeConns { + _, err = conn.Write(part) + if err != nil { + logger.Warn(ctx, + "error writing to active connection", + slog.F("connection_id", cid), + slog.Error(err), + ) + rpty.metrics.WithLabelValues("write").Add(1) + } + } + rpty.state.cond.L.Unlock() + } + }() + + return rpty +} + +// lifecycle manages the lifecycle of the reconnecting pty. If the context ends +// or the reconnecting pty closes the pty will be shut down. +func (rpty *bufferedReconnectingPTY) lifecycle(ctx context.Context, logger slog.Logger) { + rpty.timer = time.AfterFunc(attachTimeout, func() { + rpty.Close(xerrors.New("reconnecting pty timeout")) + }) + + logger.Debug(ctx, "reconnecting pty ready") + rpty.state.setState(StateReady, nil) + + state, reasonErr := rpty.state.waitForStateOrContext(ctx, StateClosing) + if state < StateClosing { + // If we have not closed yet then the context is what unblocked us (which + // means the agent is shutting down) so move into the closing phase. + rpty.Close(reasonErr) + } + rpty.timer.Stop() + + rpty.state.cond.L.Lock() + // Log these closes only for debugging since the connections or processes + // might have already closed on their own. + for _, conn := range rpty.activeConns { + err := conn.Close() + if err != nil { + logger.Debug(ctx, "closed conn with error", slog.Error(err)) + } + } + // Connections get removed once they close but it is possible there is still + // some data that will be written before that happens so clear the map now to + // avoid writing to closed connections. + rpty.activeConns = map[string]net.Conn{} + rpty.state.cond.L.Unlock() + + // Log close/kill only for debugging since the process might have already + // closed on its own. + err := rpty.ptty.Close() + if err != nil { + logger.Debug(ctx, "closed ptty with error", slog.Error(err)) + } + + err = rpty.process.Kill() + if err != nil { + logger.Debug(ctx, "killed process with error", slog.Error(err)) + } + + logger.Info(ctx, "closed reconnecting pty") + rpty.state.setState(StateDone, reasonErr) +} + +func (rpty *bufferedReconnectingPTY) Attach(ctx context.Context, connID string, conn net.Conn, height, width uint16, logger slog.Logger) error { + logger.Info(ctx, "attach to reconnecting pty") + + // This will kill the heartbeat once we hit EOF or an error. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + err := rpty.doAttach(connID, conn) + if err != nil { + return err + } + + defer func() { + rpty.state.cond.L.Lock() + defer rpty.state.cond.L.Unlock() + delete(rpty.activeConns, connID) + }() + + state, err := rpty.state.waitForStateOrContext(ctx, StateReady) + if state != StateReady { + return err + } + + go heartbeat(ctx, rpty.timer, rpty.timeout) + + // Resize the PTY to initial height + width. + err = rpty.ptty.Resize(height, width) + if err != nil { + // We can continue after this, it's not fatal! + logger.Warn(ctx, "reconnecting PTY initial resize failed, but will continue", slog.Error(err)) + rpty.metrics.WithLabelValues("resize").Add(1) + } + + // Pipe conn -> pty and block. pty -> conn is handled in newBuffered(). + readConnLoop(ctx, conn, rpty.ptty, rpty.metrics, logger) + return nil +} + +// doAttach adds the connection to the map and replays the buffer. It exists +// separately only for convenience to defer the mutex unlock which is not +// possible in Attach since it blocks. +func (rpty *bufferedReconnectingPTY) doAttach(connID string, conn net.Conn) error { + rpty.state.cond.L.Lock() + defer rpty.state.cond.L.Unlock() + + // Write any previously stored data for the TTY. Since the command might be + // short-lived and have already exited, make sure we always at least output + // the buffer before returning, mostly just so tests pass. + prevBuf := slices.Clone(rpty.circularBuffer.Bytes()) + _, err := conn.Write(prevBuf) + if err != nil { + rpty.metrics.WithLabelValues("write").Add(1) + return xerrors.Errorf("write buffer to conn: %w", err) + } + + rpty.activeConns[connID] = conn + + return nil +} + +func (rpty *bufferedReconnectingPTY) Wait() { + _, _ = rpty.state.waitForState(StateClosing) +} + +func (rpty *bufferedReconnectingPTY) Close(error error) { + // The closing state change will be handled by the lifecycle. + rpty.state.setState(StateClosing, error) +} diff --git a/agent/reconnectingpty/reconnectingpty.go b/agent/reconnectingpty/reconnectingpty.go new file mode 100644 index 0000000000000..280cf62aaa841 --- /dev/null +++ b/agent/reconnectingpty/reconnectingpty.go @@ -0,0 +1,226 @@ +package reconnectingpty + +import ( + "context" + "encoding/json" + "io" + "net" + "os/exec" + "runtime" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty" +) + +// attachTimeout is the initial timeout for attaching and will probably be far +// shorter than the reconnect timeout in most cases; in tests it might be +// longer. It should be at least long enough for the first screen attach to be +// able to start up the daemon and for the buffered pty to start. +const attachTimeout = 30 * time.Second + +// Options allows configuring the reconnecting pty. +type Options struct { + // Timeout describes how long to keep the pty alive without any connections. + // Once elapsed the pty will be killed. + Timeout time.Duration + // Metrics tracks various error counters. + Metrics *prometheus.CounterVec +} + +// ReconnectingPTY is a pty that can be reconnected within a timeout and to +// simultaneous connections. The reconnecting pty can be backed by screen if +// installed or a (buggy) buffer replay fallback. +type ReconnectingPTY interface { + // Attach pipes the connection and pty, spawning it if necessary, replays + // history, then blocks until EOF, an error, or the context's end. The + // connection is expected to send JSON-encoded messages and accept raw output + // from the ptty. If the context ends or the process dies the connection will + // be detached. + Attach(ctx context.Context, connID string, conn net.Conn, height, width uint16, logger slog.Logger) error + // Wait waits for the reconnecting pty to close. The underlying process might + // still be exiting. + Wait() + // Close kills the reconnecting pty process. + Close(err error) +} + +// New sets up a new reconnecting pty that wraps the provided command. Any +// errors with starting are returned on Attach(). The reconnecting pty will +// close itself (and all connections to it) if nothing is attached for the +// duration of the timeout, if the context ends, or the process exits (buffered +// backend only). +func New(ctx context.Context, cmd *pty.Cmd, options *Options, logger slog.Logger) ReconnectingPTY { + if options.Timeout == 0 { + options.Timeout = 5 * time.Minute + } + // Screen seems flaky on Darwin. Locally the tests pass 100% of the time (100 + // runs) but in CI screen often incorrectly claims the session name does not + // exist even though screen -list shows it. For now, restrict screen to + // Linux. + backendType := "buffered" + if runtime.GOOS == "linux" { + _, err := exec.LookPath("screen") + if err == nil { + backendType = "screen" + } + } + + logger.Info(ctx, "start reconnecting pty", slog.F("backend_type", backendType)) + + switch backendType { + case "screen": + return newScreen(ctx, cmd, options, logger) + default: + return newBuffered(ctx, cmd, options, logger) + } +} + +// heartbeat resets timer before timeout elapses and blocks until ctx ends. +func heartbeat(ctx context.Context, timer *time.Timer, timeout time.Duration) { + // Reset now in case it is near the end. + timer.Reset(timeout) + + // Reset when the context ends to ensure the pty stays up for the full + // timeout. + defer timer.Reset(timeout) + + heartbeat := time.NewTicker(timeout / 2) + defer heartbeat.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-heartbeat.C: + timer.Reset(timeout) + } + } +} + +// State represents the current state of the reconnecting pty. States are +// sequential and will only move forward. +type State int + +const ( + // StateStarting is the default/start state. Attaching will block until the + // reconnecting pty becomes ready. + StateStarting = iota + // StateReady means the reconnecting pty is ready to be attached. + StateReady + // StateClosing means the reconnecting pty has begun closing. The underlying + // process may still be exiting. Attaching will result in an error. + StateClosing + // StateDone means the reconnecting pty has completely shut down and the + // process has exited. Attaching will result in an error. + StateDone +) + +// ptyState is a helper for tracking the reconnecting PTY's state. +type ptyState struct { + // cond broadcasts state changes and any accompanying errors. + cond *sync.Cond + // error describes the error that caused the state change, if there was one. + // It is not safe to access outside of cond.L. + error error + // state holds the current reconnecting pty state. It is not safe to access + // this outside of cond.L. + state State +} + +func newState() *ptyState { + return &ptyState{ + cond: sync.NewCond(&sync.Mutex{}), + state: StateStarting, + } +} + +// setState sets and broadcasts the provided state if it is greater than the +// current state and the error if one has not already been set. +func (s *ptyState) setState(state State, err error) { + s.cond.L.Lock() + defer s.cond.L.Unlock() + // Cannot regress states. For example, trying to close after the process is + // done should leave us in the done state and not the closing state. + if state <= s.state { + return + } + s.error = err + s.state = state + s.cond.Broadcast() +} + +// waitForState blocks until the state or a greater one is reached. +func (s *ptyState) waitForState(state State) (State, error) { + s.cond.L.Lock() + defer s.cond.L.Unlock() + for state > s.state { + s.cond.Wait() + } + return s.state, s.error +} + +// waitForStateOrContext blocks until the state or a greater one is reached or +// the provided context ends. +func (s *ptyState) waitForStateOrContext(ctx context.Context, state State) (State, error) { + s.cond.L.Lock() + defer s.cond.L.Unlock() + + nevermind := make(chan struct{}) + defer close(nevermind) + go func() { + select { + case <-ctx.Done(): + // Wake up when the context ends. + s.cond.Broadcast() + case <-nevermind: + } + }() + + for ctx.Err() == nil && state > s.state { + s.cond.Wait() + } + if ctx.Err() != nil { + return s.state, ctx.Err() + } + return s.state, s.error +} + +// readConnLoop reads messages from conn and writes to ptty as needed. Blocks +// until EOF or an error writing to ptty or reading from conn. +func readConnLoop(ctx context.Context, conn net.Conn, ptty pty.PTYCmd, metrics *prometheus.CounterVec, logger slog.Logger) { + decoder := json.NewDecoder(conn) + for { + var req codersdk.ReconnectingPTYRequest + err := decoder.Decode(&req) + if xerrors.Is(err, io.EOF) { + return + } + if err != nil { + logger.Warn(ctx, "reconnecting pty failed with read error", slog.Error(err)) + return + } + _, err = ptty.InputWriter().Write([]byte(req.Data)) + if err != nil { + logger.Warn(ctx, "reconnecting pty failed with write error", slog.Error(err)) + metrics.WithLabelValues("input_writer").Add(1) + return + } + // Check if a resize needs to happen! + if req.Height == 0 || req.Width == 0 { + continue + } + err = ptty.Resize(req.Height, req.Width) + if err != nil { + // We can continue after this, it's not fatal! + logger.Warn(ctx, "reconnecting pty resize failed, but will continue", slog.Error(err)) + metrics.WithLabelValues("resize").Add(1) + } + } +} diff --git a/agent/reconnectingpty/screen.go b/agent/reconnectingpty/screen.go new file mode 100644 index 0000000000000..3d0f7b2ef52d9 --- /dev/null +++ b/agent/reconnectingpty/screen.go @@ -0,0 +1,389 @@ +package reconnectingpty + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gliderlabs/ssh" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/v2/pty" +) + +// screenReconnectingPTY provides a reconnectable PTY via `screen`. +type screenReconnectingPTY struct { + command *pty.Cmd + + // id holds the id of the session for both creating and attaching. This will + // be generated uniquely for each session because without control of the + // screen daemon we do not have its PID and without the PID screen will do + // partial matching. Enforcing a unique ID should guarantee we match on the + // right session. + id string + + // mutex prevents concurrent attaches to the session. Screen will happily + // spawn two separate sessions with the same name if multiple attaches happen + // in a close enough interval. We are not able to control the screen daemon + // ourselves to prevent this because the daemon will spawn with a hardcoded + // 24x80 size which results in confusing padding above the prompt once the + // attach comes in and resizes. + mutex sync.Mutex + + configFile string + + metrics *prometheus.CounterVec + + state *ptyState + // timer will close the reconnecting pty when it expires. The timer will be + // reset as long as there are active connections. + timer *time.Timer + timeout time.Duration +} + +// newScreen creates a new screen-backed reconnecting PTY. It writes config +// settings and creates the socket directory. If we could, we would want to +// spawn the daemon here and attach each connection to it but since doing that +// spawns the daemon with a hardcoded 24x80 size it is not a very good user +// experience. Instead we will let the attach command spawn the daemon on its +// own which causes it to spawn with the specified size. +func newScreen(ctx context.Context, cmd *pty.Cmd, options *Options, logger slog.Logger) *screenReconnectingPTY { + rpty := &screenReconnectingPTY{ + command: cmd, + metrics: options.Metrics, + state: newState(), + timeout: options.Timeout, + } + + go rpty.lifecycle(ctx, logger) + + // Socket paths are limited to around 100 characters on Linux and macOS which + // depending on the temporary directory can be a problem. To give more leeway + // use a short ID. + buf := make([]byte, 4) + _, err := rand.Read(buf) + if err != nil { + rpty.state.setState(StateDone, xerrors.Errorf("generate screen id: %w", err)) + return rpty + } + rpty.id = hex.EncodeToString(buf) + + settings := []string{ + // Tell screen not to handle motion for xterm* terminals which allows + // scrolling the terminal via the mouse wheel or scroll bar (by default + // screen uses it to cycle through the command history). There does not + // seem to be a way to make screen itself scroll on mouse wheel. tmux can + // do it but then there is no scroll bar and it kicks you into copy mode + // where keys stop working until you exit copy mode which seems like it + // could be confusing. + "termcapinfo xterm* ti@:te@", + // Enable alternate screen emulation otherwise applications get rendered in + // the current window which wipes out visible output resulting in missing + // output when scrolling back with the mouse wheel (copy mode still works + // since that is screen itself scrolling). + "altscreen on", + // Remap the control key to C-s since C-a may be used in applications. C-s + // is chosen because it cannot actually be used because by default it will + // pause and C-q to resume will just kill the browser window. We may not + // want people using the control key anyway since it will not be obvious + // they are in screen and doing things like switching windows makes mouse + // wheel scroll wonky due to the terminal doing the scrolling rather than + // screen itself (but again copy mode will work just fine). + "escape ^Ss", + } + + rpty.configFile = filepath.Join(os.TempDir(), "coder-screen", "config") + err = os.MkdirAll(filepath.Dir(rpty.configFile), 0o700) + if err != nil { + rpty.state.setState(StateDone, xerrors.Errorf("make screen config dir: %w", err)) + return rpty + } + + err = os.WriteFile(rpty.configFile, []byte(strings.Join(settings, "\n")), 0o600) + if err != nil { + rpty.state.setState(StateDone, xerrors.Errorf("create config file: %w", err)) + return rpty + } + + return rpty +} + +// lifecycle manages the lifecycle of the reconnecting pty. If the context ends +// the reconnecting pty will be closed. +func (rpty *screenReconnectingPTY) lifecycle(ctx context.Context, logger slog.Logger) { + rpty.timer = time.AfterFunc(attachTimeout, func() { + rpty.Close(xerrors.New("reconnecting pty timeout")) + }) + + logger.Debug(ctx, "reconnecting pty ready") + rpty.state.setState(StateReady, nil) + + state, reasonErr := rpty.state.waitForStateOrContext(ctx, StateClosing) + if state < StateClosing { + // If we have not closed yet then the context is what unblocked us (which + // means the agent is shutting down) so move into the closing phase. + rpty.Close(reasonErr) + } + rpty.timer.Stop() + + // If the command errors that the session is already gone that is fine. + err := rpty.sendCommand(context.Background(), "quit", []string{"No screen session found"}) + if err != nil { + logger.Error(ctx, "close screen session", slog.Error(err)) + } + + logger.Info(ctx, "closed reconnecting pty") + rpty.state.setState(StateDone, reasonErr) +} + +func (rpty *screenReconnectingPTY) Attach(ctx context.Context, _ string, conn net.Conn, height, width uint16, logger slog.Logger) error { + logger.Info(ctx, "attach to reconnecting pty") + + // This will kill the heartbeat once we hit EOF or an error. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + state, err := rpty.state.waitForStateOrContext(ctx, StateReady) + if state != StateReady { + return err + } + + go heartbeat(ctx, rpty.timer, rpty.timeout) + + ptty, process, err := rpty.doAttach(ctx, conn, height, width, logger) + if err != nil { + if errors.Is(err, context.Canceled) { + // Likely the process was too short-lived and canceled the version command. + // TODO: Is it worth distinguishing between that and a cancel from the + // Attach() caller? Additionally, since this could also happen if + // the command was invalid, should we check the process's exit code? + return nil + } + return err + } + + defer func() { + // Log only for debugging since the process might have already exited on its + // own. + err := ptty.Close() + if err != nil { + logger.Debug(ctx, "closed ptty with error", slog.Error(err)) + } + err = process.Kill() + if err != nil { + logger.Debug(ctx, "killed process with error", slog.Error(err)) + } + }() + + // Pipe conn -> pty and block. + readConnLoop(ctx, conn, ptty, rpty.metrics, logger) + return nil +} + +// doAttach spawns the screen client and starts the heartbeat. It exists +// separately only so we can defer the mutex unlock which is not possible in +// Attach since it blocks. +func (rpty *screenReconnectingPTY) doAttach(ctx context.Context, conn net.Conn, height, width uint16, logger slog.Logger) (pty.PTYCmd, pty.Process, error) { + // Ensure another attach does not come in and spawn a duplicate session. + rpty.mutex.Lock() + defer rpty.mutex.Unlock() + + logger.Debug(ctx, "spawning screen client", slog.F("screen_id", rpty.id)) + + // Wrap the command with screen and tie it to the connection's context. + cmd := pty.CommandContext(ctx, "screen", append([]string{ + // -S is for setting the session's name. + "-S", rpty.id, + // -U tells screen to use UTF-8 encoding. + // -x allows attaching to an already attached session. + // -RR reattaches to the daemon or creates the session daemon if missing. + // -q disables the "New screen..." message that appears for five seconds + // when creating a new session with -RR. + // -c is the flag for the config file. + "-UxRRqc", rpty.configFile, + rpty.command.Path, + // pty.Cmd duplicates Path as the first argument so remove it. + }, rpty.command.Args[1:]...)...) + cmd.Env = append(rpty.command.Env, "TERM=xterm-256color") + cmd.Dir = rpty.command.Dir + ptty, process, err := pty.Start(cmd, pty.WithPTYOption( + pty.WithSSHRequest(ssh.Pty{ + Window: ssh.Window{ + // Make sure to spawn at the right size because if we resize afterward it + // leaves confusing padding (screen will resize such that the screen + // contents are aligned to the bottom). + Height: int(height), + Width: int(width), + }, + }), + )) + if err != nil { + rpty.metrics.WithLabelValues("screen_spawn").Add(1) + return nil, nil, err + } + + // This context lets us abort the version command if the process dies. + versionCtx, versionCancel := context.WithCancel(ctx) + defer versionCancel() + + // Pipe pty -> conn and close the connection when the process exits. + // We do not need to separately monitor for the process exiting. When it + // exits, our ptty.OutputReader() will return EOF after reading all process + // output. + go func() { + defer versionCancel() + defer func() { + err := conn.Close() + if err != nil { + // Log only for debugging since the connection might have already closed + // on its own. + logger.Debug(ctx, "closed connection with error", slog.Error(err)) + } + }() + buffer := make([]byte, 1024) + for { + read, err := ptty.OutputReader().Read(buffer) + if err != nil { + // When the PTY is closed, this is triggered. + // Error is typically a benign EOF, so only log for debugging. + if errors.Is(err, io.EOF) { + logger.Debug(ctx, "unable to read pty output; screen might have exited", slog.Error(err)) + } else { + logger.Warn(ctx, "unable to read pty output; screen might have exited", slog.Error(err)) + rpty.metrics.WithLabelValues("screen_output_reader").Add(1) + } + // The process might have died because the session itself died or it + // might have been separately killed and the session is still up (for + // example `exit` or we killed it when the connection closed). If the + // session is still up we might leave the reconnecting pty in memory + // around longer than it needs to be but it will eventually clean up + // with the timer or context, or the next attach will respawn the screen + // daemon which is fine too. + break + } + part := buffer[:read] + _, err = conn.Write(part) + if err != nil { + // Connection might have been closed. + if errors.Unwrap(err).Error() != "endpoint is closed for send" { + logger.Warn(ctx, "error writing to active conn", slog.Error(err)) + rpty.metrics.WithLabelValues("screen_write").Add(1) + } + break + } + } + }() + + // Version seems to be the only command without a side effect (other than + // making the version pop up briefly) so use it to wait for the session to + // come up. If we do not wait we could end up spawning multiple sessions with + // the same name. + err = rpty.sendCommand(versionCtx, "version", nil) + if err != nil { + // Log only for debugging since the process might already have closed. + closeErr := ptty.Close() + if closeErr != nil { + logger.Debug(ctx, "closed ptty with error", slog.Error(closeErr)) + } + closeErr = process.Kill() + if closeErr != nil { + logger.Debug(ctx, "killed process with error", slog.Error(closeErr)) + } + rpty.metrics.WithLabelValues("screen_wait").Add(1) + return nil, nil, err + } + + return ptty, process, nil +} + +// sendCommand runs a screen command against a running screen session. If the +// command fails with an error matching anything in successErrors it will be +// considered a success state (for example "no session" when quitting and the +// session is already dead). The command will be retried until successful, the +// timeout is reached, or the context ends. A canceled context will return the +// canceled context's error as-is while a timed-out context returns together +// with the last error from the command. +func (rpty *screenReconnectingPTY) sendCommand(ctx context.Context, command string, successErrors []string) error { + ctx, cancel := context.WithTimeout(ctx, attachTimeout) + defer cancel() + + var lastErr error + run := func() bool { + var stdout bytes.Buffer + //nolint:gosec + cmd := exec.CommandContext(ctx, "screen", + // -x targets an attached session. + "-x", rpty.id, + // -c is the flag for the config file. + "-c", rpty.configFile, + // -X runs a command in the matching session. + "-X", command, + ) + cmd.Env = append(rpty.command.Env, "TERM=xterm-256color") + cmd.Dir = rpty.command.Dir + cmd.Stdout = &stdout + err := cmd.Run() + if err == nil { + return true + } + + stdoutStr := stdout.String() + for _, se := range successErrors { + if strings.Contains(stdoutStr, se) { + return true + } + } + + // Things like "exit status 1" are imprecise so include stdout as it may + // contain more information ("no screen session found" for example). + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + lastErr = xerrors.Errorf("`screen -x %s -X %s`: %w: %s", rpty.id, command, err, stdoutStr) + } + + return false + } + + // Run immediately. + if done := run(); done { + return nil + } + + // Then run on an interval. + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.Canceled) { + return ctx.Err() + } + return errors.Join(ctx.Err(), lastErr) + case <-ticker.C: + if done := run(); done { + return nil + } + } + } +} + +func (rpty *screenReconnectingPTY) Wait() { + _, _ = rpty.state.waitForState(StateClosing) +} + +func (rpty *screenReconnectingPTY) Close(err error) { + // The closing state change will be handled by the lifecycle. + rpty.state.setState(StateClosing, err) +} diff --git a/agent/usershell/usershell_test.go b/agent/usershell/usershell_test.go index 676ee462ffe63..ee49afcb14412 100644 --- a/agent/usershell/usershell_test.go +++ b/agent/usershell/usershell_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/agent/usershell" + "github.com/coder/coder/v2/agent/usershell" ) //nolint:paralleltest,tparallel // This test sets an environment variable. diff --git a/buildinfo/boring.go b/buildinfo/boring.go new file mode 100644 index 0000000000000..ec2f0b4e3c286 --- /dev/null +++ b/buildinfo/boring.go @@ -0,0 +1,7 @@ +//go:build boringcrypto + +package buildinfo + +import "crypto/boring" + +var boringcrypto = boring.Enabled() diff --git a/buildinfo/buildinfo.go b/buildinfo/buildinfo.go index bafd3a916bcf2..e1fd90fe2fadb 100644 --- a/buildinfo/buildinfo.go +++ b/buildinfo/buildinfo.go @@ -30,8 +30,15 @@ var ( ) const ( - // develPrefix is prefixed to developer versions of the application. - develPrefix = "v0.0.0-devel" + // noVersion is the reported version when the version cannot be determined. + // Usually because `go build` is run instead of `make build`. + noVersion = "v0.0.0" + + // develPreRelease is the pre-release tag for developer versions of the + // application. This includes CI builds. The pre-release tag should be appended + // to the version with a "-". + // Example: v0.0.0-devel + develPreRelease = "devel" ) // Version returns the semantic version of the build. @@ -45,7 +52,8 @@ func Version() string { if tag == "" { // This occurs when the tag hasn't been injected, // like when using "go run". - version = develPrefix + revision + // -+ + version = fmt.Sprintf("%s-%s%s", noVersion, develPreRelease, revision) return } version = "v" + tag @@ -63,18 +71,23 @@ func Version() string { // disregarded. If it detects that either version is a developer build it // returns true. func VersionsMatch(v1, v2 string) bool { - // Developer versions are disregarded...hopefully they know what they are - // doing. - if strings.HasPrefix(v1, develPrefix) || strings.HasPrefix(v2, develPrefix) { + // If no version is attached, then it is a dev build outside of CI. The version + // will be disregarded... hopefully they know what they are doing. + if strings.Contains(v1, noVersion) || strings.Contains(v2, noVersion) { return true } return semver.MajorMinor(v1) == semver.MajorMinor(v2) } +func IsDevVersion(v string) bool { + return strings.Contains(v, "-"+develPreRelease) +} + // IsDev returns true if this is a development build. +// CI builds are also considered development builds. func IsDev() bool { - return strings.HasPrefix(Version(), develPrefix) + return IsDevVersion(Version()) } // IsSlim returns true if this is a slim build. @@ -87,6 +100,10 @@ func IsAGPL() bool { return strings.Contains(agpl, "t") } +func IsBoringCrypto() bool { + return boringcrypto +} + // ExternalURL returns a URL referencing the current Coder version. // For production builds, this will link directly to a release. // For development builds, this will link to a commit. diff --git a/buildinfo/buildinfo_test.go b/buildinfo/buildinfo_test.go index 12cc8c99a3ee7..b83c106148e9e 100644 --- a/buildinfo/buildinfo_test.go +++ b/buildinfo/buildinfo_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/mod/semver" - "github.com/coder/coder/buildinfo" + "github.com/coder/coder/v2/buildinfo" ) func TestBuildInfo(t *testing.T) { @@ -57,13 +57,19 @@ func TestBuildInfo(t *testing.T) { expectMatch: true, }, // Our CI instance uses a "-devel" prerelease - // flag. This is not the same as a developer WIP build. + // flag. { - name: "DevelPreleaseNotIgnored", + name: "DevelPreleaseMajor", v1: "v1.1.1-devel+123abac", v2: "v1.2.3", expectMatch: false, }, + { + name: "DevelPreleaseSame", + v1: "v1.1.1-devel+123abac", + v2: "v1.1.9", + expectMatch: true, + }, { name: "MajorMismatch", v1: "v1.2.3", diff --git a/buildinfo/notboring.go b/buildinfo/notboring.go new file mode 100644 index 0000000000000..70799b2c8d1eb --- /dev/null +++ b/buildinfo/notboring.go @@ -0,0 +1,5 @@ +//go:build !boringcrypto + +package buildinfo + +var boringcrypto = false diff --git a/cli/agent.go b/cli/agent.go index 1d9a2ba02d51c..8a836cd4c3c04 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -12,6 +12,7 @@ import ( "path/filepath" "runtime" "strconv" + "strings" "sync" "time" @@ -27,12 +28,13 @@ import ( "cdr.dev/slog/sloggers/sloghuman" "cdr.dev/slog/sloggers/slogjson" "cdr.dev/slog/sloggers/slogstackdriver" - "github.com/coder/coder/agent" - "github.com/coder/coder/agent/reaper" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agentproc" + "github.com/coder/coder/v2/agent/reaper" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" ) func (r *RootCmd) workspaceAgent() *clibase.Cmd { @@ -197,9 +199,19 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd { var exchangeToken func(context.Context) (agentsdk.AuthenticateResponse, error) switch auth { case "token": - token, err := inv.ParsedFlags().GetString(varAgentToken) - if err != nil { - return xerrors.Errorf("CODER_AGENT_TOKEN must be set for token auth: %w", err) + token, _ := inv.ParsedFlags().GetString(varAgentToken) + if token == "" { + tokenFile, _ := inv.ParsedFlags().GetString(varAgentTokenFile) + if tokenFile != "" { + tokenBytes, err := os.ReadFile(tokenFile) + if err != nil { + return xerrors.Errorf("read token file %q: %w", tokenFile, err) + } + token = strings.TrimSpace(string(tokenBytes)) + } + } + if token == "" { + return xerrors.Errorf("CODER_AGENT_TOKEN or CODER_AGENT_TOKEN_FILE must be set for token auth") } client.SetSessionToken(token) case "google-instance-identity": @@ -253,7 +265,21 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd { } prometheusRegistry := prometheus.NewRegistry() - subsystem := inv.Environ.Get(agent.EnvAgentSubsystem) + subsystemsRaw := inv.Environ.Get(agent.EnvAgentSubsystem) + subsystems := []codersdk.AgentSubsystem{} + for _, s := range strings.Split(subsystemsRaw, ",") { + subsystem := codersdk.AgentSubsystem(strings.TrimSpace(s)) + if subsystem == "" { + continue + } + if !subsystem.Valid() { + return xerrors.Errorf("invalid subsystem %q", subsystem) + } + subsystems = append(subsystems, subsystem) + } + + procTicker := time.NewTicker(time.Second) + defer procTicker.Stop() agnt := agent.New(agent.Options{ Client: client, Logger: logger, @@ -271,13 +297,18 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd { return resp.SessionToken, nil }, EnvironmentVariables: map[string]string{ - "GIT_ASKPASS": executablePath, + "GIT_ASKPASS": executablePath, + agent.EnvProcPrioMgmt: os.Getenv(agent.EnvProcPrioMgmt), }, IgnorePorts: ignorePorts, SSHMaxTimeout: sshMaxTimeout, - Subsystem: codersdk.AgentSubsystem(subsystem), + Subsystems: subsystems, PrometheusRegistry: prometheusRegistry, + Syscaller: agentproc.NewSyscaller(), + // Intentionally set this to nil. It's mainly used + // for testing. + ModifiedProcesses: nil, }) prometheusSrvClose := ServeHandler(ctx, logger, prometheusMetricsHandler(prometheusRegistry, logger), prometheusAddress, "prometheus") diff --git a/cli/agent_test.go b/cli/agent_test.go index 462ef3c204541..dd2266ec14394 100644 --- a/cli/agent_test.go +++ b/cli/agent_test.go @@ -2,6 +2,7 @@ package cli_test import ( "context" + "fmt" "os" "path/filepath" "runtime" @@ -12,13 +13,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/agent" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" ) func TestWorkspaceAgent(t *testing.T) { @@ -37,9 +38,9 @@ func TestWorkspaceAgent(t *testing.T) { ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) logDir := t.TempDir() inv, _ := clitest.New(t, @@ -74,9 +75,9 @@ func TestWorkspaceAgent(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", @@ -91,9 +92,9 @@ func TestWorkspaceAgent(t *testing.T) { }}, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--agent-url", client.URL.String()) inv = inv.WithContext( @@ -126,9 +127,9 @@ func TestWorkspaceAgent(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", @@ -143,9 +144,9 @@ func TestWorkspaceAgent(t *testing.T) { }}, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, _ := clitest.New(t, "agent", "--auth", "aws-instance-identity", "--agent-url", client.URL.String()) inv = inv.WithContext( @@ -175,12 +176,13 @@ func TestWorkspaceAgent(t *testing.T) { GoogleTokenValidator: validator, IncludeProvisionerDaemon: true, }) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", @@ -194,14 +196,14 @@ func TestWorkspaceAgent(t *testing.T) { }, }}, }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, cfg := clitest.New(t, "agent", "--auth", "google-instance-identity", "--agent-url", client.URL.String()) ptytest.New(t).Attach(inv) - clitest.SetupConfig(t, client, cfg) + clitest.SetupConfig(t, member, cfg) clitest.Start(t, inv.WithContext( //nolint:revive,staticcheck @@ -252,9 +254,9 @@ func TestWorkspaceAgent(t *testing.T) { ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) logDir := t.TempDir() inv, _ := clitest.New(t, @@ -264,8 +266,8 @@ func TestWorkspaceAgent(t *testing.T) { "--agent-url", client.URL.String(), "--log-dir", logDir, ) - // Set the subsystem for the agent. - inv.Environ.Set(agent.EnvAgentSubsystem, string(codersdk.AgentSubsystemEnvbox)) + // Set the subsystems for the agent. + inv.Environ.Set(agent.EnvAgentSubsystem, fmt.Sprintf("%s,%s", codersdk.AgentSubsystemExectrace, codersdk.AgentSubsystemEnvbox)) pty := ptytest.New(t).Attach(inv) @@ -275,6 +277,9 @@ func TestWorkspaceAgent(t *testing.T) { resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) require.Len(t, resources, 1) require.Len(t, resources[0].Agents, 1) - require.Equal(t, codersdk.AgentSubsystemEnvbox, resources[0].Agents[0].Subsystem) + require.Len(t, resources[0].Agents[0].Subsystems, 2) + // Sorted + require.Equal(t, codersdk.AgentSubsystemEnvbox, resources[0].Agents[0].Subsystems[0]) + require.Equal(t, codersdk.AgentSubsystemExectrace, resources[0].Agents[0].Subsystems[1]) }) } diff --git a/cli/clibase/cmd.go b/cli/clibase/cmd.go index 3e7dfe3903633..c3729d2d586cb 100644 --- a/cli/clibase/cmd.go +++ b/cli/clibase/cmd.go @@ -14,6 +14,8 @@ import ( "golang.org/x/exp/slices" "golang.org/x/xerrors" "gopkg.in/yaml.v3" + + "github.com/coder/coder/v2/coderd/util/slice" ) // Cmd describes an executable command. @@ -102,11 +104,11 @@ func (c *Cmd) PrepareAll() error { } } - slices.SortFunc(c.Options, func(a, b Option) bool { - return a.Name < b.Name + slices.SortFunc(c.Options, func(a, b Option) int { + return slice.Ascending(a.Name, b.Name) }) - slices.SortFunc(c.Children, func(a, b *Cmd) bool { - return a.Name() < b.Name() + slices.SortFunc(c.Children, func(a, b *Cmd) int { + return slice.Ascending(a.Name(), b.Name()) }) for _, child := range c.Children { child.Parent = c diff --git a/cli/clibase/cmd_test.go b/cli/clibase/cmd_test.go index fedbcd0cf2fd7..f0c21dd0b0bbb 100644 --- a/cli/clibase/cmd_test.go +++ b/cli/clibase/cmd_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/v2/cli/clibase" ) // ioBufs is the standard input, output, and error for a command. diff --git a/cli/clibase/env_test.go b/cli/clibase/env_test.go index d8830e64f580f..19dcc4e76d9a9 100644 --- a/cli/clibase/env_test.go +++ b/cli/clibase/env_test.go @@ -4,7 +4,7 @@ import ( "reflect" "testing" - "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/v2/cli/clibase" ) func TestFilterNamePrefix(t *testing.T) { diff --git a/cli/clibase/option.go b/cli/clibase/option.go index 8c7fed92e20f7..5743b3a4d1efe 100644 --- a/cli/clibase/option.go +++ b/cli/clibase/option.go @@ -1,6 +1,8 @@ package clibase import ( + "bytes" + "encoding/json" "os" "strings" @@ -65,6 +67,20 @@ type Option struct { ValueSource ValueSource `json:"value_source,omitempty"` } +// optionNoMethods is just a wrapper around Option so we can defer to the +// default json.Unmarshaler behavior. +type optionNoMethods Option + +func (o *Option) UnmarshalJSON(data []byte) error { + // If an option has no values, we have no idea how to unmarshal it. + // So just discard the json data. + if o.Value == nil { + o.Value = &DiscardValue + } + + return json.Unmarshal(data, (*optionNoMethods)(o)) +} + func (o Option) YAMLPath() string { if o.YAML == "" { return "" @@ -79,15 +95,101 @@ func (o Option) YAMLPath() string { // OptionSet is a group of options that can be applied to a command. type OptionSet []Option +// UnmarshalJSON implements json.Unmarshaler for OptionSets. Options have an +// interface Value type that cannot handle unmarshalling because the types cannot +// be inferred. Since it is a slice, instantiating the Options first does not +// help. +// +// However, we typically do instantiate the slice to have the correct types. +// So this unmarshaller will attempt to find the named option in the existing +// set, if it cannot, the value is discarded. If the option exists, the value +// is unmarshalled into the existing option, and replaces the existing option. +// +// The value is discarded if it's type cannot be inferred. This behavior just +// feels "safer", although it should never happen if the correct option set +// is passed in. The situation where this could occur is if a client and server +// are on different versions with different options. +func (optSet *OptionSet) UnmarshalJSON(data []byte) error { + dec := json.NewDecoder(bytes.NewBuffer(data)) + // Should be a json array, so consume the starting open bracket. + t, err := dec.Token() + if err != nil { + return xerrors.Errorf("read array open bracket: %w", err) + } + if t != json.Delim('[') { + return xerrors.Errorf("expected array open bracket, got %q", t) + } + + // As long as json elements exist, consume them. The counter is used for + // better errors. + var i int +OptionSetDecodeLoop: + for dec.More() { + var opt Option + // jValue is a placeholder value that allows us to capture the + // raw json for the value to attempt to unmarshal later. + var jValue jsonValue + opt.Value = &jValue + err := dec.Decode(&opt) + if err != nil { + return xerrors.Errorf("decode %d option: %w", i, err) + } + // This counter is used to contextualize errors to show which element of + // the array we failed to decode. It is only used in the error above, as + // if the above works, we can instead use the Option.Name which is more + // descriptive and useful. So increment here for the next decode. + i++ + + // Try to see if the option already exists in the option set. + // If it does, just update the existing option. + for optIndex, have := range *optSet { + if have.Name == opt.Name { + if jValue != nil { + err := json.Unmarshal(jValue, &(*optSet)[optIndex].Value) + if err != nil { + return xerrors.Errorf("decode option %q value: %w", have.Name, err) + } + // Set the opt's value + opt.Value = (*optSet)[optIndex].Value + } else { + // Hopefully the user passed empty values in the option set. There is no easy way + // to tell, and if we do not do this, it breaks json.Marshal if we do it again on + // this new option set. + opt.Value = (*optSet)[optIndex].Value + } + // Override the existing. + (*optSet)[optIndex] = opt + // Go to the next option to decode. + continue OptionSetDecodeLoop + } + } + + // If the option doesn't exist, the value will be discarded. + // We do this because we cannot infer the type of the value. + opt.Value = DiscardValue + *optSet = append(*optSet, opt) + } + + t, err = dec.Token() + if err != nil { + return xerrors.Errorf("read array close bracket: %w", err) + } + if t != json.Delim(']') { + return xerrors.Errorf("expected array close bracket, got %q", t) + } + + return nil +} + // Add adds the given Options to the OptionSet. -func (s *OptionSet) Add(opts ...Option) { - *s = append(*s, opts...) +func (optSet *OptionSet) Add(opts ...Option) { + *optSet = append(*optSet, opts...) } // Filter will only return options that match the given filter. (return true) -func (s OptionSet) Filter(filter func(opt Option) bool) OptionSet { +func (optSet OptionSet) Filter(filter func(opt Option) bool) OptionSet { cpy := make(OptionSet, 0) - for _, opt := range s { + for _, opt := range optSet { if filter(opt) { cpy = append(cpy, opt) } @@ -96,13 +198,13 @@ func (s OptionSet) Filter(filter func(opt Option) bool) OptionSet { } // FlagSet returns a pflag.FlagSet for the OptionSet. -func (s *OptionSet) FlagSet() *pflag.FlagSet { - if s == nil { +func (optSet *OptionSet) FlagSet() *pflag.FlagSet { + if optSet == nil { return &pflag.FlagSet{} } fs := pflag.NewFlagSet("", pflag.ContinueOnError) - for _, opt := range *s { + for _, opt := range *optSet { if opt.Flag == "" { continue } @@ -139,8 +241,8 @@ func (s *OptionSet) FlagSet() *pflag.FlagSet { // ParseEnv parses the given environment variables into the OptionSet. // Use EnvsWithPrefix to filter out prefixes. -func (s *OptionSet) ParseEnv(vs []EnvVar) error { - if s == nil { +func (optSet *OptionSet) ParseEnv(vs []EnvVar) error { + if optSet == nil { return nil } @@ -154,12 +256,21 @@ func (s *OptionSet) ParseEnv(vs []EnvVar) error { envs[v.Name] = v.Value } - for i, opt := range *s { + for i, opt := range *optSet { if opt.Env == "" { continue } envVal, ok := envs[opt.Env] + if !ok { + // Homebrew strips all environment variables that do not start with `HOMEBREW_`. + // This prevented using brew to invoke the Coder agent, because the environment + // variables to not get passed down. + // + // A customer wanted to use their custom tap inside a workspace, which was failing + // because the agent lacked the environment variables to authenticate with Git. + envVal, ok = envs[`HOMEBREW_`+opt.Env] + } // Currently, empty values are treated as if the environment variable is // unset. This behavior is technically not correct as there is now no // way for a user to change a Default value to an empty string from @@ -172,7 +283,7 @@ func (s *OptionSet) ParseEnv(vs []EnvVar) error { continue } - (*s)[i].ValueSource = ValueSourceEnv + (*optSet)[i].ValueSource = ValueSourceEnv if err := opt.Value.Set(envVal); err != nil { merr = multierror.Append( merr, xerrors.Errorf("parse %q: %w", opt.Name, err), @@ -185,14 +296,14 @@ func (s *OptionSet) ParseEnv(vs []EnvVar) error { // SetDefaults sets the default values for each Option, skipping values // that already have a value source. -func (s *OptionSet) SetDefaults() error { - if s == nil { +func (optSet *OptionSet) SetDefaults() error { + if optSet == nil { return nil } var merr *multierror.Error - for i, opt := range *s { + for i, opt := range *optSet { // Skip values that may have already been set by the user. if opt.ValueSource != ValueSourceNone { continue @@ -212,7 +323,7 @@ func (s *OptionSet) SetDefaults() error { ) continue } - (*s)[i].ValueSource = ValueSourceDefault + (*optSet)[i].ValueSource = ValueSourceDefault if err := opt.Value.Set(opt.Default); err != nil { merr = multierror.Append( merr, xerrors.Errorf("parse %q: %w", opt.Name, err), @@ -224,9 +335,9 @@ func (s *OptionSet) SetDefaults() error { // ByName returns the Option with the given name, or nil if no such option // exists. -func (s *OptionSet) ByName(name string) *Option { - for i := range *s { - opt := &(*s)[i] +func (optSet *OptionSet) ByName(name string) *Option { + for i := range *optSet { + opt := &(*optSet)[i] if opt.Name == name { return opt } diff --git a/cli/clibase/option_test.go b/cli/clibase/option_test.go index cacd8d3a10793..f093a20ec18da 100644 --- a/cli/clibase/option_test.go +++ b/cli/clibase/option_test.go @@ -1,11 +1,17 @@ package clibase_test import ( + "bytes" + "encoding/json" + "regexp" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" ) func TestOptionSet_ParseFlags(t *testing.T) { @@ -72,6 +78,40 @@ func TestOptionSet_ParseFlags(t *testing.T) { err := os.FlagSet().Parse([]string{"--some-unknown", "foo"}) require.Error(t, err) }) + + t.Run("RegexValid", func(t *testing.T) { + t.Parallel() + + var regexpString clibase.Regexp + + os := clibase.OptionSet{ + clibase.Option{ + Name: "RegexpString", + Value: ®expString, + Flag: "regexp-string", + }, + } + + err := os.FlagSet().Parse([]string{"--regexp-string", "$test^"}) + require.NoError(t, err) + }) + + t.Run("RegexInvalid", func(t *testing.T) { + t.Parallel() + + var regexpString clibase.Regexp + + os := clibase.OptionSet{ + clibase.Option{ + Name: "RegexpString", + Value: ®expString, + Flag: "regexp-string", + }, + } + + err := os.FlagSet().Parse([]string{"--regexp-string", "(("}) + require.Error(t, err) + }) } func TestOptionSet_ParseEnv(t *testing.T) { @@ -166,4 +206,186 @@ func TestOptionSet_ParseEnv(t *testing.T) { require.NoError(t, err) require.EqualValues(t, expected, actual.Value) }) + + t.Run("Homebrew", func(t *testing.T) { + t.Parallel() + + var agentToken clibase.String + + os := clibase.OptionSet{ + clibase.Option{ + Name: "Agent Token", + Value: &agentToken, + Env: "AGENT_TOKEN", + }, + } + + err := os.ParseEnv([]clibase.EnvVar{ + {Name: "HOMEBREW_AGENT_TOKEN", Value: "foo"}, + }) + require.NoError(t, err) + require.EqualValues(t, "foo", agentToken) + }) +} + +func TestOptionSet_JsonMarshal(t *testing.T) { + t.Parallel() + + // This unit test ensures if the source optionset is missing the option + // and cannot determine the type, it will not panic. The unmarshal will + // succeed with a best effort. + t.Run("MissingSrcOption", func(t *testing.T) { + t.Parallel() + + var str clibase.String = "something" + var arr clibase.StringArray = []string{"foo", "bar"} + opts := clibase.OptionSet{ + clibase.Option{ + Name: "StringOpt", + Value: &str, + }, + clibase.Option{ + Name: "ArrayOpt", + Value: &arr, + }, + } + data, err := json.Marshal(opts) + require.NoError(t, err, "marshal option set") + + tgt := clibase.OptionSet{} + err = json.Unmarshal(data, &tgt) + require.NoError(t, err, "unmarshal option set") + for i := range opts { + compareOptionsExceptValues(t, opts[i], tgt[i]) + require.Empty(t, tgt[i].Value.String(), "unknown value types are empty") + } + }) + + t.Run("RegexCase", func(t *testing.T) { + t.Parallel() + + val := clibase.Regexp(*regexp.MustCompile(".*")) + opts := clibase.OptionSet{ + clibase.Option{ + Name: "Regex", + Value: &val, + Default: ".*", + }, + } + data, err := json.Marshal(opts) + require.NoError(t, err, "marshal option set") + + var foundVal clibase.Regexp + newOpts := clibase.OptionSet{ + clibase.Option{ + Name: "Regex", + Value: &foundVal, + }, + } + err = json.Unmarshal(data, &newOpts) + require.NoError(t, err, "unmarshal option set") + + require.EqualValues(t, opts[0].Value.String(), newOpts[0].Value.String()) + }) + + t.Run("AllValues", func(t *testing.T) { + t.Parallel() + + vals := coderdtest.DeploymentValues(t) + opts := vals.Options() + sources := []clibase.ValueSource{ + clibase.ValueSourceNone, + clibase.ValueSourceFlag, + clibase.ValueSourceEnv, + clibase.ValueSourceYAML, + clibase.ValueSourceDefault, + } + for i := range opts { + opts[i].ValueSource = sources[i%len(sources)] + } + + data, err := json.Marshal(opts) + require.NoError(t, err, "marshal option set") + + newOpts := (&codersdk.DeploymentValues{}).Options() + err = json.Unmarshal(data, &newOpts) + require.NoError(t, err, "unmarshal option set") + + for i := range opts { + exp := opts[i] + found := newOpts[i] + + compareOptionsExceptValues(t, exp, found) + compareValues(t, exp, found) + } + + thirdOpts := (&codersdk.DeploymentValues{}).Options() + data, err = json.Marshal(newOpts) + require.NoError(t, err, "marshal option set") + + err = json.Unmarshal(data, &thirdOpts) + require.NoError(t, err, "unmarshal option set") + // Compare to the original opts again + for i := range opts { + exp := opts[i] + found := thirdOpts[i] + + compareOptionsExceptValues(t, exp, found) + compareValues(t, exp, found) + } + }) +} + +func compareOptionsExceptValues(t *testing.T, exp, found clibase.Option) { + t.Helper() + + require.Equalf(t, exp.Name, found.Name, "option name %q", exp.Name) + require.Equalf(t, exp.Description, found.Description, "option description %q", exp.Name) + require.Equalf(t, exp.Required, found.Required, "option required %q", exp.Name) + require.Equalf(t, exp.Flag, found.Flag, "option flag %q", exp.Name) + require.Equalf(t, exp.FlagShorthand, found.FlagShorthand, "option flag shorthand %q", exp.Name) + require.Equalf(t, exp.Env, found.Env, "option env %q", exp.Name) + require.Equalf(t, exp.YAML, found.YAML, "option yaml %q", exp.Name) + require.Equalf(t, exp.Default, found.Default, "option default %q", exp.Name) + require.Equalf(t, exp.ValueSource, found.ValueSource, "option value source %q", exp.Name) + require.Equalf(t, exp.Hidden, found.Hidden, "option hidden %q", exp.Name) + require.Equalf(t, exp.Annotations, found.Annotations, "option annotations %q", exp.Name) + require.Equalf(t, exp.Group, found.Group, "option group %q", exp.Name) + // UseInstead is the same comparison problem, just check the length + require.Equalf(t, len(exp.UseInstead), len(found.UseInstead), "option use instead %q", exp.Name) +} + +func compareValues(t *testing.T, exp, found clibase.Option) { + t.Helper() + + if (exp.Value == nil || found.Value == nil) || (exp.Value.String() != found.Value.String() && found.Value.String() == "") { + // If the string values are different, this can be a "nil" issue. + // So only run this case if the found string is the empty string. + // We use MarshalYAML for struct strings, and it will return an + // empty string '""' for nil slices/maps/etc. + // So use json to compare. + + expJSON, err := json.Marshal(exp.Value) + require.NoError(t, err, "marshal") + foundJSON, err := json.Marshal(found.Value) + require.NoError(t, err, "marshal") + + expJSON = normalizeJSON(expJSON) + foundJSON = normalizeJSON(foundJSON) + assert.Equalf(t, string(expJSON), string(foundJSON), "option value %q", exp.Name) + } else { + assert.Equal(t, + exp.Value.String(), + found.Value.String(), + "option value %q", exp.Name) + } +} + +// normalizeJSON handles the fact that an empty map/slice is not the same +// as a nil empty/slice. For our purposes, they are the same. +func normalizeJSON(data []byte) []byte { + if bytes.Equal(data, []byte("[]")) || bytes.Equal(data, []byte("{}")) { + return []byte("null") + } + return data } diff --git a/cli/clibase/values.go b/cli/clibase/values.go index 288a7c372b152..d390fe2f89bc6 100644 --- a/cli/clibase/values.go +++ b/cli/clibase/values.go @@ -7,6 +7,7 @@ import ( "net" "net/url" "reflect" + "regexp" "strconv" "strings" "time" @@ -429,6 +430,35 @@ func (discardValue) Type() string { return "discard" } +func (discardValue) UnmarshalJSON([]byte) error { + return nil +} + +// jsonValue is intentionally not exported. It is just used to store the raw JSON +// data for a value to defer it's unmarshal. It implements the pflag.Value to be +// usable in an Option. +type jsonValue json.RawMessage + +func (jsonValue) Set(string) error { + return xerrors.Errorf("json value is read-only") +} + +func (jsonValue) String() string { + return "" +} + +func (jsonValue) Type() string { + return "json" +} + +func (j *jsonValue) UnmarshalJSON(data []byte) error { + if j == nil { + return xerrors.New("json.RawMessage: UnmarshalJSON on nil pointer") + } + *j = append((*j)[0:0], data...) + return nil +} + var _ pflag.Value = (*Enum)(nil) type Enum struct { @@ -461,6 +491,62 @@ func (e *Enum) String() string { return *e.Value } +type Regexp regexp.Regexp + +func (r *Regexp) MarshalJSON() ([]byte, error) { + return json.Marshal(r.String()) +} + +func (r *Regexp) UnmarshalJSON(data []byte) error { + var source string + err := json.Unmarshal(data, &source) + if err != nil { + return err + } + + exp, err := regexp.Compile(source) + if err != nil { + return xerrors.Errorf("invalid regex expression: %w", err) + } + *r = Regexp(*exp) + return nil +} + +func (r *Regexp) MarshalYAML() (interface{}, error) { + return yaml.Node{ + Kind: yaml.ScalarNode, + Value: r.String(), + }, nil +} + +func (r *Regexp) UnmarshalYAML(n *yaml.Node) error { + return r.Set(n.Value) +} + +func (r *Regexp) Set(v string) error { + exp, err := regexp.Compile(v) + if err != nil { + return xerrors.Errorf("invalid regex expression: %w", err) + } + *r = Regexp(*exp) + return nil +} + +func (r Regexp) String() string { + return r.Value().String() +} + +func (r *Regexp) Value() *regexp.Regexp { + if r == nil { + return nil + } + return (*regexp.Regexp)(r) +} + +func (Regexp) Type() string { + return "regexp" +} + var _ pflag.Value = (*YAMLConfigPath)(nil) // YAMLConfigPath is a special value type that encodes a path to a YAML diff --git a/cli/clibase/yaml.go b/cli/clibase/yaml.go index fbb1529b3bf4d..9bb1763571eb4 100644 --- a/cli/clibase/yaml.go +++ b/cli/clibase/yaml.go @@ -51,12 +51,12 @@ func deepMapNode(n *yaml.Node, path []string, headComment string) *yaml.Node { // the stack. // // It is isomorphic with FromYAML. -func (s *OptionSet) MarshalYAML() (any, error) { +func (optSet *OptionSet) MarshalYAML() (any, error) { root := yaml.Node{ Kind: yaml.MappingNode, } - for _, opt := range *s { + for _, opt := range *optSet { if opt.YAML == "" { continue } @@ -222,7 +222,7 @@ func (o *Option) setFromYAMLNode(n *yaml.Node) error { // UnmarshalYAML converts the given YAML node into the option set. // It is isomorphic with ToYAML. -func (s *OptionSet) UnmarshalYAML(rootNode *yaml.Node) error { +func (optSet *OptionSet) UnmarshalYAML(rootNode *yaml.Node) error { // The rootNode will be a DocumentNode if it's read from a file. We do // not support multiple documents in a single file. if rootNode.Kind == yaml.DocumentNode { @@ -240,8 +240,8 @@ func (s *OptionSet) UnmarshalYAML(rootNode *yaml.Node) error { matchedNodes := make(map[string]*yaml.Node, len(yamlNodes)) var merr error - for i := range *s { - opt := &(*s)[i] + for i := range *optSet { + opt := &(*optSet)[i] if opt.YAML == "" { continue } diff --git a/cli/clibase/yaml_test.go b/cli/clibase/yaml_test.go index d14bfc7c75ea6..77a8880019649 100644 --- a/cli/clibase/yaml_test.go +++ b/cli/clibase/yaml_test.go @@ -8,7 +8,7 @@ import ( "golang.org/x/exp/slices" "gopkg.in/yaml.v3" - "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/v2/cli/clibase" ) func TestOptionSet_YAML(t *testing.T) { diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index 00ec1310043dc..23acc7c01b9d3 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -19,17 +19,17 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/cli" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/config" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/config" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/testutil" ) // New creates a CLI instance with a configuration pointed to a // temporary testing directory. -func New(t *testing.T, args ...string) (*clibase.Invocation, config.Root) { +func New(t testing.TB, args ...string) (*clibase.Invocation, config.Root) { var root cli.RootCmd cmd, err := root.Command(root.AGPL()) @@ -56,7 +56,7 @@ func (l *logWriter) Write(p []byte) (n int, err error) { } func NewWithCommand( - t *testing.T, cmd *clibase.Cmd, args ...string, + t testing.TB, cmd *clibase.Cmd, args ...string, ) (*clibase.Invocation, config.Root) { configDir := config.Root(t.TempDir()) logger := slogtest.Make(t, nil) diff --git a/cli/clitest/clitest_test.go b/cli/clitest/clitest_test.go index 283f7b48ca588..db31513d182c7 100644 --- a/cli/clitest/clitest_test.go +++ b/cli/clitest/clitest_test.go @@ -5,9 +5,9 @@ import ( "go.uber.org/goleak" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" ) func TestMain(m *testing.M) { diff --git a/cli/clitest/golden.go b/cli/clitest/golden.go index ba445efcea577..2a3ad2dc605c9 100644 --- a/cli/clitest/golden.go +++ b/cli/clitest/golden.go @@ -11,16 +11,14 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/config" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database/dbtestutil" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/config" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) // UpdateGoldenFiles indicates golden files should be updated. @@ -28,7 +26,7 @@ import ( // make update-golden-files var UpdateGoldenFiles = flag.Bool("update", false, "update .golden files") -var timestampRegex = regexp.MustCompile(`(?i)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?Z`) +var timestampRegex = regexp.MustCompile(`(?i)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?(Z|[+-]\d+:\d+)`) type CommandHelpCase struct { Name string @@ -50,16 +48,8 @@ func DefaultCases() []CommandHelpCase { // TestCommandHelp will test the help output of the given commands // using golden files. -// -//nolint:tparallel,paralleltest func TestCommandHelp(t *testing.T, getRoot func(t *testing.T) *clibase.Cmd, cases []CommandHelpCase) { - ogColorProfile := lipgloss.ColorProfile() - // ANSI256 escape codes are far easier for humans to parse in a diff, - // but TrueColor is probably more popular with modern terminals. - lipgloss.SetColorProfile(termenv.ANSI) - t.Cleanup(func() { - lipgloss.SetColorProfile(ogColorProfile) - }) + t.Parallel() rootClient, replacements := prepareTestData(t) root := getRoot(t) @@ -192,14 +182,14 @@ func prepareTestData(t *testing.T) (*codersdk.Client, map[string]string) { }) require.NoError(t, err) version := coderdtest.CreateTemplateVersion(t, rootClient, firstUser.OrganizationID, nil) - version = coderdtest.AwaitTemplateVersionJob(t, rootClient, version.ID) + version = coderdtest.AwaitTemplateVersionJobCompleted(t, rootClient, version.ID) template := coderdtest.CreateTemplate(t, rootClient, firstUser.OrganizationID, version.ID, func(req *codersdk.CreateTemplateRequest) { req.Name = "test-template" }) workspace := coderdtest.CreateWorkspace(t, rootClient, firstUser.OrganizationID, template.ID, func(req *codersdk.CreateWorkspaceRequest) { req.Name = "test-workspace" }) - workspaceBuild := coderdtest.AwaitWorkspaceBuildJob(t, rootClient, workspace.LatestBuild.ID) + workspaceBuild := coderdtest.AwaitWorkspaceBuildJobCompleted(t, rootClient, workspace.LatestBuild.ID) replacements := map[string]string{ firstUser.UserID.String(): "[first user ID]", diff --git a/cli/clitest/handlers.go b/cli/clitest/handlers.go index 5151bc6c0ed6c..2af0c4a5bee0c 100644 --- a/cli/clitest/handlers.go +++ b/cli/clitest/handlers.go @@ -3,7 +3,7 @@ package clitest import ( "testing" - "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/v2/cli/clibase" ) // HandlersOK asserts that all commands have a handler. diff --git a/cli/cliui/agent.go b/cli/cliui/agent.go index faad31f411f90..7620efa83b1e6 100644 --- a/cli/cliui/agent.go +++ b/cli/cliui/agent.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" ) var errAgentShuttingDown = xerrors.New("agent is shutting down") @@ -80,6 +80,10 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO if err != nil { return xerrors.Errorf("fetch: %w", err) } + logSources := map[uuid.UUID]codersdk.WorkspaceAgentLogSource{} + for _, source := range agent.LogSources { + logSources[source.ID] = source + } sw := &stageWriter{w: writer} @@ -123,7 +127,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO return nil } - stage := "Running workspace agent startup script" + stage := "Running workspace agent startup scripts" follow := opts.Wait if !follow { stage += " (non-blocking)" @@ -173,7 +177,12 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO return nil } for _, log := range logs { - sw.Log(log.CreatedAt, log.Level, log.Output) + source, hasSource := logSources[log.SourceID] + output := log.Output + if hasSource && source.DisplayName != "" { + output = source.DisplayName + ": " + output + } + sw.Log(log.CreatedAt, log.Level, output) lastLog = log } } @@ -192,16 +201,19 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO switch agent.LifecycleState { case codersdk.WorkspaceAgentLifecycleReady: sw.Complete(stage, agent.ReadyAt.Sub(*agent.StartedAt)) + case codersdk.WorkspaceAgentLifecycleStartTimeout: + sw.Fail(stage, 0) + sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: A startup script timed out and your workspace may be incomplete.") case codersdk.WorkspaceAgentLifecycleStartError: sw.Fail(stage, agent.ReadyAt.Sub(*agent.StartedAt)) // Use zero time (omitted) to separate these from the startup logs. - sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: The startup script exited with an error and your workspace may be incomplete.") + sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: A startup script exited with an error and your workspace may be incomplete.") sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates#startup-script-exited-with-an-error")) default: switch { case agent.LifecycleState.Starting(): // Use zero time (omitted) to separate these from the startup logs. - sw.Log(time.Time{}, codersdk.LogLevelWarn, "Notice: The startup script is still running and your workspace may be incomplete.") + sw.Log(time.Time{}, codersdk.LogLevelWarn, "Notice: The startup scripts are still running and your workspace may be incomplete.") sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates#your-workspace-may-be-incomplete")) // Note: We don't complete or fail the stage here, it's // intentionally left open to indicate this stage didn't @@ -225,12 +237,14 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO sw.Start(stage) sw.Log(time.Now(), codersdk.LogLevelWarn, "Wait for it to reconnect or restart your workspace.") sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates#agent-connection-issues")) + + disconnectedAt := *agent.DisconnectedAt for agent.Status == codersdk.WorkspaceAgentDisconnected { if agent, err = fetch(); err != nil { return xerrors.Errorf("fetch: %w", err) } } - sw.Complete(stage, agent.LastConnectedAt.Sub(*agent.DisconnectedAt)) + sw.Complete(stage, agent.LastConnectedAt.Sub(disconnectedAt)) } } } diff --git a/cli/cliui/agent_test.go b/cli/cliui/agent_test.go index c910bf11c301a..4cb10d8ec073e 100644 --- a/cli/cliui/agent_test.go +++ b/cli/cliui/agent_test.go @@ -5,29 +5,53 @@ import ( "bytes" "context" "io" + "os" "strings" "sync/atomic" "testing" "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func TestAgent(t *testing.T) { t.Parallel() + waitLines := func(t *testing.T, output <-chan string, lines ...string) error { + t.Helper() + + var got []string + outerLoop: + for _, want := range lines { + for { + select { + case line := <-output: + got = append(got, line) + if strings.Contains(line, want) { + continue outerLoop + } + case <-time.After(testutil.WaitShort): + assert.Failf(t, "timed out waiting for line", "want: %q; got: %q", want, got) + return xerrors.Errorf("timed out waiting for line: %q; got: %q", want, got) + } + } + } + return nil + } + for _, tc := range []struct { name string - iter []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error + iter []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error logs chan []codersdk.WorkspaceAgentLog opts cliui.AgentOptions want []string @@ -38,12 +62,15 @@ func TestAgent(t *testing.T) { opts: cliui.AgentOptions{ FetchInterval: time.Millisecond, }, - iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{ - func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error { + iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{ + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentConnecting return nil }, - func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error { + func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { + return waitLines(t, output, "⧗ Waiting for the workspace agent to connect") + }, + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentConnected agent.FirstConnectedAt = ptr.Ref(time.Now()) return nil @@ -52,28 +79,62 @@ func TestAgent(t *testing.T) { want: []string{ "⧗ Waiting for the workspace agent to connect", "✔ Waiting for the workspace agent to connect", - "⧗ Running workspace agent startup script (non-blocking)", - "Notice: The startup script is still running and your workspace may be incomplete.", + "⧗ Running workspace agent startup scripts (non-blocking)", + "Notice: The startup scripts are still running and your workspace may be incomplete.", "For more information and troubleshooting, see", }, }, + { + name: "Start timeout", + opts: cliui.AgentOptions{ + FetchInterval: time.Millisecond, + }, + iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{ + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { + agent.Status = codersdk.WorkspaceAgentConnecting + return nil + }, + func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { + return waitLines(t, output, "⧗ Waiting for the workspace agent to connect") + }, + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { + agent.Status = codersdk.WorkspaceAgentConnected + agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStartTimeout + agent.FirstConnectedAt = ptr.Ref(time.Now()) + return nil + }, + }, + want: []string{ + "⧗ Waiting for the workspace agent to connect", + "✔ Waiting for the workspace agent to connect", + "⧗ Running workspace agent startup scripts (non-blocking)", + "✘ Running workspace agent startup scripts (non-blocking)", + "Warning: A startup script timed out and your workspace may be incomplete.", + }, + }, { name: "Initial connection timeout", opts: cliui.AgentOptions{ FetchInterval: 1 * time.Millisecond, }, - iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{ - func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error { + iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{ + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentConnecting agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStarting agent.StartedAt = ptr.Ref(time.Now()) return nil }, - func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error { + func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { + return waitLines(t, output, "⧗ Waiting for the workspace agent to connect") + }, + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentTimeout return nil }, - func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error { + func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { + return waitLines(t, output, "The workspace agent is having trouble connecting, wait for it to connect or restart your workspace.") + }, + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentConnected agent.FirstConnectedAt = ptr.Ref(time.Now()) agent.LifecycleState = codersdk.WorkspaceAgentLifecycleReady @@ -86,8 +147,8 @@ func TestAgent(t *testing.T) { "The workspace agent is having trouble connecting, wait for it to connect or restart your workspace.", "For more information and troubleshooting, see", "✔ Waiting for the workspace agent to connect", - "⧗ Running workspace agent startup script (non-blocking)", - "✔ Running workspace agent startup script (non-blocking)", + "⧗ Running workspace agent startup scripts (non-blocking)", + "✔ Running workspace agent startup scripts (non-blocking)", }, }, { @@ -95,8 +156,8 @@ func TestAgent(t *testing.T) { opts: cliui.AgentOptions{ FetchInterval: 1 * time.Millisecond, }, - iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{ - func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error { + iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{ + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentDisconnected agent.FirstConnectedAt = ptr.Ref(time.Now().Add(-1 * time.Minute)) agent.LastConnectedAt = ptr.Ref(time.Now().Add(-1 * time.Minute)) @@ -106,8 +167,12 @@ func TestAgent(t *testing.T) { agent.ReadyAt = ptr.Ref(time.Now()) return nil }, - func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error { + func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { + return waitLines(t, output, "⧗ The workspace agent lost connection") + }, + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentConnected + agent.DisconnectedAt = nil agent.LastConnectedAt = ptr.Ref(time.Now()) return nil }, @@ -120,26 +185,31 @@ func TestAgent(t *testing.T) { }, }, { - name: "Startup script logs", + name: "Startup Logs", opts: cliui.AgentOptions{ FetchInterval: time.Millisecond, Wait: true, }, - iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{ - func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error { + iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{ + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, logs chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentConnected agent.FirstConnectedAt = ptr.Ref(time.Now()) agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStarting agent.StartedAt = ptr.Ref(time.Now()) + agent.LogSources = []codersdk.WorkspaceAgentLogSource{{ + ID: uuid.Nil, + DisplayName: "testing", + }} logs <- []codersdk.WorkspaceAgentLog{ { CreatedAt: time.Now(), Output: "Hello world", + SourceID: uuid.Nil, }, } return nil }, - func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error { + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, logs chan []codersdk.WorkspaceAgentLog) error { agent.LifecycleState = codersdk.WorkspaceAgentLifecycleReady agent.ReadyAt = ptr.Ref(time.Now()) logs <- []codersdk.WorkspaceAgentLog{ @@ -152,10 +222,10 @@ func TestAgent(t *testing.T) { }, }, want: []string{ - "⧗ Running workspace agent startup script", - "Hello world", + "⧗ Running workspace agent startup scripts", + "testing: Hello world", "Bye now", - "✔ Running workspace agent startup script", + "✔ Running workspace agent startup scripts", }, }, { @@ -164,8 +234,8 @@ func TestAgent(t *testing.T) { FetchInterval: time.Millisecond, Wait: true, }, - iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{ - func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error { + iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{ + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, logs chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentConnected agent.FirstConnectedAt = ptr.Ref(time.Now()) agent.StartedAt = ptr.Ref(time.Now()) @@ -181,10 +251,10 @@ func TestAgent(t *testing.T) { }, }, want: []string{ - "⧗ Running workspace agent startup script", + "⧗ Running workspace agent startup scripts", "Hello world", - "✘ Running workspace agent startup script", - "Warning: The startup script exited with an error and your workspace may be incomplete.", + "✘ Running workspace agent startup scripts", + "Warning: A startup script exited with an error and your workspace may be incomplete.", "For more information and troubleshooting, see", }, }, @@ -193,8 +263,8 @@ func TestAgent(t *testing.T) { opts: cliui.AgentOptions{ FetchInterval: time.Millisecond, }, - iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{ - func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error { + iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{ + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, logs chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentDisconnected agent.LifecycleState = codersdk.WorkspaceAgentLifecycleOff return nil @@ -208,8 +278,8 @@ func TestAgent(t *testing.T) { FetchInterval: time.Millisecond, Wait: true, }, - iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{ - func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error { + iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{ + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, logs chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentConnected agent.FirstConnectedAt = ptr.Ref(time.Now()) agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStarting @@ -222,16 +292,19 @@ func TestAgent(t *testing.T) { } return nil }, - func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error { + func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { + return waitLines(t, output, "Hello world") + }, + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { agent.ReadyAt = ptr.Ref(time.Now()) agent.LifecycleState = codersdk.WorkspaceAgentLifecycleShuttingDown return nil }, }, want: []string{ - "⧗ Running workspace agent startup script", + "⧗ Running workspace agent startup scripts", "Hello world", - "✔ Running workspace agent startup script", + "✔ Running workspace agent startup scripts", }, wantErr: true, }, @@ -241,12 +314,15 @@ func TestAgent(t *testing.T) { FetchInterval: time.Millisecond, Wait: true, }, - iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{ - func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error { + iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{ + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentConnecting return nil }, - func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error { + func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { + return waitLines(t, output, "⧗ Waiting for the workspace agent to connect") + }, + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { return xerrors.New("bad") }, }, @@ -261,13 +337,16 @@ func TestAgent(t *testing.T) { FetchInterval: time.Millisecond, Wait: true, }, - iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{ - func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error { + iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{ + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { agent.Status = codersdk.WorkspaceAgentTimeout agent.TroubleshootingURL = "https://troubleshoot" return nil }, - func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error { + func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { + return waitLines(t, output, "The workspace agent is having trouble connecting, wait for it to connect or restart your workspace.") + }, + func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error { return xerrors.New("bad") }, }, @@ -286,22 +365,27 @@ func TestAgent(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() - var buf bytes.Buffer + r, w, err := os.Pipe() + require.NoError(t, err, "create pipe failed") + defer r.Close() + defer w.Close() + agent := codersdk.WorkspaceAgent{ - ID: uuid.New(), - Status: codersdk.WorkspaceAgentConnecting, - StartupScriptBehavior: codersdk.WorkspaceAgentStartupScriptBehaviorNonBlocking, - CreatedAt: time.Now(), - LifecycleState: codersdk.WorkspaceAgentLifecycleCreated, + ID: uuid.New(), + Status: codersdk.WorkspaceAgentConnecting, + CreatedAt: time.Now(), + LifecycleState: codersdk.WorkspaceAgentLifecycleCreated, } + output := make(chan string, 100) // Buffered to avoid blocking, overflow is discarded. logs := make(chan []codersdk.WorkspaceAgentLog, 1) cmd := &clibase.Cmd{ Handler: func(inv *clibase.Invocation) error { tc.opts.Fetch = func(_ context.Context, _ uuid.UUID) (codersdk.WorkspaceAgent, error) { + t.Log("iter", len(tc.iter)) var err error if len(tc.iter) > 0 { - err = tc.iter[0](ctx, &agent, logs) + err = tc.iter[0](ctx, t, &agent, output, logs) tc.iter = tc.iter[1:] } return agent, err @@ -322,25 +406,26 @@ func TestAgent(t *testing.T) { close(fetchLogs) return fetchLogs, closeFunc(func() error { return nil }), nil } - err := cliui.Agent(inv.Context(), &buf, uuid.Nil, tc.opts) + err := cliui.Agent(inv.Context(), w, uuid.Nil, tc.opts) + _ = w.Close() return err }, } inv := cmd.Invoke() - w := clitest.StartWithWaiter(t, inv) - if tc.wantErr { - w.RequireError() - } else { - w.RequireSuccess() - } + waiter := clitest.StartWithWaiter(t, inv) - s := bufio.NewScanner(&buf) + s := bufio.NewScanner(r) for s.Scan() { line := s.Text() t.Log(line) + select { + case output <- line: + default: + t.Logf("output overflow: %s", line) + } if len(tc.want) == 0 { - require.Fail(t, "unexpected line: "+line) + require.Fail(t, "unexpected line", line) } require.Contains(t, line, tc.want[0]) tc.want = tc.want[1:] @@ -349,6 +434,12 @@ func TestAgent(t *testing.T) { if len(tc.want) > 0 { require.Fail(t, "missing lines: "+strings.Join(tc.want, ", ")) } + + if tc.wantErr { + waiter.RequireError() + } else { + waiter.RequireSuccess() + } }) } diff --git a/cli/cliui/cliui.go b/cli/cliui/cliui.go index b59ccd61298cc..db655749e94bf 100644 --- a/cli/cliui/cliui.go +++ b/cli/cliui/cliui.go @@ -1,12 +1,15 @@ package cliui import ( + "flag" "os" + "sync" + "time" - "github.com/charmbracelet/charm/ui/common" - "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" "golang.org/x/xerrors" + + "github.com/coder/pretty" ) var Canceled = xerrors.New("canceled") @@ -15,55 +18,147 @@ var Canceled = xerrors.New("canceled") var DefaultStyles Styles type Styles struct { - Bold, - Checkmark, Code, - Crossmark, DateTimeStamp, Error, Field, Keyword, - Paragraph, Placeholder, Prompt, FocusedPrompt, Fuchsia, - Logo, Warn, - Wrap lipgloss.Style + Wrap pretty.Style } -func init() { - lipgloss.SetDefaultRenderer( - lipgloss.NewRenderer(os.Stdout, termenv.WithColorCache(true)), - ) +var ( + color termenv.Profile + colorOnce sync.Once +) + +var ( + Green = Color("#04B575") + Red = Color("#ED567A") + Fuchsia = Color("#EE6FF8") + Yellow = Color("#ECFD65") + Blue = Color("#5000ff") +) + +// Color returns a color for the given string. +func Color(s string) termenv.Color { + colorOnce.Do(func() { + color = termenv.NewOutput(os.Stdout).ColorProfile() + if flag.Lookup("test.v") != nil { + // Use a consistent colorless profile in tests so that results + // are deterministic. + color = termenv.Ascii + } + }) + return color.Color(s) +} + +func isTerm() bool { + return color != termenv.Ascii +} + +// Bold returns a formatter that renders text in bold +// if the terminal supports it. +func Bold(s string) string { + if !isTerm() { + return s + } + return pretty.Sprint(pretty.Bold(), s) +} + +// BoldFmt returns a formatter that renders text in bold +// if the terminal supports it. +func BoldFmt() pretty.Formatter { + if !isTerm() { + return pretty.Style{} + } + return pretty.Bold() +} - // All Styles are set after we change the DefaultRenderer so that the ColorCache - // is in effect, mitigating the severe performance issue seen here: - // https://github.com/coder/coder/issues/7884. +// Timestamp formats a timestamp for display. +func Timestamp(t time.Time) string { + return pretty.Sprint(DefaultStyles.DateTimeStamp, t.Format(time.Stamp)) +} + +// Keyword formats a keyword for display. +func Keyword(s string) string { + return pretty.Sprint(DefaultStyles.Keyword, s) +} + +// Placeholder formats a placeholder for display. +func Placeholder(s string) string { + return pretty.Sprint(DefaultStyles.Placeholder, s) +} + +// Wrap prevents the text from overflowing the terminal. +func Wrap(s string) string { + return pretty.Sprint(DefaultStyles.Wrap, s) +} + +// Code formats code for display. +func Code(s string) string { + return pretty.Sprint(DefaultStyles.Code, s) +} + +// Field formats a field for display. +func Field(s string) string { + return pretty.Sprint(DefaultStyles.Field, s) +} - charmStyles := common.DefaultStyles() +func ifTerm(fmt pretty.Formatter) pretty.Formatter { + if !isTerm() { + return pretty.Nop + } + return fmt +} +func init() { + // We do not adapt the color based on whether the terminal is light or dark. + // Doing so would require a round-trip between the program and the terminal + // due to the OSC query and response. DefaultStyles = Styles{ - Bold: lipgloss.NewStyle().Bold(true), - Checkmark: charmStyles.Checkmark, - Code: charmStyles.Code, - Crossmark: charmStyles.Error.Copy().SetString("✘"), - DateTimeStamp: charmStyles.LabelDim, - Error: charmStyles.Error, - Field: charmStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}), - Keyword: charmStyles.Keyword, - Paragraph: charmStyles.Paragraph, - Placeholder: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#585858", Dark: "#4d46b3"}), - Prompt: charmStyles.Prompt.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}), - FocusedPrompt: charmStyles.FocusedPrompt.Copy().Foreground(lipgloss.Color("#651fff")), - Fuchsia: charmStyles.SelectedMenuItem.Copy(), - Logo: charmStyles.Logo.Copy().SetString("Coder"), - Warn: lipgloss.NewStyle().Foreground( - lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}, - ), - Wrap: lipgloss.NewStyle().Width(80), + Code: pretty.Style{ + ifTerm(pretty.XPad(1, 1)), + pretty.FgColor(Red), + pretty.BgColor(color.Color("#2c2c2c")), + }, + DateTimeStamp: pretty.Style{ + pretty.FgColor(color.Color("#7571F9")), + }, + Error: pretty.Style{ + pretty.FgColor(Red), + }, + Field: pretty.Style{ + pretty.XPad(1, 1), + pretty.FgColor(color.Color("#FFFFFF")), + pretty.BgColor(color.Color("#2b2a2a")), + }, + Keyword: pretty.Style{ + pretty.FgColor(Green), + }, + Placeholder: pretty.Style{ + pretty.FgColor(color.Color("#4d46b3")), + }, + Prompt: pretty.Style{ + pretty.FgColor(color.Color("#5C5C5C")), + pretty.Wrap("> ", ""), + }, + Warn: pretty.Style{ + pretty.FgColor(Yellow), + }, + Wrap: pretty.Style{ + pretty.LineWrap(80), + }, } + + DefaultStyles.FocusedPrompt = append( + DefaultStyles.Prompt, + pretty.FgColor(Blue), + ) } // ValidateNotEmpty is a helper function to disallow empty inputs! diff --git a/cli/cliui/externalauth.go b/cli/cliui/externalauth.go new file mode 100644 index 0000000000000..2e416ae3b5825 --- /dev/null +++ b/cli/cliui/externalauth.go @@ -0,0 +1,72 @@ +package cliui + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/briandowns/spinner" + + "github.com/coder/coder/v2/codersdk" +) + +type ExternalAuthOptions struct { + Fetch func(context.Context) ([]codersdk.TemplateVersionExternalAuth, error) + FetchInterval time.Duration +} + +func ExternalAuth(ctx context.Context, writer io.Writer, opts ExternalAuthOptions) error { + if opts.FetchInterval == 0 { + opts.FetchInterval = 500 * time.Millisecond + } + gitAuth, err := opts.Fetch(ctx) + if err != nil { + return err + } + + spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen")) + spin.Writer = writer + spin.ForceOutput = true + spin.Suffix = " Waiting for Git authentication..." + defer spin.Stop() + + ticker := time.NewTicker(opts.FetchInterval) + defer ticker.Stop() + for _, auth := range gitAuth { + if auth.Authenticated { + return nil + } + + _, _ = fmt.Fprintf(writer, "You must authenticate with %s to create a workspace with this template. Visit:\n\n\t%s\n\n", auth.DisplayName, auth.AuthenticateURL) + + ticker.Reset(opts.FetchInterval) + spin.Start() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + gitAuth, err := opts.Fetch(ctx) + if err != nil { + return err + } + var authed bool + for _, a := range gitAuth { + if !a.Authenticated || a.ID != auth.ID { + continue + } + authed = true + break + } + // The user authenticated with the provider! + if authed { + break + } + } + spin.Stop() + _, _ = fmt.Fprintf(writer, "Successfully authenticated with %s!\n\n", auth.DisplayName) + } + return nil +} diff --git a/cli/cliui/externalauth_test.go b/cli/cliui/externalauth_test.go new file mode 100644 index 0000000000000..32deb7290502a --- /dev/null +++ b/cli/cliui/externalauth_test.go @@ -0,0 +1,57 @@ +package cliui_test + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestExternalAuth(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + ptty := ptytest.New(t) + cmd := &clibase.Cmd{ + Handler: func(inv *clibase.Invocation) error { + var fetched atomic.Bool + return cliui.ExternalAuth(inv.Context(), inv.Stdout, cliui.ExternalAuthOptions{ + Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) { + defer fetched.Store(true) + return []codersdk.TemplateVersionExternalAuth{{ + ID: "github", + DisplayName: "GitHub", + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + Authenticated: fetched.Load(), + AuthenticateURL: "https://example.com/gitauth/github", + }}, nil + }, + FetchInterval: time.Millisecond, + }) + }, + } + + inv := cmd.Invoke().WithContext(ctx) + + ptty.Attach(inv) + done := make(chan struct{}) + go func() { + defer close(done) + err := inv.Run() + assert.NoError(t, err) + }() + ptty.ExpectMatchContext(ctx, "You must authenticate with") + ptty.ExpectMatchContext(ctx, "https://example.com/gitauth/github") + ptty.ExpectMatchContext(ctx, "Successfully authenticated with GitHub") + <-done +} diff --git a/cli/cliui/gitauth.go b/cli/cliui/gitauth.go deleted file mode 100644 index 7b4bd6f30e264..0000000000000 --- a/cli/cliui/gitauth.go +++ /dev/null @@ -1,72 +0,0 @@ -package cliui - -import ( - "context" - "fmt" - "io" - "time" - - "github.com/briandowns/spinner" - - "github.com/coder/coder/codersdk" -) - -type GitAuthOptions struct { - Fetch func(context.Context) ([]codersdk.TemplateVersionGitAuth, error) - FetchInterval time.Duration -} - -func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error { - if opts.FetchInterval == 0 { - opts.FetchInterval = 500 * time.Millisecond - } - gitAuth, err := opts.Fetch(ctx) - if err != nil { - return err - } - - spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen")) - spin.Writer = writer - spin.ForceOutput = true - spin.Suffix = " Waiting for Git authentication..." - defer spin.Stop() - - ticker := time.NewTicker(opts.FetchInterval) - defer ticker.Stop() - for _, auth := range gitAuth { - if auth.Authenticated { - return nil - } - - _, _ = fmt.Fprintf(writer, "You must authenticate with %s to create a workspace with this template. Visit:\n\n\t%s\n\n", auth.Type.Pretty(), auth.AuthenticateURL) - - ticker.Reset(opts.FetchInterval) - spin.Start() - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-ticker.C: - } - gitAuth, err := opts.Fetch(ctx) - if err != nil { - return err - } - var authed bool - for _, a := range gitAuth { - if !a.Authenticated || a.ID != auth.ID { - continue - } - authed = true - break - } - // The user authenticated with the provider! - if authed { - break - } - } - spin.Stop() - _, _ = fmt.Fprintf(writer, "Successfully authenticated with %s!\n\n", auth.Type.Pretty()) - } - return nil -} diff --git a/cli/cliui/gitauth_test.go b/cli/cliui/gitauth_test.go deleted file mode 100644 index dfe142f99be28..0000000000000 --- a/cli/cliui/gitauth_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package cliui_test - -import ( - "context" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" -) - -func TestGitAuth(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) - defer cancel() - - ptty := ptytest.New(t) - cmd := &clibase.Cmd{ - Handler: func(inv *clibase.Invocation) error { - var fetched atomic.Bool - return cliui.GitAuth(inv.Context(), inv.Stdout, cliui.GitAuthOptions{ - Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionGitAuth, error) { - defer fetched.Store(true) - return []codersdk.TemplateVersionGitAuth{{ - ID: "github", - Type: codersdk.GitProviderGitHub, - Authenticated: fetched.Load(), - AuthenticateURL: "https://example.com/gitauth/github", - }}, nil - }, - FetchInterval: time.Millisecond, - }) - }, - } - - inv := cmd.Invoke().WithContext(ctx) - - ptty.Attach(inv) - done := make(chan struct{}) - go func() { - defer close(done) - err := inv.Run() - assert.NoError(t, err) - }() - ptty.ExpectMatchContext(ctx, "You must authenticate with") - ptty.ExpectMatchContext(ctx, "https://example.com/gitauth/github") - ptty.ExpectMatchContext(ctx, "Successfully authenticated with GitHub") - <-done -} diff --git a/cli/cliui/log.go b/cli/cliui/log.go index fcc1d9b317c17..675aed0adc53c 100644 --- a/cli/cliui/log.go +++ b/cli/cliui/log.go @@ -5,12 +5,12 @@ import ( "io" "strings" - "github.com/charmbracelet/lipgloss" + "github.com/coder/pretty" ) // cliMessage provides a human-readable message for CLI errors and messages. type cliMessage struct { - Style lipgloss.Style + Style pretty.Style Header string Prefix string Lines []string @@ -21,13 +21,13 @@ func (m cliMessage) String() string { var str strings.Builder if m.Prefix != "" { - _, _ = str.WriteString(m.Style.Bold(true).Render(m.Prefix)) + _, _ = str.WriteString(Bold(m.Prefix)) } - _, _ = str.WriteString(m.Style.Bold(false).Render(m.Header)) + pretty.Fprint(&str, m.Style, m.Header) _, _ = str.WriteString("\r\n") for _, line := range m.Lines { - _, _ = fmt.Fprintf(&str, " %s %s\r\n", m.Style.Render("|"), line) + _, _ = fmt.Fprintf(&str, " %s %s\r\n", pretty.Sprint(m.Style, "|"), line) } return str.String() } @@ -35,7 +35,7 @@ func (m cliMessage) String() string { // Warn writes a log to the writer provided. func Warn(wtr io.Writer, header string, lines ...string) { _, _ = fmt.Fprint(wtr, cliMessage{ - Style: DefaultStyles.Warn.Copy(), + Style: DefaultStyles.Warn, Prefix: "WARN: ", Header: header, Lines: lines, @@ -63,7 +63,7 @@ func Infof(wtr io.Writer, fmtStr string, args ...interface{}) { // Error writes a log to the writer provided. func Error(wtr io.Writer, header string, lines ...string) { _, _ = fmt.Fprint(wtr, cliMessage{ - Style: DefaultStyles.Error.Copy(), + Style: DefaultStyles.Error, Prefix: "ERROR: ", Header: header, Lines: lines, diff --git a/cli/cliui/output.go b/cli/cliui/output.go index d4cada78f1a03..63a4d4ee5d2c4 100644 --- a/cli/cliui/output.go +++ b/cli/cliui/output.go @@ -9,7 +9,7 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/v2/cli/clibase" ) type OutputFormat interface { diff --git a/cli/cliui/output_test.go b/cli/cliui/output_test.go index 22ef241fba7ea..e74213803f09b 100644 --- a/cli/cliui/output_test.go +++ b/cli/cliui/output_test.go @@ -8,8 +8,8 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" ) type format struct { diff --git a/cli/cliui/parameter.go b/cli/cliui/parameter.go index f0f4fd99e45d0..3482e285e002d 100644 --- a/cli/cliui/parameter.go +++ b/cli/cliui/parameter.go @@ -5,8 +5,9 @@ import ( "fmt" "strings" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.TemplateVersionParameter) (string, error) { @@ -16,10 +17,10 @@ func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.Te } if templateVersionParameter.Ephemeral { - label += DefaultStyles.Warn.Render(" (build option)") + label += pretty.Sprint(DefaultStyles.Warn, " (build option)") } - _, _ = fmt.Fprintln(inv.Stdout, DefaultStyles.Bold.Render(label)) + _, _ = fmt.Fprintln(inv.Stdout, Bold(label)) if templateVersionParameter.DescriptionPlaintext != "" { _, _ = fmt.Fprintln(inv.Stdout, " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.DescriptionPlaintext, "\n"), "\n "))+"\n") @@ -45,7 +46,10 @@ func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.Te } _, _ = fmt.Fprintln(inv.Stdout) - _, _ = fmt.Fprintln(inv.Stdout, " "+DefaultStyles.Prompt.String()+DefaultStyles.Field.Render(strings.Join(values, ", "))) + pretty.Fprintf( + inv.Stdout, + DefaultStyles.Prompt, "%s\n", strings.Join(values, ", "), + ) value = string(v) } } else if len(templateVersionParameter.Options) > 0 { @@ -59,7 +63,7 @@ func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.Te }) if err == nil { _, _ = fmt.Fprintln(inv.Stdout) - _, _ = fmt.Fprintln(inv.Stdout, " "+DefaultStyles.Prompt.String()+DefaultStyles.Field.Render(richParameterOption.Name)) + pretty.Fprintf(inv.Stdout, DefaultStyles.Prompt, "%s\n", richParameterOption.Name) value = richParameterOption.Value } } else { @@ -70,7 +74,7 @@ func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.Te text += ":" value, err = Prompt(inv, PromptOptions{ - Text: DefaultStyles.Bold.Render(text), + Text: Bold(text), Validate: func(value string) error { return validateRichPrompt(value, templateVersionParameter) }, diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index f927b60749769..4d7cb6d4166df 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -13,7 +13,8 @@ import ( "github.com/mattn/go-isatty" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/pretty" ) // PromptOptions supply a set of options to the prompt. @@ -55,21 +56,24 @@ func Prompt(inv *clibase.Invocation, opts PromptOptions) (string, error) { } } - _, _ = fmt.Fprint(inv.Stdout, DefaultStyles.FocusedPrompt.String()+opts.Text+" ") + pretty.Fprintf(inv.Stdout, DefaultStyles.FocusedPrompt, "") + pretty.Fprintf(inv.Stdout, pretty.Nop, "%s ", opts.Text) if opts.IsConfirm { if len(opts.Default) == 0 { opts.Default = ConfirmYes } - renderedYes := DefaultStyles.Placeholder.Render(ConfirmYes) - renderedNo := DefaultStyles.Placeholder.Render(ConfirmNo) + var ( + renderedYes = pretty.Sprint(DefaultStyles.Placeholder, ConfirmYes) + renderedNo = pretty.Sprint(DefaultStyles.Placeholder, ConfirmNo) + ) if opts.Default == ConfirmYes { - renderedYes = DefaultStyles.Bold.Render(ConfirmYes) + renderedYes = Bold(ConfirmYes) } else { - renderedNo = DefaultStyles.Bold.Render(ConfirmNo) + renderedNo = Bold(ConfirmNo) } - _, _ = fmt.Fprint(inv.Stdout, DefaultStyles.Placeholder.Render("("+renderedYes+DefaultStyles.Placeholder.Render("/"+renderedNo+DefaultStyles.Placeholder.Render(") ")))) + pretty.Fprintf(inv.Stdout, DefaultStyles.Placeholder, "(%s/%s)", renderedYes, renderedNo) } else if opts.Default != "" { - _, _ = fmt.Fprint(inv.Stdout, DefaultStyles.Placeholder.Render("("+opts.Default+") ")) + _, _ = fmt.Fprint(inv.Stdout, pretty.Sprint(DefaultStyles.Placeholder, "("+opts.Default+") ")) } interrupt := make(chan os.Signal, 1) @@ -126,7 +130,7 @@ func Prompt(inv *clibase.Invocation, opts PromptOptions) (string, error) { if opts.Validate != nil { err := opts.Validate(line) if err != nil { - _, _ = fmt.Fprintln(inv.Stdout, DefaultStyles.Error.Render(err.Error())) + _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(DefaultStyles.Error, err.Error())) return Prompt(inv, opts) } } diff --git a/cli/cliui/prompt_test.go b/cli/cliui/prompt_test.go index 49f6dee46e957..69fc3a539f4df 100644 --- a/cli/cliui/prompt_test.go +++ b/cli/cliui/prompt_test.go @@ -11,11 +11,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/pty" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/pty" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestPrompt(t *testing.T) { diff --git a/cli/cliui/provisionerjob.go b/cli/cliui/provisionerjob.go index 16d2f366e531c..aeaea7a34cf45 100644 --- a/cli/cliui/provisionerjob.go +++ b/cli/cliui/provisionerjob.go @@ -14,7 +14,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) func WorkspaceBuild(ctx context.Context, writer io.Writer, client *codersdk.Client, build uuid.UUID) error { @@ -54,7 +55,7 @@ func (err *ProvisionerJobError) Error() string { } // ProvisionerJob renders a provisioner job with interactive cancellation. -func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOptions) error { +func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOptions) error { if opts.FetchInterval == 0 { opts.FetchInterval = time.Second } @@ -70,7 +71,7 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp jobMutex sync.Mutex ) - sw := &stageWriter{w: writer, verbose: opts.Verbose, silentLogs: opts.Silent} + sw := &stageWriter{w: wr, verbose: opts.Verbose, silentLogs: opts.Silent} printStage := func() { sw.Start(currentStage) @@ -127,7 +128,11 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp return } } - _, _ = fmt.Fprintf(writer, DefaultStyles.FocusedPrompt.String()+DefaultStyles.Bold.Render("Gracefully canceling...")+"\n\n") + pretty.Fprintf( + wr, + DefaultStyles.FocusedPrompt.With(BoldFmt()), + "Gracefully canceling...\n\n", + ) err := opts.Cancel() if err != nil { errChan <- xerrors.Errorf("cancel: %w", err) @@ -236,7 +241,7 @@ func (s *stageWriter) Log(createdAt time.Time, level codersdk.LogLevel, line str w = &s.logBuf } - render := func(s ...string) string { return strings.Join(s, " ") } + var style pretty.Style var lines []string if !createdAt.IsZero() { @@ -249,14 +254,14 @@ func (s *stageWriter) Log(createdAt time.Time, level codersdk.LogLevel, line str if !s.verbose { return } - render = DefaultStyles.Placeholder.Render + style = DefaultStyles.Placeholder case codersdk.LogLevelError: - render = DefaultStyles.Error.Render + style = DefaultStyles.Error case codersdk.LogLevelWarn: - render = DefaultStyles.Warn.Render + style = DefaultStyles.Warn case codersdk.LogLevelInfo: } - _, _ = fmt.Fprintf(w, "%s\n", render(lines...)) + pretty.Fprintf(w, style, "%s\n", strings.Join(lines, " ")) } func (s *stageWriter) flushLogs() { diff --git a/cli/cliui/provisionerjob_test.go b/cli/cliui/provisionerjob_test.go index 2aa25b6046517..b180a1ec9b52d 100644 --- a/cli/cliui/provisionerjob_test.go +++ b/cli/cliui/provisionerjob_test.go @@ -11,11 +11,11 @@ import ( "github.com/stretchr/testify/assert" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" ) // This cannot be ran in parallel because it uses a signal. @@ -29,13 +29,13 @@ func TestProvisionerJob(t *testing.T) { <-test.Next test.JobMutex.Lock() test.Job.Status = codersdk.ProvisionerJobRunning - now := database.Now() + now := dbtime.Now() test.Job.StartedAt = &now test.JobMutex.Unlock() <-test.Next test.JobMutex.Lock() test.Job.Status = codersdk.ProvisionerJobSucceeded - now = database.Now() + now = dbtime.Now() test.Job.CompletedAt = &now close(test.Logs) test.JobMutex.Unlock() @@ -56,17 +56,17 @@ func TestProvisionerJob(t *testing.T) { <-test.Next test.JobMutex.Lock() test.Job.Status = codersdk.ProvisionerJobRunning - now := database.Now() + now := dbtime.Now() test.Job.StartedAt = &now test.Logs <- codersdk.ProvisionerJobLog{ - CreatedAt: database.Now(), + CreatedAt: dbtime.Now(), Stage: "Something", } test.JobMutex.Unlock() <-test.Next test.JobMutex.Lock() test.Job.Status = codersdk.ProvisionerJobSucceeded - now = database.Now() + now = dbtime.Now() test.Job.CompletedAt = &now close(test.Logs) test.JobMutex.Unlock() @@ -99,7 +99,7 @@ func TestProvisionerJob(t *testing.T) { <-test.Next test.JobMutex.Lock() test.Job.Status = codersdk.ProvisionerJobCanceled - now := database.Now() + now := dbtime.Now() test.Job.CompletedAt = &now close(test.Logs) test.JobMutex.Unlock() @@ -123,7 +123,7 @@ type provisionerJobTest struct { func newProvisionerJob(t *testing.T) provisionerJobTest { job := &codersdk.ProvisionerJob{ Status: codersdk.ProvisionerJobPending, - CreatedAt: database.Now(), + CreatedAt: dbtime.Now(), } jobLock := sync.Mutex{} logs := make(chan codersdk.ProvisionerJobLog, 1) diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index 586d37eb5cc81..a9204c968c10a 100644 --- a/cli/cliui/resources.go +++ b/cli/cliui/resources.go @@ -9,9 +9,9 @@ import ( "github.com/jedib0t/go-pretty/v6/table" "golang.org/x/mod/semver" - "github.com/coder/coder/coderd/database" - - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) type WorkspaceResourcesOptions struct { @@ -79,7 +79,7 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource // Display a line for the resource. tableWriter.AppendRow(table.Row{ - DefaultStyles.Bold.Render(resourceAddress), + Bold(resourceAddress), "", "", "", @@ -108,7 +108,7 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource if totalAgents > 1 { sshCommand += "." + agent.Name } - sshCommand = DefaultStyles.Code.Render(sshCommand) + sshCommand = pretty.Sprint(DefaultStyles.Code, sshCommand) row = append(row, sshCommand) } tableWriter.AppendRow(row) @@ -122,32 +122,32 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource func renderAgentStatus(agent codersdk.WorkspaceAgent) string { switch agent.Status { case codersdk.WorkspaceAgentConnecting: - since := database.Now().Sub(agent.CreatedAt) - return DefaultStyles.Warn.Render("⦾ connecting") + " " + - DefaultStyles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]") + since := dbtime.Now().Sub(agent.CreatedAt) + return pretty.Sprint(DefaultStyles.Warn, "⦾ connecting") + " " + + pretty.Sprint(DefaultStyles.Placeholder, "["+strconv.Itoa(int(since.Seconds()))+"s]") case codersdk.WorkspaceAgentDisconnected: - since := database.Now().Sub(*agent.DisconnectedAt) - return DefaultStyles.Error.Render("⦾ disconnected") + " " + - DefaultStyles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]") + since := dbtime.Now().Sub(*agent.DisconnectedAt) + return pretty.Sprint(DefaultStyles.Error, "⦾ disconnected") + " " + + pretty.Sprint(DefaultStyles.Placeholder, "["+strconv.Itoa(int(since.Seconds()))+"s]") case codersdk.WorkspaceAgentTimeout: - since := database.Now().Sub(agent.CreatedAt) + since := dbtime.Now().Sub(agent.CreatedAt) return fmt.Sprintf( "%s %s", - DefaultStyles.Warn.Render("⦾ timeout"), - DefaultStyles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]"), + pretty.Sprint(DefaultStyles.Warn, "⦾ timeout"), + pretty.Sprint(DefaultStyles.Placeholder, "["+strconv.Itoa(int(since.Seconds()))+"s]"), ) case codersdk.WorkspaceAgentConnected: - return DefaultStyles.Keyword.Render("⦿ connected") + return pretty.Sprint(DefaultStyles.Keyword, "⦿ connected") default: - return DefaultStyles.Warn.Render("○ unknown") + return pretty.Sprint(DefaultStyles.Warn, "○ unknown") } } func renderAgentHealth(agent codersdk.WorkspaceAgent) string { if agent.Health.Healthy { - return DefaultStyles.Keyword.Render("✔ healthy") + return pretty.Sprint(DefaultStyles.Keyword, "✔ healthy") } - return DefaultStyles.Error.Render("✘ " + agent.Health.Reason) + return pretty.Sprint(DefaultStyles.Error, "✘ "+agent.Health.Reason) } func renderAgentVersion(agentVersion, serverVersion string) string { @@ -155,11 +155,11 @@ func renderAgentVersion(agentVersion, serverVersion string) string { agentVersion = "(unknown)" } if !semver.IsValid(serverVersion) || !semver.IsValid(agentVersion) { - return DefaultStyles.Placeholder.Render(agentVersion) + return pretty.Sprint(DefaultStyles.Placeholder, agentVersion) } outdated := semver.Compare(agentVersion, serverVersion) < 0 if outdated { - return DefaultStyles.Warn.Render(agentVersion + " (outdated)") + return pretty.Sprint(DefaultStyles.Warn, agentVersion+" (outdated)") } - return DefaultStyles.Keyword.Render(agentVersion) + return pretty.Sprint(DefaultStyles.Keyword, agentVersion) } diff --git a/cli/cliui/resources_internal_test.go b/cli/cliui/resources_internal_test.go index 21212f8873691..0c76e18eb1d1f 100644 --- a/cli/cliui/resources_internal_test.go +++ b/cli/cliui/resources_internal_test.go @@ -44,7 +44,7 @@ func TestRenderAgentVersion(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { t.Parallel() actual := renderAgentVersion(testCase.agentVersion, testCase.serverVersion) - assert.Equal(t, testCase.expected, actual) + assert.Equal(t, testCase.expected, (actual)) }) } } diff --git a/cli/cliui/resources_test.go b/cli/cliui/resources_test.go index c9d87c258a6e4..fb9bea8773cac 100644 --- a/cli/cliui/resources_test.go +++ b/cli/cliui/resources_test.go @@ -6,10 +6,10 @@ import ( "github.com/stretchr/testify/assert" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" ) func TestWorkspaceResources(t *testing.T) { @@ -44,7 +44,7 @@ func TestWorkspaceResources(t *testing.T) { t.Run("MultipleStates", func(t *testing.T) { t.Parallel() ptty := ptytest.New(t) - disconnected := database.Now().Add(-4 * time.Second) + disconnected := dbtime.Now().Add(-4 * time.Second) done := make(chan struct{}) go func() { err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{ @@ -60,7 +60,7 @@ func TestWorkspaceResources(t *testing.T) { Type: "google_compute_instance", Name: "dev", Agents: []codersdk.WorkspaceAgent{{ - CreatedAt: database.Now().Add(-10 * time.Second), + CreatedAt: dbtime.Now().Add(-10 * time.Second), Status: codersdk.WorkspaceAgentConnecting, LifecycleState: codersdk.WorkspaceAgentLifecycleCreated, Name: "dev", diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 52a255367ebcf..fafd1c9fcd368 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -10,8 +10,8 @@ import ( "github.com/AlecAivazis/survey/v2/terminal" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/codersdk" ) func init() { diff --git a/cli/cliui/select_test.go b/cli/cliui/select_test.go index f7467098cb263..9465d82b45c8f 100644 --- a/cli/cliui/select_test.go +++ b/cli/cliui/select_test.go @@ -6,10 +6,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" ) func TestSelect(t *testing.T) { diff --git a/cli/cliui/table_test.go b/cli/cliui/table_test.go index aca6f7bc825fd..32159abb9fc2b 100644 --- a/cli/cliui/table_test.go +++ b/cli/cliui/table_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/v2/cli/cliui" ) type stringWrapper struct { diff --git a/cli/config/file_test.go b/cli/config/file_test.go index b3ca15322e217..3177bbfaca101 100644 --- a/cli/config/file_test.go +++ b/cli/config/file_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/config" + "github.com/coder/coder/v2/cli/config" ) func TestFile(t *testing.T) { diff --git a/cli/configssh.go b/cli/configssh.go index 162c3c2a95855..7e9e8109ea554 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -22,9 +22,10 @@ import ( "golang.org/x/sync/errgroup" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" ) const ( @@ -189,7 +190,6 @@ func sshPrepareWorkspaceConfigs(ctx context.Context, client *codersdk.Client) (r } } -//nolint:gocyclo func (r *RootCmd) configSSH() *clibase.Cmd { var ( sshConfigFile string @@ -367,8 +367,8 @@ func (r *RootCmd) configSSH() *clibase.Cmd { } // Ensure stable sorting of output. - slices.SortFunc(workspaceConfigs, func(a, b sshWorkspaceConfig) bool { - return a.Name < b.Name + slices.SortFunc(workspaceConfigs, func(a, b sshWorkspaceConfig) int { + return slice.Ascending(a.Name, b.Name) }) for _, wc := range workspaceConfigs { sort.Strings(wc.Hosts) diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 34da7dd03fcc0..a39afe606f873 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -19,17 +19,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "cdr.dev/slog/sloggers/slogtest" - - "github.com/coder/coder/agent" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func sshConfigFileName(t *testing.T) (sshConfig string) { @@ -78,13 +75,14 @@ func TestConfigSSH(t *testing.T) { }, }, }) - user := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -98,19 +96,11 @@ func TestConfigSSH(t *testing.T) { }}, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(authToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - Logger: slogtest.Make(t, nil).Named("agent"), - }) - defer func() { - _ = agentCloser.Close() - }() + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + _ = agenttest.New(t, client.URL, authToken) resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) agentConn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil) require.NoError(t, err) @@ -156,7 +146,7 @@ func TestConfigSSH(t *testing.T) { "--ssh-option", "Port "+strconv.Itoa(tcpAddr.Port), "--ssh-config-file", sshConfigFile, "--skip-proxy-command") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t) inv.Stdin = pty.Input() inv.Stdout = pty.Output() @@ -605,10 +595,10 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, tt.echoResponse) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) ) // Prepare ssh config files. @@ -720,31 +710,21 @@ func TestConfigSSH_Hostnames(t *testing.T) { resources = append(resources, resource) } - provisionResponse := []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: resources, - }, - }, - }} - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) // authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: provisionResponse, - ProvisionApply: provisionResponse, - }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, + echo.WithResources(resources)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) sshConfigFile := sshConfigFileName(t) inv, root := clitest.New(t, "config-ssh", "--ssh-config-file", sshConfigFile) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t) inv.Stdin = pty.Input() diff --git a/cli/create.go b/cli/create.go index 602b7b40a45bc..733eb99a7103d 100644 --- a/cli/create.go +++ b/cli/create.go @@ -10,19 +10,24 @@ import ( "golang.org/x/exp/slices" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) create() *clibase.Cmd { var ( - richParameterFile string - templateName string - startAt string - stopAfter time.Duration - workspaceName string + templateName string + startAt string + stopAfter time.Duration + workspaceName string + + parameterFlags workspaceParameterFlags + autoUpdates string ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -73,15 +78,15 @@ func (r *RootCmd) create() *clibase.Cmd { var template codersdk.Template if templateName == "" { - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Wrap.Render("Select a template below to preview the provisioned infrastructure:")) + _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:")) templates, err := client.TemplatesByOrganization(inv.Context(), organization.ID) if err != nil { return err } - slices.SortFunc(templates, func(a, b codersdk.Template) bool { - return a.ActiveUserCount > b.ActiveUserCount + slices.SortFunc(templates, func(a, b codersdk.Template) int { + return slice.Descending(a.ActiveUserCount, b.ActiveUserCount) }) templateNames := make([]string, 0, len(templates)) @@ -91,7 +96,7 @@ func (r *RootCmd) create() *clibase.Cmd { templateName := template.Name if template.ActiveUserCount > 0 { - templateName += cliui.DefaultStyles.Placeholder.Render( + templateName += cliui.Placeholder( fmt.Sprintf( " (used by %s)", formatActiveDevelopers(template.ActiveUserCount), @@ -129,10 +134,18 @@ func (r *RootCmd) create() *clibase.Cmd { schedSpec = ptr.Ref(sched.String()) } - buildParams, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ - Template: template, - RichParameterFile: richParameterFile, - NewWorkspaceName: workspaceName, + cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters) + if err != nil { + return xerrors.Errorf("can't parse given parameter values: %w", err) + } + + richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ + Action: WorkspaceCreate, + Template: template, + NewWorkspaceName: workspaceName, + + RichParameterFile: parameterFlags.richParameterFile, + RichParameters: cliRichParameters, }) if err != nil { return xerrors.Errorf("prepare build: %w", err) @@ -156,7 +169,8 @@ func (r *RootCmd) create() *clibase.Cmd { Name: workspaceName, AutostartSchedule: schedSpec, TTLMillis: ttlMillis, - RichParameterValues: buildParams.richParameters, + RichParameterValues: richParameters, + AutomaticUpdates: codersdk.AutomaticUpdates(autoUpdates), }) if err != nil { return xerrors.Errorf("create workspace: %w", err) @@ -167,7 +181,12 @@ func (r *RootCmd) create() *clibase.Cmd { return xerrors.Errorf("watch build: %w", err) } - _, _ = fmt.Fprintf(inv.Stdout, "\nThe %s workspace has been created at %s!\n", cliui.DefaultStyles.Keyword.Render(workspace.Name), cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))) + _, _ = fmt.Fprintf( + inv.Stdout, + "\nThe %s workspace has been created at %s!\n", + cliui.Keyword(workspace.Name), + cliui.Timestamp(time.Now()), + ) return nil }, } @@ -179,12 +198,6 @@ func (r *RootCmd) create() *clibase.Cmd { Description: "Specify a template name.", Value: clibase.StringOf(&templateName), }, - clibase.Option{ - Flag: "rich-parameter-file", - Env: "CODER_RICH_PARAMETER_FILE", - Description: "Specify a file path with values for rich parameters defined in the template.", - Value: clibase.StringOf(&richParameterFile), - }, clibase.Option{ Flag: "start-at", Env: "CODER_WORKSPACE_START_AT", @@ -197,106 +210,73 @@ func (r *RootCmd) create() *clibase.Cmd { Description: "Specify a duration after which the workspace should shut down (e.g. 8h).", Value: clibase.DurationOf(&stopAfter), }, + clibase.Option{ + Flag: "automatic-updates", + Env: "CODER_WORKSPACE_AUTOMATIC_UPDATES", + Description: "Specify automatic updates setting for the workspace (accepts 'always' or 'never').", + Default: string(codersdk.AutomaticUpdatesNever), + Value: clibase.StringOf(&autoUpdates), + }, cliui.SkipPromptOption(), ) + cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...) return cmd } type prepWorkspaceBuildArgs struct { - Template codersdk.Template - ExistingRichParams []codersdk.WorkspaceBuildParameter - RichParameterFile string - NewWorkspaceName string - - UpdateWorkspace bool - BuildOptions bool - WorkspaceID uuid.UUID -} + Action WorkspaceCLIAction + Template codersdk.Template + NewWorkspaceName string + WorkspaceID uuid.UUID -type buildParameters struct { - // Rich parameters stores values for build parameters annotated with description, icon, type, etc. - richParameters []codersdk.WorkspaceBuildParameter + LastBuildParameters []codersdk.WorkspaceBuildParameter + + PromptBuildOptions bool + BuildOptions []codersdk.WorkspaceBuildParameter + + PromptRichParameters bool + RichParameters []codersdk.WorkspaceBuildParameter + RichParameterFile string } // prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version. -// Any missing params will be prompted to the user. It supports legacy and rich parameters. -func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) (*buildParameters, error) { +// Any missing params will be prompted to the user. It supports rich parameters. +func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.WorkspaceBuildParameter, error) { ctx := inv.Context() templateVersion, err := client.TemplateVersion(ctx, args.Template.ActiveVersionID) if err != nil { - return nil, err + return nil, xerrors.Errorf("get template version: %w", err) } - // Rich parameters templateVersionParameters, err := client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID) if err != nil { return nil, xerrors.Errorf("get template version rich parameters: %w", err) } - parameterMapFromFile := map[string]string{} - useParamFile := false + parameterFile := map[string]string{} if args.RichParameterFile != "" { - useParamFile = true - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Paragraph.Render("Attempting to read the variables from the rich parameter file.")+"\r\n") - parameterMapFromFile, err = createParameterMapFromFile(args.RichParameterFile) + parameterFile, err = parseParameterMapFile(args.RichParameterFile) if err != nil { - return nil, err + return nil, xerrors.Errorf("can't parse parameter map file: %w", err) } } - disclaimerPrinted := false - richParameters := make([]codersdk.WorkspaceBuildParameter, 0) -PromptRichParamLoop: - for _, templateVersionParameter := range templateVersionParameters { - if !args.BuildOptions && templateVersionParameter.Ephemeral { - continue - } - - if !disclaimerPrinted { - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n") - disclaimerPrinted = true - } - - // Param file is all or nothing - if !useParamFile && !templateVersionParameter.Ephemeral { - for _, e := range args.ExistingRichParams { - if e.Name == templateVersionParameter.Name { - // If the param already exists, we do not need to prompt it again. - // The workspace scope will reuse params for each build. - continue PromptRichParamLoop - } - } - } - if args.UpdateWorkspace && !templateVersionParameter.Mutable { - // Check if the immutable parameter was used in the previous build. If so, then it isn't a fresh one - // and the user should be warned. - exists, err := workspaceBuildParameterExists(ctx, client, args.WorkspaceID, templateVersionParameter) - if err != nil { - return nil, err - } - - if exists { - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Warn.Render(fmt.Sprintf(`Parameter %q is not mutable, so can't be customized after workspace creation.`, templateVersionParameter.Name))) - continue - } - } - - parameterValue, err := getWorkspaceBuildParameterValueFromMapOrInput(inv, parameterMapFromFile, templateVersionParameter) - if err != nil { - return nil, err - } - - richParameters = append(richParameters, *parameterValue) - } - - if disclaimerPrinted { - _, _ = fmt.Fprintln(inv.Stdout) + resolver := new(ParameterResolver). + WithLastBuildParameters(args.LastBuildParameters). + WithPromptBuildOptions(args.PromptBuildOptions). + WithBuildOptions(args.BuildOptions). + WithPromptRichParameters(args.PromptRichParameters). + WithRichParameters(args.RichParameters). + WithRichParametersFile(parameterFile) + buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters) + if err != nil { + return nil, err } - err = cliui.GitAuth(ctx, inv.Stdout, cliui.GitAuthOptions{ - Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionGitAuth, error) { - return client.TemplateVersionGitAuth(ctx, templateVersion.ID) + err = cliui.ExternalAuth(ctx, inv.Stdout, cliui.ExternalAuthOptions{ + Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) { + return client.TemplateVersionExternalAuth(ctx, templateVersion.ID) }, }) if err != nil { @@ -306,7 +286,7 @@ PromptRichParamLoop: // Run a dry-run with the given parameters to check correctness dryRun, err := client.CreateTemplateVersionDryRun(inv.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{ WorkspaceName: args.NewWorkspaceName, - RichParameterValues: richParameters, + RichParameterValues: buildParameters, }) if err != nil { return nil, xerrors.Errorf("begin workspace dry-run: %w", err) @@ -346,21 +326,5 @@ PromptRichParamLoop: return nil, xerrors.Errorf("get resources: %w", err) } - return &buildParameters{ - richParameters: richParameters, - }, nil -} - -func workspaceBuildParameterExists(ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID, templateVersionParameter codersdk.TemplateVersionParameter) (bool, error) { - lastBuildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceID) - if err != nil { - return false, xerrors.Errorf("can't fetch last workspace build parameters: %w", err) - } - - for _, p := range lastBuildParameters { - if p.Name == templateVersionParameter.Name { - return true, nil - } - } - return false, nil + return buildParameters, nil } diff --git a/cli/create_test.go b/cli/create_test.go index 8f2bb6719a377..993ae9e57b441 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -2,6 +2,7 @@ package cli_test import ( "context" + "fmt" "net/http" "os" "regexp" @@ -11,15 +12,15 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/externalauth" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestCreate(t *testing.T) { @@ -27,23 +28,21 @@ func TestCreate(t *testing.T) { t.Run("Create", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - ProvisionPlan: provisionCompleteWithAgent, - }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) args := []string{ "create", "my-workspace", "--template", template.Name, "--start-at", "9:30AM Mon-Fri US/Central", "--stop-after", "8h", + "--automatic-updates", "always", } inv, root := clitest.New(t, args...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) go func() { @@ -67,7 +66,7 @@ func TestCreate(t *testing.T) { } <-doneChan - ws, err := client.WorkspaceByOwnerAndName(context.Background(), "testuser", "my-workspace", codersdk.WorkspaceOptions{}) + ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) if assert.NoError(t, err, "expected workspace to be created") { assert.Equal(t, ws.TemplateName, template.Name) if assert.NotNil(t, ws.AutostartSchedule) { @@ -76,6 +75,7 @@ func TestCreate(t *testing.T) { if assert.NotNil(t, ws.TTLMillis) { assert.Equal(t, *ws.TTLMillis, 8*time.Hour.Milliseconds()) } + assert.Equal(t, codersdk.AutomaticUpdatesAlways, ws.AutomaticUpdates) } }) @@ -83,12 +83,8 @@ func TestCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - ProvisionPlan: provisionCompleteWithAgent, - }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) _, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) args := []string{ @@ -100,6 +96,7 @@ func TestCreate(t *testing.T) { } inv, root := clitest.New(t, args...) + //nolint:gocritic // Creating a workspace for another user requires owner permissions. clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) @@ -139,14 +136,11 @@ func TestCreate(t *testing.T) { t.Run("InheritStopAfterFromTemplate", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - ProvisionPlan: provisionCompleteWithAgent, - }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { var defaultTTLMillis int64 = 2 * 60 * 60 * 1000 // 2 hours ctr.DefaultTTLMillis = &defaultTTLMillis }) @@ -156,7 +150,7 @@ func TestCreate(t *testing.T) { "--template", template.Name, } inv, root := clitest.New(t, args...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t).Attach(inv) waiter := clitest.StartWithWaiter(t, inv) matches := []struct { @@ -175,7 +169,7 @@ func TestCreate(t *testing.T) { } waiter.RequireSuccess() - ws, err := client.WorkspaceByOwnerAndName(context.Background(), "testuser", "my-workspace", codersdk.WorkspaceOptions{}) + ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) require.NoError(t, err, "expected workspace to be created") assert.Equal(t, ws.TemplateName, template.Name) assert.Equal(t, *ws.TTLMillis, template.DefaultTTLMillis) @@ -186,7 +180,7 @@ func TestCreate(t *testing.T) { client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) inv, root := clitest.New(t, "create", "my-workspace", "-y") @@ -206,12 +200,13 @@ func TestCreate(t *testing.T) { t.Run("FromNothing", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) inv, root := clitest.New(t, "create", "") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) go func() { @@ -231,7 +226,7 @@ func TestCreate(t *testing.T) { } <-doneChan - ws, err := client.WorkspaceByOwnerAndName(inv.Context(), "testuser", "my-workspace", codersdk.WorkspaceOptions{}) + ws, err := member.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) if assert.NoError(t, err, "expected workspace to be created") { assert.Equal(t, ws.TemplateName, template.Name) assert.Nil(t, ws.AutostartSchedule, "expected workspace autostart schedule to be nil") @@ -239,6 +234,22 @@ func TestCreate(t *testing.T) { }) } +func prepareEchoResponses(parameters []*proto.RichParameter) *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: parameters, + }, + }, + }, + }, + ProvisionApply: echo.ApplyComplete, + } +} + func TestCreateWithRichParameters(t *testing.T) { t.Parallel() @@ -257,40 +268,26 @@ func TestCreateWithRichParameters(t *testing.T) { immutableParameterValue = "4" ) - echoResponses := &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: []*proto.RichParameter{ - {Name: firstParameterName, Description: firstParameterDescription, Mutable: true}, - {Name: secondParameterName, DisplayName: secondParameterDisplayName, Description: secondParameterDescription, Mutable: true}, - {Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false}, - }, - }, - }, - }, - }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, - } + echoResponses := prepareEchoResponses([]*proto.RichParameter{ + {Name: firstParameterName, Description: firstParameterDescription, Mutable: true}, + {Name: secondParameterName, DisplayName: secondParameterDisplayName, Description: secondParameterDescription, Mutable: true}, + {Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false}, + }, + ) t.Run("InputParameters", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) go func() { @@ -322,11 +319,12 @@ func TestCreateWithRichParameters(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) tempDir := t.TempDir() removeTmpDirUntilSuccessAfterTest(t, tempDir) @@ -336,7 +334,7 @@ func TestCreateWithRichParameters(t *testing.T) { secondParameterName + ": " + secondParameterValue + "\n" + immutableParameterName + ": " + immutableParameterValue) inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name()) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) @@ -357,6 +355,42 @@ func TestCreateWithRichParameters(t *testing.T) { } <-doneChan }) + + t.Run("ParameterFlags", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, + "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue), + "--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue), + "--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue)) + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + matches := []string{ + "Confirm create?", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + <-doneChan + }) } func TestCreateValidateRichParameters(t *testing.T) { @@ -391,40 +425,19 @@ func TestCreateValidateRichParameters(t *testing.T) { {Name: boolParameterName, Type: "bool", Mutable: true}, } - prepareEchoResponses := func(richParameters []*proto.RichParameter) *echo.Responses { - return &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: richParameters, - }, - }, - }, - }, - ProvisionApply: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }, - }, - } - } - t.Run("ValidateString", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(stringRichParameters)) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(stringRichParameters)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) go func() { @@ -443,7 +456,9 @@ func TestCreateValidateRichParameters(t *testing.T) { match := matches[i] value := matches[i+1] pty.ExpectMatch(match) - pty.WriteLine(value) + if value != "" { + pty.WriteLine(value) + } } <-doneChan }) @@ -452,14 +467,15 @@ func TestCreateValidateRichParameters(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(numberRichParameters)) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(numberRichParameters)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) go func() { @@ -478,7 +494,6 @@ func TestCreateValidateRichParameters(t *testing.T) { match := matches[i] value := matches[i+1] pty.ExpectMatch(match) - if value != "" { pty.WriteLine(value) } @@ -490,14 +505,15 @@ func TestCreateValidateRichParameters(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(boolRichParameters)) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(boolRichParameters)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) go func() { @@ -516,7 +532,9 @@ func TestCreateValidateRichParameters(t *testing.T) { match := matches[i] value := matches[i+1] pty.ExpectMatch(match) - pty.WriteLine(value) + if value != "" { + pty.WriteLine(value) + } } <-doneChan }) @@ -525,13 +543,14 @@ func TestCreateValidateRichParameters(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(listOfStringsRichParameters)) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(listOfStringsRichParameters)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t).Attach(inv) clitest.Start(t, inv) @@ -554,10 +573,11 @@ func TestCreateValidateRichParameters(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(listOfStringsRichParameters)) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(listOfStringsRichParameters)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) tempDir := t.TempDir() removeTmpDirUntilSuccessAfterTest(t, tempDir) @@ -567,7 +587,7 @@ func TestCreateValidateRichParameters(t *testing.T) { - eee - fff`) inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name()) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t).Attach(inv) clitest.Start(t, inv) @@ -590,43 +610,41 @@ func TestCreateWithGitAuth(t *testing.T) { t.Parallel() echoResponses := &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - GitAuthProviders: []string{"github"}, + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + ExternalAuthProviders: []string{"github"}, }, }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, + ProvisionApply: echo.ApplyComplete, } client := coderdtest.New(t, &coderdtest.Options{ - GitAuthConfigs: []*gitauth.Config{{ + ExternalAuthConfigs: []*externalauth.Config{{ OAuth2Config: &testutil.OAuth2Config{}, ID: "github", Regex: regexp.MustCompile(`github\.com`), - Type: codersdk.GitProviderGitHub, + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", }}, IncludeProvisionerDaemon: true, }) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t).Attach(inv) clitest.Start(t, inv) pty.ExpectMatch("You must authenticate with GitHub to create a workspace") - resp := coderdtest.RequestGitAuthCallback(t, "github", client) + resp := coderdtest.RequestExternalAuthCallback(t, "github", member) _ = resp.Body.Close() require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) pty.ExpectMatch("Confirm create?") diff --git a/cli/delete.go b/cli/delete.go index 867abe0326a30..a29a821490d9f 100644 --- a/cli/delete.go +++ b/cli/delete.go @@ -4,9 +4,9 @@ import ( "fmt" "time" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) // nolint @@ -22,16 +22,19 @@ func (r *RootCmd) deleteWorkspace() *clibase.Cmd { r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { - _, err := cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Confirm delete workspace?", - IsConfirm: true, - Default: cliui.ConfirmNo, - }) + workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0]) if err != nil { return err } - workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0]) + sinceLastUsed := time.Since(workspace.LastUsedAt) + cliui.Infof(inv.Stderr, "%v was last used %.0f days ago", workspace.FullName(), sinceLastUsed.Hours()/24) + + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Confirm delete workspace?", + IsConfirm: true, + Default: cliui.ConfirmNo, + }) if err != nil { return err } @@ -51,7 +54,11 @@ func (r *RootCmd) deleteWorkspace() *clibase.Cmd { return err } - _, _ = fmt.Fprintf(inv.Stdout, "\nThe %s workspace has been deleted at %s!\n", cliui.DefaultStyles.Keyword.Render(workspace.Name), cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))) + _, _ = fmt.Fprintf( + inv.Stdout, + "\n%s has been deleted at %s!\n", cliui.Keyword(workspace.FullName()), + cliui.Timestamp(time.Now()), + ) return nil }, } diff --git a/cli/delete_test.go b/cli/delete_test.go index 40f5b9ac22168..a44cd6e5b2e3c 100644 --- a/cli/delete_test.go +++ b/cli/delete_test.go @@ -9,13 +9,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestDelete(t *testing.T) { @@ -23,14 +23,15 @@ func TestDelete(t *testing.T) { t.Run("WithParameter", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, root := clitest.New(t, "delete", workspace.Name, "-y") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) go func() { @@ -41,21 +42,22 @@ func TestDelete(t *testing.T) { assert.ErrorIs(t, err, io.EOF) } }() - pty.ExpectMatch("workspace has been deleted") + pty.ExpectMatch("has been deleted") <-doneChan }) t.Run("Orphan", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + owner := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan") + //nolint:gocritic // Deleting orphaned workspaces requires an admin. clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) @@ -68,7 +70,7 @@ func TestDelete(t *testing.T) { assert.ErrorIs(t, err, io.EOF) } }() - pty.ExpectMatch("workspace has been deleted") + pty.ExpectMatch("has been deleted") <-doneChan }) @@ -80,14 +82,14 @@ func TestDelete(t *testing.T) { t.Run("OrphanDeletedUser", func(t *testing.T) { t.Parallel() client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - deleteMeClient, deleteMeUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + deleteMeClient, deleteMeUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, deleteMeClient, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, deleteMeClient, workspace.LatestBuild.ID) + workspace := coderdtest.CreateWorkspace(t, deleteMeClient, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, deleteMeClient, workspace.LatestBuild.ID) // The API checks if the user has any workspaces, so we cannot delete a user // this way. @@ -101,6 +103,7 @@ func TestDelete(t *testing.T) { inv, root := clitest.New(t, "delete", fmt.Sprintf("%s/%s", deleteMeUser.ID, workspace.Name), "-y", "--orphan") + //nolint:gocritic // Deleting orphaned workspaces requires an admin. clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) @@ -113,7 +116,7 @@ func TestDelete(t *testing.T) { assert.ErrorIs(t, err, io.EOF) } }() - pty.ExpectMatch("workspace has been deleted") + pty.ExpectMatch("has been deleted") <-doneChan }) @@ -127,12 +130,13 @@ func TestDelete(t *testing.T) { require.NoError(t, err) version := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil) - coderdtest.AwaitTemplateVersionJob(t, adminClient, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID) template := coderdtest.CreateTemplate(t, adminClient, orgID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, root := clitest.New(t, "delete", user.Username+"/"+workspace.Name, "-y") + //nolint:gocritic // This requires an admin. clitest.SetupConfig(t, adminClient, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) @@ -145,7 +149,7 @@ func TestDelete(t *testing.T) { } }() - pty.ExpectMatch("workspace has been deleted") + pty.ExpectMatch("has been deleted") <-doneChan workspace, err = client.Workspace(context.Background(), workspace.ID) diff --git a/cli/dotfiles.go b/cli/dotfiles.go index 60be52a0fc629..f3d15515585e3 100644 --- a/cli/dotfiles.go +++ b/cli/dotfiles.go @@ -13,13 +13,16 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" ) func (r *RootCmd) dotfiles() *clibase.Cmd { var symlinkDir string var gitbranch string + var dotfilesRepoDir string cmd := &clibase.Cmd{ Use: "dotfiles ", @@ -33,11 +36,10 @@ func (r *RootCmd) dotfiles() *clibase.Cmd { ), Handler: func(inv *clibase.Invocation) error { var ( - dotfilesRepoDir = "dotfiles" - gitRepo = inv.Args[0] - cfg = r.createConfig() - cfgDir = string(cfg) - dotfilesDir = filepath.Join(cfgDir, dotfilesRepoDir) + gitRepo = inv.Args[0] + cfg = r.createConfig() + cfgDir = string(cfg) + dotfilesDir = filepath.Join(cfgDir, dotfilesRepoDir) // This follows the same pattern outlined by others in the market: // https://github.com/coder/coder/pull/1696#issue-1245742312 installScriptSet = []string{ @@ -143,7 +145,7 @@ func (r *RootCmd) dotfiles() *clibase.Cmd { return err } // if the repo exists we soft fail the update operation and try to continue - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Error.Render("Failed to update repo, continuing...")) + _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Error, "Failed to update repo, continuing...")) } if dotfilesExists && gitbranch != "" { @@ -159,7 +161,7 @@ func (r *RootCmd) dotfiles() *clibase.Cmd { if err != nil { // Do not block on this error, just log it and continue _, _ = fmt.Fprintln(inv.Stdout, - cliui.DefaultStyles.Error.Render(fmt.Sprintf("Failed to use branch %q (%s), continuing...", err.Error(), gitbranch))) + pretty.Sprint(cliui.DefaultStyles.Error, fmt.Sprintf("Failed to use branch %q (%s), continuing...", err.Error(), gitbranch))) } } @@ -176,8 +178,8 @@ func (r *RootCmd) dotfiles() *clibase.Cmd { var dotfiles []string for _, f := range files { - // make sure we do not copy `.git*` files - if strings.HasPrefix(f.Name(), ".") && !strings.HasPrefix(f.Name(), ".git") { + // make sure we do not copy `.git*` files except `.gitconfig` + if strings.HasPrefix(f.Name(), ".") && (!strings.HasPrefix(f.Name(), ".git") || f.Name() == ".gitconfig") { dotfiles = append(dotfiles, f.Name()) } } @@ -288,6 +290,13 @@ func (r *RootCmd) dotfiles() *clibase.Cmd { "If empty, will default to cloning the default branch or using the existing branch in the cloned repo on disk.", Value: clibase.StringOf(&gitbranch), }, + { + Flag: "repo-dir", + Default: "dotfiles", + Env: "CODER_DOTFILES_REPO_DIR", + Description: "Specifies the directory for the dotfiles repository, relative to global config directory.", + Value: clibase.StringOf(&dotfilesRepoDir), + }, cliui.SkipPromptOption(), } return cmd diff --git a/cli/dotfiles_test.go b/cli/dotfiles_test.go index e979fec3e7980..6726f35b785ad 100644 --- a/cli/dotfiles_test.go +++ b/cli/dotfiles_test.go @@ -10,9 +10,9 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/cli/config" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/config" + "github.com/coder/coder/v2/cryptorand" ) func TestDotfiles(t *testing.T) { @@ -50,6 +50,68 @@ func TestDotfiles(t *testing.T) { require.NoError(t, err) require.Equal(t, string(b), "wow") }) + t.Run("SwitchRepoDir", func(t *testing.T) { + t.Parallel() + _, root := clitest.New(t) + testRepo := testGitRepo(t, root) + + // nolint:gosec + err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0o750) + require.NoError(t, err) + + c := exec.Command("git", "add", ".bashrc") + c.Dir = testRepo + err = c.Run() + require.NoError(t, err) + + c = exec.Command("git", "commit", "-m", `"add .bashrc"`) + c.Dir = testRepo + out, err := c.CombinedOutput() + require.NoError(t, err, string(out)) + + inv, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "--repo-dir", "testrepo", "-y", testRepo) + err = inv.Run() + require.NoError(t, err) + + b, err := os.ReadFile(filepath.Join(string(root), ".bashrc")) + require.NoError(t, err) + require.Equal(t, string(b), "wow") + + stat, staterr := os.Stat(filepath.Join(string(root), "testrepo")) + require.NoError(t, staterr) + require.True(t, stat.IsDir()) + }) + t.Run("SwitchRepoDirRelative", func(t *testing.T) { + t.Parallel() + _, root := clitest.New(t) + testRepo := testGitRepo(t, root) + + // nolint:gosec + err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0o750) + require.NoError(t, err) + + c := exec.Command("git", "add", ".bashrc") + c.Dir = testRepo + err = c.Run() + require.NoError(t, err) + + c = exec.Command("git", "commit", "-m", `"add .bashrc"`) + c.Dir = testRepo + out, err := c.CombinedOutput() + require.NoError(t, err, string(out)) + + inv, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "--repo-dir", "./relrepo", "-y", testRepo) + err = inv.Run() + require.NoError(t, err) + + b, err := os.ReadFile(filepath.Join(string(root), ".bashrc")) + require.NoError(t, err) + require.Equal(t, string(b), "wow") + + stat, staterr := os.Stat(filepath.Join(string(root), "relrepo")) + require.NoError(t, staterr) + require.True(t, stat.IsDir()) + }) t.Run("InstallScript", func(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { diff --git a/cli/errors.go b/cli/errors.go new file mode 100644 index 0000000000000..12567e0400ac5 --- /dev/null +++ b/cli/errors.go @@ -0,0 +1,106 @@ +package cli + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/codersdk" +) + +func (RootCmd) errorExample() *clibase.Cmd { + errorCmd := func(use string, err error) *clibase.Cmd { + return &clibase.Cmd{ + Use: use, + Handler: func(inv *clibase.Invocation) error { + return err + }, + } + } + + // Make an api error + recorder := httptest.NewRecorder() + recorder.WriteHeader(http.StatusBadRequest) + resp := recorder.Result() + _ = resp.Body.Close() + resp.Request, _ = http.NewRequest(http.MethodPost, "http://example.com", nil) + apiError := codersdk.ReadBodyAsError(resp) + //nolint:errorlint,forcetypeassert + apiError.(*codersdk.Error).Response = codersdk.Response{ + Message: "Top level sdk error message.", + Detail: "magic dust unavailable, please try again later", + Validations: []codersdk.ValidationError{ + { + Field: "region", + Detail: "magic dust is not available in your region", + }, + }, + } + //nolint:errorlint,forcetypeassert + apiError.(*codersdk.Error).Helper = "Have you tried turning it off and on again?" + + // Some flags + var magicWord clibase.String + + cmd := &clibase.Cmd{ + Use: "example-error", + Short: "Shows what different error messages look like", + Long: "This command is pretty pointless, but without it testing errors is" + + "difficult to visually inspect. Error message formatting is inherently" + + "visual, so we need a way to quickly see what they look like.", + Handler: func(inv *clibase.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Children: []*clibase.Cmd{ + // Typical codersdk api error + errorCmd("api", apiError), + + // Typical cli error + errorCmd("cmd", xerrors.Errorf("some error: %w", errorWithStackTrace())), + + // A multi-error + { + Use: "multi-error", + Handler: func(inv *clibase.Invocation) error { + // Closing the stdin file descriptor will cause the next close + // to fail. This is joined to the returned Command error. + if f, ok := inv.Stdin.(*os.File); ok { + _ = f.Close() + } + + return xerrors.Errorf("some error: %w", errorWithStackTrace()) + }, + }, + + { + Use: "validation", + Options: clibase.OptionSet{ + clibase.Option{ + Name: "magic-word", + Description: "Take a good guess.", + Required: true, + Flag: "magic-word", + Default: "", + Value: clibase.Validate(&magicWord, func(value *clibase.String) error { + return xerrors.Errorf("magic word is incorrect") + }), + }, + }, + Handler: func(i *clibase.Invocation) error { + _, _ = fmt.Fprint(i.Stdout, "Try setting the --magic-word flag\n") + return nil + }, + }, + }, + } + + return cmd +} + +func errorWithStackTrace() error { + return xerrors.Errorf("function decided not to work, and it never will") +} diff --git a/cli/exp.go b/cli/exp.go index 2513a8fda43ee..e190653f0f321 100644 --- a/cli/exp.go +++ b/cli/exp.go @@ -1,6 +1,6 @@ package cli -import "github.com/coder/coder/cli/clibase" +import "github.com/coder/coder/v2/cli/clibase" func (r *RootCmd) expCmd() *clibase.Cmd { cmd := &clibase.Cmd{ @@ -12,6 +12,7 @@ func (r *RootCmd) expCmd() *clibase.Cmd { Hidden: true, Children: []*clibase.Cmd{ r.scaletestCmd(), + r.errorExample(), }, } return cmd diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go index d2ee36c1819eb..f615e650b53b2 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -1,3 +1,5 @@ +//go:build !slim + package cli import ( @@ -5,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "math/rand" "net/http" "os" "strconv" @@ -22,19 +25,19 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" - "github.com/coder/coder/scaletest/agentconn" - "github.com/coder/coder/scaletest/createworkspaces" - "github.com/coder/coder/scaletest/dashboard" - "github.com/coder/coder/scaletest/harness" - "github.com/coder/coder/scaletest/reconnectingpty" - "github.com/coder/coder/scaletest/workspacebuild" - "github.com/coder/coder/scaletest/workspacetraffic" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/scaletest/agentconn" + "github.com/coder/coder/v2/scaletest/createworkspaces" + "github.com/coder/coder/v2/scaletest/dashboard" + "github.com/coder/coder/v2/scaletest/harness" + "github.com/coder/coder/v2/scaletest/reconnectingpty" + "github.com/coder/coder/v2/scaletest/workspacebuild" + "github.com/coder/coder/v2/scaletest/workspacetraffic" ) const scaletestTracerName = "coder_scaletest" @@ -105,7 +108,6 @@ func (s *scaletestTracingFlags) provider(ctx context.Context) (trace.TracerProvi tracerProvider, closeTracing, err := tracing.TracerProvider(ctx, scaletestTracerName, tracing.TracerOpts{ Default: s.traceEnable, - Coder: s.traceCoder, Honeycomb: s.traceHoneycombAPIKey, }) if err != nil { @@ -427,7 +429,7 @@ func (r *RootCmd) scaletestCleanup() *clibase.Cmd { cliui.Errorf(inv.Stderr, "Found %d scaletest workspaces\n", len(workspaces)) if len(workspaces) != 0 { - cliui.Infof(inv.Stdout, "Deleting scaletest workspaces..."+"\n") + cliui.Infof(inv.Stdout, "Deleting scaletest workspaces...") harness := harness.NewTestHarness(cleanupStrategy.toStrategy(), harness.ConcurrentExecutionStrategy{}) for i, w := range workspaces { @@ -443,7 +445,7 @@ func (r *RootCmd) scaletestCleanup() *clibase.Cmd { return xerrors.Errorf("run test harness to delete workspaces (harness failure, not a test failure): %w", err) } - cliui.Infof(inv.Stdout, "Done deleting scaletest workspaces:"+"\n") + cliui.Infof(inv.Stdout, "Done deleting scaletest workspaces:") res := harness.Results() res.PrintText(inv.Stderr) @@ -460,7 +462,7 @@ func (r *RootCmd) scaletestCleanup() *clibase.Cmd { cliui.Errorf(inv.Stderr, "Found %d scaletest users\n", len(users)) if len(users) != 0 { - cliui.Infof(inv.Stdout, "Deleting scaletest users..."+"\n") + cliui.Infof(inv.Stdout, "Deleting scaletest users...") harness := harness.NewTestHarness(cleanupStrategy.toStrategy(), harness.ConcurrentExecutionStrategy{}) for i, u := range users { @@ -479,7 +481,7 @@ func (r *RootCmd) scaletestCleanup() *clibase.Cmd { return xerrors.Errorf("run test harness to delete users (harness failure, not a test failure): %w", err) } - cliui.Infof(inv.Stdout, "Done deleting scaletest users:"+"\n") + cliui.Infof(inv.Stdout, "Done deleting scaletest users:") res := harness.Results() res.PrintText(inv.Stderr) @@ -501,7 +503,6 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd { count int64 template string - noPlan bool noCleanup bool // TODO: implement this flag // noCleanupFailures bool @@ -523,6 +524,8 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd { useHostUser bool + parameterFlags workspaceParameterFlags + tracingFlags = &scaletestTracingFlags{} strategy = &scaletestStrategyFlags{} cleanupStrategy = &scaletestStrategyFlags{cleanup: true} @@ -590,37 +593,22 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd { if tpl.ID == uuid.Nil { return xerrors.Errorf("could not find template %q in any organization", template) } - templateVersion, err := client.TemplateVersion(ctx, tpl.ActiveVersionID) + + cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters) if err != nil { - return xerrors.Errorf("get template version %q: %w", tpl.ActiveVersionID, err) + return xerrors.Errorf("can't parse given parameter values: %w", err) } - // Do a dry-run to ensure the template and parameters are valid - // before we start creating users and workspaces. - if !noPlan { - dryRun, err := client.CreateTemplateVersionDryRun(ctx, templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{ - WorkspaceName: "scaletest", - }) - if err != nil { - return xerrors.Errorf("start dry run workspace creation: %w", err) - } - _, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...") - err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{ - Fetch: func() (codersdk.ProvisionerJob, error) { - return client.TemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID) - }, - Cancel: func() error { - return client.CancelTemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID) - }, - Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) { - return client.TemplateVersionDryRunLogsAfter(inv.Context(), templateVersion.ID, dryRun.ID, 0) - }, - // Don't show log output for the dry-run unless there's an error. - Silent: true, - }) - if err != nil { - return xerrors.Errorf("dry-run workspace: %w", err) - } + richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ + Action: WorkspaceCreate, + Template: tpl, + NewWorkspaceName: "scaletest-N", // TODO: the scaletest runner will pass in a different name here. Does this matter? + + RichParameterFile: parameterFlags.richParameterFile, + RichParameters: cliRichParameters, + }) + if err != nil { + return xerrors.Errorf("prepare build: %w", err) } tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx) @@ -651,7 +639,8 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd { OrganizationID: me.OrganizationIDs[0], // UserID is set by the test automatically. Request: codersdk.CreateWorkspaceRequest{ - TemplateID: tpl.ID, + TemplateID: tpl.ID, + RichParameterValues: richParameters, }, NoWaitForAgents: noWaitForAgents, }, @@ -770,12 +759,6 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd { Description: "Required: Name or ID of the template to use for workspaces.", Value: clibase.StringOf(&template), }, - { - Flag: "no-plan", - Env: "CODER_SCALETEST_NO_PLAN", - Description: `Skip the dry-run step to plan the workspace creation. This step ensures that the given parameters are valid for the given template.`, - Value: clibase.BoolOf(&noPlan), - }, { Flag: "no-cleanup", Env: "CODER_SCALETEST_NO_CLEANUP", @@ -858,11 +841,12 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd { Flag: "use-host-login", Env: "CODER_SCALETEST_USE_HOST_LOGIN", Default: "false", - Description: "Use the use logged in on the host machine, instead of creating users.", + Description: "Use the user logged in on the host machine, instead of creating users.", Value: clibase.BoolOf(&useHostUser), }, } + cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...) tracingFlags.attach(&cmd.Options) strategy.attach(&cmd.Options) cleanupStrategy.attach(&cmd.Options) @@ -1047,9 +1031,10 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *clibase.Cmd { func (r *RootCmd) scaletestDashboard() *clibase.Cmd { var ( - count int64 - minWait time.Duration - maxWait time.Duration + interval time.Duration + jitter time.Duration + headless bool + randSeed int64 client = &codersdk.Client{} tracingFlags = &scaletestTracingFlags{} @@ -1066,8 +1051,17 @@ func (r *RootCmd) scaletestDashboard() *clibase.Cmd { r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { + if !(interval > 0) { + return xerrors.Errorf("--interval must be greater than zero") + } + if !(jitter < interval) { + return xerrors.Errorf("--jitter must be less than --interval") + } ctx := inv.Context() logger := slog.Make(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelInfo) + if r.verbose { + logger = logger.Leveled(slog.LevelDebug) + } tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx) if err != nil { return xerrors.Errorf("create tracer provider: %w", err) @@ -1095,19 +1089,47 @@ func (r *RootCmd) scaletestDashboard() *clibase.Cmd { th := harness.NewTestHarness(strategy.toStrategy(), cleanupStrategy.toStrategy()) - for i := int64(0); i < count; i++ { - name := fmt.Sprintf("dashboard-%d", i) + users, err := getScaletestUsers(ctx, client) + if err != nil { + return xerrors.Errorf("get scaletest users") + } + + for _, usr := range users { + //nolint:gosec // not used for cryptographic purposes + rndGen := rand.New(rand.NewSource(randSeed)) + name := fmt.Sprintf("dashboard-%s", usr.Username) + userTokResp, err := client.CreateToken(ctx, usr.ID.String(), codersdk.CreateTokenRequest{ + Lifetime: 30 * 24 * time.Hour, + Scope: "", + TokenName: fmt.Sprintf("scaletest-%d", time.Now().Unix()), + }) + if err != nil { + return xerrors.Errorf("create token for user: %w", err) + } + + userClient := codersdk.New(client.URL) + userClient.SetSessionToken(userTokResp.Key) + config := dashboard.Config{ - MinWait: minWait, - MaxWait: maxWait, - Trace: tracingEnabled, - Logger: logger.Named(name), - RollTable: dashboard.DefaultActions, + Interval: interval, + Jitter: jitter, + Trace: tracingEnabled, + Logger: logger.Named(name), + Headless: headless, + RandIntn: rndGen.Intn, + } + // Only take a screenshot if we're in verbose mode. + // This could be useful for debugging, but it will blow up the disk. + if r.verbose { + config.Screenshot = dashboard.Screenshot } + //nolint:gocritic + logger.Info(ctx, "runner config", slog.F("interval", interval), slog.F("jitter", jitter), slog.F("headless", headless), slog.F("trace", tracingEnabled)) if err := config.Validate(); err != nil { + logger.Fatal(ctx, "validate config", slog.Error(err)) return err } - var runner harness.Runnable = dashboard.NewRunner(client, metrics, config) + var runner harness.Runnable = dashboard.NewRunner(userClient, metrics, config) if tracingEnabled { runner = &runnableTraceWrapper{ tracer: tracer, @@ -1144,25 +1166,32 @@ func (r *RootCmd) scaletestDashboard() *clibase.Cmd { cmd.Options = []clibase.Option{ { - Flag: "count", - Env: "CODER_SCALETEST_DASHBOARD_COUNT", - Default: "1", - Description: "Number of concurrent workers.", - Value: clibase.Int64Of(&count), + Flag: "interval", + Env: "CODER_SCALETEST_DASHBOARD_INTERVAL", + Default: "10s", + Description: "Interval between actions.", + Value: clibase.DurationOf(&interval), }, { - Flag: "min-wait", - Env: "CODER_SCALETEST_DASHBOARD_MIN_WAIT", - Default: "100ms", - Description: "Minimum wait between fetches.", - Value: clibase.DurationOf(&minWait), + Flag: "jitter", + Env: "CODER_SCALETEST_DASHBOARD_JITTER", + Default: "5s", + Description: "Jitter between actions.", + Value: clibase.DurationOf(&jitter), }, { - Flag: "max-wait", - Env: "CODER_SCALETEST_DASHBOARD_MAX_WAIT", - Default: "1s", - Description: "Maximum wait between fetches.", - Value: clibase.DurationOf(&maxWait), + Flag: "headless", + Env: "CODER_SCALETEST_DASHBOARD_HEADLESS", + Default: "true", + Description: "Controls headless mode. Setting to false is useful for debugging.", + Value: clibase.BoolOf(&headless), + }, + { + Flag: "rand-seed", + Env: "CODER_SCALETEST_DASHBOARD_RAND_SEED", + Default: "0", + Description: "Seed for the random number generator.", + Value: clibase.Int64Of(&randSeed), }, } @@ -1212,7 +1241,7 @@ func (r *runnableTraceWrapper) Run(ctx context.Context, id string, logs io.Write return r.runner.Run(ctx2, id, logs) } -func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string) error { +func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string, logs io.Writer) error { c, ok := r.runner.(harness.Cleanable) if !ok { return nil @@ -1224,7 +1253,7 @@ func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string) error { ctx, span := r.tracer.Start(ctx, r.spanName+" cleanup") defer span.End() - return c.Cleanup(ctx, id) + return c.Cleanup(ctx, id, logs) } // newScaleTestUser returns a random username and email address that can be used diff --git a/cli/exp_scaletest_slim.go b/cli/exp_scaletest_slim.go new file mode 100644 index 0000000000000..d9ccd325e5ccd --- /dev/null +++ b/cli/exp_scaletest_slim.go @@ -0,0 +1,18 @@ +//go:build slim + +package cli + +import "github.com/coder/coder/v2/cli/clibase" + +func (r *RootCmd) scaletestCmd() *clibase.Cmd { + cmd := &clibase.Cmd{ + Use: "scaletest", + Short: "Run a scale test against the Coder API", + Handler: func(inv *clibase.Invocation) error { + SlimUnsupported(inv.Stderr, "exp scaletest") + return nil + }, + } + + return cmd +} diff --git a/cli/exp_scaletest_test.go b/cli/exp_scaletest_test.go index 4c10b722ca357..556aed6c21a82 100644 --- a/cli/exp_scaletest_test.go +++ b/cli/exp_scaletest_test.go @@ -1,17 +1,18 @@ package cli_test import ( - "bytes" "context" "path/filepath" "testing" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestScaleTestCreateWorkspaces(t *testing.T) { @@ -22,7 +23,12 @@ func TestScaleTestCreateWorkspaces(t *testing.T) { ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancelFunc() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + client := coderdtest.New(t, &coderdtest.Options{ + // We are not including any provisioner daemons because we do not actually + // build any workspaces here. + Logger: &log, + }) _ = coderdtest.CreateFirstUser(t, client) // Write a parameters file. @@ -42,6 +48,8 @@ func TestScaleTestCreateWorkspaces(t *testing.T) { "--cleanup-job-timeout", "15s", "--output", "text", "--output", "json:"+outputFile, + "--parameter", "foo=baz", + "--rich-parameter-file", "/path/to/some/parameter/file.ext", ) clitest.SetupConfig(t, client, root) pty := ptytest.New(t) @@ -60,7 +68,10 @@ func TestScaleTestWorkspaceTraffic(t *testing.T) { ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitMedium) defer cancelFunc() - client := coderdtest.New(t, nil) + log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + client := coderdtest.New(t, &coderdtest.Options{ + Logger: &log, + }) _ = coderdtest.CreateFirstUser(t, client) inv, root := clitest.New(t, "exp", "scaletest", "workspace-traffic", @@ -72,9 +83,10 @@ func TestScaleTestWorkspaceTraffic(t *testing.T) { "--ssh", ) clitest.SetupConfig(t, client, root) - var stdout, stderr bytes.Buffer - inv.Stdout = &stdout - inv.Stderr = &stderr + pty := ptytest.New(t) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + err := inv.WithContext(ctx).Run() require.ErrorContains(t, err, "no scaletest workspaces exist") } @@ -82,25 +94,78 @@ func TestScaleTestWorkspaceTraffic(t *testing.T) { // This test just validates that the CLI command accepts its known arguments. func TestScaleTestDashboard(t *testing.T) { t.Parallel() - - ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitMedium) - defer cancelFunc() - - client := coderdtest.New(t, nil) - _ = coderdtest.CreateFirstUser(t, client) - - inv, root := clitest.New(t, "exp", "scaletest", "dashboard", - "--count", "1", - "--min-wait", "100ms", - "--max-wait", "1s", - "--timeout", "1s", - "--scaletest-prometheus-address", "127.0.0.1:0", - "--scaletest-prometheus-wait", "0s", - ) - clitest.SetupConfig(t, client, root) - var stdout, stderr bytes.Buffer - inv.Stdout = &stdout - inv.Stderr = &stderr - err := inv.WithContext(ctx).Run() - require.NoError(t, err, "") + t.Run("MinWait", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancelFunc() + + log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + client := coderdtest.New(t, &coderdtest.Options{ + Logger: &log, + }) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "exp", "scaletest", "dashboard", + "--interval", "0s", + ) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "--interval must be greater than zero") + }) + + t.Run("MaxWait", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancelFunc() + + log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + client := coderdtest.New(t, &coderdtest.Options{ + Logger: &log, + }) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "exp", "scaletest", "dashboard", + "--interval", "1s", + "--jitter", "1s", + ) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "--jitter must be less than --interval") + }) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancelFunc() + + log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + client := coderdtest.New(t, &coderdtest.Options{ + Logger: &log, + }) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "exp", "scaletest", "dashboard", + "--interval", "1s", + "--jitter", "500ms", + "--timeout", "5s", + "--scaletest-prometheus-address", "127.0.0.1:0", + "--scaletest-prometheus-wait", "0s", + "--rand-seed", "1234567890", + ) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + + err := inv.WithContext(ctx).Run() + require.NoError(t, err, "") + }) } diff --git a/cli/externalauth.go b/cli/externalauth.go new file mode 100644 index 0000000000000..c81795d95d6fc --- /dev/null +++ b/cli/externalauth.go @@ -0,0 +1,109 @@ +package cli + +import ( + "encoding/json" + "os/signal" + + "golang.org/x/xerrors" + + "github.com/tidwall/gjson" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk/agentsdk" +) + +func (r *RootCmd) externalAuth() *clibase.Cmd { + return &clibase.Cmd{ + Use: "external-auth", + Short: "Manage external authentication", + Long: "Authenticate with external services inside of a workspace.", + Handler: func(i *clibase.Invocation) error { + return i.Command.HelpHandler(i) + }, + Children: []*clibase.Cmd{ + r.externalAuthAccessToken(), + }, + } +} + +func (r *RootCmd) externalAuthAccessToken() *clibase.Cmd { + var extra string + return &clibase.Cmd{ + Use: "access-token ", + Short: "Print auth for an external provider", + Long: "Print an access-token for an external auth provider. " + + "The access-token will be validated and sent to stdout with exit code 0. " + + "If a valid access-token cannot be obtained, the URL to authenticate will be sent to stdout with exit code 1\n" + formatExamples( + example{ + Description: "Ensure that the user is authenticated with GitHub before cloning.", + Command: `#!/usr/bin/env sh + +OUTPUT=$(coder external-auth access-token github) +if [ $? -eq 0 ]; then + echo "Authenticated with GitHub" +else + echo "Please authenticate with GitHub:" + echo $OUTPUT +fi +`, + }, + example{ + Description: "Obtain an extra property of an access token for additional metadata.", + Command: "coder external-auth access-token slack --extra \"authed_user.id\"", + }, + ), + Options: clibase.OptionSet{{ + Name: "Extra", + Flag: "extra", + Description: "Extract a field from the \"extra\" properties of the OAuth token.", + Value: clibase.StringOf(&extra), + }}, + + Handler: func(inv *clibase.Invocation) error { + ctx := inv.Context() + + ctx, stop := signal.NotifyContext(ctx, InterruptSignals...) + defer stop() + + client, err := r.createAgentClient() + if err != nil { + return xerrors.Errorf("create agent client: %w", err) + } + + extAuth, err := client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{ + ID: inv.Args[0], + }) + if err != nil { + return xerrors.Errorf("get external auth token: %w", err) + } + if extAuth.URL != "" { + _, err = inv.Stdout.Write([]byte(extAuth.URL)) + if err != nil { + return err + } + return cliui.Canceled + } + if extra != "" { + if extAuth.TokenExtra == nil { + return xerrors.Errorf("no extra properties found for token") + } + data, err := json.Marshal(extAuth.TokenExtra) + if err != nil { + return xerrors.Errorf("marshal extra properties: %w", err) + } + result := gjson.GetBytes(data, extra) + _, err = inv.Stdout.Write([]byte(result.String())) + if err != nil { + return err + } + return nil + } + _, err = inv.Stdout.Write([]byte(extAuth.AccessToken)) + if err != nil { + return err + } + return nil + }, + } +} diff --git a/cli/externalauth_test.go b/cli/externalauth_test.go new file mode 100644 index 0000000000000..63b058c3fd764 --- /dev/null +++ b/cli/externalauth_test.go @@ -0,0 +1,67 @@ +package cli_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/pty/ptytest" +) + +func TestExternalAuth(t *testing.T) { + t.Parallel() + t.Run("CanceledWithURL", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{ + URL: "https://github.com", + }) + })) + t.Cleanup(srv.Close) + url := srv.URL + inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github") + pty := ptytest.New(t) + inv.Stdout = pty.Output() + waiter := clitest.StartWithWaiter(t, inv) + pty.ExpectMatch("https://github.com") + waiter.RequireIs(cliui.Canceled) + }) + t.Run("SuccessWithToken", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{ + AccessToken: "bananas", + }) + })) + t.Cleanup(srv.Close) + url := srv.URL + inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github") + pty := ptytest.New(t) + inv.Stdout = pty.Output() + clitest.Start(t, inv) + pty.ExpectMatch("bananas") + }) + t.Run("SuccessWithExtra", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{ + AccessToken: "bananas", + TokenExtra: map[string]interface{}{ + "hey": "there", + }, + }) + })) + t.Cleanup(srv.Close) + url := srv.URL + inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github", "--extra", "hey") + pty := ptytest.New(t) + inv.Stdout = pty.Output() + clitest.Start(t, inv) + pty.ExpectMatch("there") + }) +} diff --git a/cli/gitaskpass.go b/cli/gitaskpass.go index 5bb67adf82416..83ac98094e72e 100644 --- a/cli/gitaskpass.go +++ b/cli/gitaskpass.go @@ -9,10 +9,11 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/gitauth" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/retry" ) @@ -38,30 +39,41 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd { return xerrors.Errorf("create agent client: %w", err) } - token, err := client.GitAuth(ctx, host, false) + token, err := client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{ + Match: host, + }) if err != nil { var apiError *codersdk.Error if errors.As(err, &apiError) && apiError.StatusCode() == http.StatusNotFound { // This prevents the "Run 'coder --help' for usage" // message from occurring. - cliui.Errorf(inv.Stderr, "%s\n", apiError.Message) + lines := []string{apiError.Message} + if apiError.Detail != "" { + lines = append(lines, apiError.Detail) + } + cliui.Warn(inv.Stderr, "Coder was unable to handle this git request. The default git behavior will be used instead.", + lines..., + ) return cliui.Canceled } return xerrors.Errorf("get git token: %w", err) } if token.URL != "" { if err := openURL(inv, token.URL); err == nil { - cliui.Infof(inv.Stderr, "Your browser has been opened to authenticate with Git:\n\n%s\n", token.URL) + cliui.Infof(inv.Stderr, "Your browser has been opened to authenticate with Git:\n%s", token.URL) } else { - cliui.Infof(inv.Stderr, "Open the following URL to authenticate with Git:\n\n%s\n", token.URL) + cliui.Infof(inv.Stderr, "Open the following URL to authenticate with Git:\n%s", token.URL) } for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); { - token, err = client.GitAuth(ctx, host, true) + token, err = client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{ + Match: host, + Listen: true, + }) if err != nil { continue } - cliui.Infof(inv.Stderr, "You've been authenticated with Git!\n") + cliui.Infof(inv.Stderr, "You've been authenticated with Git!") break } } diff --git a/cli/gitaskpass_test.go b/cli/gitaskpass_test.go index 809bc1035005f..92fe3943c1eb8 100644 --- a/cli/gitaskpass_test.go +++ b/cli/gitaskpass_test.go @@ -10,12 +10,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/pty/ptytest" ) func TestGitAskpass(t *testing.T) { @@ -23,7 +23,7 @@ func TestGitAskpass(t *testing.T) { t.Run("UsernameAndPassword", func(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.GitAuthResponse{ + httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{ Username: "something", Password: "bananas", }) @@ -65,8 +65,8 @@ func TestGitAskpass(t *testing.T) { t.Run("Poll", func(t *testing.T) { t.Parallel() - resp := atomic.Pointer[agentsdk.GitAuthResponse]{} - resp.Store(&agentsdk.GitAuthResponse{ + resp := atomic.Pointer[agentsdk.ExternalAuthResponse]{} + resp.Store(&agentsdk.ExternalAuthResponse{ URL: "https://something.org", }) poll := make(chan struct{}, 10) @@ -96,7 +96,7 @@ func TestGitAskpass(t *testing.T) { }() <-poll stderr.ExpectMatch("Open the following URL to authenticate") - resp.Store(&agentsdk.GitAuthResponse{ + resp.Store(&agentsdk.ExternalAuthResponse{ Username: "username", Password: "password", }) diff --git a/coderd/gitauth/askpass.go b/cli/gitauth/askpass.go similarity index 100% rename from coderd/gitauth/askpass.go rename to cli/gitauth/askpass.go diff --git a/coderd/gitauth/askpass_test.go b/cli/gitauth/askpass_test.go similarity index 97% rename from coderd/gitauth/askpass_test.go rename to cli/gitauth/askpass_test.go index ce7cc75989603..d70e791c97afb 100644 --- a/coderd/gitauth/askpass_test.go +++ b/cli/gitauth/askpass_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/gitauth" + "github.com/coder/coder/v2/cli/gitauth" ) func TestCheckCommand(t *testing.T) { diff --git a/coderd/gitauth/vscode.go b/cli/gitauth/vscode.go similarity index 100% rename from coderd/gitauth/vscode.go rename to cli/gitauth/vscode.go diff --git a/coderd/gitauth/vscode_test.go b/cli/gitauth/vscode_test.go similarity index 97% rename from coderd/gitauth/vscode_test.go rename to cli/gitauth/vscode_test.go index f61fb97ea681a..7bff62fafdb06 100644 --- a/coderd/gitauth/vscode_test.go +++ b/cli/gitauth/vscode_test.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/gitauth" + "github.com/coder/coder/v2/cli/gitauth" ) func TestOverrideVSCodeConfigs(t *testing.T) { diff --git a/cli/gitssh.go b/cli/gitssh.go index 6c4046c03cafe..ea461394c3241 100644 --- a/cli/gitssh.go +++ b/cli/gitssh.go @@ -14,8 +14,9 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/pretty" ) func (r *RootCmd) gitssh() *clibase.Cmd { @@ -90,12 +91,15 @@ func (r *RootCmd) gitssh() *clibase.Cmd { exitErr := &exec.ExitError{} if xerrors.As(err, &exitErr) && exitErr.ExitCode() == 255 { _, _ = fmt.Fprintln(inv.Stderr, - "\n"+cliui.DefaultStyles.Wrap.Render("Coder authenticates with "+cliui.DefaultStyles.Field.Render("git")+ - " using the public key below. All clones with SSH are authenticated automatically 🪄.")+"\n") - _, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n") + "\n"+pretty.Sprintf( + cliui.DefaultStyles.Wrap, + "Coder authenticates with "+pretty.Sprint(cliui.DefaultStyles.Field, "git")+ + " using the public key below. All clones with SSH are authenticated automatically 🪄.")+"\n", + ) + _, _ = fmt.Fprintln(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Code, strings.TrimSpace(key.PublicKey))+"\n") _, _ = fmt.Fprintln(inv.Stderr, "Add to GitHub and GitLab:") - _, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Prompt.String()+"https://github.com/settings/ssh/new") - _, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Prompt.String()+"https://gitlab.com/-/profile/keys") + pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "%s", "https://github.com/settings/ssh/new\n\n") + pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "%s", "https://gitlab.com/-/profile/keys\n\n") _, _ = fmt.Fprintln(inv.Stderr) return err } diff --git a/cli/gitssh_test.go b/cli/gitssh_test.go index 39daab430c01c..354a57c732953 100644 --- a/cli/gitssh_test.go +++ b/cli/gitssh_test.go @@ -20,15 +20,18 @@ import ( "github.com/stretchr/testify/require" gossh "golang.org/x/crypto/ssh" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) -func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, string, gossh.PublicKey) { +func prepareTestGitSSH(ctx context.Context, t *testing.T) (*agentsdk.Client, string, gossh.PublicKey) { t.Helper() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) @@ -48,22 +51,21 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, str agentToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(agentToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) // start workspace agent - inv, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String()) - agentClient := codersdk.New(client.URL) + agentClient := agentsdk.New(client.URL) agentClient.SetSessionToken(agentToken) - clitest.SetupConfig(t, agentClient, root) - clitest.Start(t, inv) - - coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { + o.Client = agentClient + }) + _ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) return agentClient, agentToken, pubkey } @@ -140,7 +142,7 @@ func TestGitSSH(t *testing.T) { // set to agent config dir inv, _ := clitest.New(t, "gitssh", - "--agent-url", client.URL.String(), + "--agent-url", client.SDK.URL.String(), "--agent-token", token, "--", fmt.Sprintf("-p%d", addr.Port), @@ -203,7 +205,7 @@ func TestGitSSH(t *testing.T) { pty := ptytest.New(t) cmdArgs := []string{ "gitssh", - "--agent-url", client.URL.String(), + "--agent-url", client.SDK.URL.String(), "--agent-token", token, "--", "-F", config, diff --git a/cli/help.go b/cli/help.go index fa813febc53e9..e0c043e7951d4 100644 --- a/cli/help.go +++ b/cli/help.go @@ -2,23 +2,22 @@ package cli import ( "bufio" - "bytes" _ "embed" "fmt" - "io" "regexp" "sort" "strings" "text/tabwriter" "text/template" - "unicode" "github.com/mitchellh/go-wordwrap" "golang.org/x/crypto/ssh/terminal" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/pretty" ) //go:embed help.tpl @@ -44,203 +43,222 @@ func wrapTTY(s string) string { return wordwrap.WrapString(s, uint(ttyWidth())) } -var usageTemplate = template.Must( - template.New("usage").Funcs( - template.FuncMap{ - "wrapTTY": func(s string) string { - return wrapTTY(s) - }, - "trimNewline": func(s string) string { - return strings.TrimSuffix(s, "\n") - }, - "typeHelper": func(opt *clibase.Option) string { - switch v := opt.Value.(type) { - case *clibase.Enum: - return strings.Join(v.Choices, "|") - default: - return v.Type() - } - }, - "joinStrings": func(s []string) string { - return strings.Join(s, ", ") - }, - "indent": func(body string, spaces int) string { - twidth := ttyWidth() - - spacing := strings.Repeat(" ", spaces) - - body = wordwrap.WrapString(body, uint(twidth-len(spacing))) - - var sb strings.Builder - for _, line := range strings.Split(body, "\n") { - // Remove existing indent, if any. - line = strings.TrimSpace(line) - // Use spaces so we can easily calculate wrapping. - _, _ = sb.WriteString(spacing) - _, _ = sb.WriteString(line) - _, _ = sb.WriteString("\n") - } - return sb.String() - }, - "formatSubcommand": func(cmd *clibase.Cmd) string { - // Minimize padding by finding the longest neighboring name. - maxNameLength := len(cmd.Name()) - if parent := cmd.Parent; parent != nil { - for _, c := range parent.Children { - if len(c.Name()) > maxNameLength { - maxNameLength = len(c.Name()) - } +var usageTemplate = func() *template.Template { + var ( + optionFg = pretty.FgColor( + cliui.Color("#04A777"), + ) + headerFg = pretty.FgColor( + cliui.Color("#337CA0"), + ) + ) + return template.Must( + template.New("usage").Funcs( + template.FuncMap{ + "version": func() string { + return buildinfo.Version() + }, + "wrapTTY": func(s string) string { + return wrapTTY(s) + }, + "trimNewline": func(s string) string { + return strings.TrimSuffix(s, "\n") + }, + "keyword": func(s string) string { + txt := pretty.String(s) + optionFg.Format(txt) + return txt.String() + }, + "prettyHeader": func(s string) string { + s = strings.ToUpper(s) + txt := pretty.String(s, ":") + headerFg.Format(txt) + return txt.String() + }, + "typeHelper": func(opt *clibase.Option) string { + switch v := opt.Value.(type) { + case *clibase.Enum: + return strings.Join(v.Choices, "|") + default: + return v.Type() } - } + }, + "joinStrings": func(s []string) string { + return strings.Join(s, ", ") + }, + "indent": func(body string, spaces int) string { + twidth := ttyWidth() - var sb strings.Builder - _, _ = fmt.Fprintf( - &sb, "%s%s%s", - strings.Repeat(" ", 4), cmd.Name(), strings.Repeat(" ", maxNameLength-len(cmd.Name())+4), - ) + spacing := strings.Repeat(" ", spaces) - // This is the point at which indentation begins if there's a - // next line. - descStart := sb.Len() + wrapLim := twidth - len(spacing) + body = wordwrap.WrapString(body, uint(wrapLim)) - twidth := ttyWidth() + sc := bufio.NewScanner(strings.NewReader(body)) - for i, line := range strings.Split( - wordwrap.WrapString(cmd.Short, uint(twidth-descStart)), "\n", - ) { - if i > 0 { - _, _ = sb.WriteString(strings.Repeat(" ", descStart)) + var sb strings.Builder + for sc.Scan() { + // Remove existing indent, if any. + // line = strings.TrimSpace(line) + // Use spaces so we can easily calculate wrapping. + _, _ = sb.WriteString(spacing) + _, _ = sb.Write(sc.Bytes()) + _, _ = sb.WriteString("\n") } - _, _ = sb.WriteString(line) - _, _ = sb.WriteString("\n") - } - - return sb.String() - }, - "envName": func(opt clibase.Option) string { - if opt.Env == "" { - return "" - } - return opt.Env - }, - "flagName": func(opt clibase.Option) string { - return opt.Flag - }, - "prettyHeader": func(s string) string { - return cliui.DefaultStyles.Bold.Render(s) - }, - "isEnterprise": func(opt clibase.Option) bool { - return opt.Annotations.IsSet("enterprise") - }, - "isDeprecated": func(opt clibase.Option) bool { - return len(opt.UseInstead) > 0 - }, - "useInstead": func(opt clibase.Option) string { - var sb strings.Builder - for i, s := range opt.UseInstead { - if i > 0 { - if i == len(opt.UseInstead)-1 { - _, _ = sb.WriteString(" and ") - } else { - _, _ = sb.WriteString(", ") + return sb.String() + }, + "formatSubcommand": func(cmd *clibase.Cmd) string { + // Minimize padding by finding the longest neighboring name. + maxNameLength := len(cmd.Name()) + if parent := cmd.Parent; parent != nil { + for _, c := range parent.Children { + if len(c.Name()) > maxNameLength { + maxNameLength = len(c.Name()) + } } } - if s.Flag != "" { - _, _ = sb.WriteString("--") - _, _ = sb.WriteString(s.Flag) - } else if s.FlagShorthand != "" { - _, _ = sb.WriteString("-") - _, _ = sb.WriteString(s.FlagShorthand) - } else if s.Env != "" { - _, _ = sb.WriteString("$") - _, _ = sb.WriteString(s.Env) - } else { - _, _ = sb.WriteString(s.Name) + + var sb strings.Builder + _, _ = fmt.Fprintf( + &sb, "%s%s%s", + strings.Repeat(" ", 4), cmd.Name(), strings.Repeat(" ", maxNameLength-len(cmd.Name())+4), + ) + + // This is the point at which indentation begins if there's a + // next line. + descStart := sb.Len() + + twidth := ttyWidth() + + for i, line := range strings.Split( + wordwrap.WrapString(cmd.Short, uint(twidth-descStart)), "\n", + ) { + if i > 0 { + _, _ = sb.WriteString(strings.Repeat(" ", descStart)) + } + _, _ = sb.WriteString(line) + _, _ = sb.WriteString("\n") } - } - return sb.String() - }, - "formatLong": func(long string) string { - // We intentionally don't wrap here because it would misformat - // examples, where the new line would start without the prior - // line's indentation. - return strings.TrimSpace(long) - }, - "formatGroupDescription": func(s string) string { - s = strings.ReplaceAll(s, "\n", "") - s = s + "\n" - s = wrapTTY(s) - return s - }, - "visibleChildren": func(cmd *clibase.Cmd) []*clibase.Cmd { - return filterSlice(cmd.Children, func(c *clibase.Cmd) bool { - return !c.Hidden - }) - }, - "optionGroups": func(cmd *clibase.Cmd) []optionGroup { - groups := []optionGroup{{ - // Default group. - Name: "", - Description: "", - }} - - enterpriseGroup := optionGroup{ - Name: "Enterprise", - Description: `These options are only available in the Enterprise Edition.`, - } - - // Sort options lexicographically. - sort.Slice(cmd.Options, func(i, j int) bool { - return cmd.Options[i].Name < cmd.Options[j].Name - }) - - optionLoop: - for _, opt := range cmd.Options { - if opt.Hidden { - continue + + return sb.String() + }, + "envName": func(opt clibase.Option) string { + if opt.Env == "" { + return "" } - // Enterprise options are always grouped separately. - if opt.Annotations.IsSet("enterprise") { - enterpriseGroup.Options = append(enterpriseGroup.Options, opt) - continue + return opt.Env + }, + "flagName": func(opt clibase.Option) string { + return opt.Flag + }, + + "isEnterprise": func(opt clibase.Option) bool { + return opt.Annotations.IsSet("enterprise") + }, + "isDeprecated": func(opt clibase.Option) bool { + return len(opt.UseInstead) > 0 + }, + "useInstead": func(opt clibase.Option) string { + var sb strings.Builder + for i, s := range opt.UseInstead { + if i > 0 { + if i == len(opt.UseInstead)-1 { + _, _ = sb.WriteString(" and ") + } else { + _, _ = sb.WriteString(", ") + } + } + if s.Flag != "" { + _, _ = sb.WriteString("--") + _, _ = sb.WriteString(s.Flag) + } else if s.FlagShorthand != "" { + _, _ = sb.WriteString("-") + _, _ = sb.WriteString(s.FlagShorthand) + } else if s.Env != "" { + _, _ = sb.WriteString("$") + _, _ = sb.WriteString(s.Env) + } else { + _, _ = sb.WriteString(s.Name) + } } - if len(opt.Group.Ancestry()) == 0 { - // Just add option to default group. - groups[0].Options = append(groups[0].Options, opt) - continue + return sb.String() + }, + "formatGroupDescription": func(s string) string { + s = strings.ReplaceAll(s, "\n", "") + s = s + "\n" + s = wrapTTY(s) + return s + }, + "visibleChildren": func(cmd *clibase.Cmd) []*clibase.Cmd { + return filterSlice(cmd.Children, func(c *clibase.Cmd) bool { + return !c.Hidden + }) + }, + "optionGroups": func(cmd *clibase.Cmd) []optionGroup { + groups := []optionGroup{{ + // Default group. + Name: "", + Description: "", + }} + + enterpriseGroup := optionGroup{ + Name: "Enterprise", + Description: `These options are only available in the Enterprise Edition.`, } - groupName := opt.Group.FullName() + // Sort options lexicographically. + sort.Slice(cmd.Options, func(i, j int) bool { + return cmd.Options[i].Name < cmd.Options[j].Name + }) - for i, foundGroup := range groups { - if foundGroup.Name != groupName { + optionLoop: + for _, opt := range cmd.Options { + if opt.Hidden { + continue + } + // Enterprise options are always grouped separately. + if opt.Annotations.IsSet("enterprise") { + enterpriseGroup.Options = append(enterpriseGroup.Options, opt) + continue + } + if len(opt.Group.Ancestry()) == 0 { + // Just add option to default group. + groups[0].Options = append(groups[0].Options, opt) continue } - groups[i].Options = append(groups[i].Options, opt) - continue optionLoop + + groupName := opt.Group.FullName() + + for i, foundGroup := range groups { + if foundGroup.Name != groupName { + continue + } + groups[i].Options = append(groups[i].Options, opt) + continue optionLoop + } + + groups = append(groups, optionGroup{ + Name: groupName, + Description: opt.Group.Description, + Options: clibase.OptionSet{opt}, + }) } + sort.Slice(groups, func(i, j int) bool { + // Sort groups lexicographically. + return groups[i].Name < groups[j].Name + }) - groups = append(groups, optionGroup{ - Name: groupName, - Description: opt.Group.Description, - Options: clibase.OptionSet{opt}, + // Always show enterprise group last. + groups = append(groups, enterpriseGroup) + + return filterSlice(groups, func(g optionGroup) bool { + return len(g.Options) > 0 }) - } - sort.Slice(groups, func(i, j int) bool { - // Sort groups lexicographically. - return groups[i].Name < groups[j].Name - }) - - // Always show enterprise group last. - groups = append(groups, enterpriseGroup) - - return filterSlice(groups, func(g optionGroup) bool { - return len(g.Options) > 0 - }) + }, }, - }, - ).Parse(helpTemplateRaw), -) + ).Parse(helpTemplateRaw), + ) +}() func filterSlice[T any](s []T, f func(T) bool) []T { var r []T @@ -254,31 +272,41 @@ func filterSlice[T any](s []T, f func(T) bool) []T { // newLineLimiter makes working with Go templates more bearable. Without this, // modifying the template is a slow toil of counting newlines and constantly -// checking that a change to one command's help doesn't clobber break another. +// checking that a change to one command's help doesn't break another. type newlineLimiter struct { - w io.Writer + // w is not an interface since we call WriteRune byte-wise, + // and the devirtualization overhead is significant. + w *bufio.Writer limit int newLineCounter int } +// isSpace is a based on unicode.IsSpace, but only checks ASCII characters. +func isSpace(b byte) bool { + switch b { + case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0: + return true + } + return false +} + func (lm *newlineLimiter) Write(p []byte) (int, error) { - rd := bytes.NewReader(p) - for r, n, _ := rd.ReadRune(); n > 0; r, n, _ = rd.ReadRune() { + for _, b := range p { switch { - case r == '\r': + case b == '\r': // Carriage returns can sneak into `help.tpl` when `git clone` // is configured to automatically convert line endings. continue - case r == '\n': + case b == '\n': lm.newLineCounter++ if lm.newLineCounter > lm.limit { continue } - case !unicode.IsSpace(r): + case !isSpace(b): lm.newLineCounter = 0 } - _, err := lm.w.Write([]byte(string(r))) + err := lm.w.WriteByte(b) if err != nil { return 0, err } diff --git a/cli/help.tpl b/cli/help.tpl index 97d79db936e43..59e1f0dce50a7 100644 --- a/cli/help.tpl +++ b/cli/help.tpl @@ -1,20 +1,22 @@ -{{- /* Heavily inspired by the Go toolchain formatting. */ -}} -Usage: {{.FullUsage}} +{{- /* Heavily inspired by the Go toolchain and fd */ -}} +coder {{version}} + +{{prettyHeader "Usage"}} +{{indent .FullUsage 2}} {{ with .Short }} -{{- wrapTTY . }} +{{- indent . 2 | wrapTTY }} {{"\n"}} {{- end}} {{ with .Aliases }} -{{ "\n" }} -{{ "Aliases:"}} {{ joinStrings .}} -{{ "\n" }} +{{" Aliases: "}} {{- joinStrings .}} {{- end }} {{- with .Long}} -{{- formatLong . }} +{{"\n"}} +{{- indent . 2}} {{ "\n" }} {{- end }} {{ with visibleChildren . }} @@ -34,11 +36,11 @@ Usage: {{.FullUsage}} {{- else }} {{- end }} {{- range $index, $option := $group.Options }} - {{- if not (eq $option.FlagShorthand "") }}{{- print "\n -" $option.FlagShorthand ", " -}} + {{- if not (eq $option.FlagShorthand "") }}{{- print "\n "}} {{ keyword "-"}}{{keyword $option.FlagShorthand }}{{", "}} {{- else }}{{- print "\n " -}} {{- end }} - {{- with flagName $option }}--{{ . }}{{ end }} {{- with typeHelper $option }} {{ . }}{{ end }} - {{- with envName $option }}, ${{ . }}{{ end }} + {{- with flagName $option }}{{keyword "--"}}{{ keyword . }}{{ end }} {{- with typeHelper $option }} {{ . }}{{ end }} + {{- with envName $option }}, {{ print "$" . | keyword }}{{ end }} {{- with $option.Default }} (default: {{ . }}){{ end }} {{- with $option.Description }} {{- $desc := $option.Description }} @@ -47,7 +49,7 @@ Usage: {{.FullUsage}} {{- end -}} {{- end }} {{- end }} ---- +——— {{- if .Parent }} Run `coder --help` for a list of global options. {{- else }} diff --git a/cli/list.go b/cli/list.go index 4b50ba16a7d34..b82d6f31579bf 100644 --- a/cli/list.go +++ b/cli/list.go @@ -7,11 +7,13 @@ import ( "github.com/google/uuid" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/schedule/cron" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" ) // workspaceListRow is the type provided to the OutputFormatter. This is a bit @@ -30,6 +32,7 @@ type workspaceListRow struct { Outdated bool `json:"-" table:"outdated"` StartsAt string `json:"-" table:"starts at"` StopsAfter string `json:"-" table:"stops after"` + DailyCost string `json:"-" table:"daily cost"` } func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]codersdk.User, workspace codersdk.Workspace) workspaceListRow { @@ -38,7 +41,7 @@ func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]coders lastBuilt := now.UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second) autostartDisplay := "-" if !ptr.NilOrEmpty(workspace.AutostartSchedule) { - if sched, err := schedule.Weekly(*workspace.AutostartSchedule); err == nil { + if sched, err := cron.Weekly(*workspace.AutostartSchedule); err == nil { autostartDisplay = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location()) } } @@ -68,6 +71,7 @@ func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]coders Outdated: workspace.Outdated, StartsAt: autostartDisplay, StopsAfter: autostopDisplay, + DailyCost: strconv.Itoa(int(workspace.LatestBuild.DailyCost)), } } @@ -78,7 +82,19 @@ func (r *RootCmd) list() *clibase.Cmd { searchQuery string displayWorkspaces []workspaceListRow formatter = cliui.NewOutputFormatter( - cliui.TableFormat([]workspaceListRow{}, nil), + cliui.TableFormat( + []workspaceListRow{}, + []string{ + "workspace", + "template", + "status", + "healthy", + "last built", + "outdated", + "starts at", + "stops after", + }, + ), cliui.JSONFormat(), ) ) @@ -105,9 +121,9 @@ func (r *RootCmd) list() *clibase.Cmd { return err } if len(res.Workspaces) == 0 { - _, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Prompt.String()+"No workspaces found! Create one:") + pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n") _, _ = fmt.Fprintln(inv.Stderr) - _, _ = fmt.Fprintln(inv.Stderr, " "+cliui.DefaultStyles.Code.Render("coder create ")) + _, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder create ")) _, _ = fmt.Fprintln(inv.Stderr) return nil } diff --git a/cli/list_test.go b/cli/list_test.go index 39567cd6d9167..cdc47821b0ced 100644 --- a/cli/list_test.go +++ b/cli/list_test.go @@ -9,11 +9,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestList(t *testing.T) { @@ -21,14 +21,15 @@ func TestList(t *testing.T) { t.Run("Single", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, root := clitest.New(t, "ls") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t).Attach(inv) ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -48,15 +49,16 @@ func TestList(t *testing.T) { t.Run("JSON", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, root := clitest.New(t, "list", "--output=json") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancelFunc() diff --git a/cli/login.go b/cli/login.go index e16118dfec0d6..2727743e1b487 100644 --- a/cli/login.go +++ b/cli/login.go @@ -16,10 +16,12 @@ import ( "github.com/pkg/browser" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/userpassword" - "github.com/coder/coder/codersdk" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/codersdk" ) const ( @@ -37,6 +39,91 @@ func init() { browser.Stdout = io.Discard } +func promptFirstUsername(inv *clibase.Invocation) (string, error) { + currentUser, err := user.Current() + if err != nil { + return "", xerrors.Errorf("get current user: %w", err) + } + username, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "What " + pretty.Sprint(cliui.DefaultStyles.Field, "username") + " would you like?", + Default: currentUser.Username, + }) + if errors.Is(err, cliui.Canceled) { + return "", nil + } + if err != nil { + return "", err + } + + return username, nil +} + +func promptFirstPassword(inv *clibase.Invocation) (string, error) { +retry: + password, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password") + ":", + Secret: true, + Validate: func(s string) error { + return userpassword.Validate(s) + }, + }) + if err != nil { + return "", xerrors.Errorf("specify password prompt: %w", err) + } + confirm, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Confirm " + pretty.Sprint(cliui.DefaultStyles.Field, "password") + ":", + Secret: true, + Validate: cliui.ValidateNotEmpty, + }) + if err != nil { + return "", xerrors.Errorf("confirm password prompt: %w", err) + } + + if confirm != password { + _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Error, "Passwords do not match")) + goto retry + } + + return password, nil +} + +func (r *RootCmd) loginWithPassword( + inv *clibase.Invocation, + client *codersdk.Client, + email, password string, +) error { + resp, err := client.LoginWithPassword(inv.Context(), codersdk.LoginWithPasswordRequest{ + Email: email, + Password: password, + }) + if err != nil { + return xerrors.Errorf("login with password: %w", err) + } + + sessionToken := resp.SessionToken + config := r.createConfig() + err = config.Session().Write(sessionToken) + if err != nil { + return xerrors.Errorf("write session token: %w", err) + } + + client.SetSessionToken(sessionToken) + + // Nice side-effect: validates the token. + u, err := client.User(inv.Context(), "me") + if err != nil { + return xerrors.Errorf("get user: %w", err) + } + + _, _ = fmt.Fprintf( + inv.Stdout, + "Welcome to Coder, %s! You're authenticated.", + pretty.Sprint(cliui.DefaultStyles.Keyword, u.Username), + ) + + return nil +} + func (r *RootCmd) login() *clibase.Cmd { const firstUserTrialEnv = "CODER_FIRST_USER_TRIAL" @@ -76,7 +163,7 @@ func (r *RootCmd) login() *clibase.Cmd { serverURL.Scheme = "https" } - client, err := r.createUnauthenticatedClient(serverURL) + client, err := r.createUnauthenticatedClient(ctx, serverURL) if err != nil { return err } @@ -88,50 +175,39 @@ func (r *RootCmd) login() *clibase.Cmd { if err != nil { // Checking versions isn't a fatal error so we print a warning // and proceed. - _, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Warn.Render(err.Error())) + _, _ = fmt.Fprintln(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Warn, err.Error())) } - hasInitialUser, err := client.HasFirstUser(ctx) + hasFirstUser, err := client.HasFirstUser(ctx) if err != nil { return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err) } - if !hasInitialUser { + if !hasFirstUser { _, _ = fmt.Fprintf(inv.Stdout, Caret+"Your Coder deployment hasn't been set up!\n") if username == "" { if !isTTY(inv) { return xerrors.New("the initial user cannot be created in non-interactive mode. use the API") } + _, err := cliui.Prompt(inv, cliui.PromptOptions{ Text: "Would you like to create the first user?", Default: cliui.ConfirmYes, IsConfirm: true, }) - if errors.Is(err, cliui.Canceled) { - return nil - } if err != nil { return err } - currentUser, err := user.Current() - if err != nil { - return xerrors.Errorf("get current user: %w", err) - } - username, err = cliui.Prompt(inv, cliui.PromptOptions{ - Text: "What " + cliui.DefaultStyles.Field.Render("username") + " would you like?", - Default: currentUser.Username, - }) - if errors.Is(err, cliui.Canceled) { - return nil - } + + username, err = promptFirstUsername(inv) if err != nil { - return xerrors.Errorf("pick username prompt: %w", err) + return err } } if email == "" { email, err = cliui.Prompt(inv, cliui.PromptOptions{ - Text: "What's your " + cliui.DefaultStyles.Field.Render("email") + "?", + Text: "What's your " + pretty.Sprint(cliui.DefaultStyles.Field, "email") + "?", Validate: func(s string) error { err := validator.New().Var(s, "email") if err != nil { @@ -141,37 +217,14 @@ func (r *RootCmd) login() *clibase.Cmd { }, }) if err != nil { - return xerrors.Errorf("specify email prompt: %w", err) + return err } } if password == "" { - var matching bool - - for !matching { - password, err = cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Enter a " + cliui.DefaultStyles.Field.Render("password") + ":", - Secret: true, - Validate: func(s string) error { - return userpassword.Validate(s) - }, - }) - if err != nil { - return xerrors.Errorf("specify password prompt: %w", err) - } - confirm, err := cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Confirm " + cliui.DefaultStyles.Field.Render("password") + ":", - Secret: true, - Validate: cliui.ValidateNotEmpty, - }) - if err != nil { - return xerrors.Errorf("confirm password prompt: %w", err) - } - - matching = confirm == password - if !matching { - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Error.Render("Passwords do not match")) - } + password, err = promptFirstPassword(inv) + if err != nil { + return err } } @@ -193,30 +246,22 @@ func (r *RootCmd) login() *clibase.Cmd { if err != nil { return xerrors.Errorf("create initial user: %w", err) } - resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ - Email: email, - Password: password, - }) - if err != nil { - return xerrors.Errorf("login with password: %w", err) - } - sessionToken := resp.SessionToken - config := r.createConfig() - err = config.Session().Write(sessionToken) + err := r.loginWithPassword(inv, client, email, password) if err != nil { - return xerrors.Errorf("write session token: %w", err) + return err } - err = config.URL().Write(serverURL.String()) + + err = r.createConfig().URL().Write(serverURL.String()) if err != nil { return xerrors.Errorf("write server url: %w", err) } - _, _ = fmt.Fprintf(inv.Stdout, - cliui.DefaultStyles.Paragraph.Render(fmt.Sprintf("Welcome to Coder, %s! You're authenticated.", cliui.DefaultStyles.Keyword.Render(username)))+"\n") - - _, _ = fmt.Fprintf(inv.Stdout, - cliui.DefaultStyles.Paragraph.Render("Get started by creating a template: "+cliui.DefaultStyles.Code.Render("coder templates init"))+"\n") + _, _ = fmt.Fprintf( + inv.Stdout, + "Get started by creating a template: %s\n", + pretty.Sprint(cliui.DefaultStyles.Code, "coder templates init"), + ) return nil } @@ -233,8 +278,7 @@ func (r *RootCmd) login() *clibase.Cmd { } sessionToken, err = cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Paste your token here:", - Secret: true, + Text: "Paste your token here:", Validate: func(token string) error { client.SetSessionToken(token) _, err := client.User(ctx, codersdk.Me) @@ -282,7 +326,7 @@ func (r *RootCmd) login() *clibase.Cmd { return xerrors.Errorf("write server url: %w", err) } - _, _ = fmt.Fprintf(inv.Stdout, Caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.DefaultStyles.Keyword.Render(resp.Username)) + _, _ = fmt.Fprintf(inv.Stdout, Caret+"Welcome to Coder, %s! You're authenticated.\n", pretty.Sprint(cliui.DefaultStyles.Keyword, resp.Username)) return nil }, } diff --git a/cli/login_test.go b/cli/login_test.go index 1bab4721ea181..3bda6bcd1d22f 100644 --- a/cli/login_test.go +++ b/cli/login_test.go @@ -3,15 +3,18 @@ package cli_test import ( "context" "fmt" + "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" ) func TestLogin(t *testing.T) { @@ -97,16 +100,15 @@ func TestLogin(t *testing.T) { t.Run("InitialUserFlags", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) - doneChan := make(chan struct{}) - root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "SomeSecurePassword!", "--first-user-trial") - pty := ptytest.New(t).Attach(root) - go func() { - defer close(doneChan) - err := root.Run() - assert.NoError(t, err) - }() + inv, _ := clitest.New( + t, "login", client.URL.String(), + "--first-user-username", "testuser", "--first-user-email", "user@coder.com", + "--first-user-password", "SomeSecurePassword!", "--first-user-trial", + ) + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) pty.ExpectMatch("Welcome to Coder") - <-doneChan + w.RequireSuccess() }) t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) { @@ -142,7 +144,7 @@ func TestLogin(t *testing.T) { // Validate that we reprompt for matching passwords. pty.ExpectMatch("Passwords do not match") - pty.ExpectMatch("Enter a " + cliui.DefaultStyles.Field.Render("password")) + pty.ExpectMatch("Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password")) pty.WriteLine("SomeSecurePassword!") pty.ExpectMatch("Confirm") @@ -169,6 +171,10 @@ func TestLogin(t *testing.T) { pty.ExpectMatch("Paste your token here:") pty.WriteLine(client.SessionToken()) + if runtime.GOOS != "windows" { + // For some reason, the match does not show up on Windows. + pty.ExpectMatch(client.SessionToken()) + } pty.ExpectMatch("Welcome to Coder") <-doneChan }) @@ -192,6 +198,10 @@ func TestLogin(t *testing.T) { pty.ExpectMatch("Paste your token here:") pty.WriteLine("an-invalid-token") + if runtime.GOOS != "windows" { + // For some reason, the match does not show up on Windows. + pty.ExpectMatch("an-invalid-token") + } pty.ExpectMatch("That's not a valid token!") cancelFunc() <-doneChan diff --git a/cli/logout.go b/cli/logout.go index 6a4e8872bd227..4e4008e4ffad5 100644 --- a/cli/logout.go +++ b/cli/logout.go @@ -7,9 +7,9 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) logout() *clibase.Cmd { diff --git a/cli/logout_test.go b/cli/logout_test.go index 849016a68ce81..b7c1a571a6605 100644 --- a/cli/logout_test.go +++ b/cli/logout_test.go @@ -8,10 +8,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/cli/config" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/config" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" ) func TestLogout(t *testing.T) { diff --git a/cli/netcheck.go b/cli/netcheck.go index b670e9c12b8ed..5ca7a3d99975b 100644 --- a/cli/netcheck.go +++ b/cli/netcheck.go @@ -8,9 +8,9 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/coderd/healthcheck" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/coderd/healthcheck/derphealth" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) netcheck() *clibase.Cmd { @@ -33,8 +33,8 @@ func (r *RootCmd) netcheck() *clibase.Cmd { _, _ = fmt.Fprint(inv.Stderr, "Gathering a network report. This may take a few seconds...\n\n") - var report healthcheck.DERPReport - report.Run(ctx, &healthcheck.DERPReportOptions{ + var report derphealth.Report + report.Run(ctx, &derphealth.ReportOptions{ DERPMap: connInfo.DERPMap, }) diff --git a/cli/netcheck_test.go b/cli/netcheck_test.go index 890260c1a704e..79abf775562e2 100644 --- a/cli/netcheck_test.go +++ b/cli/netcheck_test.go @@ -8,9 +8,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/healthcheck" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/healthcheck/derphealth" + "github.com/coder/coder/v2/pty/ptytest" ) func TestNetcheck(t *testing.T) { @@ -27,11 +27,11 @@ func TestNetcheck(t *testing.T) { b := out.Bytes() t.Log(string(b)) - var report healthcheck.DERPReport + var report derphealth.Report require.NoError(t, json.Unmarshal(b, &report)) assert.True(t, report.Healthy) - require.Len(t, report.Regions, 1) + require.Len(t, report.Regions, 1+1) // 1 built-in region + 1 test-managed STUN region for _, v := range report.Regions { require.Len(t, v.NodeReports, len(v.Region.Nodes)) } diff --git a/cli/parameter.go b/cli/parameter.go index 77e8ccdc8ee67..bca83ee1a62b1 100644 --- a/cli/parameter.go +++ b/cli/parameter.go @@ -4,71 +4,98 @@ import ( "encoding/json" "fmt" "os" + "strings" "golang.org/x/xerrors" "gopkg.in/yaml.v3" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/codersdk" ) -// Reads a YAML file and populates a string -> string map. -// Throws an error if the file name is empty. -func createParameterMapFromFile(parameterFile string) (map[string]string, error) { - if parameterFile != "" { - parameterFileContents, err := os.ReadFile(parameterFile) - if err != nil { - return nil, err - } +// workspaceParameterFlags are used by commands processing rich parameters and/or build options. +type workspaceParameterFlags struct { + promptBuildOptions bool + buildOptions []string - mapStringInterface := make(map[string]interface{}) - err = yaml.Unmarshal(parameterFileContents, &mapStringInterface) - if err != nil { - return nil, err - } + richParameterFile string + richParameters []string +} - parameterMap := map[string]string{} - for k, v := range mapStringInterface { - switch val := v.(type) { - case string, bool, int: - parameterMap[k] = fmt.Sprintf("%v", val) - case []interface{}: - b, err := json.Marshal(&val) - if err != nil { - return nil, err - } - parameterMap[k] = string(b) - default: - return nil, xerrors.Errorf("invalid parameter type: %T", v) - } - } - return parameterMap, nil +func (wpf *workspaceParameterFlags) cliBuildOptions() []clibase.Option { + return clibase.OptionSet{ + { + Flag: "build-option", + Env: "CODER_BUILD_OPTION", + Description: `Build option value in the format "name=value".`, + Value: clibase.StringArrayOf(&wpf.buildOptions), + }, + { + Flag: "build-options", + Description: "Prompt for one-time build options defined with ephemeral parameters.", + Value: clibase.BoolOf(&wpf.promptBuildOptions), + }, + } +} + +func (wpf *workspaceParameterFlags) cliParameters() []clibase.Option { + return clibase.OptionSet{ + clibase.Option{ + Flag: "parameter", + Env: "CODER_RICH_PARAMETER", + Description: `Rich parameter value in the format "name=value".`, + Value: clibase.StringArrayOf(&wpf.richParameters), + }, + clibase.Option{ + Flag: "rich-parameter-file", + Env: "CODER_RICH_PARAMETER_FILE", + Description: "Specify a file path with values for rich parameters defined in the template.", + Value: clibase.StringOf(&wpf.richParameterFile), + }, } +} - return nil, xerrors.Errorf("Parameter file name is not specified") +func asWorkspaceBuildParameters(nameValuePairs []string) ([]codersdk.WorkspaceBuildParameter, error) { + var params []codersdk.WorkspaceBuildParameter + for _, nameValue := range nameValuePairs { + split := strings.SplitN(nameValue, "=", 2) + if len(split) < 2 { + return nil, xerrors.Errorf("format key=value expected, but got %s", nameValue) + } + params = append(params, codersdk.WorkspaceBuildParameter{ + Name: split[0], + Value: split[1], + }) + } + return params, nil } -func getWorkspaceBuildParameterValueFromMapOrInput(inv *clibase.Invocation, parameterMap map[string]string, templateVersionParameter codersdk.TemplateVersionParameter) (*codersdk.WorkspaceBuildParameter, error) { - var parameterValue string - var err error - if parameterMap != nil { - var ok bool - parameterValue, ok = parameterMap[templateVersionParameter.Name] - if !ok { - parameterValue, err = cliui.RichParameter(inv, templateVersionParameter) +func parseParameterMapFile(parameterFile string) (map[string]string, error) { + parameterFileContents, err := os.ReadFile(parameterFile) + if err != nil { + return nil, err + } + + mapStringInterface := make(map[string]interface{}) + err = yaml.Unmarshal(parameterFileContents, &mapStringInterface) + if err != nil { + return nil, err + } + + parameterMap := map[string]string{} + for k, v := range mapStringInterface { + switch val := v.(type) { + case string, bool, int: + parameterMap[k] = fmt.Sprintf("%v", val) + case []interface{}: + b, err := json.Marshal(&val) if err != nil { return nil, err } - } - } else { - parameterValue, err = cliui.RichParameter(inv, templateVersionParameter) - if err != nil { - return nil, err + parameterMap[k] = string(b) + default: + return nil, xerrors.Errorf("invalid parameter type: %T", v) } } - return &codersdk.WorkspaceBuildParameter{ - Name: templateVersionParameter.Name, - Value: parameterValue, - }, nil + return parameterMap, nil } diff --git a/cli/parameter_internal_test.go b/cli/parameter_internal_test.go index 81dfcefdf49b2..935486c6eae26 100644 --- a/cli/parameter_internal_test.go +++ b/cli/parameter_internal_test.go @@ -16,7 +16,7 @@ func TestCreateParameterMapFromFile(t *testing.T) { parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") _, _ = parameterFile.WriteString("region: \"bananas\"\ndisk: \"20\"\n") - parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name()) + parameterMapFromFile, err := parseParameterMapFile(parameterFile.Name()) expectedMap := map[string]string{ "region": "bananas", @@ -28,18 +28,10 @@ func TestCreateParameterMapFromFile(t *testing.T) { removeTmpDirUntilSuccess(t, tempDir) }) - t.Run("WithEmptyFilename", func(t *testing.T) { - t.Parallel() - - parameterMapFromFile, err := createParameterMapFromFile("") - - assert.Nil(t, parameterMapFromFile) - assert.EqualError(t, err, "Parameter file name is not specified") - }) t.Run("WithInvalidFilename", func(t *testing.T) { t.Parallel() - parameterMapFromFile, err := createParameterMapFromFile("invalidFile.yaml") + parameterMapFromFile, err := parseParameterMapFile("invalidFile.yaml") assert.Nil(t, parameterMapFromFile) @@ -57,7 +49,7 @@ func TestCreateParameterMapFromFile(t *testing.T) { parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") _, _ = parameterFile.WriteString("region = \"bananas\"\ndisk = \"20\"\n") - parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name()) + parameterMapFromFile, err := parseParameterMapFile(parameterFile.Name()) assert.Nil(t, parameterMapFromFile) assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]interface {}") diff --git a/cli/parameterresolver.go b/cli/parameterresolver.go new file mode 100644 index 0000000000000..97cf622b75c33 --- /dev/null +++ b/cli/parameterresolver.go @@ -0,0 +1,256 @@ +package cli + +import ( + "fmt" + + "golang.org/x/xerrors" + + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" +) + +type WorkspaceCLIAction int + +const ( + WorkspaceCreate WorkspaceCLIAction = iota + WorkspaceStart + WorkspaceUpdate + WorkspaceRestart +) + +type ParameterResolver struct { + lastBuildParameters []codersdk.WorkspaceBuildParameter + + richParameters []codersdk.WorkspaceBuildParameter + richParametersFile map[string]string + buildOptions []codersdk.WorkspaceBuildParameter + + promptRichParameters bool + promptBuildOptions bool +} + +func (pr *ParameterResolver) WithLastBuildParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver { + pr.lastBuildParameters = params + return pr +} + +func (pr *ParameterResolver) WithRichParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver { + pr.richParameters = params + return pr +} + +func (pr *ParameterResolver) WithBuildOptions(params []codersdk.WorkspaceBuildParameter) *ParameterResolver { + pr.buildOptions = params + return pr +} + +func (pr *ParameterResolver) WithRichParametersFile(fileMap map[string]string) *ParameterResolver { + pr.richParametersFile = fileMap + return pr +} + +func (pr *ParameterResolver) WithPromptRichParameters(promptRichParameters bool) *ParameterResolver { + pr.promptRichParameters = promptRichParameters + return pr +} + +func (pr *ParameterResolver) WithPromptBuildOptions(promptBuildOptions bool) *ParameterResolver { + pr.promptBuildOptions = promptBuildOptions + return pr +} + +func (pr *ParameterResolver) Resolve(inv *clibase.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) { + var staged []codersdk.WorkspaceBuildParameter + var err error + + staged = pr.resolveWithParametersMapFile(staged) + staged = pr.resolveWithCommandLineOrEnv(staged) + staged = pr.resolveWithLastBuildParameters(staged, templateVersionParameters) + if err = pr.verifyConstraints(staged, action, templateVersionParameters); err != nil { + return nil, err + } + if staged, err = pr.resolveWithInput(staged, inv, action, templateVersionParameters); err != nil { + return nil, err + } + return staged, nil +} + +func (pr *ParameterResolver) resolveWithParametersMapFile(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter { +next: + for name, value := range pr.richParametersFile { + for i, r := range resolved { + if r.Name == name { + resolved[i].Value = value + continue next + } + } + + resolved = append(resolved, codersdk.WorkspaceBuildParameter{ + Name: name, + Value: value, + }) + } + return resolved +} + +func (pr *ParameterResolver) resolveWithCommandLineOrEnv(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter { +nextRichParameter: + for _, richParameter := range pr.richParameters { + for i, r := range resolved { + if r.Name == richParameter.Name { + resolved[i].Value = richParameter.Value + continue nextRichParameter + } + } + + resolved = append(resolved, richParameter) + } + +nextBuildOption: + for _, buildOption := range pr.buildOptions { + for i, r := range resolved { + if r.Name == buildOption.Name { + resolved[i].Value = buildOption.Value + continue nextBuildOption + } + } + + resolved = append(resolved, buildOption) + } + return resolved +} + +func (pr *ParameterResolver) resolveWithLastBuildParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter { + if pr.promptRichParameters { + return resolved // don't pull parameters from last build + } + +next: + for _, buildParameter := range pr.lastBuildParameters { + tvp := findTemplateVersionParameter(buildParameter, templateVersionParameters) + if tvp == nil { + continue // it looks like this parameter is not present anymore + } + + if tvp.Ephemeral { + continue // ephemeral parameters should not be passed to consecutive builds + } + + if !tvp.Mutable { + continue // immutables should not be passed to consecutive builds + } + + if len(tvp.Options) > 0 && !isValidTemplateParameterOption(buildParameter, tvp.Options) { + continue // do not propagate invalid options + } + + for i, r := range resolved { + if r.Name == buildParameter.Name { + resolved[i].Value = buildParameter.Value + continue next + } + } + + resolved = append(resolved, buildParameter) + } + return resolved +} + +func (pr *ParameterResolver) verifyConstraints(resolved []codersdk.WorkspaceBuildParameter, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) error { + for _, r := range resolved { + tvp := findTemplateVersionParameter(r, templateVersionParameters) + if tvp == nil { + return xerrors.Errorf("parameter %q is not present in the template", r.Name) + } + + if tvp.Ephemeral && !pr.promptBuildOptions && findWorkspaceBuildParameter(tvp.Name, pr.buildOptions) == nil { + return xerrors.Errorf("ephemeral parameter %q can be used only with --build-options or --build-option flag", r.Name) + } + + if !tvp.Mutable && action != WorkspaceCreate { + return xerrors.Errorf("parameter %q is immutable and cannot be updated", r.Name) + } + } + return nil +} + +func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuildParameter, inv *clibase.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) { + for _, tvp := range templateVersionParameters { + p := findWorkspaceBuildParameter(tvp.Name, resolved) + if p != nil { + continue + } + // Parameter has not been resolved yet, so CLI needs to determine if user should input it. + + firstTimeUse := pr.isFirstTimeUse(tvp.Name) + promptParameterOption := pr.isLastBuildParameterInvalidOption(tvp) + + if (tvp.Ephemeral && pr.promptBuildOptions) || + (action == WorkspaceCreate && tvp.Required) || + (action == WorkspaceCreate && !tvp.Ephemeral) || + (action == WorkspaceUpdate && promptParameterOption) || + (action == WorkspaceUpdate && tvp.Mutable && tvp.Required) || + (action == WorkspaceUpdate && !tvp.Mutable && firstTimeUse) || + (action == WorkspaceUpdate && tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) { + parameterValue, err := cliui.RichParameter(inv, tvp) + if err != nil { + return nil, err + } + + resolved = append(resolved, codersdk.WorkspaceBuildParameter{ + Name: tvp.Name, + Value: parameterValue, + }) + } else if action == WorkspaceUpdate && !tvp.Mutable && !firstTimeUse { + _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Warn, fmt.Sprintf("Parameter %q is not mutable, and cannot be customized after workspace creation.", tvp.Name))) + } + } + return resolved, nil +} + +func (pr *ParameterResolver) isFirstTimeUse(parameterName string) bool { + return findWorkspaceBuildParameter(parameterName, pr.lastBuildParameters) == nil +} + +func (pr *ParameterResolver) isLastBuildParameterInvalidOption(templateVersionParameter codersdk.TemplateVersionParameter) bool { + if len(templateVersionParameter.Options) == 0 { + return false + } + + for _, buildParameter := range pr.lastBuildParameters { + if buildParameter.Name == templateVersionParameter.Name { + return !isValidTemplateParameterOption(buildParameter, templateVersionParameter.Options) + } + } + return false +} + +func findTemplateVersionParameter(workspaceBuildParameter codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) *codersdk.TemplateVersionParameter { + for _, tvp := range templateVersionParameters { + if tvp.Name == workspaceBuildParameter.Name { + return &tvp + } + } + return nil +} + +func findWorkspaceBuildParameter(parameterName string, params []codersdk.WorkspaceBuildParameter) *codersdk.WorkspaceBuildParameter { + for _, p := range params { + if p.Name == parameterName { + return &p + } + } + return nil +} + +func isValidTemplateParameterOption(buildParameter codersdk.WorkspaceBuildParameter, options []codersdk.TemplateVersionParameterOption) bool { + for _, opt := range options { + if opt.Value == buildParameter.Value { + return true + } + } + return false +} diff --git a/cli/ping.go b/cli/ping.go index f69958666a044..2df0d57446780 100644 --- a/cli/ping.go +++ b/cli/ping.go @@ -10,9 +10,11 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) ping() *clibase.Cmd { @@ -104,14 +106,14 @@ func (r *RootCmd) ping() *clibase.Cmd { if p2p { if !didP2p { _, _ = fmt.Fprintln(inv.Stdout, "p2p connection established in", - cliui.DefaultStyles.DateTimeStamp.Render(time.Since(start).Round(time.Millisecond).String()), + pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Since(start).Round(time.Millisecond).String()), ) } didP2p = true via = fmt.Sprintf("%s via %s", - cliui.DefaultStyles.Fuchsia.Render("p2p"), - cliui.DefaultStyles.Code.Render(pong.Endpoint), + pretty.Sprint(cliui.DefaultStyles.Fuchsia, "p2p"), + pretty.Sprint(cliui.DefaultStyles.Code, pong.Endpoint), ) } else { derpName := "unknown" @@ -120,15 +122,15 @@ func (r *RootCmd) ping() *clibase.Cmd { derpName = derpRegion.RegionName } via = fmt.Sprintf("%s via %s", - cliui.DefaultStyles.Fuchsia.Render("proxied"), - cliui.DefaultStyles.Code.Render(fmt.Sprintf("DERP(%s)", derpName)), + pretty.Sprint(cliui.DefaultStyles.Fuchsia, "proxied"), + pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("DERP(%s)", derpName)), ) } _, _ = fmt.Fprintf(inv.Stdout, "pong from %s %s in %s\n", - cliui.DefaultStyles.Keyword.Render(workspaceName), + pretty.Sprint(cliui.DefaultStyles.Keyword, workspaceName), via, - cliui.DefaultStyles.DateTimeStamp.Render(dur.String()), + pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, dur.String()), ) if n == int(pingNum) { diff --git a/cli/ping_test.go b/cli/ping_test.go index 959c11c8ed9b4..f2bd4b5ff88a1 100644 --- a/cli/ping_test.go +++ b/cli/ping_test.go @@ -6,13 +6,11 @@ import ( "github.com/stretchr/testify/assert" - "cdr.dev/slog/sloggers/slogtest" - - "github.com/coder/coder/agent" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestPing(t *testing.T) { @@ -29,15 +27,8 @@ func TestPing(t *testing.T) { inv.Stderr = pty.Output() inv.Stdout = pty.Output() - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(agentToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - Logger: slogtest.Make(t, nil).Named("agent"), - }) - defer func() { - _ = agentCloser.Close() - }() + _ = agenttest.New(t, client.URL, agentToken) + _ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() diff --git a/cli/portforward.go b/cli/portforward.go index 3df1a6f6c9d9f..034b14f894db7 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -18,10 +18,10 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/agent/agentssh" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/agent/agentssh" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) portForward() *clibase.Cmd { diff --git a/cli/portforward_test.go b/cli/portforward_test.go index 5ae1997285ab7..ef4d36ee05e5a 100644 --- a/cli/portforward_test.go +++ b/cli/portforward_test.go @@ -7,28 +7,32 @@ import ( "net" "sync" "testing" + "time" "github.com/google/uuid" "github.com/pion/udp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestPortForward_None(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) - _ = coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) inv, root := clitest.New(t, "port-forward", "blah") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t).Attach(inv) inv.Stderr = pty.Output() @@ -129,8 +133,9 @@ func TestPortForward(t *testing.T) { // non-parallel setup). var ( client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user = coderdtest.CreateFirstUser(t, client) - workspace = runAgent(t, client, user.UserID) + admin = coderdtest.CreateFirstUser(t, client) + member, _ = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + workspace = runAgent(t, client, member) ) for _, c := range cases { @@ -148,7 +153,7 @@ func TestPortForward(t *testing.T) { // Launch port-forward in a goroutine so we can start dialing // the "local" listener. inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t) inv.Stdin = pty.Input() inv.Stdout = pty.Output() @@ -195,7 +200,7 @@ func TestPortForward(t *testing.T) { // Launch port-forward in a goroutine so we can start dialing // the "local" listeners. inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag1, flag2) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t) inv.Stdin = pty.Input() inv.Stdout = pty.Output() @@ -250,7 +255,7 @@ func TestPortForward(t *testing.T) { // Launch port-forward in a goroutine so we can start dialing // the "local" listeners. inv, root := clitest.New(t, append([]string{"-v", "port-forward", workspace.Name}, flags...)...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t).Attach(inv) inv.Stderr = pty.Output() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -291,46 +296,33 @@ func TestPortForward(t *testing.T) { // runAgent creates a fake workspace and starts an agent locally for that // workspace. The agent will be cleaned up on test completion. // nolint:unused -func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk.Workspace { +func runAgent(t *testing.T, adminClient, userClient *codersdk.Client) codersdk.Workspace { ctx := context.Background() - user, err := client.User(ctx, userID.String()) + user, err := userClient.User(ctx, codersdk.Me) require.NoError(t, err, "specified user does not exist") require.Greater(t, len(user.OrganizationIDs), 0, "user has no organizations") orgID := user.OrganizationIDs[0] // Setup template agentToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ + version := coderdtest.CreateTemplateVersion(t, adminClient, orgID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(agentToken), }) // Create template and workspace - template := coderdtest.CreateTemplate(t, client, orgID, version.ID) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - - // Start workspace agent in a goroutine - inv, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String()) - clitest.SetupConfig(t, client, root) - pty := ptytest.New(t) - inv.Stdin = pty.Input() - inv.Stdout = pty.Output() - inv.Stderr = pty.Output() - errC := make(chan error) - agentCtx, agentCancel := context.WithCancel(ctx) - t.Cleanup(func() { - agentCancel() - err := <-errC - require.NoError(t, err) - }) - go func() { - errC <- inv.WithContext(agentCtx).Run() - }() - - coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + template := coderdtest.CreateTemplate(t, adminClient, orgID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID) + workspace := coderdtest.CreateWorkspace(t, userClient, orgID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, adminClient, workspace.LatestBuild.ID) + + _ = agenttest.New(t, adminClient.URL, agentToken, + func(o *agent.Options) { + o.SSHMaxTimeout = 60 * time.Second + }, + ) + coderdtest.AwaitWorkspaceAgents(t, adminClient, workspace.ID) return workspace } diff --git a/cli/publickey.go b/cli/publickey.go index 43537eec428a1..f6e145377e407 100644 --- a/cli/publickey.go +++ b/cli/publickey.go @@ -5,9 +5,11 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) publickey() *clibase.Cmd { @@ -44,13 +46,13 @@ func (r *RootCmd) publickey() *clibase.Cmd { } cliui.Infof(inv.Stdout, - "This is your public key for using "+cliui.DefaultStyles.Field.Render("git")+" in "+ - "Coder. All clones with SSH will be authenticated automatically 🪄.\n\n", + "This is your public key for using "+pretty.Sprint(cliui.DefaultStyles.Field, "git")+" in "+ + "Coder. All clones with SSH will be authenticated automatically 🪄.", ) - cliui.Infof(inv.Stdout, cliui.DefaultStyles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n\n") - cliui.Infof(inv.Stdout, "Add to GitHub and GitLab:"+"\n") - cliui.Infof(inv.Stdout, cliui.DefaultStyles.Prompt.String()+"https://github.com/settings/ssh/new"+"\n") - cliui.Infof(inv.Stdout, cliui.DefaultStyles.Prompt.String()+"https://gitlab.com/-/profile/keys"+"\n") + cliui.Infof(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Code, strings.TrimSpace(key.PublicKey))+"\n") + cliui.Infof(inv.Stdout, "Add to GitHub and GitLab:") + cliui.Infof(inv.Stdout, "> https://github.com/settings/ssh/new") + cliui.Infof(inv.Stdout, "> https://gitlab.com/-/profile/keys") return nil }, diff --git a/cli/publickey_test.go b/cli/publickey_test.go index a5664ec2bda07..8d04a9b66af53 100644 --- a/cli/publickey_test.go +++ b/cli/publickey_test.go @@ -6,8 +6,8 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" ) func TestPublicKey(t *testing.T) { diff --git a/cli/remoteforward.go b/cli/remoteforward.go index 9e53669a7ee47..2c4207583b289 100644 --- a/cli/remoteforward.go +++ b/cli/remoteforward.go @@ -5,13 +5,14 @@ import ( "fmt" "io" "net" + "os" "regexp" "strconv" gossh "golang.org/x/crypto/ssh" "golang.org/x/xerrors" - "github.com/coder/coder/agent/agentssh" + "github.com/coder/coder/v2/agent/agentssh" ) // cookieAddr is a special net.Addr accepted by sshRemoteForward() which includes a @@ -23,15 +24,24 @@ type cookieAddr struct { // Format: // remote_port:local_address:local_port -var remoteForwardRegex = regexp.MustCompile(`^(\d+):(.+):(\d+)$`) +var remoteForwardRegexTCP = regexp.MustCompile(`^(\d+):(.+):(\d+)$`) -func validateRemoteForward(flag string) bool { - return remoteForwardRegex.MatchString(flag) +// remote_socket_path:local_socket_path (both absolute paths) +var remoteForwardRegexUnixSocket = regexp.MustCompile(`^(\/.+):(\/.+)$`) + +func isRemoteForwardTCP(flag string) bool { + return remoteForwardRegexTCP.MatchString(flag) } -func parseRemoteForward(flag string) (net.Addr, net.Addr, error) { - matches := remoteForwardRegex.FindStringSubmatch(flag) +func isRemoteForwardUnixSocket(flag string) bool { + return remoteForwardRegexUnixSocket.MatchString(flag) +} + +func validateRemoteForward(flag string) bool { + return isRemoteForwardTCP(flag) || isRemoteForwardUnixSocket(flag) +} +func parseRemoteForwardTCP(matches []string) (net.Addr, net.Addr, error) { remotePort, err := strconv.Atoi(matches[1]) if err != nil { return nil, nil, xerrors.Errorf("remote port is invalid: %w", err) @@ -57,6 +67,46 @@ func parseRemoteForward(flag string) (net.Addr, net.Addr, error) { return localAddr, remoteAddr, nil } +func parseRemoteForwardUnixSocket(matches []string) (net.Addr, net.Addr, error) { + remoteSocket := matches[1] + localSocket := matches[2] + + fileInfo, err := os.Stat(localSocket) + if err != nil { + return nil, nil, err + } + + if fileInfo.Mode()&os.ModeSocket == 0 { + return nil, nil, xerrors.New("File is not a Unix domain socket file") + } + + remoteAddr := &net.UnixAddr{ + Name: remoteSocket, + Net: "unix", + } + + localAddr := &net.UnixAddr{ + Name: localSocket, + Net: "unix", + } + return localAddr, remoteAddr, nil +} + +func parseRemoteForward(flag string) (net.Addr, net.Addr, error) { + tcpMatches := remoteForwardRegexTCP.FindStringSubmatch(flag) + + if len(tcpMatches) > 0 { + return parseRemoteForwardTCP(tcpMatches) + } + + unixSocketMatches := remoteForwardRegexUnixSocket.FindStringSubmatch(flag) + if len(unixSocketMatches) > 0 { + return parseRemoteForwardUnixSocket(unixSocketMatches) + } + + return nil, nil, xerrors.New("Could not match forward arguments") +} + // sshRemoteForward starts forwarding connections from a remote listener to a // local address via SSH in a goroutine. // diff --git a/cli/rename.go b/cli/rename.go index d9e2af5316603..24a201ab7d3d0 100644 --- a/cli/rename.go +++ b/cli/rename.go @@ -5,9 +5,11 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) rename() *clibase.Cmd { @@ -27,7 +29,7 @@ func (r *RootCmd) rename() *clibase.Cmd { } _, _ = fmt.Fprintf(inv.Stdout, "%s\n\n", - cliui.DefaultStyles.Wrap.Render("WARNING: A rename can result in data loss if a resource references the workspace name in the template (e.g volumes). Please backup any data before proceeding."), + pretty.Sprint(cliui.DefaultStyles.Wrap, "WARNING: A rename can result in data loss if a resource references the workspace name in the template (e.g volumes). Please backup any data before proceeding."), ) _, _ = fmt.Fprintf(inv.Stdout, "See: %s\n\n", "https://coder.com/docs/coder-oss/latest/templates/resource-persistence#%EF%B8%8F-persistence-pitfalls") _, err = cliui.Prompt(inv, cliui.PromptOptions{ diff --git a/cli/rename_test.go b/cli/rename_test.go index 6cd92ff9e1451..5a08d29c5a7c4 100644 --- a/cli/rename_test.go +++ b/cli/rename_test.go @@ -6,22 +6,23 @@ import ( "github.com/stretchr/testify/assert" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestRename(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -30,7 +31,7 @@ func TestRename(t *testing.T) { // E.g. "compassionate-chandrasekhar82" + "t". want := workspace.Name + "t" inv, root := clitest.New(t, "rename", workspace.Name, want, "--yes") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t) pty.Attach(inv) clitest.Start(t, inv) diff --git a/cli/resetpassword.go b/cli/resetpassword.go index 02a98993368cc..887aa9575a45e 100644 --- a/cli/resetpassword.go +++ b/cli/resetpassword.go @@ -1,3 +1,5 @@ +//go:build !slim + package cli import ( @@ -6,11 +8,13 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/migrations" - "github.com/coder/coder/coderd/userpassword" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/migrations" + "github.com/coder/coder/v2/coderd/userpassword" ) func (*RootCmd) resetPassword() *clibase.Cmd { @@ -47,7 +51,7 @@ func (*RootCmd) resetPassword() *clibase.Cmd { } password, err := cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Enter new " + cliui.DefaultStyles.Field.Render("password") + ":", + Text: "Enter new " + pretty.Sprint(cliui.DefaultStyles.Field, "password") + ":", Secret: true, Validate: func(s string) error { return userpassword.Validate(s) @@ -57,7 +61,7 @@ func (*RootCmd) resetPassword() *clibase.Cmd { return xerrors.Errorf("password prompt: %w", err) } confirmedPassword, err := cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Confirm " + cliui.DefaultStyles.Field.Render("password") + ":", + Text: "Confirm " + pretty.Sprint(cliui.DefaultStyles.Field, "password") + ":", Secret: true, Validate: cliui.ValidateNotEmpty, }) @@ -81,7 +85,7 @@ func (*RootCmd) resetPassword() *clibase.Cmd { return xerrors.Errorf("updating password: %w", err) } - _, _ = fmt.Fprintf(inv.Stdout, "\nPassword has been reset for user %s!\n", cliui.DefaultStyles.Keyword.Render(user.Username)) + _, _ = fmt.Fprintf(inv.Stdout, "\nPassword has been reset for user %s!\n", pretty.Sprint(cliui.DefaultStyles.Keyword, user.Username)) return nil }, } diff --git a/cli/resetpassword_slim.go b/cli/resetpassword_slim.go new file mode 100644 index 0000000000000..1b69b8d8b65a5 --- /dev/null +++ b/cli/resetpassword_slim.go @@ -0,0 +1,23 @@ +//go:build slim + +package cli + +import ( + "github.com/coder/coder/v2/cli/clibase" +) + +func (*RootCmd) resetPassword() *clibase.Cmd { + root := &clibase.Cmd{ + Use: "reset-password ", + Short: "Directly connect to the database to reset a user's password", + // We accept RawArgs so all commands and flags are accepted. + RawArgs: true, + Hidden: true, + Handler: func(inv *clibase.Invocation) error { + SlimUnsupported(inv.Stderr, "reset-password") + return nil + }, + } + + return root +} diff --git a/cli/resetpassword_test.go b/cli/resetpassword_test.go index 40cfc1042dcdc..3ae1c4acb8acb 100644 --- a/cli/resetpassword_test.go +++ b/cli/resetpassword_test.go @@ -9,11 +9,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/database/postgres" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/database/postgres" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) // nolint:paralleltest diff --git a/cli/restart.go b/cli/restart.go index 4cff7ac7571d7..e5182ff481d1c 100644 --- a/cli/restart.go +++ b/cli/restart.go @@ -2,11 +2,15 @@ package cli import ( "fmt" + "net/http" "time" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) func (r *RootCmd) restart() *clibase.Cmd { @@ -21,7 +25,7 @@ func (r *RootCmd) restart() *clibase.Cmd { clibase.RequireNArgs(1), r.InitClient(client), ), - Options: append(parameterFlags.options(), cliui.SkipPromptOption()), + Options: append(parameterFlags.cliBuildOptions(), cliui.SkipPromptOption()), Handler: func(inv *clibase.Invocation) error { ctx := inv.Context() out := inv.Stdout @@ -31,14 +35,24 @@ func (r *RootCmd) restart() *clibase.Cmd { return err } - template, err := client.Template(inv.Context(), workspace.TemplateID) + lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) if err != nil { return err } - buildParams, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ - Template: template, - BuildOptions: parameterFlags.buildOptions, + buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) + if err != nil { + return xerrors.Errorf("can't parse build options: %w", err) + } + + buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ + Action: WorkspaceRestart, + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, + + LastBuildParameters: lastBuildParameters, + + PromptBuildOptions: parameterFlags.promptBuildOptions, + BuildOptions: buildOptions, }) if err != nil { return err @@ -63,19 +77,38 @@ func (r *RootCmd) restart() *clibase.Cmd { return err } - build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + req := codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, - RichParameterValues: buildParams.richParameters, - }) - if err != nil { + RichParameterValues: buildParameters, + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, + } + + build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, req) + // It's possible for a workspace build to fail due to the template requiring starting + // workspaces with the active version. + if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusUnauthorized { + build, err = startWorkspaceActiveVersion(inv, client, startWorkspaceActiveVersionArgs{ + BuildOptions: buildOptions, + LastBuildParameters: lastBuildParameters, + PromptBuildOptions: parameterFlags.promptBuildOptions, + Workspace: workspace, + }) + if err != nil { + return xerrors.Errorf("start workspace with active template version: %w", err) + } + } else if err != nil { return err } + err = cliui.WorkspaceBuild(ctx, out, client, build.ID) if err != nil { return err } - _, _ = fmt.Fprintf(out, "\nThe %s workspace has been restarted at %s!\n", cliui.DefaultStyles.Keyword.Render(workspace.Name), cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))) + _, _ = fmt.Fprintf(out, + "\nThe %s workspace has been restarted at %s!\n", + pretty.Sprint(cliui.DefaultStyles.Keyword, workspace.Name), cliui.Timestamp(time.Now()), + ) return nil }, } diff --git a/cli/restart_test.go b/cli/restart_test.go index 83b066e4defc5..cdf22c9b982c2 100644 --- a/cli/restart_test.go +++ b/cli/restart_test.go @@ -2,63 +2,49 @@ package cli_test import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestRestart(t *testing.T) { t.Parallel() - echoResponses := &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: []*proto.RichParameter{ - { - Name: ephemeralParameterName, - Description: ephemeralParameterDescription, - Mutable: true, - Ephemeral: true, - }, - }, - }, - }, - }, + echoResponses := prepareEchoResponses([]*proto.RichParameter{ + { + Name: ephemeralParameterName, + Description: ephemeralParameterDescription, + Mutable: true, + Ephemeral: true, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, - } + }) t.Run("OK", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) ctx := testutil.Context(t, testutil.WaitLong) inv, root := clitest.New(t, "restart", workspace.Name, "--yes") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t).Attach(inv) @@ -78,15 +64,16 @@ func TestRestart(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, root := clitest.New(t, "restart", workspace.Name, "--build-options") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) go func() { @@ -117,7 +104,61 @@ func TestRestart(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() - workspace, err := client.WorkspaceByOwnerAndName(ctx, user.UserID.String(), workspace.Name, codersdk.WorkspaceOptions{}) + workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{ + Name: ephemeralParameterName, + Value: ephemeralParameterValue, + }) + }) + + t.Run("BuildOptionFlags", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + inv, root := clitest.New(t, "restart", workspace.Name, + "--build-option", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue)) + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + matches := []string{ + "Confirm restart workspace?", "yes", + "Stopping workspace", "", + "Starting workspace", "", + "workspace has been restarted", "", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + + if value != "" { + pty.WriteLine(value) + } + } + <-doneChan + + // Verify if build option is set + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{}) require.NoError(t, err) actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) require.NoError(t, err) @@ -127,3 +168,75 @@ func TestRestart(t *testing.T) { }) }) } + +func TestRestartWithParameters(t *testing.T) { + t.Parallel() + + echoResponses := &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{ + { + Name: immutableParameterName, + Description: immutableParameterDescription, + Required: true, + }, + }, + }, + }, + }, + }, + ProvisionApply: echo.ApplyComplete, + } + + t.Run("DoNotAskForImmutables", func(t *testing.T) { + t.Parallel() + + // Create the workspace + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + { + Name: immutableParameterName, + Value: immutableParameterValue, + }, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Restart the workspace again + inv, root := clitest.New(t, "restart", workspace.Name, "-y") + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + pty.ExpectMatch("workspace has been restarted") + <-doneChan + + // Verify if immutable parameter is set + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{ + Name: immutableParameterName, + Value: immutableParameterValue, + }) + }) +} diff --git a/cli/root.go b/cli/root.go index 4c268235a0f96..b4d416295cd62 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1,11 +1,12 @@ package cli import ( + "bufio" + "bytes" "context" "encoding/base64" "encoding/json" "errors" - "flag" "fmt" "io" "math/rand" @@ -13,6 +14,7 @@ import ( "net/http" "net/url" "os" + "os/exec" "os/signal" "path/filepath" "runtime" @@ -21,26 +23,26 @@ import ( "text/tabwriter" "time" - "github.com/charmbracelet/lipgloss" "github.com/mattn/go-isatty" "github.com/mitchellh/go-wordwrap" "golang.org/x/exp/slices" "golang.org/x/xerrors" + "github.com/coder/pretty" + "cdr.dev/slog" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/cli/config" - "github.com/coder/coder/coderd" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/config" + "github.com/coder/coder/v2/cli/gitauth" + "github.com/coder/coder/v2/cli/telemetry" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" ) var ( - Caret = cliui.DefaultStyles.Prompt.String() + Caret = pretty.Sprint(cliui.DefaultStyles.Prompt, "") // Applied as annotations to workspace commands // so they display in a separated "help" section. @@ -53,8 +55,10 @@ const ( varURL = "url" varToken = "token" varAgentToken = "agent-token" + varAgentTokenFile = "agent-token-file" varAgentURL = "agent-url" varHeader = "header" + varHeaderCommand = "header-command" varNoOpen = "no-open" varNoVersionCheck = "no-version-warning" varNoFeatureWarning = "no-feature-warning" @@ -68,7 +72,9 @@ const ( envSessionToken = "CODER_SESSION_TOKEN" //nolint:gosec envAgentToken = "CODER_AGENT_TOKEN" - envURL = "CODER_URL" + //nolint:gosec + envAgentTokenFile = "CODER_AGENT_TOKEN_FILE" + envURL = "CODER_URL" ) var errUnauthenticated = xerrors.New(notLoggedInMessage) @@ -77,6 +83,7 @@ func (r *RootCmd) Core() []*clibase.Cmd { // Please re-sort this list alphabetically if you change it! return []*clibase.Cmd{ r.dotfiles(), + r.externalAuth(), r.login(), r.logout(), r.netcheck(), @@ -115,10 +122,7 @@ func (r *RootCmd) Core() []*clibase.Cmd { } func (r *RootCmd) AGPL() []*clibase.Cmd { - all := append(r.Core(), r.Server(func(_ context.Context, o *coderd.Options) (*coderd.API, io.Closer, error) { - api := coderd.New(o) - return api, api, nil - })) + all := append(r.Core(), r.Server( /* Do not import coderd here. */ nil)) return all } @@ -130,14 +134,13 @@ func (r *RootCmd) RunMain(subcommands []*clibase.Cmd) { if err != nil { panic(err) } - err = cmd.Invoke().WithOS().Run() if err != nil { if errors.Is(err, cliui.Canceled) { //nolint:revive os.Exit(1) } - f := prettyErrorFormatter{w: os.Stderr} + f := prettyErrorFormatter{w: os.Stderr, verbose: r.verbose} f.format(err) //nolint:revive os.Exit(1) @@ -160,7 +163,9 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) { }, ), Handler: func(i *clibase.Invocation) error { - // fmt.Fprintf(i.Stderr, "env debug: %+v", i.Environ) + if r.versionFlag { + return r.version(defaultVersionInfo).Handler(i) + } // The GIT_ASKPASS environment variable must point at // a binary with no arguments. To prevent writing // cross-platform scripts to invoke the Coder binary @@ -327,6 +332,14 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) { Hidden: true, Group: globalGroup, }, + { + Flag: varAgentTokenFile, + Env: envAgentTokenFile, + Description: "A file containing an agent authentication token.", + Value: clibase.StringOf(&r.agentTokenFile), + Hidden: true, + Group: globalGroup, + }, { Flag: varAgentURL, Env: "CODER_AGENT_URL", @@ -356,6 +369,13 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) { Value: clibase.StringArrayOf(&r.header), Group: globalGroup, }, + { + Flag: varHeaderCommand, + Env: "CODER_HEADER_COMMAND", + Description: "An external command that outputs additional HTTP headers added to all requests. The command must output each header as `key=value` on its own line.", + Value: clibase.StringOf(&r.headerCommand), + Group: globalGroup, + }, { Flag: varNoOpen, Env: "CODER_NO_OPEN", @@ -402,6 +422,15 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) { Value: clibase.StringOf(&r.globalConfig), Group: globalGroup, }, + { + Flag: "version", + // This was requested by a customer to assist with their migration. + // They have two Coder CLIs, and want to tell the difference by running + // the same base command. + Description: "Run the version command. Useful for v1 customers migrating to v2.", + Value: clibase.BoolOf(&r.versionFlag), + Hidden: true, + }, } err := cmd.PrepareAll() @@ -427,23 +456,22 @@ func LoggerFromContext(ctx context.Context) (slog.Logger, bool) { return l, ok } -func isTest() bool { - return flag.Lookup("test.v") != nil -} - // RootCmd contains parameters and helpers useful to all commands. type RootCmd struct { - clientURL *url.URL - token string - globalConfig string - header []string - agentToken string - agentURL *url.URL - forceTTY bool - noOpen bool - verbose bool - disableDirect bool - debugHTTP bool + clientURL *url.URL + token string + globalConfig string + header []string + headerCommand string + agentToken string + agentTokenFile string + agentURL *url.URL + forceTTY bool + noOpen bool + verbose bool + versionFlag bool + disableDirect bool + debugHTTP bool noVersionCheck bool noFeatureWarning bool @@ -459,17 +487,17 @@ func addTelemetryHeader(client *codersdk.Client, inv *clibase.Invocation) { client.HTTPClient.Transport = transport } - var topts []telemetry.CLIOption + var topts []telemetry.Option for _, opt := range inv.Command.FullOptions() { if opt.ValueSource == clibase.ValueSourceNone || opt.ValueSource == clibase.ValueSourceDefault { continue } - topts = append(topts, telemetry.CLIOption{ + topts = append(topts, telemetry.Option{ Name: opt.Name, ValueSource: string(opt.ValueSource), }) } - ti := telemetry.CLIInvocation{ + ti := telemetry.Invocation{ Command: inv.Command.FullName(), Options: topts, InvokedAt: time.Now(), @@ -494,6 +522,15 @@ func addTelemetryHeader(client *codersdk.Client, inv *clibase.Invocation) { // InitClient sets client to a new client. // It reads from global configuration files if flags are not set. func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc { + return r.initClientInternal(client, false) +} + +func (r *RootCmd) InitClientMissingTokenOK(client *codersdk.Client) clibase.MiddlewareFunc { + return r.initClientInternal(client, true) +} + +// nolint: revive +func (r *RootCmd) initClientInternal(client *codersdk.Client, allowTokenMissing bool) clibase.MiddlewareFunc { if client == nil { panic("client is nil") } @@ -508,7 +545,7 @@ func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc { rawURL, err := conf.URL().Read() // If the configuration files are absent, the user is logged out if os.IsNotExist(err) { - return (errUnauthenticated) + return errUnauthenticated } if err != nil { return err @@ -524,15 +561,14 @@ func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc { r.token, err = conf.Session().Read() // If the configuration files are absent, the user is logged out if os.IsNotExist(err) { - return (errUnauthenticated) - } - if err != nil { + if !allowTokenMissing { + return errUnauthenticated + } + } else if err != nil { return err } } - err = r.setClient( - client, r.clientURL, - ) + err = r.setClient(inv.Context(), client, r.clientURL) if err != nil { return err } @@ -565,15 +601,13 @@ func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc { if err = <-versionErr; err != nil { // Just log the error here. We never want to fail a command // due to a pre-run. - _, _ = fmt.Fprintf(inv.Stderr, - cliui.DefaultStyles.Warn.Render("check versions error: %s"), err) + pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Warn, "check versions error: %s", err) _, _ = fmt.Fprintln(inv.Stderr) } if err = <-warningErr; err != nil { // Same as above - _, _ = fmt.Fprintf(inv.Stderr, - cliui.DefaultStyles.Warn.Render("check entitlement warnings error: %s"), err) + pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Warn, "check entitlement warnings error: %s", err) _, _ = fmt.Fprintln(inv.Stderr) } @@ -582,12 +616,38 @@ func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc { } } -func (r *RootCmd) setClient(client *codersdk.Client, serverURL *url.URL) error { +func (r *RootCmd) setClient(ctx context.Context, client *codersdk.Client, serverURL *url.URL) error { transport := &headerTransport{ transport: http.DefaultTransport, header: http.Header{}, } - for _, header := range r.header { + headers := r.header + if r.headerCommand != "" { + shell := "sh" + caller := "-c" + if runtime.GOOS == "windows" { + shell = "cmd.exe" + caller = "/c" + } + var outBuf bytes.Buffer + // #nosec + cmd := exec.CommandContext(ctx, shell, caller, r.headerCommand) + cmd.Env = append(os.Environ(), "CODER_URL="+serverURL.String()) + cmd.Stdout = &outBuf + cmd.Stderr = io.Discard + err := cmd.Run() + if err != nil { + return xerrors.Errorf("failed to run %v: %w", cmd.Args, err) + } + scanner := bufio.NewScanner(&outBuf) + for scanner.Scan() { + headers = append(headers, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return xerrors.Errorf("scan %v: %w", cmd.Args, err) + } + } + for _, header := range headers { parts := strings.SplitN(header, "=", 2) if len(parts) < 2 { return xerrors.Errorf("split header %q had less than two parts", header) @@ -601,9 +661,9 @@ func (r *RootCmd) setClient(client *codersdk.Client, serverURL *url.URL) error { return nil } -func (r *RootCmd) createUnauthenticatedClient(serverURL *url.URL) (*codersdk.Client, error) { +func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *url.URL) (*codersdk.Client, error) { var client codersdk.Client - err := r.setClient(&client, serverURL) + err := r.setClient(ctx, &client, serverURL) return &client, err } @@ -711,18 +771,18 @@ type example struct { func formatExamples(examples ...example) string { var sb strings.Builder - padStyle := cliui.DefaultStyles.Wrap.Copy().PaddingLeft(4) + padStyle := cliui.DefaultStyles.Wrap.With(pretty.XPad(4, 0)) for i, e := range examples { if len(e.Description) > 0 { wordwrap.WrapString(e.Description, 80) _, _ = sb.WriteString( - " - " + padStyle.Render(e.Description + ":")[4:] + "\n\n ", + " - " + pretty.Sprint(padStyle, e.Description+":")[4:] + "\n\n ", ) } // We add 1 space here because `cliui.DefaultStyles.Code` adds an extra // space. This makes the code block align at an even 2 or 6 // spaces for symmetry. - _, _ = sb.WriteString(" " + cliui.DefaultStyles.Code.Render(fmt.Sprintf("$ %s", e.Command))) + _, _ = sb.WriteString(" " + pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("$ %s", e.Command))) if i < len(examples)-1 { _, _ = sb.WriteString("\n\n") } @@ -760,8 +820,8 @@ func (r *RootCmd) checkVersions(i *clibase.Invocation, client *codersdk.Client) } if !buildinfo.VersionsMatch(clientVersion, info.Version) { - warn := cliui.DefaultStyles.Warn.Copy().Align(lipgloss.Left) - _, _ = fmt.Fprintf(i.Stderr, warn.Render(fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v")) + warn := cliui.DefaultStyles.Warn + _, _ = fmt.Fprintf(i.Stderr, pretty.Sprint(warn, fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v")) _, _ = fmt.Fprintln(i.Stderr) } @@ -776,15 +836,30 @@ func (r *RootCmd) checkWarnings(i *clibase.Invocation, client *codersdk.Client) ctx, cancel := context.WithTimeout(i.Context(), 10*time.Second) defer cancel() + user, err := client.User(ctx, codersdk.Me) + if err != nil { + return xerrors.Errorf("get user me: %w", err) + } + entitlements, err := client.Entitlements(ctx) if err == nil { - for _, w := range entitlements.Warnings { - _, _ = fmt.Fprintln(i.Stderr, cliui.DefaultStyles.Warn.Render(w)) + // Don't show warning to regular users. + if len(user.Roles) > 0 { + for _, w := range entitlements.Warnings { + _, _ = fmt.Fprintln(i.Stderr, pretty.Sprint(cliui.DefaultStyles.Warn, w)) + } } } return nil } +// Verbosef logs a message if verbose mode is enabled. +func (r *RootCmd) Verbosef(inv *clibase.Invocation, fmtStr string, args ...interface{}) { + if r.verbose { + cliui.Infof(inv.Stdout, fmtStr, args...) + } +} + type headerTransport struct { transport http.RoundTripper header http.Header @@ -908,63 +983,157 @@ func isConnectionError(err error) bool { type prettyErrorFormatter struct { w io.Writer + // verbose turns on more detailed error logs, such as stack traces. + verbose bool } +// format formats the error to the console. This error should be human +// readable. func (p *prettyErrorFormatter) format(err error) { - errTail := errors.Unwrap(err) + output := cliHumanFormatError(err, &formatOpts{ + Verbose: p.verbose, + }) + // always trail with a newline + _, _ = p.w.Write([]byte(output + "\n")) +} - //nolint:errorlint - if _, ok := err.(*clibase.RunCommandError); ok && errTail != nil { - // Avoid extra nesting. - p.format(errTail) - return +type formatOpts struct { + Verbose bool +} + +const indent = " " + +// cliHumanFormatError formats an error for the CLI. Newlines and styling are +// included. +func cliHumanFormatError(err error, opts *formatOpts) string { + if opts == nil { + opts = &formatOpts{} } - var headErr string - if errTail != nil { - headErr = strings.TrimSuffix(err.Error(), ": "+errTail.Error()) - } else { - headErr = err.Error() + //nolint:errorlint + if multi, ok := err.(interface{ Unwrap() []error }); ok { + multiErrors := multi.Unwrap() + if len(multiErrors) == 1 { + // Format as a single error + return cliHumanFormatError(multiErrors[0], opts) + } + return formatMultiError(multiErrors, opts) } - var msg string + // First check for sentinel errors that we want to handle specially. + // Order does matter! We want to check for the most specific errors first. var sdkError *codersdk.Error if errors.As(err, &sdkError) { - // We don't want to repeat the same error message twice, so we - // only show the SDK error on the top of the stack. - msg = sdkError.Message - if sdkError.Helper != "" { - msg = msg + "\n" + sdkError.Helper + return formatCoderSDKError(sdkError, opts) + } + + var cmdErr *clibase.RunCommandError + if errors.As(err, &cmdErr) { + return formatRunCommandError(cmdErr, opts) + } + + // Default just printing the error. Use +v for verbose to handle stack + // traces of xerrors. + if opts.Verbose { + return pretty.Sprint(headLineStyle(), fmt.Sprintf("%+v", err)) + } + + return pretty.Sprint(headLineStyle(), fmt.Sprintf("%v", err)) +} + +// formatMultiError formats a multi-error. It formats it as a list of errors. +// +// Multiple Errors: +// <# errors encountered>: +// 1. +// +// 2. +// +func formatMultiError(multi []error, opts *formatOpts) string { + var errorStrings []string + for _, err := range multi { + errorStrings = append(errorStrings, cliHumanFormatError(err, opts)) + } + + // Write errors out + var str strings.Builder + _, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("%d errors encountered:", len(multi)))) + for i, errStr := range errorStrings { + // Indent each error + errStr = strings.ReplaceAll(errStr, "\n", "\n"+indent) + // Error now looks like + // | + // | + prefix := fmt.Sprintf("%d. ", i+1) + if len(prefix) < len(indent) { + // Indent the prefix to match the indent + prefix = prefix + strings.Repeat(" ", len(indent)-len(prefix)) } - // The SDK error is usually good enough, and we don't want to overwhelm - // the user with output. - errTail = nil - } else { - msg = headErr + errStr = prefix + errStr + // Now looks like + // |1. + // | + _, _ = str.WriteString("\n" + errStr) } + return str.String() +} - headStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#D16644")) - p.printf( - headStyle, - "%s", - msg, - ) +// formatRunCommandError are cli command errors. This kind of error is very +// broad, as it contains all errors that occur when running a command. +// If you know the error is something else, like a codersdk.Error, make a new +// formatter and add it to cliHumanFormatError function. +func formatRunCommandError(err *clibase.RunCommandError, opts *formatOpts) string { + var str strings.Builder + _, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("Encountered an error running %q", err.Cmd.FullName()))) + + msgString := fmt.Sprintf("%v", err.Err) + if opts.Verbose { + // '%+v' includes stack traces + msgString = fmt.Sprintf("%+v", err.Err) + } + _, _ = str.WriteString("\n") + _, _ = str.WriteString(pretty.Sprint(tailLineStyle(), msgString)) + return str.String() +} - tailStyle := headStyle.Copy().Foreground(lipgloss.Color("#969696")) +// formatCoderSDKError come from API requests. In verbose mode, add the +// request debug information. +func formatCoderSDKError(err *codersdk.Error, opts *formatOpts) string { + var str strings.Builder + if opts.Verbose { + _, _ = str.WriteString(pretty.Sprint(headLineStyle(), fmt.Sprintf("API request error to \"%s:%s\". Status code %d", err.Method(), err.URL(), err.StatusCode()))) + _, _ = str.WriteString("\n") + } - if errTail != nil { - p.printf(headStyle, ": ") - // Grey out the less important, deep errors. - p.printf(tailStyle, "%s", errTail.Error()) + _, _ = str.WriteString(pretty.Sprint(headLineStyle(), err.Message)) + if err.Helper != "" { + _, _ = str.WriteString("\n") + _, _ = str.WriteString(pretty.Sprint(tailLineStyle(), err.Helper)) } - p.printf(tailStyle, "\n") + // By default we do not show the Detail with the helper. + if opts.Verbose || (err.Helper == "" && err.Detail != "") { + _, _ = str.WriteString("\n") + _, _ = str.WriteString(pretty.Sprint(tailLineStyle(), err.Detail)) + } + return str.String() } -func (p *prettyErrorFormatter) printf(style lipgloss.Style, format string, a ...interface{}) { - s := style.Render(fmt.Sprintf(format, a...)) - _, _ = p.w.Write( - []byte( - s, - ), - ) +// These styles are arbitrary. +func headLineStyle() pretty.Style { + return cliui.DefaultStyles.Error +} + +func tailLineStyle() pretty.Style { + return pretty.Style{pretty.Nop} +} + +//nolint:unused +func SlimUnsupported(w io.Writer, cmd string) { + _, _ = fmt.Fprintf(w, "You are using a 'slim' build of Coder, which does not support the %s subcommand.\n", pretty.Sprint(cliui.DefaultStyles.Code, cmd)) + _, _ = fmt.Fprintln(w, "") + _, _ = fmt.Fprintln(w, "Please use a build of Coder from GitHub releases:") + _, _ = fmt.Fprintln(w, " https://github.com/coder/coder/releases") + + //nolint:revive + os.Exit(1) } diff --git a/cli/root_internal_test.go b/cli/root_internal_test.go index e8c463e95cc90..2d99ab8247518 100644 --- a/cli/root_internal_test.go +++ b/cli/root_internal_test.go @@ -1,6 +1,8 @@ package cli import ( + "os" + "runtime" "testing" "github.com/stretchr/testify/require" @@ -67,6 +69,11 @@ func Test_formatExamples(t *testing.T) { } func TestMain(m *testing.M) { + if runtime.GOOS == "windows" { + // Don't run goleak on windows tests, they're super flaky right now. + // See: https://github.com/coder/coder/issues/8954 + os.Exit(m.Run()) + } goleak.VerifyTestMain(m, // The lumberjack library is used by by agent and seems to leave // goroutines after Close(), fails TestGitSSH tests. diff --git a/cli/root_test.go b/cli/root_test.go index c892701a3acbc..4d95e5381b578 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -5,23 +5,24 @@ import ( "fmt" "net/http" "net/http/httptest" + "runtime" "strings" "sync/atomic" "testing" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/coderd" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/cli" - "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/cli/clitest" ) //nolint:tparallel,paralleltest @@ -72,20 +73,29 @@ func TestRoot(t *testing.T) { t.Run("Header", func(t *testing.T) { t.Parallel() + var url string var called int64 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&called, 1) assert.Equal(t, "wow", r.Header.Get("X-Testing")) assert.Equal(t, "Dean was Here!", r.Header.Get("Cool-Header")) + assert.Equal(t, "very-wow-"+url, r.Header.Get("X-Process-Testing")) + assert.Equal(t, "more-wow", r.Header.Get("X-Process-Testing2")) w.WriteHeader(http.StatusGone) })) defer srv.Close() + url = srv.URL buf := new(bytes.Buffer) + coderURLEnv := "$CODER_URL" + if runtime.GOOS == "windows" { + coderURLEnv = "%CODER_URL%" + } inv, _ := clitest.New(t, "--no-feature-warning", "--no-version-warning", "--header", "X-Testing=wow", "--header", "Cool-Header=Dean was Here!", + "--header-command", "printf X-Process-Testing=very-wow-"+coderURLEnv+"'\\r\\n'X-Process-Testing2=more-wow", "login", srv.URL, ) inv.Stdout = buf @@ -97,14 +107,18 @@ func TestRoot(t *testing.T) { }) } -// TestDERPHeaders ensures that the client sends the global `--header`s to the -// DERP server when connecting. +// TestDERPHeaders ensures that the client sends the global `--header`s and +// `--header-command` to the DERP server when connecting. func TestDERPHeaders(t *testing.T) { t.Parallel() // Create a coderd API instance the hard way since we need to change the // handler to inject our custom /derp handler. - setHandler, cancelFunc, serverURL, newOptions := coderdtest.NewOptions(t, nil) + dv := coderdtest.DeploymentValues(t) + dv.DERP.Config.BlockDirect = true + setHandler, cancelFunc, serverURL, newOptions := coderdtest.NewOptions(t, &coderdtest.Options{ + DeploymentValues: dv, + }) // We set the handler after server creation for the access URL. coderAPI := coderd.New(newOptions) @@ -122,15 +136,17 @@ func TestDERPHeaders(t *testing.T) { }) var ( - user = coderdtest.CreateFirstUser(t, client) - workspace = runAgent(t, client, user.UserID) + admin = coderdtest.CreateFirstUser(t, client) + member, _ = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + workspace = runAgent(t, client, member) ) // Inject custom /derp handler so we can inspect the headers. var ( expectedHeaders = map[string]string{ - "X-Test-Header": "test-value", - "Cool-Header": "Dean was Here!", + "X-Test-Header": "test-value", + "Cool-Header": "Dean was Here!", + "X-Process-Testing": "very-wow", } derpCalled int64 ) @@ -155,16 +171,20 @@ func TestDERPHeaders(t *testing.T) { // Connect with the headers set as args. args := []string{ + "-v", "--no-feature-warning", "--no-version-warning", "ping", workspace.Name, "-n", "1", + "--header-command", "printf X-Process-Testing=very-wow", } for k, v := range expectedHeaders { - args = append(args, "--header", fmt.Sprintf("%s=%s", k, v)) + if k != "X-Process-Testing" { + args = append(args, "--header", fmt.Sprintf("%s=%s", k, v)) + } } inv, root := clitest.New(t, args...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) pty := ptytest.New(t) inv.Stdin = pty.Input() inv.Stderr = pty.Output() diff --git a/cli/schedule.go b/cli/schedule.go index 8fff0121ae8db..6b0f105875c80 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -8,12 +8,12 @@ import ( "github.com/jedib0t/go-pretty/v6/table" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/coderd/util/tz" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/schedule/cron" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/util/tz" + "github.com/coder/coder/v2/codersdk" ) const ( @@ -255,7 +255,7 @@ func displaySchedule(workspace codersdk.Workspace, out io.Writer) error { schedNextStop = "-" ) if !ptr.NilOrEmpty(workspace.AutostartSchedule) { - sched, err := schedule.Weekly(ptr.NilToEmpty(workspace.AutostartSchedule)) + sched, err := cron.Weekly(ptr.NilToEmpty(workspace.AutostartSchedule)) if err != nil { // This should never happen. _, _ = fmt.Fprintf(out, "Invalid autostart schedule %q for workspace %s: %s\n", *workspace.AutostartSchedule, workspace.Name, err.Error()) diff --git a/cli/schedule_test.go b/cli/schedule_test.go index d1e6fe2da543f..dfb992976bc62 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -11,11 +11,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" ) func TestScheduleShow(t *testing.T) { @@ -31,13 +31,13 @@ func TestScheduleShow(t *testing.T) { client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.AutostartSchedule = ptr.Ref(schedCron) cwr.TTLMillis = ptr.Ref(ttl.Milliseconds()) }) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) cmdArgs = []string{"schedule", "show", workspace.Name} stdoutBuf = &bytes.Buffer{} ) @@ -68,13 +68,13 @@ func TestScheduleShow(t *testing.T) { client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.AutostartSchedule = nil cwr.TTLMillis = nil }) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) cmdArgs = []string{"schedule", "show", workspace.Name} stdoutBuf = &bytes.Buffer{} ) @@ -101,7 +101,7 @@ func TestScheduleShow(t *testing.T) { client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) ) inv, root := clitest.New(t, "schedule", "show", "doesnotexist") @@ -120,12 +120,12 @@ func TestScheduleStart(t *testing.T) { client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.AutostartSchedule = nil }) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) tz = "Europe/Dublin" sched = "CRON_TZ=Europe/Dublin 30 9 * * Mon-Fri" stdoutBuf = &bytes.Buffer{} @@ -177,11 +177,11 @@ func TestScheduleStop(t *testing.T) { client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) ttl = 8*time.Hour + 30*time.Minute workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) stdoutBuf = &bytes.Buffer{} ) @@ -230,7 +230,7 @@ func TestScheduleOverride(t *testing.T) { client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) cmdArgs = []string{"schedule", "override-stop", workspace.Name, "10h"} @@ -238,7 +238,7 @@ func TestScheduleOverride(t *testing.T) { ) // Given: we wait for the workspace to be built - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) workspace, err = client.Workspace(ctx, workspace.ID) require.NoError(t, err) expectedDeadline := time.Now().Add(10 * time.Hour) @@ -271,7 +271,7 @@ func TestScheduleOverride(t *testing.T) { client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) cmdArgs = []string{"schedule", "override-stop", workspace.Name, "kwyjibo"} @@ -279,7 +279,7 @@ func TestScheduleOverride(t *testing.T) { ) // Given: we wait for the workspace to be built - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) workspace, err = client.Workspace(ctx, workspace.ID) require.NoError(t, err) @@ -307,7 +307,7 @@ func TestScheduleOverride(t *testing.T) { client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.TTLMillis = nil @@ -316,8 +316,8 @@ func TestScheduleOverride(t *testing.T) { stdoutBuf = &bytes.Buffer{} ) require.Zero(t, template.DefaultTTLMillis) - require.Empty(t, template.RestartRequirement.DaysOfWeek) - require.Zero(t, template.RestartRequirement.Weeks) + require.Empty(t, template.AutostopRequirement.DaysOfWeek) + require.EqualValues(t, 1, template.AutostopRequirement.Weeks) // Unset the workspace TTL err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}) @@ -327,7 +327,7 @@ func TestScheduleOverride(t *testing.T) { require.Nil(t, workspace.TTLMillis) // Given: we wait for the workspace to build - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) workspace, err = client.Workspace(ctx, workspace.ID) require.NoError(t, err) @@ -362,7 +362,7 @@ func TestScheduleStartDefaults(t *testing.T) { client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.AutostartSchedule = nil diff --git a/cli/server.go b/cli/server.go index 170b7c5eb9f00..61755840382e1 100644 --- a/cli/server.go +++ b/cli/server.go @@ -41,7 +41,8 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/spf13/afero" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/trace" "golang.org/x/mod/semver" "golang.org/x/oauth2" @@ -53,140 +54,216 @@ import ( "gopkg.in/yaml.v3" "tailscale.com/tailcfg" + "github.com/coder/pretty" + "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" "cdr.dev/slog/sloggers/slogjson" "cdr.dev/slog/sloggers/slogstackdriver" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/cli/config" - "github.com/coder/coder/coderd" - "github.com/coder/coder/coderd/autobuild" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbmetrics" - "github.com/coder/coder/coderd/database/dbpurge" - "github.com/coder/coder/coderd/database/migrations" - "github.com/coder/coder/coderd/database/pubsub" - "github.com/coder/coder/coderd/devtunnel" - "github.com/coder/coder/coderd/dormancy" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/gitsshkey" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/prometheusmetrics" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/coderd/unhanger" - "github.com/coder/coder/coderd/updatecheck" - "github.com/coder/coder/coderd/util/slice" - "github.com/coder/coder/coderd/workspaceapps" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisioner/terraform" - "github.com/coder/coder/provisionerd" - "github.com/coder/coder/provisionerd/proto" - "github.com/coder/coder/provisionersdk" - sdkproto "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/tailnet" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/config" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/autobuild" + "github.com/coder/coder/v2/coderd/batchstats" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmem" + "github.com/coder/coder/v2/coderd/database/dbmetrics" + "github.com/coder/coder/v2/coderd/database/dbpurge" + "github.com/coder/coder/v2/coderd/database/migrations" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/devtunnel" + "github.com/coder/coder/v2/coderd/externalauth" + "github.com/coder/coder/v2/coderd/gitsshkey" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/oauthpki" + "github.com/coder/coder/v2/coderd/prometheusmetrics" + "github.com/coder/coder/v2/coderd/prometheusmetrics/insights" + "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/coderd/unhanger" + "github.com/coder/coder/v2/coderd/updatecheck" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/coderd/workspaceapps" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisioner/terraform" + "github.com/coder/coder/v2/provisionerd" + "github.com/coder/coder/v2/provisionerd/proto" + "github.com/coder/coder/v2/provisionersdk" + sdkproto "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/tailnet" "github.com/coder/retry" "github.com/coder/wgtunnel/tunnelsdk" ) -// ReadGitAuthProvidersFromEnv is provided for compatibility purposes with the -// viper CLI. -// DEPRECATED -func ReadGitAuthProvidersFromEnv(environ []string) ([]codersdk.GitAuthConfig, error) { - // The index numbers must be in-order. - sort.Strings(environ) +func createOIDCConfig(ctx context.Context, vals *codersdk.DeploymentValues) (*coderd.OIDCConfig, error) { + if vals.OIDC.ClientID == "" { + return nil, xerrors.Errorf("OIDC client ID must be set!") + } + if vals.OIDC.IssuerURL == "" { + return nil, xerrors.Errorf("OIDC issuer URL must be set!") + } - var providers []codersdk.GitAuthConfig - for _, v := range clibase.ParseEnviron(environ, "CODER_GITAUTH_") { - tokens := strings.SplitN(v.Name, "_", 2) - if len(tokens) != 2 { - return nil, xerrors.Errorf("invalid env var: %s", v.Name) + oidcProvider, err := oidc.NewProvider( + ctx, vals.OIDC.IssuerURL.String(), + ) + if err != nil { + return nil, xerrors.Errorf("configure oidc provider: %w", err) + } + redirectURL, err := vals.AccessURL.Value().Parse("/api/v2/users/oidc/callback") + if err != nil { + return nil, xerrors.Errorf("parse oidc oauth callback url: %w", err) + } + // If the scopes contain 'groups', we enable group support. + // Do not override any custom value set by the user. + if slice.Contains(vals.OIDC.Scopes, "groups") && vals.OIDC.GroupField == "" { + vals.OIDC.GroupField = "groups" + } + oauthCfg := &oauth2.Config{ + ClientID: vals.OIDC.ClientID.String(), + ClientSecret: vals.OIDC.ClientSecret.String(), + RedirectURL: redirectURL.String(), + Endpoint: oidcProvider.Endpoint(), + Scopes: vals.OIDC.Scopes, + } + + var useCfg httpmw.OAuth2Config = oauthCfg + if vals.OIDC.ClientKeyFile != "" { + // PKI authentication is done in the params. If a + // counter example is found, we can add a config option to + // change this. + oauthCfg.Endpoint.AuthStyle = oauth2.AuthStyleInParams + if vals.OIDC.ClientSecret != "" { + return nil, xerrors.Errorf("cannot specify both oidc client secret and oidc client key file") } - providerNum, err := strconv.Atoi(tokens[0]) + pkiCfg, err := configureOIDCPKI(oauthCfg, vals.OIDC.ClientKeyFile.Value(), vals.OIDC.ClientCertFile.Value()) if err != nil { - return nil, xerrors.Errorf("parse number: %s", v.Name) + return nil, xerrors.Errorf("configure oauth pki authentication: %w", err) } + useCfg = pkiCfg + } + return &coderd.OIDCConfig{ + OAuth2Config: useCfg, + Provider: oidcProvider, + Verifier: oidcProvider.Verifier(&oidc.Config{ + ClientID: vals.OIDC.ClientID.String(), + }), + EmailDomain: vals.OIDC.EmailDomain, + AllowSignups: vals.OIDC.AllowSignups.Value(), + UsernameField: vals.OIDC.UsernameField.String(), + EmailField: vals.OIDC.EmailField.String(), + AuthURLParams: vals.OIDC.AuthURLParams.Value, + IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(), + GroupField: vals.OIDC.GroupField.String(), + GroupFilter: vals.OIDC.GroupRegexFilter.Value(), + CreateMissingGroups: vals.OIDC.GroupAutoCreate.Value(), + GroupMapping: vals.OIDC.GroupMapping.Value, + UserRoleField: vals.OIDC.UserRoleField.String(), + UserRoleMapping: vals.OIDC.UserRoleMapping.Value, + UserRolesDefault: vals.OIDC.UserRolesDefault.GetSlice(), + SignInText: vals.OIDC.SignInText.String(), + IconURL: vals.OIDC.IconURL.String(), + IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(), + }, nil +} - var provider codersdk.GitAuthConfig - switch { - case len(providers) < providerNum: - return nil, xerrors.Errorf( - "provider num %v skipped: %s", - len(providers), - v.Name, - ) - case len(providers) == providerNum: - // At the next next provider. - providers = append(providers, provider) - case len(providers) == providerNum+1: - // At the current provider. - provider = providers[providerNum] +func afterCtx(ctx context.Context, fn func()) { + go func() { + <-ctx.Done() + fn() + }() +} + +func enablePrometheus( + ctx context.Context, + logger slog.Logger, + vals *codersdk.DeploymentValues, + options *coderd.Options, +) (closeFn func(), err error) { + options.PrometheusRegistry.MustRegister(collectors.NewGoCollector()) + options.PrometheusRegistry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + + closeUsersFunc, err := prometheusmetrics.ActiveUsers(ctx, options.PrometheusRegistry, options.Database, 0) + if err != nil { + return nil, xerrors.Errorf("register active users prometheus metric: %w", err) + } + afterCtx(ctx, closeUsersFunc) + + closeWorkspacesFunc, err := prometheusmetrics.Workspaces(ctx, options.PrometheusRegistry, options.Database, 0) + if err != nil { + return nil, xerrors.Errorf("register workspaces prometheus metric: %w", err) + } + afterCtx(ctx, closeWorkspacesFunc) + + insightsMetricsCollector, err := insights.NewMetricsCollector(options.Database, options.Logger, 0, 0) + if err != nil { + return nil, xerrors.Errorf("unable to initialize insights metrics collector: %w", err) + } + err = options.PrometheusRegistry.Register(insightsMetricsCollector) + if err != nil { + return nil, xerrors.Errorf("unable to register insights metrics collector: %w", err) + } + + closeInsightsMetricsCollector, err := insightsMetricsCollector.Run(ctx) + if err != nil { + return nil, xerrors.Errorf("unable to run insights metrics collector: %w", err) + } + afterCtx(ctx, closeInsightsMetricsCollector) + + if vals.Prometheus.CollectAgentStats { + closeAgentStatsFunc, err := prometheusmetrics.AgentStats(ctx, logger, options.PrometheusRegistry, options.Database, time.Now(), 0) + if err != nil { + return nil, xerrors.Errorf("register agent stats prometheus metric: %w", err) } + afterCtx(ctx, closeAgentStatsFunc) - key := tokens[1] - switch key { - case "ID": - provider.ID = v.Value - case "TYPE": - provider.Type = v.Value - case "CLIENT_ID": - provider.ClientID = v.Value - case "CLIENT_SECRET": - provider.ClientSecret = v.Value - case "AUTH_URL": - provider.AuthURL = v.Value - case "TOKEN_URL": - provider.TokenURL = v.Value - case "VALIDATE_URL": - provider.ValidateURL = v.Value - case "REGEX": - provider.Regex = v.Value - case "DEVICE_FLOW": - b, err := strconv.ParseBool(v.Value) - if err != nil { - return nil, xerrors.Errorf("parse bool: %s", v.Value) - } - provider.DeviceFlow = b - case "DEVICE_CODE_URL": - provider.DeviceCodeURL = v.Value - case "NO_REFRESH": - b, err := strconv.ParseBool(v.Value) - if err != nil { - return nil, xerrors.Errorf("parse bool: %s", v.Value) - } - provider.NoRefresh = b - case "SCOPES": - provider.Scopes = strings.Split(v.Value, " ") - case "APP_INSTALL_URL": - provider.AppInstallURL = v.Value - case "APP_INSTALLATIONS_URL": - provider.AppInstallationsURL = v.Value + metricsAggregator, err := prometheusmetrics.NewMetricsAggregator(logger, options.PrometheusRegistry, 0) + if err != nil { + return nil, xerrors.Errorf("can't initialize metrics aggregator: %w", err) + } + + cancelMetricsAggregator := metricsAggregator.Run(ctx) + afterCtx(ctx, cancelMetricsAggregator) + + options.UpdateAgentMetrics = metricsAggregator.Update + err = options.PrometheusRegistry.Register(metricsAggregator) + if err != nil { + return nil, xerrors.Errorf("can't register metrics aggregator as collector: %w", err) } - providers[providerNum] = provider } - return providers, nil + + //nolint:revive + return ServeHandler( + ctx, logger, promhttp.InstrumentMetricHandler( + options.PrometheusRegistry, promhttp.HandlerFor(options.PrometheusRegistry, promhttp.HandlerOpts{}), + ), vals.Prometheus.Address.String(), "prometheus", + ), nil } -// nolint:gocyclo func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, io.Closer, error)) *clibase.Cmd { + if newAPI == nil { + newAPI = func(_ context.Context, o *coderd.Options) (*coderd.API, io.Closer, error) { + api := coderd.New(o) + return api, api, nil + } + } + var ( - cfg = new(codersdk.DeploymentValues) - opts = cfg.Options() + vals = new(codersdk.DeploymentValues) + opts = vals.Options() ) serverCmd := &clibase.Cmd{ Use: "server", Short: "Start a Coder server", Options: opts, Middleware: clibase.Chain( - WriteConfigMW(cfg), + WriteConfigMW(vals), PrintDeprecatedOptions(), clibase.RequireNArgs(0), ), @@ -196,32 +273,32 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. ctx, cancel := context.WithCancel(inv.Context()) defer cancel() - if cfg.Config != "" { + if vals.Config != "" { cliui.Warnf(inv.Stderr, "YAML support is experimental and offers no compatibility guarantees.") } go DumpHandler(ctx) // Validate bind addresses. - if cfg.Address.String() != "" { - if cfg.TLS.Enable { - cfg.HTTPAddress = "" - cfg.TLS.Address = cfg.Address + if vals.Address.String() != "" { + if vals.TLS.Enable { + vals.HTTPAddress = "" + vals.TLS.Address = vals.Address } else { - _ = cfg.HTTPAddress.Set(cfg.Address.String()) - cfg.TLS.Address.Host = "" - cfg.TLS.Address.Port = "" + _ = vals.HTTPAddress.Set(vals.Address.String()) + vals.TLS.Address.Host = "" + vals.TLS.Address.Port = "" } } - if cfg.TLS.Enable && cfg.TLS.Address.String() == "" { + if vals.TLS.Enable && vals.TLS.Address.String() == "" { return xerrors.Errorf("TLS address must be set if TLS is enabled") } - if !cfg.TLS.Enable && cfg.HTTPAddress.String() == "" { + if !vals.TLS.Enable && vals.HTTPAddress.String() == "" { return xerrors.Errorf("TLS is disabled. Enable with --tls-enable or specify a HTTP address") } - if cfg.AccessURL.String() != "" && - !(cfg.AccessURL.Scheme == "http" || cfg.AccessURL.Scheme == "https") { + if vals.AccessURL.String() != "" && + !(vals.AccessURL.Scheme == "http" || vals.AccessURL.Scheme == "https") { return xerrors.Errorf("access-url must include a scheme (e.g. 'http://' or 'https://)") } @@ -229,14 +306,14 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // was specified. loginRateLimit := 60 filesRateLimit := 12 - if cfg.RateLimit.DisableAll { - cfg.RateLimit.API = -1 + if vals.RateLimit.DisableAll { + vals.RateLimit.API = -1 loginRateLimit = -1 filesRateLimit = -1 } PrintLogo(inv, "Coder") - logger, logCloser, err := BuildLogger(inv, cfg) + logger, logCloser, err := BuildLogger(inv, vals) if err != nil { return xerrors.Errorf("make logger: %w", err) } @@ -259,7 +336,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. notifyCtx, notifyStop := signal.NotifyContext(ctx, InterruptSignals...) defer notifyStop() - cacheDir := cfg.CacheDir.String() + cacheDir := vals.CacheDir.String() err = os.MkdirAll(cacheDir, 0o700) if err != nil { return xerrors.Errorf("create cache directory: %w", err) @@ -270,14 +347,14 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // which is caught by goleaks. defer http.DefaultClient.CloseIdleConnections() - tracerProvider, sqlDriver, closeTracing := ConfigureTraceProvider(ctx, logger, inv, cfg) + tracerProvider, sqlDriver, closeTracing := ConfigureTraceProvider(ctx, logger, vals) defer func() { logger.Debug(ctx, "closing tracing") traceCloseErr := shutdownWithTimeout(closeTracing, 5*time.Second) logger.Debug(ctx, "tracing closed", slog.Error(traceCloseErr)) }() - httpServers, err := ConfigureHTTPServers(inv, cfg) + httpServers, err := ConfigureHTTPServers(inv, vals) if err != nil { return xerrors.Errorf("configure http(s): %w", err) } @@ -287,7 +364,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. builtinPostgres := false // Only use built-in if PostgreSQL URL isn't specified! - if !cfg.InMemoryDatabase && cfg.PostgresURL == "" { + if !vals.InMemoryDatabase && vals.PostgresURL == "" { var closeFunc func() error cliui.Infof(inv.Stdout, "Using built-in PostgreSQL (%s)", config.PostgresPath()) pgURL, closeFunc, err := startBuiltinPostgres(ctx, config, logger) @@ -295,7 +372,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return err } - err = cfg.PostgresURL.Set(pgURL) + err = vals.PostgresURL.Set(pgURL) if err != nil { return err } @@ -319,9 +396,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. ctx, httpClient, err := ConfigureHTTPClient( ctx, - cfg.TLS.ClientCertFile.String(), - cfg.TLS.ClientKeyFile.String(), - cfg.TLS.ClientCAFile.String(), + vals.TLS.ClientCertFile.String(), + vals.TLS.ClientKeyFile.String(), + vals.TLS.ClientCAFile.String(), ) if err != nil { return xerrors.Errorf("configure http client: %w", err) @@ -333,30 +410,30 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. tunnel *tunnelsdk.Tunnel tunnelDone <-chan struct{} = make(chan struct{}, 1) ) - if cfg.AccessURL.String() == "" { + if vals.AccessURL.String() == "" { cliui.Infof(inv.Stderr, "Opening tunnel so workspaces can connect to your deployment. For production scenarios, specify an external access URL") - tunnel, err = devtunnel.New(ctx, logger.Named("net.devtunnel"), cfg.WgtunnelHost.String()) + tunnel, err = devtunnel.New(ctx, logger.Named("net.devtunnel"), vals.WgtunnelHost.String()) if err != nil { return xerrors.Errorf("create tunnel: %w", err) } defer tunnel.Close() tunnelDone = tunnel.Wait() - cfg.AccessURL = clibase.URL(*tunnel.URL) + vals.AccessURL = clibase.URL(*tunnel.URL) - if cfg.WildcardAccessURL.String() == "" { + if vals.WildcardAccessURL.String() == "" { // Suffixed wildcard access URL. u, err := url.Parse(fmt.Sprintf("*--%s", tunnel.URL.Hostname())) if err != nil { return xerrors.Errorf("parse wildcard url: %w", err) } - cfg.WildcardAccessURL = clibase.URL(*u) + vals.WildcardAccessURL = clibase.URL(*u) } } - _, accessURLPortRaw, _ := net.SplitHostPort(cfg.AccessURL.Host) + _, accessURLPortRaw, _ := net.SplitHostPort(vals.AccessURL.Host) if accessURLPortRaw == "" { accessURLPortRaw = "80" - if cfg.AccessURL.Scheme == "https" { + if vals.AccessURL.Scheme == "https" { accessURLPortRaw = "443" } } @@ -366,8 +443,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("parse access URL port: %w", err) } - // Warn the user if the access URL appears to be a loopback address. - isLocal, err := IsLocalURL(ctx, cfg.AccessURL.Value()) + // Warn the user if the access URL is loopback or unresolvable. + isLocal, err := IsLocalURL(ctx, vals.AccessURL.Value()) if isLocal || err != nil { reason := "could not be resolved" if isLocal { @@ -376,12 +453,12 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. cliui.Warnf( inv.Stderr, "The access URL %s %s, this may cause unexpected problems when creating workspaces. Generate a unique *.try.coder.app URL by not specifying an access URL.\n", - cliui.DefaultStyles.Field.Render(cfg.AccessURL.String()), reason, + pretty.Sprint(cliui.DefaultStyles.Field, vals.AccessURL.String()), reason, ) } // A newline is added before for visibility in terminal output. - cliui.Infof(inv.Stdout, "\nView the Web UI: %s", cfg.AccessURL.String()) + cliui.Infof(inv.Stdout, "\nView the Web UI: %s", vals.AccessURL.String()) // Used for zero-trust instance identity with Google Cloud. googleTokenValidator, err := idtoken.NewValidator(ctx, option.WithoutAuthentication()) @@ -389,51 +466,39 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return err } - sshKeygenAlgorithm, err := gitsshkey.ParseAlgorithm(cfg.SSHKeygenAlgorithm.String()) + sshKeygenAlgorithm, err := gitsshkey.ParseAlgorithm(vals.SSHKeygenAlgorithm.String()) if err != nil { - return xerrors.Errorf("parse ssh keygen algorithm %s: %w", cfg.SSHKeygenAlgorithm, err) + return xerrors.Errorf("parse ssh keygen algorithm %s: %w", vals.SSHKeygenAlgorithm, err) } defaultRegion := &tailcfg.DERPRegion{ EmbeddedRelay: true, - RegionID: int(cfg.DERP.Server.RegionID.Value()), - RegionCode: cfg.DERP.Server.RegionCode.String(), - RegionName: cfg.DERP.Server.RegionName.String(), + RegionID: int(vals.DERP.Server.RegionID.Value()), + RegionCode: vals.DERP.Server.RegionCode.String(), + RegionName: vals.DERP.Server.RegionName.String(), Nodes: []*tailcfg.DERPNode{{ - Name: fmt.Sprintf("%db", cfg.DERP.Server.RegionID), - RegionID: int(cfg.DERP.Server.RegionID.Value()), - HostName: cfg.AccessURL.Value().Hostname(), + Name: fmt.Sprintf("%db", vals.DERP.Server.RegionID), + RegionID: int(vals.DERP.Server.RegionID.Value()), + HostName: vals.AccessURL.Value().Hostname(), DERPPort: accessURLPort, STUNPort: -1, - ForceHTTP: cfg.AccessURL.Scheme == "http", + ForceHTTP: vals.AccessURL.Scheme == "http", }}, } - if !cfg.DERP.Server.Enable { + if !vals.DERP.Server.Enable { defaultRegion = nil } - // HACK: see https://github.com/coder/coder/issues/6791. - for _, addr := range cfg.DERP.Server.STUNAddresses { - if addr != "disable" { - continue - } - err := cfg.DERP.Server.STUNAddresses.Replace(nil) - if err != nil { - panic(err) - } - break - } - derpMap, err := tailnet.NewDERPMap( - ctx, defaultRegion, cfg.DERP.Server.STUNAddresses, - cfg.DERP.Config.URL.String(), cfg.DERP.Config.Path.String(), - cfg.DERP.Config.BlockDirect.Value(), + ctx, defaultRegion, vals.DERP.Server.STUNAddresses, + vals.DERP.Config.URL.String(), vals.DERP.Config.Path.String(), + vals.DERP.Config.BlockDirect.Value(), ) if err != nil { return xerrors.Errorf("create derp map: %w", err) } - appHostname := cfg.WildcardAccessURL.String() + appHostname := vals.WildcardAccessURL.String() var appHostnameRegex *regexp.Regexp if appHostname != "" { appHostnameRegex, err = httpapi.CompileHostnamePattern(appHostname) @@ -442,64 +507,68 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } } - gitAuthEnv, err := ReadGitAuthProvidersFromEnv(os.Environ()) + extAuthEnv, err := ReadExternalAuthProvidersFromEnv(os.Environ()) if err != nil { - return xerrors.Errorf("read git auth providers from env: %w", err) + return xerrors.Errorf("read external auth providers from env: %w", err) } - cfg.GitAuthProviders.Value = append(cfg.GitAuthProviders.Value, gitAuthEnv...) - gitAuthConfigs, err := gitauth.ConvertConfig( - cfg.GitAuthProviders.Value, - cfg.AccessURL.Value(), + vals.ExternalAuthConfigs.Value = append(vals.ExternalAuthConfigs.Value, extAuthEnv...) + externalAuthConfigs, err := externalauth.ConvertConfig( + vals.ExternalAuthConfigs.Value, + vals.AccessURL.Value(), ) if err != nil { - return xerrors.Errorf("convert git auth config: %w", err) + return xerrors.Errorf("convert external auth config: %w", err) } - for _, c := range gitAuthConfigs { + for _, c := range externalAuthConfigs { logger.Debug( - ctx, "loaded git auth config", + ctx, "loaded external auth config", slog.F("id", c.ID), ) } - realIPConfig, err := httpmw.ParseRealIPConfig(cfg.ProxyTrustedHeaders, cfg.ProxyTrustedOrigins) + realIPConfig, err := httpmw.ParseRealIPConfig(vals.ProxyTrustedHeaders, vals.ProxyTrustedOrigins) if err != nil { return xerrors.Errorf("parse real ip config: %w", err) } - configSSHOptions, err := cfg.SSHConfig.ParseOptions() + configSSHOptions, err := vals.SSHConfig.ParseOptions() if err != nil { - return xerrors.Errorf("parse ssh config options %q: %w", cfg.SSHConfig.SSHConfigOptions.String(), err) + return xerrors.Errorf("parse ssh config options %q: %w", vals.SSHConfig.SSHConfigOptions.String(), err) } options := &coderd.Options{ - AccessURL: cfg.AccessURL.Value(), + AccessURL: vals.AccessURL.Value(), AppHostname: appHostname, AppHostnameRegex: appHostnameRegex, Logger: logger.Named("coderd"), - Database: dbfake.New(), + Database: dbmem.New(), BaseDERPMap: derpMap, Pubsub: pubsub.NewInMemory(), CacheDir: cacheDir, GoogleTokenValidator: googleTokenValidator, - GitAuthConfigs: gitAuthConfigs, + ExternalAuthConfigs: externalAuthConfigs, RealIPConfig: realIPConfig, - SecureAuthCookie: cfg.SecureAuthCookie.Value(), + SecureAuthCookie: vals.SecureAuthCookie.Value(), SSHKeygenAlgorithm: sshKeygenAlgorithm, TracerProvider: tracerProvider, Telemetry: telemetry.NewNoop(), - MetricsCacheRefreshInterval: cfg.MetricsCacheRefreshInterval.Value(), - AgentStatsRefreshInterval: cfg.AgentStatRefreshInterval.Value(), - DeploymentValues: cfg, + MetricsCacheRefreshInterval: vals.MetricsCacheRefreshInterval.Value(), + AgentStatsRefreshInterval: vals.AgentStatRefreshInterval.Value(), + DeploymentValues: vals, + // Do not pass secret values to DeploymentOptions. All values should be read from + // the DeploymentValues instead, this just serves to indicate the source of each + // option. This is just defensive to prevent accidentally leaking. + DeploymentOptions: codersdk.DeploymentOptionsWithoutSecrets(opts), PrometheusRegistry: prometheus.NewRegistry(), - APIRateLimit: int(cfg.RateLimit.API.Value()), + APIRateLimit: int(vals.RateLimit.API.Value()), LoginRateLimit: loginRateLimit, FilesRateLimit: filesRateLimit, HTTPClient: httpClient, TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{}, UserQuietHoursScheduleStore: &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{}, SSHConfig: codersdk.SSHConfigResponse{ - HostnamePrefix: cfg.SSHConfig.DeploymentName.String(), + HostnamePrefix: vals.SSHConfig.DeploymentName.String(), SSHConfigOptions: configSSHOptions, }, } @@ -507,16 +576,16 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.TLSCertificates = httpServers.TLSConfig.Certificates } - if cfg.StrictTransportSecurity > 0 { + if vals.StrictTransportSecurity > 0 { options.StrictTransportSecurityCfg, err = httpmw.HSTSConfigOptions( - int(cfg.StrictTransportSecurity.Value()), cfg.StrictTransportSecurityOptions, + int(vals.StrictTransportSecurity.Value()), vals.StrictTransportSecurityOptions, ) if err != nil { - return xerrors.Errorf("coderd: setting hsts header failed (options: %v): %w", cfg.StrictTransportSecurityOptions, err) + return xerrors.Errorf("coderd: setting hsts header failed (options: %v): %w", vals.StrictTransportSecurityOptions, err) } } - if cfg.UpdateCheck { + if vals.UpdateCheck { options.UpdateCheckOptions = &updatecheck.Options{ // Avoid spamming GitHub API checking for updates. Interval: 24 * time.Hour, @@ -535,83 +604,39 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } } - if cfg.OAuth2.Github.ClientSecret != "" { - options.GithubOAuth2Config, err = configureGithubOAuth2(cfg.AccessURL.Value(), - cfg.OAuth2.Github.ClientID.String(), - cfg.OAuth2.Github.ClientSecret.String(), - cfg.OAuth2.Github.AllowSignups.Value(), - cfg.OAuth2.Github.AllowEveryone.Value(), - cfg.OAuth2.Github.AllowedOrgs, - cfg.OAuth2.Github.AllowedTeams, - cfg.OAuth2.Github.EnterpriseBaseURL.String(), + if vals.OAuth2.Github.ClientSecret != "" { + options.GithubOAuth2Config, err = configureGithubOAuth2(vals.AccessURL.Value(), + vals.OAuth2.Github.ClientID.String(), + vals.OAuth2.Github.ClientSecret.String(), + vals.OAuth2.Github.AllowSignups.Value(), + vals.OAuth2.Github.AllowEveryone.Value(), + vals.OAuth2.Github.AllowedOrgs, + vals.OAuth2.Github.AllowedTeams, + vals.OAuth2.Github.EnterpriseBaseURL.String(), ) if err != nil { return xerrors.Errorf("configure github oauth2: %w", err) } } - if cfg.OIDC.ClientSecret != "" { - if cfg.OIDC.ClientID == "" { - return xerrors.Errorf("OIDC client ID be set!") - } - if cfg.OIDC.IssuerURL == "" { - return xerrors.Errorf("OIDC issuer URL must be set!") - } - - if cfg.OIDC.IgnoreEmailVerified { + if vals.OIDC.ClientKeyFile != "" || vals.OIDC.ClientSecret != "" { + if vals.OIDC.IgnoreEmailVerified { logger.Warn(ctx, "coder will not check email_verified for OIDC logins") } - oidcProvider, err := oidc.NewProvider( - ctx, cfg.OIDC.IssuerURL.String(), - ) - if err != nil { - return xerrors.Errorf("configure oidc provider: %w", err) - } - redirectURL, err := cfg.AccessURL.Value().Parse("/api/v2/users/oidc/callback") + oc, err := createOIDCConfig(ctx, vals) if err != nil { - return xerrors.Errorf("parse oidc oauth callback url: %w", err) - } - // If the scopes contain 'groups', we enable group support. - // Do not override any custom value set by the user. - if slice.Contains(cfg.OIDC.Scopes, "groups") && cfg.OIDC.GroupField == "" { - cfg.OIDC.GroupField = "groups" - } - options.OIDCConfig = &coderd.OIDCConfig{ - OAuth2Config: &oauth2.Config{ - ClientID: cfg.OIDC.ClientID.String(), - ClientSecret: cfg.OIDC.ClientSecret.String(), - RedirectURL: redirectURL.String(), - Endpoint: oidcProvider.Endpoint(), - Scopes: cfg.OIDC.Scopes, - }, - Provider: oidcProvider, - Verifier: oidcProvider.Verifier(&oidc.Config{ - ClientID: cfg.OIDC.ClientID.String(), - }), - EmailDomain: cfg.OIDC.EmailDomain, - AllowSignups: cfg.OIDC.AllowSignups.Value(), - UsernameField: cfg.OIDC.UsernameField.String(), - EmailField: cfg.OIDC.EmailField.String(), - AuthURLParams: cfg.OIDC.AuthURLParams.Value, - IgnoreUserInfo: cfg.OIDC.IgnoreUserInfo.Value(), - GroupField: cfg.OIDC.GroupField.String(), - GroupMapping: cfg.OIDC.GroupMapping.Value, - UserRoleField: cfg.OIDC.UserRoleField.String(), - UserRoleMapping: cfg.OIDC.UserRoleMapping.Value, - UserRolesDefault: cfg.OIDC.UserRolesDefault.GetSlice(), - SignInText: cfg.OIDC.SignInText.String(), - IconURL: cfg.OIDC.IconURL.String(), - IgnoreEmailVerified: cfg.OIDC.IgnoreEmailVerified.Value(), + return xerrors.Errorf("create oidc config: %w", err) } + options.OIDCConfig = oc } - if cfg.InMemoryDatabase { + if vals.InMemoryDatabase { // This is only used for testing. - options.Database = dbfake.New() + options.Database = dbmem.New() options.Pubsub = pubsub.NewInMemory() } else { - sqlDB, err := connectToPostgres(ctx, logger, sqlDriver, cfg.PostgresURL.String()) + sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, vals.PostgresURL.String()) if err != nil { return xerrors.Errorf("connect to postgres: %w", err) } @@ -620,7 +645,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. }() options.Database = database.New(sqlDB) - options.Pubsub, err = pubsub.New(ctx, sqlDB, cfg.PostgresURL.String()) + options.Pubsub, err = pubsub.New(ctx, sqlDB, vals.PostgresURL.String()) if err != nil { return xerrors.Errorf("create pubsub: %w", err) } @@ -727,10 +752,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return err } - if cfg.Telemetry.Enable { + if vals.Telemetry.Enable { gitAuth := make([]telemetry.GitAuth, 0) // TODO: - var gitAuthConfigs []codersdk.GitAuthConfig + var gitAuthConfigs []codersdk.ExternalAuthConfig for _, cfg := range gitAuthConfigs { gitAuth = append(gitAuth, telemetry.GitAuth{ Type: cfg.Type, @@ -742,79 +767,57 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. DeploymentID: deploymentID, Database: options.Database, Logger: logger.Named("telemetry"), - URL: cfg.Telemetry.URL.Value(), - Wildcard: cfg.WildcardAccessURL.String() != "", - DERPServerRelayURL: cfg.DERP.Server.RelayURL.String(), + URL: vals.Telemetry.URL.Value(), + Wildcard: vals.WildcardAccessURL.String() != "", + DERPServerRelayURL: vals.DERP.Server.RelayURL.String(), GitAuth: gitAuth, - GitHubOAuth: cfg.OAuth2.Github.ClientID != "", - OIDCAuth: cfg.OIDC.ClientID != "", - OIDCIssuerURL: cfg.OIDC.IssuerURL.String(), - Prometheus: cfg.Prometheus.Enable.Value(), - STUN: len(cfg.DERP.Server.STUNAddresses) != 0, + GitHubOAuth: vals.OAuth2.Github.ClientID != "", + OIDCAuth: vals.OIDC.ClientID != "", + OIDCIssuerURL: vals.OIDC.IssuerURL.String(), + Prometheus: vals.Prometheus.Enable.Value(), + STUN: len(vals.DERP.Server.STUNAddresses) != 0, Tunnel: tunnel != nil, }) if err != nil { return xerrors.Errorf("create telemetry reporter: %w", err) } defer options.Telemetry.Close() + } else { + logger.Warn(ctx, `telemetry disabled, unable to notify of security issues. Read more: https://coder.com/docs/v2/latest/admin/telemetry`) } // This prevents the pprof import from being accidentally deleted. _ = pprof.Handler - if cfg.Pprof.Enable { + if vals.Pprof.Enable { //nolint:revive - defer ServeHandler(ctx, logger, nil, cfg.Pprof.Address.String(), "pprof")() - } - if cfg.Prometheus.Enable { - options.PrometheusRegistry.MustRegister(collectors.NewGoCollector()) - options.PrometheusRegistry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) - - closeUsersFunc, err := prometheusmetrics.ActiveUsers(ctx, options.PrometheusRegistry, options.Database, 0) - if err != nil { - return xerrors.Errorf("register active users prometheus metric: %w", err) - } - defer closeUsersFunc() - - closeWorkspacesFunc, err := prometheusmetrics.Workspaces(ctx, options.PrometheusRegistry, options.Database, 0) + defer ServeHandler(ctx, logger, nil, vals.Pprof.Address.String(), "pprof")() + } + if vals.Prometheus.Enable { + closeFn, err := enablePrometheus( + ctx, + logger.Named("prometheus"), + vals, + options, + ) if err != nil { - return xerrors.Errorf("register workspaces prometheus metric: %w", err) - } - defer closeWorkspacesFunc() - - if cfg.Prometheus.CollectAgentStats { - closeAgentStatsFunc, err := prometheusmetrics.AgentStats(ctx, logger, options.PrometheusRegistry, options.Database, time.Now(), 0) - if err != nil { - return xerrors.Errorf("register agent stats prometheus metric: %w", err) - } - defer closeAgentStatsFunc() - - metricsAggregator, err := prometheusmetrics.NewMetricsAggregator(logger, options.PrometheusRegistry, 0) - if err != nil { - return xerrors.Errorf("can't initialize metrics aggregator: %w", err) - } - - cancelMetricsAggregator := metricsAggregator.Run(ctx) - defer cancelMetricsAggregator() - - options.UpdateAgentMetrics = metricsAggregator.Update - err = options.PrometheusRegistry.Register(metricsAggregator) - if err != nil { - return xerrors.Errorf("can't register metrics aggregator as collector: %w", err) - } + return xerrors.Errorf("enable prometheus: %w", err) } - - //nolint:revive - defer ServeHandler(ctx, logger, promhttp.InstrumentMetricHandler( - options.PrometheusRegistry, promhttp.HandlerFor(options.PrometheusRegistry, promhttp.HandlerOpts{}), - ), cfg.Prometheus.Address.String(), "prometheus")() + defer closeFn() } - if cfg.Swagger.Enable { - options.SwaggerEndpoint = cfg.Swagger.Enable.Value() + if vals.Swagger.Enable { + options.SwaggerEndpoint = vals.Swagger.Enable.Value() } - closeCheckInactiveUsersFunc := dormancy.CheckInactiveUsers(ctx, logger, options.Database) - defer closeCheckInactiveUsersFunc() + batcher, closeBatcher, err := batchstats.New(ctx, + batchstats.WithLogger(options.Logger.Named("batchstats")), + batchstats.WithStore(options.Database), + ) + if err != nil { + return xerrors.Errorf("failed to create agent stats batcher: %w", err) + } + options.StatsBatcher = batcher + defer closeBatcher() // We use a separate coderAPICloser so the Enterprise API // can have it's own close functions. This is cleaner @@ -824,7 +827,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("create coder API: %w", err) } - if cfg.Prometheus.Enable { + if vals.Prometheus.Enable { // Agent metrics require reference to the tailnet coordinator, so must be initiated after Coder API. closeAgentsFunc, err := prometheusmetrics.Agents(ctx, logger, options.PrometheusRegistry, coderAPI.Database, &coderAPI.TailnetCoordinator, coderAPI.DERPMap, coderAPI.Options.AgentInactiveDisconnectTimeout, 0) if err != nil { @@ -872,10 +875,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. var provisionerdWaitGroup sync.WaitGroup defer provisionerdWaitGroup.Wait() provisionerdMetrics := provisionerd.NewMetrics(options.PrometheusRegistry) - for i := int64(0); i < cfg.Provisioner.Daemons.Value(); i++ { + for i := int64(0); i < vals.Provisioner.Daemons.Value(); i++ { daemonCacheDir := filepath.Join(cacheDir, fmt.Sprintf("provisioner-%d", i)) daemon, err := newProvisionerDaemon( - ctx, coderAPI, provisionerdMetrics, logger, cfg, daemonCacheDir, errCh, &provisionerdWaitGroup, + ctx, coderAPI, provisionerdMetrics, logger, vals, daemonCacheDir, errCh, &provisionerdWaitGroup, ) if err != nil { return xerrors.Errorf("create provisioner daemon: %w", err) @@ -894,8 +897,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // Wrap the server in middleware that redirects to the access URL if // the request is not to a local IP. var handler http.Handler = coderAPI.RootHandler - if cfg.RedirectToAccessURL { - handler = redirectToAccessURL(handler, cfg.AccessURL.Value(), tunnel != nil, appHostnameRegex) + if vals.RedirectToAccessURL { + handler = redirectToAccessURL(handler, vals.AccessURL.Value(), tunnel != nil, appHostnameRegex) } // ReadHeaderTimeout is purposefully not enabled. It caused some @@ -952,12 +955,13 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("notify systemd: %w", err) } - autobuildTicker := time.NewTicker(cfg.AutobuildPollInterval.Value()) + autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value()) defer autobuildTicker.Stop() - autobuildExecutor := autobuild.NewExecutor(ctx, options.Database, coderAPI.TemplateScheduleStore, logger, autobuildTicker.C) + autobuildExecutor := autobuild.NewExecutor( + ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C) autobuildExecutor.Run() - hangDetectorTicker := time.NewTicker(cfg.JobHangDetectorInterval.Value()) + hangDetectorTicker := time.NewTicker(vals.JobHangDetectorInterval.Value()) defer hangDetectorTicker.Stop() hangDetector := unhanger.New(ctx, options.Database, options.Pubsub, logger, hangDetectorTicker.C) hangDetector.Start() @@ -970,9 +974,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. select { case <-notifyCtx.Done(): exitErr = notifyCtx.Err() - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Bold.Render( - "Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit", - )) + _, _ = io.WriteString(inv.Stdout, cliui.Bold("Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit")) case <-tunnelDone: exitErr = xerrors.New("dev tunnel closed unexpectedly") case exitErr = <-errCh: @@ -1016,9 +1018,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. go func() { defer wg.Done() - if ok, _ := inv.ParsedFlags().GetBool(varVerbose); ok { - cliui.Infof(inv.Stdout, "Shutting down provisioner daemon %d...\n", id) - } + r.Verbosef(inv, "Shutting down provisioner daemon %d...", id) err := shutdownWithTimeout(provisionerDaemon.Shutdown, 5*time.Second) if err != nil { cliui.Errorf(inv.Stderr, "Failed to shutdown provisioner daemon %d: %s\n", id, err) @@ -1029,9 +1029,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. cliui.Errorf(inv.Stderr, "Close provisioner daemon %d: %s\n", id, err) return } - if ok, _ := inv.ParsedFlags().GetBool(varVerbose); ok { - cliui.Infof(inv.Stdout, "Gracefully shut down provisioner daemon %d\n", id) - } + r.Verbosef(inv, "Gracefully shut down provisioner daemon %d", id) }() } wg.Wait() @@ -1082,7 +1080,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. if pgRawURL { _, _ = fmt.Fprintf(inv.Stdout, "%s\n", url) } else { - _, _ = fmt.Fprintf(inv.Stdout, "%s\n", cliui.DefaultStyles.Code.Render(fmt.Sprintf("psql %q", url))) + _, _ = fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("psql %q", url))) } return nil }, @@ -1112,7 +1110,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. if pgRawURL { _, _ = fmt.Fprintf(inv.Stdout, "%s\n", url) } else { - _, _ = fmt.Fprintf(inv.Stdout, "%s\n", cliui.DefaultStyles.Code.Render(fmt.Sprintf("psql %q", url))) + _, _ = fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("psql %q", url))) } <-ctx.Done() @@ -1208,6 +1206,14 @@ func WriteConfigMW(cfg *codersdk.DeploymentValues) clibase.MiddlewareFunc { // isLocalURL returns true if the hostname of the provided URL appears to // resolve to a loopback address. func IsLocalURL(ctx context.Context, u *url.URL) (bool, error) { + // In tests, we commonly use "example.com" or "google.com", which + // are not loopback, so avoid the DNS lookup to avoid flakes. + if flag.Lookup("test.v") != nil { + if u.Hostname() == "example.com" || u.Hostname() == "google.com" { + return false, nil + } + } + resolver := &net.Resolver{} ips, err := resolver.LookupIPAddr(ctx, u.Hostname()) if err != nil { @@ -1257,7 +1263,7 @@ func newProvisionerDaemon( return nil, xerrors.Errorf("mkdir work dir: %w", err) } - provisioners := provisionerd.Provisioners{} + connector := provisionerd.LocalProvisioners{} if cfg.Provisioner.DaemonsEcho { echoClient, echoServer := provisionersdk.MemTransportPipe() wg.Add(1) @@ -1272,7 +1278,11 @@ func newProvisionerDaemon( defer wg.Done() defer cancel() - err := echo.Serve(ctx, afero.NewOsFs(), &provisionersdk.ServeOptions{Listener: echoServer}) + err := echo.Serve(ctx, &provisionersdk.ServeOptions{ + Listener: echoServer, + WorkDirectory: workDir, + Logger: logger.Named("echo"), + }) if err != nil { select { case errCh <- err: @@ -1280,7 +1290,7 @@ func newProvisionerDaemon( } } }() - provisioners[string(database.ProvisionerTypeEcho)] = sdkproto.NewDRPCProvisionerClient(echoClient) + connector[string(database.ProvisionerTypeEcho)] = sdkproto.NewDRPCProvisionerClient(echoClient) } else { tfDir := filepath.Join(cacheDir, "tf") err = os.MkdirAll(tfDir, 0o700) @@ -1304,10 +1314,11 @@ func newProvisionerDaemon( err := terraform.Serve(ctx, &terraform.ServeOptions{ ServeOptions: &provisionersdk.ServeOptions{ - Listener: terraformServer, + Listener: terraformServer, + Logger: logger.Named("terraform"), + WorkDirectory: workDir, }, CachePath: tfDir, - Logger: logger, Tracer: tracer, }) if err != nil && !xerrors.Is(err, context.Canceled) { @@ -1318,23 +1329,18 @@ func newProvisionerDaemon( } }() - provisioners[string(database.ProvisionerTypeTerraform)] = sdkproto.NewDRPCProvisionerClient(terraformClient) + connector[string(database.ProvisionerTypeTerraform)] = sdkproto.NewDRPCProvisionerClient(terraformClient) } - debounce := time.Second return provisionerd.New(func(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) { // This debounces calls to listen every second. Read the comment // in provisionerdserver.go to learn more! - return coderAPI.CreateInMemoryProvisionerDaemon(ctx, debounce) + return coderAPI.CreateInMemoryProvisionerDaemon(ctx) }, &provisionerd.Options{ - Logger: logger, - JobPollInterval: cfg.Provisioner.DaemonPollInterval.Value(), - JobPollJitter: cfg.Provisioner.DaemonPollJitter.Value(), - JobPollDebounce: debounce, + Logger: logger.Named("provisionerd"), UpdateInterval: time.Second, ForceCancelInterval: cfg.Provisioner.ForceCancelInterval.Value(), - Provisioners: provisioners, - WorkDirectory: workDir, + Connector: connector, TracerProvider: coderAPI.TracerProvider, Metrics: &metrics, }), nil @@ -1347,7 +1353,7 @@ func PrintLogo(inv *clibase.Invocation, daemonTitle string) { return } - versionString := cliui.DefaultStyles.Bold.Render(daemonTitle + " " + buildinfo.Version()) + versionString := cliui.Bold(daemonTitle + " " + buildinfo.Version()) _, _ = fmt.Fprintf(inv.Stdout, "%s - Your Self-Hosted Remote Development Platform\n", versionString) } @@ -1481,6 +1487,33 @@ func configureTLS(tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles return tlsConfig, nil } +func configureOIDCPKI(orig *oauth2.Config, keyFile string, certFile string) (*oauthpki.Config, error) { + // Read the files + keyData, err := os.ReadFile(keyFile) + if err != nil { + return nil, xerrors.Errorf("read oidc client key file: %w", err) + } + + var certData []byte + // According to the spec, this is not required. So do not require it on the initial loading + // of the PKI config. + if certFile != "" { + certData, err = os.ReadFile(certFile) + if err != nil { + return nil, xerrors.Errorf("read oidc client cert file: %w", err) + } + } + + return oauthpki.NewOauth2PKIConfig(oauthpki.ConfigParams{ + ClientID: orig.ClientID, + TokenURL: orig.Endpoint.TokenURL, + Scopes: orig.Scopes, + PemEncodedKey: keyData, + PemEncodedCert: certData, + Config: orig, + }) +} + func configureCAPool(tlsClientCAFile string, tlsConfig *tls.Config) error { if tlsClientCAFile != "" { caPool := x509.NewCertPool() @@ -1786,7 +1819,7 @@ func (f *debugFilterSink) compile(res []string) error { func (f *debugFilterSink) LogEntry(ctx context.Context, ent slog.SinkEntry) { if ent.Level == slog.LevelDebug { logName := strings.Join(ent.LoggerNames, ".") - if f.re != nil && !f.re.MatchString(logName) { + if f.re != nil && !f.re.MatchString(logName) && !f.re.MatchString(ent.Message) { return } } @@ -1870,45 +1903,49 @@ func BuildLogger(inv *clibase.Invocation, cfg *codersdk.DeploymentValues) (slog. }, nil } -func connectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (*sql.DB, error) { +func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (sqlDB *sql.DB, err error) { logger.Debug(ctx, "connecting to postgresql") // Try to connect for 30 seconds. ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - var ( - sqlDB *sql.DB - err error - ok = false - tries int - ) + defer func() { + if err == nil { + return + } + if sqlDB != nil { + _ = sqlDB.Close() + sqlDB = nil + } + logger.Error(ctx, "connect to postgres failed", slog.Error(err)) + }() + + var tries int for r := retry.New(time.Second, 3*time.Second); r.Wait(ctx); { tries++ sqlDB, err = sql.Open(driver, dbURL) if err != nil { - logger.Warn(ctx, "connect to postgres; retrying", slog.Error(err), slog.F("try", tries)) + logger.Warn(ctx, "connect to postgres: retrying", slog.Error(err), slog.F("try", tries)) continue } err = pingPostgres(ctx, sqlDB) if err != nil { - logger.Warn(ctx, "ping postgres; retrying", slog.Error(err), slog.F("try", tries)) + logger.Warn(ctx, "ping postgres: retrying", slog.Error(err), slog.F("try", tries)) + _ = sqlDB.Close() + sqlDB = nil continue } break } - // Make sure we close the DB in case it opened but the ping failed for some - // reason. - defer func() { - if !ok && sqlDB != nil { - _ = sqlDB.Close() - } - }() + if err == nil { + err = ctx.Err() + } if err != nil { - return nil, xerrors.Errorf("connect to postgres; tries %d; last error: %w", tries, err) + return nil, xerrors.Errorf("unable to connect after %d tries; last error: %w", tries, err) } // Ensure the PostgreSQL version is >=13.0.0! @@ -1953,7 +1990,6 @@ func connectToPostgres(ctx context.Context, logger slog.Logger, driver string, d // of connection churn. sqlDB.SetMaxIdleConns(3) - ok = true return sqlDB, nil } @@ -2004,7 +2040,6 @@ func (s *HTTPServers) Close() { func ConfigureTraceProvider( ctx context.Context, logger slog.Logger, - inv *clibase.Invocation, cfg *codersdk.DeploymentValues, ) (trace.TracerProvider, string, func(context.Context) error) { var ( @@ -2012,19 +2047,18 @@ func ConfigureTraceProvider( closeTracing = func(context.Context) error { return nil } sqlDriver = "postgres" ) - // Coder tracing should be disabled if telemetry is disabled unless - // --telemetry-trace was explicitly provided. - shouldCoderTrace := cfg.Telemetry.Enable.Value() && !isTest() - // Only override if telemetryTraceEnable was specifically set. - // By default we want it to be controlled by telemetryEnable. - if inv.ParsedFlags().Changed("telemetry-trace") { - shouldCoderTrace = cfg.Telemetry.Trace.Value() - } - if cfg.Trace.Enable.Value() || shouldCoderTrace || cfg.Trace.HoneycombAPIKey != "" { + otel.SetTextMapPropagator( + propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ), + ) + + if cfg.Trace.Enable.Value() || cfg.Trace.DataDog.Value() || cfg.Trace.HoneycombAPIKey != "" { sdkTracerProvider, _closeTracing, err := tracing.TracerProvider(ctx, "coderd", tracing.TracerOpts{ Default: cfg.Trace.Enable.Value(), - Coder: shouldCoderTrace, + DataDog: cfg.Trace.DataDog.Value(), Honeycomb: cfg.Trace.HoneycombAPIKey.String(), }) if err != nil { @@ -2167,3 +2201,103 @@ func ConfigureHTTPServers(inv *clibase.Invocation, cfg *codersdk.DeploymentValue return httpServers, nil } + +// ReadExternalAuthProvidersFromEnv is provided for compatibility purposes with +// the viper CLI. +func ReadExternalAuthProvidersFromEnv(environ []string) ([]codersdk.ExternalAuthConfig, error) { + providers, err := parseExternalAuthProvidersFromEnv("CODER_EXTERNAL_AUTH_", environ) + if err != nil { + return nil, err + } + // Deprecated: To support legacy git auth! + gitProviders, err := parseExternalAuthProvidersFromEnv("CODER_GITAUTH_", environ) + if err != nil { + return nil, err + } + return append(providers, gitProviders...), nil +} + +// parseExternalAuthProvidersFromEnv consumes environment variables to parse +// external auth providers. A prefix is provided to support the legacy +// parsing of `GITAUTH` environment variables. +func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]codersdk.ExternalAuthConfig, error) { + // The index numbers must be in-order. + sort.Strings(environ) + + var providers []codersdk.ExternalAuthConfig + for _, v := range clibase.ParseEnviron(environ, prefix) { + tokens := strings.SplitN(v.Name, "_", 2) + if len(tokens) != 2 { + return nil, xerrors.Errorf("invalid env var: %s", v.Name) + } + + providerNum, err := strconv.Atoi(tokens[0]) + if err != nil { + return nil, xerrors.Errorf("parse number: %s", v.Name) + } + + var provider codersdk.ExternalAuthConfig + switch { + case len(providers) < providerNum: + return nil, xerrors.Errorf( + "provider num %v skipped: %s", + len(providers), + v.Name, + ) + case len(providers) == providerNum: + // At the next next provider. + providers = append(providers, provider) + case len(providers) == providerNum+1: + // At the current provider. + provider = providers[providerNum] + } + + key := tokens[1] + switch key { + case "ID": + provider.ID = v.Value + case "TYPE": + provider.Type = v.Value + case "CLIENT_ID": + provider.ClientID = v.Value + case "CLIENT_SECRET": + provider.ClientSecret = v.Value + case "AUTH_URL": + provider.AuthURL = v.Value + case "TOKEN_URL": + provider.TokenURL = v.Value + case "VALIDATE_URL": + provider.ValidateURL = v.Value + case "REGEX": + provider.Regex = v.Value + case "DEVICE_FLOW": + b, err := strconv.ParseBool(v.Value) + if err != nil { + return nil, xerrors.Errorf("parse bool: %s", v.Value) + } + provider.DeviceFlow = b + case "DEVICE_CODE_URL": + provider.DeviceCodeURL = v.Value + case "NO_REFRESH": + b, err := strconv.ParseBool(v.Value) + if err != nil { + return nil, xerrors.Errorf("parse bool: %s", v.Value) + } + provider.NoRefresh = b + case "SCOPES": + provider.Scopes = strings.Split(v.Value, " ") + case "EXTRA_TOKEN_KEYS": + provider.ExtraTokenKeys = strings.Split(v.Value, " ") + case "APP_INSTALL_URL": + provider.AppInstallURL = v.Value + case "APP_INSTALLATIONS_URL": + provider.AppInstallationsURL = v.Value + case "DISPLAY_NAME": + provider.DisplayName = v.Value + case "DISPLAY_ICON": + provider.DisplayIcon = v.Value + } + providers[providerNum] = provider + } + return providers, nil +} diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index fbdfed6b8016e..fa82e4fbcd051 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -12,14 +12,15 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/gitsshkey" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/userpassword" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/gitsshkey" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd { @@ -51,7 +52,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd { defer cancel() if newUserDBURL == "" { - cliui.Infof(inv.Stdout, "Using built-in PostgreSQL (%s)\n", cfg.PostgresPath()) + cliui.Infof(inv.Stdout, "Using built-in PostgreSQL (%s)", cfg.PostgresPath()) url, closePg, err := startBuiltinPostgres(ctx, cfg, logger) if err != nil { return err @@ -62,7 +63,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd { newUserDBURL = url } - sqlDB, err := connectToPostgres(ctx, logger, "postgres", newUserDBURL) + sqlDB, err := ConnectToPostgres(ctx, logger, "postgres", newUserDBURL) if err != nil { return xerrors.Errorf("connect to postgres: %w", err) } @@ -180,8 +181,8 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd { Email: newUserEmail, Username: newUserUsername, HashedPassword: []byte(hashedPassword), - CreatedAt: database.Now(), - UpdatedAt: database.Now(), + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), RBACRoles: []string{rbac.RoleOwner()}, LoginType: database.LoginTypePassword, }) @@ -196,8 +197,8 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd { } _, err = tx.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{ UserID: newUser.ID, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), PrivateKey: privateKey, PublicKey: publicKey, }) @@ -210,8 +211,8 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd { _, err := tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ OrganizationID: org.ID, UserID: newUser.ID, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), Roles: []string{rbac.RoleOrgAdmin(org.ID)}, }) if err != nil { diff --git a/cli/server_createadminuser_test.go b/cli/server_createadminuser_test.go index 4daca225d71f7..024c3f50f231d 100644 --- a/cli/server_createadminuser_test.go +++ b/cli/server_createadminuser_test.go @@ -11,13 +11,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/postgres" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/userpassword" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/database/postgres" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) //nolint:paralleltest, tparallel @@ -106,15 +107,15 @@ func TestServerCreateAdminUser(t *testing.T) { _, err = db.InsertOrganization(ctx, database.InsertOrganizationParams{ ID: org1ID, Name: org1Name, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), }) require.NoError(t, err) _, err = db.InsertOrganization(ctx, database.InsertOrganizationParams{ ID: org2ID, Name: org2Name, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), }) require.NoError(t, err) diff --git a/cli/server_slim.go b/cli/server_slim.go index 4703f20b7669f..d3a4693ec7634 100644 --- a/cli/server_slim.go +++ b/cli/server_slim.go @@ -3,17 +3,10 @@ package cli import ( - "context" - "fmt" - "io" - "os" - - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd" + "github.com/coder/coder/v2/cli/clibase" ) -func (r *RootCmd) Server(_ func(context.Context, *coderd.Options) (*coderd.API, io.Closer, error)) *clibase.Cmd { +func (r *RootCmd) Server(_ func()) *clibase.Cmd { root := &clibase.Cmd{ Use: "server", Short: "Start a Coder server", @@ -21,18 +14,10 @@ func (r *RootCmd) Server(_ func(context.Context, *coderd.Options) (*coderd.API, RawArgs: true, Hidden: true, Handler: func(inv *clibase.Invocation) error { - serverUnsupported(inv.Stderr) + SlimUnsupported(inv.Stderr, "server") return nil }, } return root } - -func serverUnsupported(w io.Writer) { - _, _ = fmt.Fprintf(w, "You are using a 'slim' build of Coder, which does not support the %s subcommand.\n", cliui.DefaultStyles.Code.Render("server")) - _, _ = fmt.Fprintln(w, "") - _, _ = fmt.Fprintln(w, "Please use a build of Coder from GitHub releases:") - _, _ = fmt.Fprintln(w, " https://github.com/coder/coder/releases") - os.Exit(1) -} diff --git a/cli/server_test.go b/cli/server_test.go index ee00499c4d2a6..7034f2fa33d33 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -34,23 +34,65 @@ import ( "go.uber.org/goleak" "gopkg.in/yaml.v3" - "github.com/coder/coder/cli" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/cli/config" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database/postgres" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/config" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/postgres" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) +func TestReadExternalAuthProvidersFromEnv(t *testing.T) { + t.Parallel() + t.Run("Valid", func(t *testing.T) { + t.Parallel() + providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{ + "CODER_EXTERNAL_AUTH_0_ID=1", + "CODER_EXTERNAL_AUTH_0_TYPE=gitlab", + "CODER_EXTERNAL_AUTH_1_ID=2", + "CODER_EXTERNAL_AUTH_1_CLIENT_ID=sid", + "CODER_EXTERNAL_AUTH_1_CLIENT_SECRET=hunter12", + "CODER_EXTERNAL_AUTH_1_TOKEN_URL=google.com", + "CODER_EXTERNAL_AUTH_1_VALIDATE_URL=bing.com", + "CODER_EXTERNAL_AUTH_1_SCOPES=repo:read repo:write", + "CODER_EXTERNAL_AUTH_1_NO_REFRESH=true", + "CODER_EXTERNAL_AUTH_1_DISPLAY_NAME=Google", + "CODER_EXTERNAL_AUTH_1_DISPLAY_ICON=/icon/google.svg", + }) + require.NoError(t, err) + require.Len(t, providers, 2) + + // Validate the first provider. + assert.Equal(t, "1", providers[0].ID) + assert.Equal(t, "gitlab", providers[0].Type) + + // Validate the second provider. + assert.Equal(t, "2", providers[1].ID) + assert.Equal(t, "sid", providers[1].ClientID) + assert.Equal(t, "hunter12", providers[1].ClientSecret) + assert.Equal(t, "google.com", providers[1].TokenURL) + assert.Equal(t, "bing.com", providers[1].ValidateURL) + assert.Equal(t, []string{"repo:read", "repo:write"}, providers[1].Scopes) + assert.Equal(t, true, providers[1].NoRefresh) + assert.Equal(t, "Google", providers[1].DisplayName) + assert.Equal(t, "/icon/google.svg", providers[1].DisplayIcon) + }) +} + +// TestReadGitAuthProvidersFromEnv ensures that the deprecated `CODER_GITAUTH_` +// environment variables are still supported. func TestReadGitAuthProvidersFromEnv(t *testing.T) { t.Parallel() t.Run("Empty", func(t *testing.T) { t.Parallel() - providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ + providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{ "HOME=/home/frodo", }) require.NoError(t, err) @@ -58,7 +100,7 @@ func TestReadGitAuthProvidersFromEnv(t *testing.T) { }) t.Run("InvalidKey", func(t *testing.T) { t.Parallel() - providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ + providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{ "CODER_GITAUTH_XXX=invalid", }) require.Error(t, err, "providers: %+v", providers) @@ -66,7 +108,7 @@ func TestReadGitAuthProvidersFromEnv(t *testing.T) { }) t.Run("SkipKey", func(t *testing.T) { t.Parallel() - providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ + providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{ "CODER_GITAUTH_0_ID=invalid", "CODER_GITAUTH_2_ID=invalid", }) @@ -75,7 +117,7 @@ func TestReadGitAuthProvidersFromEnv(t *testing.T) { }) t.Run("Valid", func(t *testing.T) { t.Parallel() - providers, err := cli.ReadGitAuthProvidersFromEnv([]string{ + providers, err := cli.ReadExternalAuthProvidersFromEnv([]string{ "CODER_GITAUTH_0_ID=1", "CODER_GITAUTH_0_TYPE=gitlab", "CODER_GITAUTH_1_ID=2", @@ -1185,6 +1227,22 @@ func TestServer(t *testing.T) { require.Equal(t, map[string]string{"serious_business_unit": "serious_business_unit"}, deploymentConfig.Values.OIDC.GroupMapping.Value) require.Equal(t, "Sign In With Coder", deploymentConfig.Values.OIDC.SignInText.Value()) require.Equal(t, "https://example.com/icon.png", deploymentConfig.Values.OIDC.IconURL.Value().String()) + + // Verify the option values + for _, opt := range deploymentConfig.Options { + switch opt.Flag { + case "access-url": + require.Equal(t, "http://example.com", opt.Value.String()) + case "oidc-icon-url": + require.Equal(t, "https://example.com/icon.png", opt.Value.String()) + case "oidc-sign-in-text": + require.Equal(t, "Sign In With Coder", opt.Value.String()) + case "redirect-to-access-url": + require.Equal(t, "false", opt.Value.String()) + case "derp-server-region-id": + require.Equal(t, "999", opt.Value.String()) + } + } }) }) @@ -1309,6 +1367,7 @@ func TestServer(t *testing.T) { "--in-memory", "--http-address", ":0", "--access-url", "http://example.com", + "--provisioner-daemons-echo", "--log-human", fiName, ) clitest.Start(t, root) @@ -1326,6 +1385,7 @@ func TestServer(t *testing.T) { "--in-memory", "--http-address", ":0", "--access-url", "http://example.com", + "--provisioner-daemons-echo", "--log-human", fi, ) clitest.Start(t, root) @@ -1343,6 +1403,7 @@ func TestServer(t *testing.T) { "--in-memory", "--http-address", ":0", "--access-url", "http://example.com", + "--provisioner-daemons-echo", "--log-json", fi, ) clitest.Start(t, root) @@ -1363,6 +1424,7 @@ func TestServer(t *testing.T) { "--in-memory", "--http-address", ":0", "--access-url", "http://example.com", + "--provisioner-daemons-echo", "--log-stackdriver", fi, ) // Attach pty so we get debug output from the command if this test @@ -1397,6 +1459,7 @@ func TestServer(t *testing.T) { "--in-memory", "--http-address", ":0", "--access-url", "http://example.com", + "--provisioner-daemons-echo", "--log-human", fi1, "--log-json", fi2, "--log-stackdriver", fi3, @@ -1480,6 +1543,9 @@ func TestServer(t *testing.T) { gotConfig.Options.ByName("Config Path").Value.Set("") // We check the options individually for better error messages. for i := range wantConfig.Options { + // ValueSource is not going to be correct on the `want`, so just + // match that field. + wantConfig.Options[i].ValueSource = gotConfig.Options[i].ValueSource assert.Equal( t, wantConfig.Options[i], gotConfig.Options[i], @@ -1491,31 +1557,6 @@ func TestServer(t *testing.T) { w.RequireSuccess() }) }) - t.Run("DisableDERP", func(t *testing.T) { - t.Parallel() - - // Make sure that $CODER_DERP_SERVER_STUN_ADDRESSES can be set to - // disable STUN. - - inv, cfg := clitest.New(t, - "server", - "--in-memory", - "--http-address", ":0", - "--access-url", "https://example.com", - ) - inv.Environ.Set("CODER_DERP_SERVER_STUN_ADDRESSES", "disable") - ptytest.New(t).Attach(inv) - clitest.Start(t, inv) - gotURL := waitAccessURL(t, cfg) - client := codersdk.New(gotURL) - - ctx := testutil.Context(t, testutil.WaitMedium) - _ = coderdtest.CreateFirstUser(t, client) - gotConfig, err := client.DeploymentConfig(ctx) - require.NoError(t, err) - - require.Len(t, gotConfig.Values.DERP.Server.STUNAddresses, 0) - }) } func TestServer_Production(t *testing.T) { @@ -1581,6 +1622,20 @@ func TestServer_Shutdown(t *testing.T) { require.NoError(t, err) } +func BenchmarkServerHelp(b *testing.B) { + // server --help is a good proxy for measuring the + // constant overhead of each command. + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + inv, _ := clitest.New(b, "server", "--help") + inv.Stdout = io.Discard + inv.Stderr = io.Discard + err := inv.Run() + require.NoError(b, err) + } +} + func generateTLSCertificate(t testing.TB, commonName ...string) (certPath, keyPath string) { dir := t.TempDir() @@ -1677,3 +1732,26 @@ func TestServerYAMLConfig(t *testing.T) { require.Equal(t, string(wantByt), string(got)) } + +func TestConnectToPostgres(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("this test does not make sense without postgres") + } + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + + log := slogtest.Make(t, nil) + + dbURL, closeFunc, err := postgres.Open() + require.NoError(t, err) + t.Cleanup(closeFunc) + + sqlDB, err := cli.ConnectToPostgres(ctx, log, "postgres", dbURL) + require.NoError(t, err) + t.Cleanup(func() { + _ = sqlDB.Close() + }) + require.NoError(t, sqlDB.PingContext(ctx)) +} diff --git a/cli/show.go b/cli/show.go index 3dff78fcaefdc..477c6e0ffbb60 100644 --- a/cli/show.go +++ b/cli/show.go @@ -3,9 +3,9 @@ package cli import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) show() *clibase.Cmd { diff --git a/cli/show_test.go b/cli/show_test.go index 6f5faaa3fde11..eff2789e75a02 100644 --- a/cli/show_test.go +++ b/cli/show_test.go @@ -5,10 +5,9 @@ import ( "github.com/stretchr/testify/assert" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" ) func TestShow(t *testing.T) { @@ -16,23 +15,20 @@ func TestShow(t *testing.T) { t.Run("Exists", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - ProvisionPlan: provisionCompleteWithAgent, - }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) args := []string{ "show", workspace.Name, } inv, root := clitest.New(t, args...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) go func() { diff --git a/cli/speedtest.go b/cli/speedtest.go index 150605b3330ce..ca6c5e50a6f05 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -11,9 +11,9 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) speedtest() *clibase.Cmd { @@ -85,14 +85,14 @@ func (r *RootCmd) speedtest() *clibase.Cmd { } peer := status.Peer[status.Peers()[0]] if !p2p && direct { - cliui.Infof(inv.Stdout, "Waiting for a direct connection... (%dms via %s)\n", dur.Milliseconds(), peer.Relay) + cliui.Infof(inv.Stdout, "Waiting for a direct connection... (%dms via %s)", dur.Milliseconds(), peer.Relay) continue } via := peer.Relay if via == "" { via = "direct" } - cliui.Infof(inv.Stdout, "%dms via %s\n", dur.Milliseconds(), via) + cliui.Infof(inv.Stdout, "%dms via %s", dur.Milliseconds(), via) break } } else { @@ -107,7 +107,7 @@ func (r *RootCmd) speedtest() *clibase.Cmd { default: return xerrors.Errorf("invalid direction: %q", direction) } - cliui.Infof(inv.Stdout, "Starting a %ds %s test...\n", int(duration.Seconds()), tsDir) + cliui.Infof(inv.Stdout, "Starting a %ds %s test...", int(duration.Seconds()), tsDir) results, err := conn.Speedtest(ctx, tsDir, duration) if err != nil { return err diff --git a/cli/speedtest_test.go b/cli/speedtest_test.go index b05e3689347a3..f16b769cc85e7 100644 --- a/cli/speedtest_test.go +++ b/cli/speedtest_test.go @@ -9,14 +9,13 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "github.com/coder/coder/cli" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestSpeedtest(t *testing.T) { @@ -26,13 +25,7 @@ func TestSpeedtest(t *testing.T) { t.Skip("This test takes a minimum of 5ms per a hardcoded value in Tailscale!") } client, workspace, agentToken := setupWorkspaceForAgent(t, nil) - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(agentToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), - }) - defer agentCloser.Close() + _ = agenttest.New(t, client.URL, agentToken) coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) diff --git a/cli/ssh.go b/cli/ssh.go index 2db1f4b4e2cb4..dbff0ea52017e 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -26,12 +26,12 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/autobuild/notify" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/autobuild/notify" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" "github.com/coder/retry" ) @@ -40,7 +40,6 @@ var ( autostopNotifyCountdown = []time.Duration{30 * time.Minute} ) -//nolint:gocyclo func (r *RootCmd) ssh() *clibase.Cmd { var ( stdio bool @@ -144,13 +143,11 @@ func (r *RootCmd) ssh() *clibase.Cmd { case "no": wait = false case "auto": - switch workspaceAgent.StartupScriptBehavior { - case codersdk.WorkspaceAgentStartupScriptBehaviorBlocking: - wait = true - case codersdk.WorkspaceAgentStartupScriptBehaviorNonBlocking: - wait = false - default: - return xerrors.Errorf("unknown startup script behavior %q", workspaceAgent.StartupScriptBehavior) + for _, script := range workspaceAgent.Scripts { + if script.StartBlocksLogin { + wait = true + break + } } default: return xerrors.Errorf("unknown wait value %q", waitEnum) diff --git a/cli/ssh_internal_test.go b/cli/ssh_internal_test.go index d9624f393dfa6..07a6a3c5802f2 100644 --- a/cli/ssh_internal_test.go +++ b/cli/ssh_internal_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" ) const ( diff --git a/cli/ssh_test.go b/cli/ssh_test.go index e933839e9ba48..9a0f6b6f6e4e2 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -29,18 +29,22 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/pty" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +const ( + startupScriptPattern = "i-am-ready" ) func setupWorkspaceForAgent(t *testing.T, mutate func([]*proto.Agent) []*proto.Agent) (*codersdk.Client, codersdk.Workspace, string) { @@ -56,10 +60,10 @@ func setupWorkspaceForAgent(t *testing.T, mutate func([]*proto.Agent) []*proto.A agentToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "dev", Type: "google_compute_instance", @@ -68,16 +72,22 @@ func setupWorkspaceForAgent(t *testing.T, mutate func([]*proto.Agent) []*proto.A Auth: &proto.Agent_Token{ Token: agentToken, }, + Scripts: []*proto.Script{ + { + Script: fmt.Sprintf("echo '%s'", startupScriptPattern), + RunOnStart: true, + }, + }, }}), }}, }, }, }}, }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) workspace, err := client.Workspace(context.Background(), workspace.ID) require.NoError(t, err) @@ -103,15 +113,8 @@ func TestSSH(t *testing.T) { }) pty.ExpectMatch("Waiting") - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(agentToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - Logger: slogtest.Make(t, nil).Named("agent"), - }) - defer func() { - _ = agentCloser.Close() - }() + _ = agenttest.New(t, client.URL, agentToken) + coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) // Shells on Mac, Windows, and Linux all exit shells with the "exit" command. pty.WriteLine("exit") @@ -167,15 +170,8 @@ func TestSSH(t *testing.T) { }) pty.ExpectMatch("Waiting") - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(agentToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - Logger: slogtest.Make(t, nil).Named("agent"), - }) - defer func() { - _ = agentCloser.Close() - }() + _ = agenttest.New(t, client.URL, agentToken) + coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) // Ensure the agent is connected. pty.WriteLine("echo hell'o'") @@ -196,14 +192,8 @@ func TestSSH(t *testing.T) { _, _ = tGoContext(t, func(ctx context.Context) { // Run this async so the SSH command has to wait for // the build and agent to connect! - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(agentToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - Logger: slogtest.Make(t, nil).Named("agent"), - }) + _ = agenttest.New(t, client.URL, agentToken) <-ctx.Done() - _ = agentCloser.Close() }) clientOutput, clientInput := io.Pipe() @@ -264,14 +254,8 @@ func TestSSH(t *testing.T) { _, _ = tGoContext(t, func(ctx context.Context) { // Run this async so the SSH command has to wait for // the build and agent to connect. - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(agentToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - Logger: slogtest.Make(t, nil).Named("agent"), - }) + _ = agenttest.New(t, client.URL, agentToken) <-ctx.Done() - _ = agentCloser.Close() }) clientOutput, clientInput := io.Pipe() @@ -333,13 +317,8 @@ func TestSSH(t *testing.T) { client, workspace, agentToken := setupWorkspaceForAgent(t, nil) - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(agentToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - Logger: slogtest.Make(t, nil).Named("agent"), - }) - defer agentCloser.Close() + _ = agenttest.New(t, client.URL, agentToken) + coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) // Generate private key. privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -397,7 +376,7 @@ func TestSSH(t *testing.T) { // Ensure that SSH_AUTH_SOCK is set. // Linux: /tmp/auth-agent3167016167/listener.sock // macOS: /var/folders/ng/m1q0wft14hj0t3rtjxrdnzsr0000gn/T/auth-agent3245553419/listener.sock - pty.WriteLine("env") + pty.WriteLine(`env | grep SSH_AUTH_SOCK=`) pty.ExpectMatch("SSH_AUTH_SOCK=") // Ensure that ssh-add lists our key. pty.WriteLine("ssh-add -L") @@ -424,22 +403,68 @@ func TestSSH(t *testing.T) { client, workspace, agentToken := setupWorkspaceForAgent(t, nil) - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(agentToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - Logger: slogtest.Make(t, nil).Named("agent"), + inv, root := clitest.New(t, + "ssh", + workspace.Name, + "--remote-forward", + "8222:"+httpServer.Listener.Addr().String(), + ) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t).Attach(inv) + inv.Stderr = pty.Output() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + cmdDone := tGo(t, func() { + err := inv.WithContext(ctx).Run() + assert.NoError(t, err, "ssh command failed") }) - defer agentCloser.Close() + + // Agent is still starting + pty.ExpectMatch("Waiting") + + _ = agenttest.New(t, client.URL, agentToken) + coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + // Startup script has just finished + pty.ExpectMatch(startupScriptPattern) + + // Download the test page + pty.WriteLine("curl localhost:8222") + pty.ExpectMatch("hello world") + + // And we're done. + pty.WriteLine("exit") + <-cmdDone + }) + + t.Run("RemoteForwardUnixSocket", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Test not supported on windows") + } + + t.Parallel() + + client, workspace, agentToken := setupWorkspaceForAgent(t, nil) + + _ = agenttest.New(t, client.URL, agentToken) + coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() + tmpdir := tempDirUnixSocket(t) + agentSock := filepath.Join(tmpdir, "agent.sock") + l, err := net.Listen("unix", agentSock) + require.NoError(t, err) + defer l.Close() + inv, root := clitest.New(t, "ssh", workspace.Name, "--remote-forward", - "8222:"+httpServer.Listener.Addr().String(), + "/tmp/test.sock:"+agentSock, ) clitest.SetupConfig(t, client, root) pty := ptytest.New(t).Attach(inv) @@ -454,8 +479,8 @@ func TestSSH(t *testing.T) { _ = pty.Peek(ctx, 1) // Download the test page - pty.WriteLine("curl localhost:8222") - pty.ExpectMatch("hello world") + pty.WriteLine("ss -xl state listening src /tmp/test.sock | wc -l") + pty.ExpectMatch("2") // And we're done. pty.WriteLine("exit") @@ -475,15 +500,8 @@ func TestSSH(t *testing.T) { pty.ExpectMatch("Waiting") - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(agentToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - Logger: slogtest.Make(t, nil).Named("agent"), - }) - defer func() { - _ = agentCloser.Close() - }() + agenttest.New(t, client.URL, agentToken) + coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) // Shells on Mac, Windows, and Linux all exit shells with the "exit" command. pty.WriteLine("exit") @@ -651,16 +669,12 @@ Expire-Date: 0 client, workspace, agentToken := setupWorkspaceForAgent(t, nil) - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(agentToken) - agentCloser := agent.New(agent.Options{ - Client: agentClient, - EnvironmentVariables: map[string]string{ + _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { + o.EnvironmentVariables = map[string]string{ "GNUPGHOME": gnupgHomeWorkspace, - }, - Logger: slogtest.Make(t, nil).Named("agent"), + } }) - defer agentCloser.Close() + coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) inv, root := clitest.New(t, "ssh", diff --git a/cli/start.go b/cli/start.go index 5bd35867fd105..b74426570e75f 100644 --- a/cli/start.go +++ b/cli/start.go @@ -2,30 +2,17 @@ package cli import ( "fmt" + "net/http" "time" + "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) -// workspaceParameterFlags are used by "start", "restart", and "update". -type workspaceParameterFlags struct { - buildOptions bool -} - -func (wpf *workspaceParameterFlags) options() []clibase.Option { - return clibase.OptionSet{ - { - Flag: "build-options", - Description: "Prompt for one-time build options defined with ephemeral parameters.", - Value: clibase.BoolOf(&wpf.buildOptions), - }, - } -} - func (r *RootCmd) start() *clibase.Cmd { var parameterFlags workspaceParameterFlags @@ -38,31 +25,56 @@ func (r *RootCmd) start() *clibase.Cmd { clibase.RequireNArgs(1), r.InitClient(client), ), - Options: append(parameterFlags.options(), cliui.SkipPromptOption()), + Options: append(parameterFlags.cliBuildOptions(), cliui.SkipPromptOption()), Handler: func(inv *clibase.Invocation) error { workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0]) if err != nil { return err } - template, err := client.Template(inv.Context(), workspace.TemplateID) + lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) if err != nil { return err } - buildParams, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ - Template: template, - BuildOptions: parameterFlags.buildOptions, + buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) + if err != nil { + return xerrors.Errorf("unable to parse build options: %w", err) + } + + buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ + Action: WorkspaceStart, + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, + + LastBuildParameters: lastBuildParameters, + + PromptBuildOptions: parameterFlags.promptBuildOptions, + BuildOptions: buildOptions, }) if err != nil { return err } - build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + req := codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, - RichParameterValues: buildParams.richParameters, - }) - if err != nil { + RichParameterValues: buildParameters, + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, + } + + build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, req) + // It's possible for a workspace build to fail due to the template requiring starting + // workspaces with the active version. + if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusUnauthorized { + build, err = startWorkspaceActiveVersion(inv, client, startWorkspaceActiveVersionArgs{ + BuildOptions: buildOptions, + LastBuildParameters: lastBuildParameters, + PromptBuildOptions: parameterFlags.promptBuildOptions, + Workspace: workspace, + }) + if err != nil { + return xerrors.Errorf("start workspace with active template version: %w", err) + } + } else if err != nil { return err } @@ -71,7 +83,10 @@ func (r *RootCmd) start() *clibase.Cmd { return err } - _, _ = fmt.Fprintf(inv.Stdout, "\nThe %s workspace has been started at %s!\n", cliui.DefaultStyles.Keyword.Render(workspace.Name), cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))) + _, _ = fmt.Fprintf( + inv.Stdout, "\nThe %s workspace has been started at %s!\n", + cliui.Keyword(workspace.Name), cliui.Timestamp(time.Now()), + ) return nil }, } @@ -79,16 +94,21 @@ func (r *RootCmd) start() *clibase.Cmd { } type prepStartWorkspaceArgs struct { - Template codersdk.Template - BuildOptions bool + Action WorkspaceCLIAction + TemplateVersionID uuid.UUID + + LastBuildParameters []codersdk.WorkspaceBuildParameter + + PromptBuildOptions bool + BuildOptions []codersdk.WorkspaceBuildParameter } -func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args prepStartWorkspaceArgs) (*buildParameters, error) { +func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args prepStartWorkspaceArgs) ([]codersdk.WorkspaceBuildParameter, error) { ctx := inv.Context() - templateVersion, err := client.TemplateVersion(ctx, args.Template.ActiveVersionID) + templateVersion, err := client.TemplateVersion(ctx, args.TemplateVersionID) if err != nil { - return nil, err + return nil, xerrors.Errorf("get template version: %w", err) } templateVersionParameters, err := client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID) @@ -96,30 +116,49 @@ func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args p return nil, xerrors.Errorf("get template version rich parameters: %w", err) } - richParameters := make([]codersdk.WorkspaceBuildParameter, 0) - if !args.BuildOptions { - return &buildParameters{ - richParameters: richParameters, - }, nil + resolver := new(ParameterResolver). + WithLastBuildParameters(args.LastBuildParameters). + WithPromptBuildOptions(args.PromptBuildOptions). + WithBuildOptions(args.BuildOptions) + return resolver.Resolve(inv, args.Action, templateVersionParameters) +} + +type startWorkspaceActiveVersionArgs struct { + BuildOptions []codersdk.WorkspaceBuildParameter + LastBuildParameters []codersdk.WorkspaceBuildParameter + PromptBuildOptions bool + Workspace codersdk.Workspace +} + +func startWorkspaceActiveVersion(inv *clibase.Invocation, client *codersdk.Client, args startWorkspaceActiveVersionArgs) (codersdk.WorkspaceBuild, error) { + _, _ = fmt.Fprintln(inv.Stdout, "Failed to restart with the template version from your last build. Policy may require you to restart with the current active template version.") + + template, err := client.Template(inv.Context(), args.Workspace.TemplateID) + if err != nil { + return codersdk.WorkspaceBuild{}, xerrors.Errorf("get template: %w", err) } - for _, templateVersionParameter := range templateVersionParameters { - if !templateVersionParameter.Ephemeral { - continue - } + buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ + Action: WorkspaceStart, + TemplateVersionID: template.ActiveVersionID, + + LastBuildParameters: args.LastBuildParameters, - parameterValue, err := cliui.RichParameter(inv, templateVersionParameter) - if err != nil { - return nil, err - } + PromptBuildOptions: args.PromptBuildOptions, + BuildOptions: args.BuildOptions, + }) + if err != nil { + return codersdk.WorkspaceBuild{}, err + } - richParameters = append(richParameters, codersdk.WorkspaceBuildParameter{ - Name: templateVersionParameter.Name, - Value: parameterValue, - }) + build, err := client.CreateWorkspaceBuild(inv.Context(), args.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + RichParameterValues: buildParameters, + TemplateVersionID: template.ActiveVersionID, + }) + if err != nil { + return codersdk.WorkspaceBuild{}, err } - return &buildParameters{ - richParameters: richParameters, - }, nil + return build, nil } diff --git a/cli/start_test.go b/cli/start_test.go index a302fe2ac1c40..8a0e015f5c2ea 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -1,25 +1,31 @@ package cli_test import ( + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/net/context" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) const ( ephemeralParameterName = "ephemeral_parameter" ephemeralParameterDescription = "This is ephemeral parameter" ephemeralParameterValue = "3" + + immutableParameterName = "immutable_parameter" + immutableParameterDescription = "This is immutable parameter" + immutableParameterValue = "abc" ) func TestStart(t *testing.T) { @@ -27,10 +33,10 @@ func TestStart(t *testing.T) { echoResponses := &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Parameters: []*proto.RichParameter{ { Name: ephemeralParameterName, @@ -43,26 +49,23 @@ func TestStart(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, + ProvisionApply: echo.ApplyComplete, } t.Run("BuildOptions", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, root := clitest.New(t, "start", workspace.Name, "--build-options") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) go func() { @@ -99,4 +102,120 @@ func TestStart(t *testing.T) { Value: ephemeralParameterValue, }) }) + + t.Run("BuildOptionFlags", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + inv, root := clitest.New(t, "start", workspace.Name, + "--build-option", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue)) + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + pty.ExpectMatch("workspace has been started") + <-doneChan + + // Verify if build option is set + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{ + Name: ephemeralParameterName, + Value: ephemeralParameterValue, + }) + }) +} + +func TestStartWithParameters(t *testing.T) { + t.Parallel() + + echoResponses := &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{ + { + Name: immutableParameterName, + Description: immutableParameterDescription, + Required: true, + }, + }, + }, + }, + }, + }, + ProvisionApply: echo.ApplyComplete, + } + + t.Run("DoNotAskForImmutables", func(t *testing.T) { + t.Parallel() + + // Create the workspace + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + { + Name: immutableParameterName, + Value: immutableParameterValue, + }, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Stop the workspace + workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceBuild.ID) + + // Start the workspace again + inv, root := clitest.New(t, "start", workspace.Name) + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + pty.ExpectMatch("workspace has been started") + <-doneChan + + // Verify if immutable parameter is set + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{ + Name: immutableParameterName, + Value: immutableParameterValue, + }) + }) } diff --git a/cli/stat.go b/cli/stat.go index 3e32c4187f93b..a2a79fdd39571 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -7,36 +7,48 @@ import ( "github.com/spf13/afero" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/clistat" - "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/clistat" + "github.com/coder/coder/v2/cli/cliui" ) -func (r *RootCmd) stat() *clibase.Cmd { - fs := afero.NewReadOnlyFs(afero.NewOsFs()) - defaultCols := []string{ - "host_cpu", - "host_memory", - "home_disk", - "container_cpu", - "container_memory", - } - formatter := cliui.NewOutputFormatter( - cliui.TableFormat([]statsRow{}, defaultCols), - cliui.JSONFormat(), - ) - st, err := clistat.New(clistat.WithFS(fs)) - if err != nil { - panic(xerrors.Errorf("initialize workspace stats collector: %w", err)) +func initStatterMW(tgt **clistat.Statter, fs afero.Fs) clibase.MiddlewareFunc { + return func(next clibase.HandlerFunc) clibase.HandlerFunc { + return func(i *clibase.Invocation) error { + var err error + stat, err := clistat.New(clistat.WithFS(fs)) + if err != nil { + return xerrors.Errorf("initialize workspace stats collector: %w", err) + } + *tgt = stat + return next(i) + } } +} +func (r *RootCmd) stat() *clibase.Cmd { + var ( + st *clistat.Statter + fs = afero.NewReadOnlyFs(afero.NewOsFs()) + formatter = cliui.NewOutputFormatter( + cliui.TableFormat([]statsRow{}, []string{ + "host_cpu", + "host_memory", + "home_disk", + "container_cpu", + "container_memory", + }), + cliui.JSONFormat(), + ) + ) cmd := &clibase.Cmd{ - Use: "stat", - Short: "Show resource usage for the current workspace.", + Use: "stat", + Short: "Show resource usage for the current workspace.", + Middleware: initStatterMW(&st, fs), Children: []*clibase.Cmd{ - r.statCPU(st, fs), - r.statMem(st, fs), - r.statDisk(st), + r.statCPU(fs), + r.statMem(fs), + r.statDisk(fs), }, Handler: func(inv *clibase.Invocation) error { var sr statsRow @@ -118,12 +130,16 @@ func (r *RootCmd) stat() *clibase.Cmd { return cmd } -func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { - var hostArg bool - formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) +func (*RootCmd) statCPU(fs afero.Fs) *clibase.Cmd { + var ( + hostArg bool + st *clistat.Statter + formatter = cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) + ) cmd := &clibase.Cmd{ - Use: "cpu", - Short: "Show CPU usage, in cores.", + Use: "cpu", + Short: "Show CPU usage, in cores.", + Middleware: initStatterMW(&st, fs), Options: clibase.OptionSet{ { Flag: "host", @@ -135,9 +151,9 @@ func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { var cs *clistat.Result var err error if ok, _ := clistat.IsContainerized(fs); ok && !hostArg { - cs, err = s.ContainerCPU() + cs, err = st.ContainerCPU() } else { - cs, err = s.HostCPU() + cs, err = st.HostCPU() } if err != nil { return err @@ -155,13 +171,17 @@ func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { return cmd } -func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { - var hostArg bool - var prefixArg string - formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) +func (*RootCmd) statMem(fs afero.Fs) *clibase.Cmd { + var ( + hostArg bool + prefixArg string + st *clistat.Statter + formatter = cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) + ) cmd := &clibase.Cmd{ - Use: "mem", - Short: "Show memory usage, in gigabytes.", + Use: "mem", + Short: "Show memory usage, in gigabytes.", + Middleware: initStatterMW(&st, fs), Options: clibase.OptionSet{ { Flag: "host", @@ -185,9 +205,9 @@ func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { var ms *clistat.Result var err error if ok, _ := clistat.IsContainerized(fs); ok && !hostArg { - ms, err = s.ContainerMemory(pfx) + ms, err = st.ContainerMemory(pfx) } else { - ms, err = s.HostMemory(pfx) + ms, err = st.HostMemory(pfx) } if err != nil { return err @@ -205,13 +225,17 @@ func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { return cmd } -func (*RootCmd) statDisk(s *clistat.Statter) *clibase.Cmd { - var pathArg string - var prefixArg string - formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) +func (*RootCmd) statDisk(fs afero.Fs) *clibase.Cmd { + var ( + pathArg string + prefixArg string + st *clistat.Statter + formatter = cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) + ) cmd := &clibase.Cmd{ - Use: "disk", - Short: "Show disk usage, in gigabytes.", + Use: "disk", + Short: "Show disk usage, in gigabytes.", + Middleware: initStatterMW(&st, fs), Options: clibase.OptionSet{ { Flag: "path", @@ -233,8 +257,16 @@ func (*RootCmd) statDisk(s *clistat.Statter) *clibase.Cmd { }, Handler: func(inv *clibase.Invocation) error { pfx := clistat.ParsePrefix(prefixArg) - ds, err := s.Disk(pfx, pathArg) + // Users may also call `coder stat disk `. + if len(inv.Args) > 0 { + pathArg = inv.Args[0] + } + ds, err := st.Disk(pfx, pathArg) if err != nil { + if os.IsNotExist(err) { + //nolint:gocritic // fmt.Errorf produces a more concise error. + return fmt.Errorf("not found: %q", pathArg) + } return err } diff --git a/cli/stat_test.go b/cli/stat_test.go index d92574e339b89..74d7d109f98d5 100644 --- a/cli/stat_test.go +++ b/cli/stat_test.go @@ -9,9 +9,9 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clistat" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clistat" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/testutil" ) // This just tests that the stat command is recognized and does not output @@ -74,7 +74,7 @@ func TestStatCPUCmd(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) t.Cleanup(cancel) - inv, _ := clitest.New(t, "stat", "cpu", "--output=text") + inv, _ := clitest.New(t, "stat", "cpu", "--output=text", "--host") buf := new(bytes.Buffer) inv.Stdout = buf err := inv.WithContext(ctx).Run() @@ -87,7 +87,7 @@ func TestStatCPUCmd(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) t.Cleanup(cancel) - inv, _ := clitest.New(t, "stat", "cpu", "--output=json") + inv, _ := clitest.New(t, "stat", "cpu", "--output=json", "--host") buf := new(bytes.Buffer) inv.Stdout = buf err := inv.WithContext(ctx).Run() @@ -170,4 +170,16 @@ func TestStatDiskCmd(t *testing.T) { require.NotZero(t, *tmp.Total) require.Equal(t, "B", tmp.Unit) }) + + t.Run("PosArg", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, "stat", "disk", "/this/path/does/not/exist", "--output=text") + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.Error(t, err) + require.Contains(t, err.Error(), `not found: "/this/path/does/not/exist"`) + }) } diff --git a/cli/state.go b/cli/state.go index dd18e56d90f41..8175cdaa68635 100644 --- a/cli/state.go +++ b/cli/state.go @@ -6,9 +6,9 @@ import ( "os" "strconv" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) state() *clibase.Cmd { diff --git a/cli/state_test.go b/cli/state_test.go index 2a208fd64d25c..5ca96f5089013 100644 --- a/cli/state_test.go +++ b/cli/state_test.go @@ -10,10 +10,11 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" ) func TestStatePull(t *testing.T) { @@ -21,25 +22,27 @@ func TestStatePull(t *testing.T) { t.Run("File", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) wantState := []byte("some state") - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ State: wantState, }, }, }}, }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + // Need to create workspace as templateAdmin to ensure we can read state. + workspace := coderdtest.CreateWorkspace(t, templateAdmin, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) statefilePath := filepath.Join(t.TempDir(), "state") inv, root := clitest.New(t, "state", "pull", workspace.Name, statefilePath) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) err := inv.Run() require.NoError(t, err) gotState, err := os.ReadFile(statefilePath) @@ -49,26 +52,27 @@ func TestStatePull(t *testing.T) { t.Run("Stdout", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) wantState := []byte("some state") - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ State: wantState, }, }, }}, }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, templateAdmin, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, root := clitest.New(t, "state", "pull", workspace.Name) var gotState bytes.Buffer inv.Stdout = &gotState - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) err := inv.Run() require.NoError(t, err) require.Equal(t, wantState, bytes.TrimSpace(gotState.Bytes())) @@ -80,15 +84,16 @@ func TestStatePush(t *testing.T) { t.Run("File", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, templateAdmin, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) stateFile, err := os.CreateTemp(t.TempDir(), "") require.NoError(t, err) wantState := []byte("some magic state") @@ -97,7 +102,7 @@ func TestStatePush(t *testing.T) { err = stateFile.Close() require.NoError(t, err) inv, root := clitest.New(t, "state", "push", workspace.Name, stateFile.Name()) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) err = inv.Run() require.NoError(t, err) }) @@ -105,17 +110,18 @@ func TestStatePush(t *testing.T) { t.Run("Stdin", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, templateAdmin, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) inv, root := clitest.New(t, "state", "push", "--build", strconv.Itoa(int(workspace.LatestBuild.BuildNumber)), workspace.Name, "-") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) inv.Stdin = strings.NewReader("some magic state") err := inv.Run() require.NoError(t, err) diff --git a/cli/stop.go b/cli/stop.go index 1dbf446ed2979..ea26e426e6323 100644 --- a/cli/stop.go +++ b/cli/stop.go @@ -4,9 +4,9 @@ import ( "fmt" "time" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) stop() *clibase.Cmd { @@ -47,7 +47,12 @@ func (r *RootCmd) stop() *clibase.Cmd { return err } - _, _ = fmt.Fprintf(inv.Stdout, "\nThe %s workspace has been stopped at %s!\n", cliui.DefaultStyles.Keyword.Render(workspace.Name), cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))) + _, _ = fmt.Fprintf( + inv.Stdout, + "\nThe %s workspace has been stopped at %s!\n", cliui.Keyword(workspace.Name), + + cliui.Timestamp(time.Now()), + ) return nil }, } diff --git a/cli/telemetry/telemetry.go b/cli/telemetry/telemetry.go new file mode 100644 index 0000000000000..e2ef8adc7409c --- /dev/null +++ b/cli/telemetry/telemetry.go @@ -0,0 +1,15 @@ +package telemetry + +import "time" + +type Option struct { + Name string `json:"name"` + ValueSource string `json:"value_source"` +} + +type Invocation struct { + Command string `json:"command"` + Options []Option `json:"options"` + // InvokedAt is provided for deduplication purposes. + InvokedAt time.Time `json:"invoked_at"` +} diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 77a869bdc0518..da0793121d949 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -14,24 +14,27 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisionerd" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) templateCreate() *clibase.Cmd { var ( - provisioner string - provisionerTags []string - variablesFile string - variables []string - disableEveryone bool - defaultTTL time.Duration - failureTTL time.Duration - inactivityTTL time.Duration + provisioner string + provisionerTags []string + variablesFile string + variables []string + disableEveryone bool + requireActiveVersion bool + + defaultTTL time.Duration + failureTTL time.Duration + inactivityTTL time.Duration + maxTTL time.Duration uploadFlags templateUploadFlags ) @@ -44,27 +47,47 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { - if failureTTL != 0 || inactivityTTL != 0 { - // This call can be removed when workspace_actions is no longer experimental - experiments, exErr := client.Experiments(inv.Context()) - if exErr != nil { - return xerrors.Errorf("get experiments: %w", exErr) - } - - if !experiments.Enabled(codersdk.ExperimentWorkspaceActions) { - return xerrors.Errorf("--failure-ttl and --inactivityTTL are experimental features. Use the workspace_actions CODER_EXPERIMENTS flag to set these configuration values.") + isTemplateSchedulingOptionsSet := failureTTL != 0 || inactivityTTL != 0 || maxTTL != 0 + + if isTemplateSchedulingOptionsSet || requireActiveVersion { + if failureTTL != 0 || inactivityTTL != 0 { + // This call can be removed when workspace_actions is no longer experimental + experiments, exErr := client.Experiments(inv.Context()) + if exErr != nil { + return xerrors.Errorf("get experiments: %w", exErr) + } + + if !experiments.Enabled(codersdk.ExperimentWorkspaceActions) { + return xerrors.Errorf("--failure-ttl and --inactivity-ttl are experimental features. Use the workspace_actions CODER_EXPERIMENTS flag to set these configuration values.") + } } entitlements, err := client.Entitlements(inv.Context()) - var sdkErr *codersdk.Error - if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { - return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --failure-ttl or --inactivityTTL") + if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusNotFound { + return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set enterprise-only flags") } else if err != nil { return xerrors.Errorf("get entitlements: %w", err) } - if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { - return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --failure-ttl or --inactivityTTL") + if isTemplateSchedulingOptionsSet { + if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { + return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --failure-ttl, --inactivity-ttl, or --max-ttl") + } + } + + if requireActiveVersion { + if !entitlements.Features[codersdk.FeatureAccessControl].Enabled { + return xerrors.Errorf("your license is not entitled to use enterprise access control, so you cannot set --require-active-version") + } + + experiments, exErr := client.Experiments(inv.Context()) + if exErr != nil { + return xerrors.Errorf("get experiments: %w", exErr) + } + + if !experiments.Enabled(codersdk.ExperimentTemplateUpdatePolicies) { + return xerrors.Errorf("--require-active-version is an experimental feature, contact an administrator to enable the 'template_update_policies' experiment on your Coder server") + } } } @@ -109,7 +132,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { Message: message, Client: client, Organization: organization, - Provisioner: database.ProvisionerType(provisioner), + Provisioner: codersdk.ProvisionerType(provisioner), FileID: resp.ID, ProvisionerTags: tags, VariablesFile: variablesFile, @@ -134,8 +157,10 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { VersionID: job.ID, DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), FailureTTLMillis: ptr.Ref(failureTTL.Milliseconds()), - InactivityTTLMillis: ptr.Ref(inactivityTTL.Milliseconds()), + MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), + TimeTilDormantMillis: ptr.Ref(inactivityTTL.Milliseconds()), DisableEveryoneGroupAccess: disableEveryone, + RequireActiveVersion: requireActiveVersion, } _, err = client.CreateTemplate(inv.Context(), organization.ID, createReq) @@ -143,11 +168,13 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { return err } - _, _ = fmt.Fprintln(inv.Stdout, "\n"+cliui.DefaultStyles.Wrap.Render( - "The "+cliui.DefaultStyles.Keyword.Render(templateName)+" template has been created at "+cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))+"! "+ + _, _ = fmt.Fprintln(inv.Stdout, "\n"+pretty.Sprint(cliui.DefaultStyles.Wrap, + "The "+pretty.Sprint( + cliui.DefaultStyles.Keyword, templateName)+" template has been created at "+ + pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Now().Format(time.Stamp))+"! "+ "Developers can provision a workspace with this template using:")+"\n") - _, _ = fmt.Fprintln(inv.Stdout, " "+cliui.DefaultStyles.Code.Render(fmt.Sprintf("coder create --template=%q [workspace name]", templateName))) + _, _ = fmt.Fprintln(inv.Stdout, " "+pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("coder create --template=%q [workspace name]", templateName))) _, _ = fmt.Fprintln(inv.Stdout) return nil @@ -182,22 +209,27 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { }, { Flag: "default-ttl", - Description: "Specify a default TTL for workspaces created from this template.", + Description: "Specify a default TTL for workspaces created from this template. It is the default time before shutdown - workspaces created from this template default to this value. Maps to \"Default autostop\" in the UI.", Default: "24h", Value: clibase.DurationOf(&defaultTTL), }, { Flag: "failure-ttl", - Description: "Specify a failure TTL for workspaces created from this template. This licensed feature's default is 0h (off).", + Description: "Specify a failure TTL for workspaces created from this template. It is the amount of time after a failed \"start\" build before coder automatically schedules a \"stop\" build to cleanup.This licensed feature's default is 0h (off). Maps to \"Failure cleanup\"in the UI.", Default: "0h", Value: clibase.DurationOf(&failureTTL), }, { Flag: "inactivity-ttl", - Description: "Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off).", + Description: "Specify an inactivity TTL for workspaces created from this template. It is the amount of time the workspace is not used before it is be stopped and auto-locked. This includes across multiple builds (e.g. auto-starts and stops). This licensed feature's default is 0h (off). Maps to \"Dormancy threshold\" in the UI.", Default: "0h", Value: clibase.DurationOf(&inactivityTTL), }, + { + Flag: "max-ttl", + Description: "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature.", + Value: clibase.DurationOf(&maxTTL), + }, { Flag: "test.provisioner", Description: "Customize the provisioner backend.", @@ -205,6 +237,13 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { Value: clibase.StringOf(&provisioner), Hidden: true, }, + { + Flag: "require-active-version", + Description: "Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature.", + Value: clibase.BoolOf(&requireActiveVersion), + Default: "false", + }, + cliui.SkipPromptOption(), } cmd.Options = append(cmd.Options, uploadFlags.options()...) @@ -216,7 +255,7 @@ type createValidTemplateVersionArgs struct { Message string Client *codersdk.Client Organization codersdk.Organization - Provisioner database.ProvisionerType + Provisioner codersdk.ProvisionerType FileID uuid.UUID VariablesFile string @@ -250,7 +289,7 @@ func createValidTemplateVersion(inv *clibase.Invocation, args createValidTemplat Message: args.Message, StorageMethod: codersdk.ProvisionerStorageMethodFile, FileID: args.FileID, - Provisioner: codersdk.ProvisionerType(args.Provisioner), + Provisioner: args.Provisioner, ProvisionerTags: args.ProvisionerTags, UserVariableValues: variableValues, } @@ -276,7 +315,10 @@ func createValidTemplateVersion(inv *clibase.Invocation, args createValidTemplat }) if err != nil { var jobErr *cliui.ProvisionerJobError - if errors.As(err, &jobErr) && !provisionerd.IsMissingParameterErrorCode(string(jobErr.Code)) { + if errors.As(err, &jobErr) && !codersdk.JobIsMissingParameterErrorCode(jobErr.Code) { + return nil, err + } + if err != nil { return nil, err } } @@ -322,12 +364,12 @@ func prettyDirectoryPath(dir string) string { if err != nil { return dir } - pretty := dir - if strings.HasPrefix(pretty, homeDir) { - pretty = strings.TrimPrefix(pretty, homeDir) - pretty = "~" + pretty + prettyDir := dir + if strings.HasPrefix(prettyDir, homeDir) { + prettyDir = strings.TrimPrefix(prettyDir, homeDir) + prettyDir = "~" + prettyDir } - return pretty + return prettyDir } func ParseProvisionerTags(rawTags []string) (map[string]string, error) { diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index 06e180f7dcd6c..ec1720ba2a6a4 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -10,35 +10,62 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) -var provisionCompleteWithAgent = []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{ - { - Type: "compute", - Name: "main", - Agents: []*proto.Agent{ +func completeWithAgent() *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{ + { + Type: "compute", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "smith", + OperatingSystem: "linux", + Architecture: "i386", + }, + }, + }, + }, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ { - Name: "smith", - OperatingSystem: "linux", - Architecture: "i386", + Type: "compute", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "smith", + OperatingSystem: "linux", + Architecture: "i386", + }, + }, }, }, }, }, }, }, - }, + } } func TestTemplateCreate(t *testing.T) { @@ -47,10 +74,7 @@ func TestTemplateCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) coderdtest.CreateFirstUser(t, client) - source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) args := []string{ "templates", "create", @@ -85,10 +109,7 @@ func TestTemplateCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) coderdtest.CreateFirstUser(t, client) - source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) require.NoError(t, os.Remove(filepath.Join(source, ".terraform.lock.hcl"))) args := []string{ "templates", @@ -128,10 +149,7 @@ func TestTemplateCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) coderdtest.CreateFirstUser(t, client) - source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) require.NoError(t, os.Remove(filepath.Join(source, ".terraform.lock.hcl"))) args := []string{ "templates", @@ -167,10 +185,7 @@ func TestTemplateCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) coderdtest.CreateFirstUser(t, client) - source, err := echo.Tar(&echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + source, err := echo.Tar(completeWithAgent()) require.NoError(t, err) args := []string{ @@ -196,10 +211,7 @@ func TestTemplateCreate(t *testing.T) { coderdtest.CreateFirstUser(t, client) create := func() error { - source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) args := []string{ "templates", "create", @@ -382,6 +394,36 @@ func TestTemplateCreate(t *testing.T) { } } }) + + t.Run("RequireActiveVersionInvalid", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{ + string(codersdk.ExperimentTemplateUpdatePolicies), + } + + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dv, + }) + coderdtest.CreateFirstUser(t, client) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) + args := []string{ + "templates", + "create", + "my-template", + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--require-active-version", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "your deployment appears to be an AGPL deployment, so you cannot set enterprise-only flags") + }) } // Need this for Windows because of a known issue with Go: diff --git a/cli/templatedelete.go b/cli/templatedelete.go index d954dbf44c081..e15fe4bd48722 100644 --- a/cli/templatedelete.go +++ b/cli/templatedelete.go @@ -7,9 +7,11 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) templateDelete() *clibase.Cmd { @@ -46,38 +48,18 @@ func (r *RootCmd) templateDelete() *clibase.Cmd { templates = append(templates, template) } } else { - allTemplates, err := client.TemplatesByOrganization(ctx, organization.ID) - if err != nil { - return xerrors.Errorf("get templates by organization: %w", err) - } - - if len(allTemplates) == 0 { - return xerrors.Errorf("no templates exist in the current organization %q", organization.Name) - } - - opts := make([]string, 0, len(allTemplates)) - for _, template := range allTemplates { - opts = append(opts, template.Name) - } - - selection, err := cliui.Select(inv, cliui.SelectOptions{ - Options: opts, - }) + template, err := selectTemplate(inv, client, organization) if err != nil { - return xerrors.Errorf("select template: %w", err) + return err } - for _, template := range allTemplates { - if template.Name == selection { - templates = append(templates, template) - templateNames = append(templateNames, template.Name) - } - } + templates = append(templates, template) + templateNames = append(templateNames, template.Name) } // Confirm deletion of the template. _, err = cliui.Prompt(inv, cliui.PromptOptions{ - Text: fmt.Sprintf("Delete these templates: %s?", cliui.DefaultStyles.Code.Render(strings.Join(templateNames, ", "))), + Text: fmt.Sprintf("Delete these templates: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(templateNames, ", "))), IsConfirm: true, Default: cliui.ConfirmNo, }) @@ -91,7 +73,9 @@ func (r *RootCmd) templateDelete() *clibase.Cmd { return xerrors.Errorf("delete template %q: %w", template.Name, err) } - _, _ = fmt.Fprintln(inv.Stdout, "Deleted template "+cliui.DefaultStyles.Code.Render(template.Name)+" at "+cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))+"!") + _, _ = fmt.Fprintln( + inv.Stdout, "Deleted template "+pretty.Sprint(cliui.DefaultStyles.Keyword, template.Name)+" at "+cliui.Timestamp(time.Now()), + ) } return nil diff --git a/cli/templatedelete_test.go b/cli/templatedelete_test.go index 1f7c032b11d59..d81a3235f59f5 100644 --- a/cli/templatedelete_test.go +++ b/cli/templatedelete_test.go @@ -8,11 +8,14 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" ) func TestTemplateDelete(t *testing.T) { @@ -22,14 +25,15 @@ func TestTemplateDelete(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) inv, root := clitest.New(t, "templates", "delete", template.Name) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) execDone := make(chan error) @@ -37,7 +41,7 @@ func TestTemplateDelete(t *testing.T) { execDone <- inv.Run() }() - pty.ExpectMatch(fmt.Sprintf("Delete these templates: %s?", cliui.DefaultStyles.Code.Render(template.Name))) + pty.ExpectMatch(fmt.Sprintf("Delete these templates: %s?", pretty.Sprint(cliui.DefaultStyles.Code, template.Name))) pty.WriteLine("yes") require.NoError(t, <-execDone) @@ -50,19 +54,20 @@ func TestTemplateDelete(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) templates := []codersdk.Template{} templateNames := []string{} for i := 0; i < 3; i++ { - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) templates = append(templates, template) templateNames = append(templateNames, template.Name) } inv, root := clitest.New(t, append([]string{"templates", "delete", "--yes"}, templateNames...)...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) require.NoError(t, inv.Run()) for _, template := range templates { @@ -75,19 +80,20 @@ func TestTemplateDelete(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) templates := []codersdk.Template{} templateNames := []string{} for i := 0; i < 3; i++ { - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) templates = append(templates, template) templateNames = append(templateNames, template.Name) } inv, root := clitest.New(t, append([]string{"templates", "delete"}, templateNames...)...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) execDone := make(chan error) @@ -95,7 +101,7 @@ func TestTemplateDelete(t *testing.T) { execDone <- inv.Run() }() - pty.ExpectMatch(fmt.Sprintf("Delete these templates: %s?", cliui.DefaultStyles.Code.Render(strings.Join(templateNames, ", ")))) + pty.ExpectMatch(fmt.Sprintf("Delete these templates: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(templateNames, ", ")))) pty.WriteLine("yes") require.NoError(t, <-execDone) @@ -110,13 +116,14 @@ func TestTemplateDelete(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) inv, root := clitest.New(t, "templates", "delete") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) diff --git a/cli/templateedit.go b/cli/templateedit.go index 6c8173c452817..1c17ec52bcab3 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -8,26 +8,30 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) templateEdit() *clibase.Cmd { var ( - name string - displayName string - description string - icon string - defaultTTL time.Duration - maxTTL time.Duration - restartRequirementDaysOfWeek []string - restartRequirementWeeks int64 - failureTTL time.Duration - inactivityTTL time.Duration - allowUserCancelWorkspaceJobs bool - allowUserAutostart bool - allowUserAutostop bool + name string + displayName string + description string + icon string + defaultTTL time.Duration + maxTTL time.Duration + autostopRequirementDaysOfWeek []string + autostopRequirementWeeks int64 + autostartRequirementDaysOfWeek []string + failureTTL time.Duration + inactivityTTL time.Duration + allowUserCancelWorkspaceJobs bool + allowUserAutostart bool + allowUserAutostop bool + requireActiveVersion bool ) client := new(codersdk.Client) @@ -47,30 +51,47 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { } if !experiments.Enabled(codersdk.ExperimentWorkspaceActions) { - return xerrors.Errorf("--failure-ttl and --inactivityTTL are experimental features. Use the workspace_actions CODER_EXPERIMENTS flag to set these configuration values.") + return xerrors.Errorf("--failure-ttl and --inactivity-ttl are experimental features. Use the workspace_actions CODER_EXPERIMENTS flag to set these configuration values.") } } - unsetRestartRequirementDaysOfWeek := len(restartRequirementDaysOfWeek) == 1 && restartRequirementDaysOfWeek[0] == "none" - requiresEntitlement := (len(restartRequirementDaysOfWeek) > 0 && !unsetRestartRequirementDaysOfWeek) || - restartRequirementWeeks > 0 || + unsetAutostopRequirementDaysOfWeek := len(autostopRequirementDaysOfWeek) == 1 && autostopRequirementDaysOfWeek[0] == "none" + requiresScheduling := (len(autostopRequirementDaysOfWeek) > 0 && !unsetAutostopRequirementDaysOfWeek) || + autostopRequirementWeeks > 0 || !allowUserAutostart || !allowUserAutostop || maxTTL != 0 || failureTTL != 0 || - inactivityTTL != 0 + inactivityTTL != 0 || + len(autostartRequirementDaysOfWeek) > 0 + + requiresEntitlement := requiresScheduling || requireActiveVersion if requiresEntitlement { entitlements, err := client.Entitlements(inv.Context()) - var sdkErr *codersdk.Error - if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { - return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl, --failure-ttl, --inactivityTTL, --allow-user-autostart=false or --allow-user-autostop=false") + if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusNotFound { + return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set enterprise-only flags") } else if err != nil { return xerrors.Errorf("get entitlements: %w", err) } - if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { + if requiresScheduling && !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl, --failure-ttl, --inactivityTTL, --allow-user-autostart=false or --allow-user-autostop=false") } + + if requireActiveVersion { + if !entitlements.Features[codersdk.FeatureAccessControl].Enabled { + return xerrors.Errorf("your license is not entitled to use enterprise access control, so you cannot set --require-active-version") + } + + experiments, exErr := client.Experiments(inv.Context()) + if exErr != nil { + return xerrors.Errorf("get experiments: %w", exErr) + } + + if !experiments.Enabled(codersdk.ExperimentTemplateUpdatePolicies) { + return xerrors.Errorf("--require-active-version is an experimental feature, contact an administrator to enable the 'template_update_policies' experiment on your Coder server") + } + } } organization, err := CurrentOrganization(inv, client) @@ -84,11 +105,17 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { // Copy the default value if the list is empty, or if the user // specified the "none" value clear the list. - if len(restartRequirementDaysOfWeek) == 0 { - restartRequirementDaysOfWeek = template.RestartRequirement.DaysOfWeek + if len(autostopRequirementDaysOfWeek) == 0 { + autostopRequirementDaysOfWeek = template.AutostopRequirement.DaysOfWeek + } + if len(autostartRequirementDaysOfWeek) == 1 && autostartRequirementDaysOfWeek[0] == "all" { + // Set it to every day of the week + autostartRequirementDaysOfWeek = []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} + } else if len(autostartRequirementDaysOfWeek) == 0 { + autostartRequirementDaysOfWeek = template.AutostartRequirement.DaysOfWeek } - if unsetRestartRequirementDaysOfWeek { - restartRequirementDaysOfWeek = []string{} + if unsetAutostopRequirementDaysOfWeek { + autostopRequirementDaysOfWeek = []string{} } // NOTE: coderd will ignore empty fields. @@ -99,22 +126,26 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Icon: icon, DefaultTTLMillis: defaultTTL.Milliseconds(), MaxTTLMillis: maxTTL.Milliseconds(), - RestartRequirement: &codersdk.TemplateRestartRequirement{ - DaysOfWeek: restartRequirementDaysOfWeek, - Weeks: restartRequirementWeeks, + AutostopRequirement: &codersdk.TemplateAutostopRequirement{ + DaysOfWeek: autostopRequirementDaysOfWeek, + Weeks: autostopRequirementWeeks, + }, + AutostartRequirement: &codersdk.TemplateAutostartRequirement{ + DaysOfWeek: autostartRequirementDaysOfWeek, }, FailureTTLMillis: failureTTL.Milliseconds(), - InactivityTTLMillis: inactivityTTL.Milliseconds(), + TimeTilDormantMillis: inactivityTTL.Milliseconds(), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, AllowUserAutostart: allowUserAutostart, AllowUserAutostop: allowUserAutostop, + RequireActiveVersion: requireActiveVersion, } _, err = client.UpdateTemplateMeta(inv.Context(), template.ID, req) if err != nil { return xerrors.Errorf("update template metadata: %w", err) } - _, _ = fmt.Fprintf(inv.Stdout, "Updated template metadata at %s!\n", cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))) + _, _ = fmt.Fprintf(inv.Stdout, "Updated template metadata at %s!\n", pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Now().Format(time.Stamp))) return nil }, } @@ -142,47 +173,63 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { }, { Flag: "default-ttl", - Description: "Edit the template default time before shutdown - workspaces created from this template default to this value.", + Description: "Edit the template default time before shutdown - workspaces created from this template default to this value. Maps to \"Default autostop\" in the UI.", Value: clibase.DurationOf(&defaultTTL), }, { Flag: "max-ttl", - Description: "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature.", + Description: "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting, regardless of user activity. This is an enterprise-only feature. Maps to \"Max lifetime\" in the UI.", Value: clibase.DurationOf(&maxTTL), }, { - Flag: "restart-requirement-weekdays", - Description: "Edit the template restart requirement weekdays - workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the restart requirement for the template), pass 'none'.", + Flag: "autostart-requirement-weekdays", + // workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the autostop requirement for the template), pass 'none'. + Description: "Edit the template autostart requirement weekdays - workspaces created from this template can only autostart on the given weekdays. To unset this value for the template (and allow autostart on all days), pass 'all'.", + Value: clibase.Validate(clibase.StringArrayOf(&autostartRequirementDaysOfWeek), func(value *clibase.StringArray) error { + v := value.GetSlice() + if len(v) == 1 && v[0] == "all" { + return nil + } + _, err := codersdk.WeekdaysToBitmap(v) + if err != nil { + return xerrors.Errorf("invalid autostart requirement days of week %q: %w", strings.Join(v, ","), err) + } + return nil + }), + }, + { + Flag: "autostop-requirement-weekdays", + Description: "Edit the template autostop requirement weekdays - workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the autostop requirement for the template), pass 'none'.", // TODO(@dean): unhide when we delete max_ttl Hidden: true, - Value: clibase.Validate(clibase.StringArrayOf(&restartRequirementDaysOfWeek), func(value *clibase.StringArray) error { + Value: clibase.Validate(clibase.StringArrayOf(&autostopRequirementDaysOfWeek), func(value *clibase.StringArray) error { v := value.GetSlice() if len(v) == 1 && v[0] == "none" { return nil } _, err := codersdk.WeekdaysToBitmap(v) if err != nil { - return xerrors.Errorf("invalid restart requirement days of week %q: %w", strings.Join(v, ","), err) + return xerrors.Errorf("invalid autostop requirement days of week %q: %w", strings.Join(v, ","), err) } return nil }), }, { - Flag: "restart-requirement-weeks", - Description: "Edit the template restart requirement weeks - workspaces created from this template must be restarted on an n-weekly basis.", + Flag: "autostop-requirement-weeks", + Description: "Edit the template autostop requirement weeks - workspaces created from this template must be restarted on an n-weekly basis.", // TODO(@dean): unhide when we delete max_ttl Hidden: true, - Value: clibase.Int64Of(&restartRequirementWeeks), + Value: clibase.Int64Of(&autostopRequirementWeeks), }, { Flag: "failure-ttl", - Description: "Specify a failure TTL for workspaces created from this template. This licensed feature's default is 0h (off).", + Description: "Specify a failure TTL for workspaces created from this template. It is the amount of time after a failed \"start\" build before coder automatically schedules a \"stop\" build to cleanup.This licensed feature's default is 0h (off). Maps to \"Failure cleanup\" in the UI.", Default: "0h", Value: clibase.DurationOf(&failureTTL), }, { Flag: "inactivity-ttl", - Description: "Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off).", + Description: "Specify an inactivity TTL for workspaces created from this template. It is the amount of time the workspace is not used before it is be stopped and auto-locked. This includes across multiple builds (e.g. auto-starts and stops). This licensed feature's default is 0h (off). Maps to \"Dormancy threshold\" in the UI.", Default: "0h", Value: clibase.DurationOf(&inactivityTTL), }, @@ -204,6 +251,12 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Default: "true", Value: clibase.BoolOf(&allowUserAutostop), }, + { + Flag: "require-active-version", + Description: "Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature.", + Value: clibase.BoolOf(&requireActiveVersion), + Default: "false", + }, cliui.SkipPromptOption(), } diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 87944cd5a0f60..de2e52894a444 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -18,11 +18,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func TestTemplateEdit(t *testing.T) { @@ -31,10 +32,11 @@ func TestTemplateEdit(t *testing.T) { t.Run("FirstEmptyThenModified", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) // Test the cli command. name := "new-template-name" @@ -56,7 +58,7 @@ func TestTemplateEdit(t *testing.T) { "--allow-user-cancel-workspace-jobs=" + strconv.FormatBool(allowUserCancelWorkspaceJobs), } inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -76,10 +78,11 @@ func TestTemplateEdit(t *testing.T) { t.Run("FirstEmptyThenNotModified", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) // Test the cli command. cmdArgs := []string{ @@ -93,7 +96,7 @@ func TestTemplateEdit(t *testing.T) { "--allow-user-cancel-workspace-jobs=" + strconv.FormatBool(template.AllowUserCancelWorkspaceJobs), } inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -112,10 +115,11 @@ func TestTemplateEdit(t *testing.T) { t.Run("InvalidDisplayName", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) // Test the cli command. cmdArgs := []string{ @@ -126,7 +130,7 @@ func TestTemplateEdit(t *testing.T) { "--display-name", " a-b-c", } inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -144,15 +148,16 @@ func TestTemplateEdit(t *testing.T) { t.Run("WithPropertiesThenModified", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) initialDisplayName := "This is a template" initialDescription := "This is description" initialIcon := "/img/icon.png" - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DisplayName = initialDisplayName ctr.Description = initialDescription ctr.Icon = initialIcon @@ -178,7 +183,7 @@ func TestTemplateEdit(t *testing.T) { "--icon", icon, } inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) ctx := testutil.Context(t, testutil.WaitLong) err = inv.WithContext(ctx).Run() @@ -196,15 +201,16 @@ func TestTemplateEdit(t *testing.T) { t.Run("WithPropertiesThenEmptyEdit", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) initialDisplayName := "This is a template" initialDescription := "This is description" initialIcon := "/img/icon.png" - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DisplayName = initialDisplayName ctr.Description = initialDescription ctr.Icon = initialIcon @@ -224,7 +230,7 @@ func TestTemplateEdit(t *testing.T) { template.Name, } inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) ctx := testutil.Context(t, testutil.WaitLong) err = inv.WithContext(ctx).Run() @@ -242,17 +248,18 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, "", updated.Icon) assert.Equal(t, "", updated.DisplayName) }) - t.Run("RestartRequirement", func(t *testing.T) { + t.Run("Autostop/startRequirement", func(t *testing.T) { t.Parallel() t.Run("BlockedAGPL", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil - ctr.RestartRequirement = nil + ctr.AutostopRequirement = nil }) cases := []struct { @@ -263,20 +270,26 @@ func TestTemplateEdit(t *testing.T) { { name: "Weekdays", flags: []string{ - "--restart-requirement-weekdays", "monday", + "--autostop-requirement-weekdays", "monday", }, }, { name: "WeekdaysNoneAllowed", flags: []string{ - "--restart-requirement-weekdays", "none", + "--autostop-requirement-weekdays", "none", }, ok: true, }, { name: "Weeks", flags: []string{ - "--restart-requirement-weeks", "1", + "--autostop-requirement-weeks", "1", + }, + }, + { + name: "AutostartDays", + flags: []string{ + "--autostart-requirement-weekdays", "monday", }, }, } @@ -293,7 +306,7 @@ func TestTemplateEdit(t *testing.T) { } cmdArgs = append(cmdArgs, c.flags...) inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -312,8 +325,10 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) - assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) + assert.Equal(t, template.AutostopRequirement.DaysOfWeek, updated.AutostopRequirement.DaysOfWeek) + assert.Equal(t, template.AutostopRequirement.Weeks, updated.AutostopRequirement.Weeks) + assert.Equal(t, template.AutostartRequirement.DaysOfWeek, updated.AutostartRequirement.DaysOfWeek) + assert.Equal(t, template.AutostartRequirement.DaysOfWeek, updated.AutostartRequirement.DaysOfWeek) }) } }) @@ -321,12 +336,13 @@ func TestTemplateEdit(t *testing.T) { t.Run("BlockedNotEntitled", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil - ctr.RestartRequirement = nil + ctr.AutostopRequirement = nil }) // Make a proxy server that will return a valid entitlements @@ -366,7 +382,7 @@ func TestTemplateEdit(t *testing.T) { proxyURL, err := url.Parse(proxy.URL) require.NoError(t, err) proxyClient := codersdk.New(proxyURL) - proxyClient.SetSessionToken(client.SessionToken()) + proxyClient.SetSessionToken(templateAdmin.SessionToken()) t.Cleanup(proxyClient.HTTPClient.CloseIdleConnections) cases := []struct { @@ -377,20 +393,20 @@ func TestTemplateEdit(t *testing.T) { { name: "Weekdays", flags: []string{ - "--restart-requirement-weekdays", "monday", + "--autostop-requirement-weekdays", "monday", }, }, { name: "WeekdaysNoneAllowed", flags: []string{ - "--restart-requirement-weekdays", "none", + "--autostop-requirement-weekdays", "none", }, ok: true, }, { name: "Weeks", flags: []string{ - "--restart-requirement-weeks", "1", + "--autostop-requirement-weeks", "1", }, }, } @@ -426,20 +442,22 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) - assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) + assert.Equal(t, template.AutostopRequirement.DaysOfWeek, updated.AutostopRequirement.DaysOfWeek) + assert.Equal(t, template.AutostopRequirement.Weeks, updated.AutostopRequirement.Weeks) + assert.Equal(t, template.AutostartRequirement.DaysOfWeek, updated.AutostartRequirement.DaysOfWeek) }) } }) t.Run("Entitled", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil - ctr.RestartRequirement = nil + ctr.AutostopRequirement = nil }) // Make a proxy server that will return a valid entitlements @@ -475,8 +493,8 @@ func TestTemplateEdit(t *testing.T) { var req codersdk.UpdateTemplateMeta err = json.Unmarshal(body, &req) require.NoError(t, err) - assert.Equal(t, req.RestartRequirement.DaysOfWeek, []string{"monday", "tuesday"}) - assert.EqualValues(t, req.RestartRequirement.Weeks, 3) + assert.Equal(t, req.AutostopRequirement.DaysOfWeek, []string{"monday", "tuesday"}) + assert.EqualValues(t, req.AutostopRequirement.Weeks, 3) r.Body = io.NopCloser(bytes.NewReader(body)) atomic.AddInt64(&updateTemplateCalled, 1) @@ -496,7 +514,7 @@ func TestTemplateEdit(t *testing.T) { proxyURL, err := url.Parse(proxy.URL) require.NoError(t, err) proxyClient := codersdk.New(proxyURL) - proxyClient.SetSessionToken(client.SessionToken()) + proxyClient.SetSessionToken(templateAdmin.SessionToken()) t.Cleanup(proxyClient.HTTPClient.CloseIdleConnections) // Test the cli command. @@ -504,8 +522,8 @@ func TestTemplateEdit(t *testing.T) { "templates", "edit", template.Name, - "--restart-requirement-weekdays", "monday,tuesday", - "--restart-requirement-weeks", "3", + "--autostop-requirement-weekdays", "monday,tuesday", + "--autostop-requirement-weeks", "3", } inv, root := clitest.New(t, cmdArgs...) clitest.SetupConfig(t, proxyClient, root) @@ -525,8 +543,9 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) - assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) + assert.Equal(t, template.AutostopRequirement.DaysOfWeek, updated.AutostopRequirement.DaysOfWeek) + assert.Equal(t, template.AutostopRequirement.Weeks, updated.AutostopRequirement.Weeks) + assert.Equal(t, template.AutostartRequirement.DaysOfWeek, updated.AutostartRequirement.DaysOfWeek) }) }) // TODO(@dean): remove this test when we remove max_ttl @@ -535,10 +554,11 @@ func TestTemplateEdit(t *testing.T) { t.Run("BlockedAGPL", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil ctr.MaxTTLMillis = nil }) @@ -551,7 +571,7 @@ func TestTemplateEdit(t *testing.T) { "--max-ttl", "1h", } inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -572,10 +592,11 @@ func TestTemplateEdit(t *testing.T) { t.Run("BlockedNotEntitled", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil ctr.MaxTTLMillis = nil }) @@ -617,7 +638,7 @@ func TestTemplateEdit(t *testing.T) { proxyURL, err := url.Parse(proxy.URL) require.NoError(t, err) proxyClient := codersdk.New(proxyURL) - proxyClient.SetSessionToken(client.SessionToken()) + proxyClient.SetSessionToken(templateAdmin.SessionToken()) t.Cleanup(proxyClient.HTTPClient.CloseIdleConnections) // Test the cli command. @@ -648,10 +669,11 @@ func TestTemplateEdit(t *testing.T) { t.Run("Entitled", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil ctr.MaxTTLMillis = nil }) @@ -709,7 +731,7 @@ func TestTemplateEdit(t *testing.T) { proxyURL, err := url.Parse(proxy.URL) require.NoError(t, err) proxyClient := codersdk.New(proxyURL) - proxyClient.SetSessionToken(client.SessionToken()) + proxyClient.SetSessionToken(templateAdmin.SessionToken()) t.Cleanup(proxyClient.HTTPClient.CloseIdleConnections) // Test the cli command. @@ -745,14 +767,15 @@ func TestTemplateEdit(t *testing.T) { t.Run("BlockedAGPL", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil - ctr.RestartRequirement = nil + ctr.AutostopRequirement = nil ctr.FailureTTLMillis = nil - ctr.InactivityTTLMillis = nil + ctr.TimeTilDormantMillis = nil }) // Test the cli command with --allow-user-autostart. @@ -763,7 +786,7 @@ func TestTemplateEdit(t *testing.T) { "--allow-user-autostart=false", } inv, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -778,7 +801,7 @@ func TestTemplateEdit(t *testing.T) { "--allow-user-autostop=false", } inv, root = clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) ctx = testutil.Context(t, testutil.WaitLong) err = inv.WithContext(ctx).Run() @@ -793,21 +816,23 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) - assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) + assert.Equal(t, template.AutostopRequirement.DaysOfWeek, updated.AutostopRequirement.DaysOfWeek) + assert.Equal(t, template.AutostopRequirement.Weeks, updated.AutostopRequirement.Weeks) + assert.Equal(t, template.AutostartRequirement.DaysOfWeek, updated.AutostartRequirement.DaysOfWeek) assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) - assert.Equal(t, template.InactivityTTLMillis, updated.InactivityTTLMillis) + assert.Equal(t, template.TimeTilDormantMillis, updated.TimeTilDormantMillis) }) t.Run("BlockedNotEntitled", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) // Make a proxy server that will return a valid entitlements // response, but without advanced scheduling entitlement. @@ -846,7 +871,7 @@ func TestTemplateEdit(t *testing.T) { proxyURL, err := url.Parse(proxy.URL) require.NoError(t, err) proxyClient := codersdk.New(proxyURL) - proxyClient.SetSessionToken(client.SessionToken()) + proxyClient.SetSessionToken(templateAdmin.SessionToken()) t.Cleanup(proxyClient.HTTPClient.CloseIdleConnections) // Test the cli command with --allow-user-autostart. @@ -887,20 +912,22 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) - assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) + assert.Equal(t, template.AutostopRequirement.DaysOfWeek, updated.AutostopRequirement.DaysOfWeek) + assert.Equal(t, template.AutostopRequirement.Weeks, updated.AutostopRequirement.Weeks) + assert.Equal(t, template.AutostartRequirement.DaysOfWeek, updated.AutostartRequirement.DaysOfWeek) assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) - assert.Equal(t, template.InactivityTTLMillis, updated.InactivityTTLMillis) + assert.Equal(t, template.TimeTilDormantMillis, updated.TimeTilDormantMillis) }) t.Run("Entitled", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) // Make a proxy server that will return a valid entitlements // response, including a valid advanced scheduling entitlement. @@ -956,7 +983,7 @@ func TestTemplateEdit(t *testing.T) { proxyURL, err := url.Parse(proxy.URL) require.NoError(t, err) proxyClient := codersdk.New(proxyURL) - proxyClient.SetSessionToken(client.SessionToken()) + proxyClient.SetSessionToken(templateAdmin.SessionToken()) t.Cleanup(proxyClient.HTTPClient.CloseIdleConnections) // Test the cli command. @@ -985,12 +1012,39 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) - assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) + assert.Equal(t, template.AutostopRequirement.DaysOfWeek, updated.AutostopRequirement.DaysOfWeek) + assert.Equal(t, template.AutostopRequirement.Weeks, updated.AutostopRequirement.Weeks) + assert.Equal(t, template.AutostartRequirement.DaysOfWeek, updated.AutostartRequirement.DaysOfWeek) assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) - assert.Equal(t, template.InactivityTTLMillis, updated.InactivityTTLMillis) + assert.Equal(t, template.TimeTilDormantMillis, updated.TimeTilDormantMillis) }) }) + + t.Run("RequireActiveVersion", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {}) + + // Test the cli command with --allow-user-autostart. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--require-active-version", + } + inv, root := clitest.New(t, cmdArgs...) + //nolint + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err := inv.WithContext(ctx).Run() + require.Error(t, err) + require.ErrorContains(t, err, "appears to be an AGPL deployment") + }) } diff --git a/cli/templateinit.go b/cli/templateinit.go index b42e555fde074..a9577733bc0fb 100644 --- a/cli/templateinit.go +++ b/cli/templateinit.go @@ -12,11 +12,12 @@ import ( "golang.org/x/exp/maps" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/examples" - "github.com/coder/coder/provisionersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/examples" + "github.com/coder/coder/v2/provisionersdk" + "github.com/coder/pretty" ) func (*RootCmd) templateInit() *clibase.Cmd { @@ -42,17 +43,21 @@ func (*RootCmd) templateInit() *clibase.Cmd { for _, example := range exampleList { name := fmt.Sprintf( "%s\n%s\n%s\n", - cliui.DefaultStyles.Bold.Render(example.Name), - cliui.DefaultStyles.Wrap.Copy().PaddingLeft(6).Render(example.Description), - cliui.DefaultStyles.Keyword.Copy().PaddingLeft(6).Render(example.URL), + cliui.Bold(example.Name), + pretty.Sprint(cliui.DefaultStyles.Wrap.With(pretty.XPad(6, 0)), example.Description), + pretty.Sprint(cliui.DefaultStyles.Keyword.With(pretty.XPad(6, 0)), example.URL), ) optsToID[name] = example.ID } opts := maps.Keys(optsToID) sort.Strings(opts) - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Wrap.Render( - "A template defines infrastructure as code to be provisioned "+ - "for individual developer workspaces. Select an example to be copied to the active directory:\n")) + _, _ = fmt.Fprintln( + inv.Stdout, + pretty.Sprint( + cliui.DefaultStyles.Wrap, + "A template defines infrastructure as code to be provisioned "+ + "for individual developer workspaces. Select an example to be copied to the active directory:\n"), + ) selected, err := cliui.Select(inv, cliui.SelectOptions{ Options: opts, }) @@ -94,7 +99,7 @@ func (*RootCmd) templateInit() *clibase.Cmd { } else { relPath = "./" + relPath } - _, _ = fmt.Fprintf(inv.Stdout, "Extracting %s to %s...\n", cliui.DefaultStyles.Field.Render(selectedTemplate.ID), relPath) + _, _ = fmt.Fprintf(inv.Stdout, "Extracting %s to %s...\n", pretty.Sprint(cliui.DefaultStyles.Field, selectedTemplate.ID), relPath) err = os.MkdirAll(directory, 0o700) if err != nil { return err @@ -104,8 +109,13 @@ func (*RootCmd) templateInit() *clibase.Cmd { return err } _, _ = fmt.Fprintln(inv.Stdout, "Create your template by running:") - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Paragraph.Render(cliui.DefaultStyles.Code.Render("cd "+relPath+" && coder templates create"))+"\n") - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Wrap.Render("Examples provide a starting point and are expected to be edited! 🎨")) + _, _ = fmt.Fprintln( + inv.Stdout, + pretty.Sprint( + cliui.DefaultStyles.Code, + "cd "+relPath+" && coder templates create"), + ) + _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "\nExamples provide a starting point and are expected to be edited! 🎨")) return nil }, } diff --git a/cli/templateinit_test.go b/cli/templateinit_test.go index ba99f76e95ece..f8172df25f560 100644 --- a/cli/templateinit_test.go +++ b/cli/templateinit_test.go @@ -6,8 +6,8 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/pty/ptytest" ) func TestTemplateInit(t *testing.T) { diff --git a/cli/templatelist.go b/cli/templatelist.go index ba17bf218e695..6d95521dad321 100644 --- a/cli/templatelist.go +++ b/cli/templatelist.go @@ -5,9 +5,9 @@ import ( "github.com/fatih/color" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) templateList() *clibase.Cmd { diff --git a/cli/templatelist_test.go b/cli/templatelist_test.go index 8e29c8f49edbc..98796a3906b06 100644 --- a/cli/templatelist_test.go +++ b/cli/templatelist_test.go @@ -9,11 +9,12 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestTemplateList(t *testing.T) { @@ -21,17 +22,18 @@ func TestTemplateList(t *testing.T) { t.Run("ListTemplates", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - firstVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, firstVersion.ID) - firstTemplate := coderdtest.CreateTemplate(t, client, user.OrganizationID, firstVersion.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + firstVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, firstVersion.ID) + firstTemplate := coderdtest.CreateTemplate(t, client, owner.OrganizationID, firstVersion.ID) - secondVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, secondVersion.ID) - secondTemplate := coderdtest.CreateTemplate(t, client, user.OrganizationID, secondVersion.ID) + secondVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, secondVersion.ID) + secondTemplate := coderdtest.CreateTemplate(t, client, owner.OrganizationID, secondVersion.ID) inv, root := clitest.New(t, "templates", "list") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) @@ -56,17 +58,18 @@ func TestTemplateList(t *testing.T) { t.Run("ListTemplatesJSON", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - firstVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, firstVersion.ID) - _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, firstVersion.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + firstVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, firstVersion.ID) + _ = coderdtest.CreateTemplate(t, client, owner.OrganizationID, firstVersion.ID) - secondVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, secondVersion.ID) - _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, secondVersion.ID) + secondVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, secondVersion.ID) + _ = coderdtest.CreateTemplate(t, client, owner.OrganizationID, secondVersion.ID) inv, root := clitest.New(t, "templates", "list", "--output=json") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancelFunc() @@ -83,10 +86,11 @@ func TestTemplateList(t *testing.T) { t.Run("NoTemplates", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{}) - coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) inv, root := clitest.New(t, "templates", "list") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t) inv.Stdin = pty.Input() diff --git a/cli/templateplan.go b/cli/templateplan.go deleted file mode 100644 index bc99d2f4e3cdf..0000000000000 --- a/cli/templateplan.go +++ /dev/null @@ -1,18 +0,0 @@ -package cli - -import ( - "github.com/coder/coder/cli/clibase" -) - -func (*RootCmd) templatePlan() *clibase.Cmd { - return &clibase.Cmd{ - Use: "plan ", - Middleware: clibase.Chain( - clibase.RequireNArgs(1), - ), - Short: "Plan a template push from the current directory", - Handler: func(inv *clibase.Invocation) error { - return nil - }, - } -} diff --git a/cli/templatepull.go b/cli/templatepull.go index 5994807325713..13286ab0331cd 100644 --- a/cli/templatepull.go +++ b/cli/templatepull.go @@ -9,18 +9,21 @@ import ( "github.com/codeclysm/extract/v3" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) templatePull() *clibase.Cmd { - var tarMode bool + var ( + tarMode bool + versionName string + ) client := new(codersdk.Client) cmd := &clibase.Cmd{ Use: "pull [destination]", - Short: "Download the latest version of a template to a path.", + Short: "Download the active, latest, or specified version of a template to a path.", Middleware: clibase.Chain( clibase.RequireRangeArgs(1, 2), r.InitClient(client), @@ -36,39 +39,67 @@ func (r *RootCmd) templatePull() *clibase.Cmd { dest = inv.Args[1] } - // TODO(JonA): Do we need to add a flag for organization? organization, err := CurrentOrganization(inv, client) if err != nil { - return xerrors.Errorf("current organization: %w", err) + return xerrors.Errorf("get current organization: %w", err) } template, err := client.TemplateByName(ctx, organization.ID, templateName) if err != nil { - return xerrors.Errorf("template by name: %w", err) + return xerrors.Errorf("get template by name: %w", err) } - // Pull the versions for the template. We'll find the latest - // one and download the source. - versions, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{ - TemplateID: template.ID, - }) - if err != nil { - return xerrors.Errorf("template versions by template: %w", err) - } + var latestVersion codersdk.TemplateVersion + { + // Determine the latest template version and compare with the + // active version. If they aren't the same, warn the user. + versions, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{ + TemplateID: template.ID, + }) + if err != nil { + return xerrors.Errorf("template versions by template: %w", err) + } + + if len(versions) == 0 { + return xerrors.Errorf("no template versions for template %q", templateName) + } + + // Sort the slice from newest to oldest template. + sort.SliceStable(versions, func(i, j int) bool { + return versions[i].CreatedAt.After(versions[j].CreatedAt) + }) - if len(versions) == 0 { - return xerrors.Errorf("no template versions for template %q", templateName) + latestVersion = versions[0] } - // Sort the slice from newest to oldest template. - sort.SliceStable(versions, func(i, j int) bool { - return versions[i].CreatedAt.After(versions[j].CreatedAt) - }) + var templateVersion codersdk.TemplateVersion + switch versionName { + case "", "active": + activeVersion, err := client.TemplateVersion(ctx, template.ActiveVersionID) + if err != nil { + return xerrors.Errorf("get active template version: %w", err) + } + if versionName == "" && activeVersion.ID != latestVersion.ID { + cliui.Warn(inv.Stderr, + "A newer template version than the active version exists. Pulling the active version instead.", + "Use "+cliui.Code("--template latest")+" to pull the latest version.", + ) + } + templateVersion = activeVersion + case "latest": + templateVersion = latestVersion + default: + version, err := client.TemplateVersionByName(ctx, template.ID, versionName) + if err != nil { + return xerrors.Errorf("get template version: %w", err) + } + templateVersion = version + } - latest := versions[0] + cliui.Info(inv.Stderr, "Pulling template version "+cliui.Bold(templateVersion.Name)+"...") // Download the tar archive. - raw, ctype, err := client.Download(ctx, latest.Job.FileID) + raw, ctype, err := client.Download(ctx, templateVersion.Job.FileID) if err != nil { return xerrors.Errorf("download template: %w", err) } @@ -83,7 +114,7 @@ func (r *RootCmd) templatePull() *clibase.Cmd { } if dest == "" { - dest = templateName + "/" + dest = templateName } err = os.MkdirAll(dest, 0o750) @@ -121,6 +152,12 @@ func (r *RootCmd) templatePull() *clibase.Cmd { Value: clibase.BoolOf(&tarMode), }, + { + Description: "The name of the template version to pull. Use 'active' to pull the active version, 'latest' to pull the latest version, or the name of the template version to pull.", + Flag: "version", + + Value: clibase.StringOf(&versionName), + }, cliui.SkipPromptOption(), } diff --git a/cli/templatepull_test.go b/cli/templatepull_test.go index 1fdfe80d6ef50..782859c6a93ca 100644 --- a/cli/templatepull_test.go +++ b/cli/templatepull_test.go @@ -7,17 +7,19 @@ import ( "encoding/hex" "os" "path/filepath" + "strings" "testing" "github.com/codeclysm/extract/v3" "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" ) // dirSum calculates a checksum of the files in a directory. @@ -40,174 +42,367 @@ func dirSum(t *testing.T, dir string) string { return hex.EncodeToString(sum.Sum(nil)) } -func TestTemplatePull(t *testing.T) { +func TestTemplatePull_NoName(t *testing.T) { t.Parallel() - t.Run("NoName", func(t *testing.T) { - t.Parallel() + inv, _ := clitest.New(t, "templates", "pull") + err := inv.Run() + require.Error(t, err) +} + +// Stdout tests that 'templates pull' pulls down the active template +// and writes it to stdout. +func TestTemplatePull_Stdout(t *testing.T) { + t.Parallel() - inv, _ := clitest.New(t, "templates", "pull") - err := inv.Run() - require.Error(t, err) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - // Stdout tests that 'templates pull' pulls down the latest template - // and writes it to stdout. - t.Run("Stdout", func(t *testing.T) { - t.Parallel() + // Create an initial template bundle. + source1 := genTemplateVersionSource() + // Create an updated template bundle. This will be used to ensure + // that templates are correctly returned in order from latest to oldest. + source2 := genTemplateVersionSource() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + expected, err := echo.Tar(source2) + require.NoError(t, err) - // Create an initial template bundle. - source1 := genTemplateVersionSource() - // Create an updated template bundle. This will be used to ensure - // that templates are correctly returned in order from latest to oldest. - source2 := genTemplateVersionSource() + version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) - expected, err := echo.Tar(source2) - require.NoError(t, err) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) - version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) + // Update the template version so that we can assert that templates + // are being sorted correctly. + updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID) + coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, updatedVersion.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name) + clitest.SetupConfig(t, templateAdmin, root) - // Update the template version so that we can assert that templates - // are being sorted correctly. - _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + var buf bytes.Buffer + inv.Stdout = &buf - inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name) - clitest.SetupConfig(t, client, root) + err = inv.Run() + require.NoError(t, err) - var buf bytes.Buffer - inv.Stdout = &buf + require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ") +} - err = inv.Run() - require.NoError(t, err) +// Stdout tests that 'templates pull' pulls down the non-latest active template +// and writes it to stdout. +func TestTemplatePull_ActiveOldStdout(t *testing.T) { + t.Parallel() - require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ") + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - // ToDir tests that 'templates pull' pulls down the latest template - // and writes it to the correct directory. - t.Run("ToDir", func(t *testing.T) { - t.Parallel() + source1 := genTemplateVersionSource() + source2 := genTemplateVersionSource() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + expected, err := echo.Tar(source1) + require.NoError(t, err) - // Create an initial template bundle. - source1 := genTemplateVersionSource() - // Create an updated template bundle. This will be used to ensure - // that templates are correctly returned in order from latest to oldest. - source2 := genTemplateVersionSource() + version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) - expected, err := echo.Tar(source2) - require.NoError(t, err) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) + + updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID) - version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) + inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name) + clitest.SetupConfig(t, templateAdmin, root) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + var buf bytes.Buffer + inv.Stdout = &buf + var stderr strings.Builder + inv.Stderr = &stderr - // Update the template version so that we can assert that templates - // are being sorted correctly. - _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + err = inv.Run() + require.NoError(t, err) + + require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ") + require.Contains(t, stderr.String(), "A newer template version than the active version exists.") +} - dir := t.TempDir() +// Stdout tests that 'templates pull' pulls down the specified template and +// writes it to stdout. +func TestTemplatePull_SpecifiedStdout(t *testing.T) { + t.Parallel() - expectedDest := filepath.Join(dir, "expected") - actualDest := filepath.Join(dir, "actual") - ctx := context.Background() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) - require.NoError(t, err) + source1 := genTemplateVersionSource() + source2 := genTemplateVersionSource() + source3 := genTemplateVersionSource() - inv, root := clitest.New(t, "templates", "pull", template.Name, actualDest) - clitest.SetupConfig(t, client, root) + expected, err := echo.Tar(source1) + require.NoError(t, err) - ptytest.New(t).Attach(inv) + version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) - require.NoError(t, inv.Run()) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) - require.Equal(t, - dirSum(t, expectedDest), - dirSum(t, actualDest), - ) + updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID) + + updatedVersion2 := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source3, template.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion2.ID) + coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, updatedVersion2.ID) + + inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name, "--version", version1.Name) + clitest.SetupConfig(t, templateAdmin, root) + + var buf bytes.Buffer + inv.Stdout = &buf + + err = inv.Run() + require.NoError(t, err) + + require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ") +} + +// Stdout tests that 'templates pull' pulls down the latest template +// and writes it to stdout. +func TestTemplatePull_LatestStdout(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - // FolderConflict tests that 'templates pull' fails when a folder with has - // existing - t.Run("FolderConflict", func(t *testing.T) { - t.Parallel() + source1 := genTemplateVersionSource() + source2 := genTemplateVersionSource() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + expected, err := echo.Tar(source1) + require.NoError(t, err) - // Create an initial template bundle. - source1 := genTemplateVersionSource() - // Create an updated template bundle. This will be used to ensure - // that templates are correctly returned in order from latest to oldest. - source2 := genTemplateVersionSource() + version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) - expected, err := echo.Tar(source2) - require.NoError(t, err) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) - version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) + updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name, "latest") + clitest.SetupConfig(t, templateAdmin, root) - // Update the template version so that we can assert that templates - // are being sorted correctly. - _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + var buf bytes.Buffer + inv.Stdout = &buf - dir := t.TempDir() + err = inv.Run() + require.NoError(t, err) - expectedDest := filepath.Join(dir, "expected") - conflictDest := filepath.Join(dir, "conflict") + require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ") +} - err = os.MkdirAll(conflictDest, 0o700) - require.NoError(t, err) +// ToDir tests that 'templates pull' pulls down the active template +// and writes it to the correct directory. +func TestTemplatePull_ToDir(t *testing.T) { + t.Parallel() - err = os.WriteFile( - filepath.Join(conflictDest, "conflict-file"), - []byte("conflict"), 0o600, - ) - require.NoError(t, err) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - ctx := context.Background() + // Create an initial template bundle. + source1 := genTemplateVersionSource() + // Create an updated template bundle. This will be used to ensure + // that templates are correctly returned in order from latest to oldest. + source2 := genTemplateVersionSource() - err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) - require.NoError(t, err) + expected, err := echo.Tar(source2) + require.NoError(t, err) - inv, root := clitest.New(t, "templates", "pull", template.Name, conflictDest) - clitest.SetupConfig(t, client, root) + version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) - pty := ptytest.New(t).Attach(inv) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) - waiter := clitest.StartWithWaiter(t, inv) + // Update the template version so that we can assert that templates + // are being sorted correctly. + updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID) + coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, updatedVersion.ID) - pty.ExpectMatch("not empty") - pty.WriteLine("no") + dir := t.TempDir() - waiter.RequireError() + expectedDest := filepath.Join(dir, "expected") + actualDest := filepath.Join(dir, "actual") + ctx := context.Background() - ents, err := os.ReadDir(conflictDest) - require.NoError(t, err) + err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) + require.NoError(t, err) + + inv, root := clitest.New(t, "templates", "pull", template.Name, actualDest) + clitest.SetupConfig(t, templateAdmin, root) + + ptytest.New(t).Attach(inv) - require.Len(t, ents, 1, "conflict folder should have single conflict file") + require.NoError(t, inv.Run()) + + require.Equal(t, + dirSum(t, expectedDest), + dirSum(t, actualDest), + ) +} + +// ToDir tests that 'templates pull' pulls down the active template and writes +// it to a directory with the name of the template if the path is not implicitly +// supplied. +// nolint: paralleltest +func TestTemplatePull_ToImplicit(t *testing.T) { + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + + // Create an initial template bundle. + source1 := genTemplateVersionSource() + // Create an updated template bundle. This will be used to ensure + // that templates are correctly returned in order from latest to oldest. + source2 := genTemplateVersionSource() + + expected, err := echo.Tar(source2) + require.NoError(t, err) + + version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) + + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) + + // Update the template version so that we can assert that templates + // are being sorted correctly. + updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID) + coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, updatedVersion.ID) + + // create a tempdir and change the working directory to it for the duration of the test (cannot run in parallel) + dir := t.TempDir() + wd, err := os.Getwd() + require.NoError(t, err) + err = os.Chdir(dir) + require.NoError(t, err) + defer func() { + err := os.Chdir(wd) + require.NoError(t, err, "if this fails, it can break other subsequent tests due to wrong working directory") + }() + + expectedDest := filepath.Join(dir, "expected") + actualDest := filepath.Join(dir, template.Name) + + ctx := context.Background() + + err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) + require.NoError(t, err) + + inv, root := clitest.New(t, "templates", "pull", template.Name) + clitest.SetupConfig(t, templateAdmin, root) + + ptytest.New(t).Attach(inv) + + require.NoError(t, inv.Run()) + + require.Equal(t, + dirSum(t, expectedDest), + dirSum(t, actualDest), + ) +} + +// FolderConflict tests that 'templates pull' fails when a folder with has +// existing +func TestTemplatePull_FolderConflict(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + + // Create an initial template bundle. + source1 := genTemplateVersionSource() + // Create an updated template bundle. This will be used to ensure + // that templates are correctly returned in order from latest to oldest. + source2 := genTemplateVersionSource() + + expected, err := echo.Tar(source2) + require.NoError(t, err) + + version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) + + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) + + // Update the template version so that we can assert that templates + // are being sorted correctly. + updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID) + coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, updatedVersion.ID) + + dir := t.TempDir() + + expectedDest := filepath.Join(dir, "expected") + conflictDest := filepath.Join(dir, "conflict") + + err = os.MkdirAll(conflictDest, 0o700) + require.NoError(t, err) + + err = os.WriteFile( + filepath.Join(conflictDest, "conflict-file"), + []byte("conflict"), 0o600, + ) + require.NoError(t, err) + + ctx := context.Background() + + err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) + require.NoError(t, err) + + inv, root := clitest.New(t, "templates", "pull", template.Name, conflictDest) + clitest.SetupConfig(t, templateAdmin, root) + + pty := ptytest.New(t).Attach(inv) + + waiter := clitest.StartWithWaiter(t, inv) + + pty.ExpectMatch("not empty") + pty.WriteLine("no") + + waiter.RequireError() + + ents, err := os.ReadDir(conflictDest) + require.NoError(t, err) + + require.Len(t, ents, 1, "conflict folder should have single conflict file") } // genTemplateVersionSource returns a unique bundle that can be used to create // a template version source. func genTemplateVersionSource() *echo.Responses { return &echo.Responses{ - Parse: []*proto.Parse_Response{ + Parse: []*proto.Response{ { - Type: &proto.Parse_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{ Output: uuid.NewString(), }, @@ -215,11 +410,11 @@ func genTemplateVersionSource() *echo.Responses { }, { - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{}, + Type: &proto.Response_Parse{ + Parse: &proto.ParseComplete{}, }, }, }, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, } } diff --git a/cli/templatepush.go b/cli/templatepush.go index 52342874144b7..ad4403324dfc4 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -11,11 +11,12 @@ import ( "github.com/briandowns/spinner" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisionersdk" + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionersdk" ) // templateUploadFlags is shared by `templates create` and `templates push`. @@ -86,7 +87,7 @@ func (pf *templateUploadFlags) upload(inv *clibase.Invocation, client *codersdk. spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond) spin.Writer = inv.Stdout - spin.Suffix = cliui.DefaultStyles.Keyword.Render(" Uploading directory...") + spin.Suffix = pretty.Sprint(cliui.DefaultStyles.Keyword, " Uploading directory...") spin.Start() defer spin.Stop() @@ -111,7 +112,7 @@ func (pf *templateUploadFlags) checkForLockfile(inv *clibase.Invocation) error { if !hasLockfile { cliui.Warn(inv.Stdout, "No .terraform.lock.hcl file found", "When provisioning, Coder will be unable to cache providers without a lockfile and must download them from the internet each time.", - "Create one by running "+cliui.DefaultStyles.Code.Render("terraform init")+" in your template directory.", + "Create one by running "+pretty.Sprint(cliui.DefaultStyles.Code, "terraform init")+" in your template directory.", ) } return nil @@ -216,7 +217,7 @@ func (r *RootCmd) templatePush() *clibase.Cmd { Message: message, Client: client, Organization: organization, - Provisioner: database.ProvisionerType(provisioner), + Provisioner: codersdk.ProvisionerType(provisioner), FileID: resp.ID, ProvisionerTags: tags, VariablesFile: variablesFile, @@ -247,9 +248,10 @@ func (r *RootCmd) templatePush() *clibase.Cmd { return err } - _, _ = fmt.Fprintln(inv.Stdout, "\n"+cliui.DefaultStyles.Wrap.Render( - "The "+cliui.DefaultStyles.Keyword.Render(name)+" template has been created at "+cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))+"! "+ - "Developers can provision a workspace with this template using:")+"\n") + _, _ = fmt.Fprintln( + inv.Stdout, "\n"+cliui.Wrap( + "The "+cliui.Keyword(name)+" template has been created at "+cliui.Timestamp(time.Now())+"! "+ + "Developers can provision a workspace with this template using:")+"\n") } else if activate { err = client.UpdateActiveTemplateVersion(inv.Context(), template.ID, codersdk.UpdateActiveTemplateVersion{ ID: job.ID, @@ -259,7 +261,7 @@ func (r *RootCmd) templatePush() *clibase.Cmd { } } - _, _ = fmt.Fprintf(inv.Stdout, "Updated version at %s!\n", cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))) + _, _ = fmt.Fprintf(inv.Stdout, "Updated version at %s!\n", pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Now().Format(time.Stamp))) return nil }, } diff --git a/cli/templatepush_test.go b/cli/templatepush_test.go index 88a1ce250543c..5736df8cc2edf 100644 --- a/cli/templatepush_test.go +++ b/cli/templatepush_test.go @@ -13,14 +13,15 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/pty/ptytest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestTemplatePush(t *testing.T) { @@ -29,19 +30,20 @@ func TestTemplatePush(t *testing.T) { t.Run("OK", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) // Test the cli command. source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) execDone := make(chan error) @@ -75,20 +77,21 @@ func TestTemplatePush(t *testing.T) { t.Run("Message less than or equal to 72 chars", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) wantMessage := strings.Repeat("a", 72) inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example", "--message", wantMessage, "--yes") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) @@ -114,14 +117,15 @@ func TestTemplatePush(t *testing.T) { t.Run("Message too long, warn but continue", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -134,8 +138,13 @@ func TestTemplatePush(t *testing.T) { {wantMessage: strings.Repeat("a", 73), wantMatch: "Template message is longer than 72 characters"}, {wantMessage: "This is my title\n\nAnd this is my body.", wantMatch: "Template message contains newlines"}, } { - inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--message", tt.wantMessage, "--yes") - clitest.SetupConfig(t, client, root) + inv, root := clitest.New(t, "templates", "push", template.Name, + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--message", tt.wantMessage, + "--yes", + ) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) inv = inv.WithContext(ctx) @@ -159,21 +168,26 @@ func TestTemplatePush(t *testing.T) { t.Run("NoLockfile", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) // Test the cli command. source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) require.NoError(t, os.Remove(filepath.Join(source, ".terraform.lock.hcl"))) - inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example") - clitest.SetupConfig(t, client, root) + inv, root := clitest.New(t, "templates", "push", template.Name, + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--name", "example", + ) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) execDone := make(chan error) @@ -202,21 +216,27 @@ func TestTemplatePush(t *testing.T) { t.Run("NoLockfileIgnored", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) // Test the cli command. source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) require.NoError(t, os.Remove(filepath.Join(source, ".terraform.lock.hcl"))) - inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example", "--ignore-lockfile") - clitest.SetupConfig(t, client, root) + inv, root := clitest.New(t, "templates", "push", template.Name, + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--name", "example", + "--ignore-lockfile", + ) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) execDone := make(chan error) @@ -239,19 +259,25 @@ func TestTemplatePush(t *testing.T) { t.Run("PushInactiveTemplateVersion", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) // Test the cli command. source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) - inv, root := clitest.New(t, "templates", "push", template.Name, "--activate=false", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example") - clitest.SetupConfig(t, client, root) + inv, root := clitest.New(t, "templates", "push", template.Name, + "--activate=false", + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--name", "example", + ) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) w := clitest.StartWithWaiter(t, inv) @@ -286,25 +312,29 @@ func TestTemplatePush(t *testing.T) { } client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) // Test the cli command. source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(r *codersdk.CreateTemplateRequest) { r.Name = filepath.Base(source) }) // Don't pass the name of the template, it should use the // directory of the source. - inv, root := clitest.New(t, "templates", "push", "--test.provisioner", string(database.ProvisionerTypeEcho), "--test.workdir", source) - clitest.SetupConfig(t, client, root) + inv, root := clitest.New(t, "templates", "push", + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--test.workdir", source, + ) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) waiter := clitest.StartWithWaiter(t, inv) @@ -334,24 +364,25 @@ func TestTemplatePush(t *testing.T) { t.Run("Stdin", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) source, err := echo.Tar(&echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) require.NoError(t, err) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) inv, root := clitest.New( t, "templates", "push", "--directory", "-", "--test.provisioner", string(database.ProvisionerTypeEcho), template.Name, ) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t) inv.Stdin = bytes.NewReader(source) inv.Stdout = pty.Output() @@ -389,11 +420,12 @@ func TestTemplatePush(t *testing.T) { t.Run("VariableIsRequired", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - templateVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, createEchoResponsesWithTemplateVariables(initialTemplateVariables)) - _ = coderdtest.AwaitTemplateVersionJob(t, client, templateVersion.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, templateVersion.ID) + templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, createEchoResponsesWithTemplateVariables(initialTemplateVariables)) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID) // Test the cli command. modifiedTemplateVariables := append(initialTemplateVariables, @@ -409,8 +441,13 @@ func TestTemplatePush(t *testing.T) { removeTmpDirUntilSuccessAfterTest(t, tempDir) variablesFile, _ := os.CreateTemp(tempDir, "variables*.yaml") _, _ = variablesFile.WriteString(`second_variable: foobar`) - inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example", "--variables-file", variablesFile.Name()) - clitest.SetupConfig(t, client, root) + inv, root := clitest.New(t, "templates", "push", template.Name, + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--name", "example", + "--variables-file", variablesFile.Name(), + ) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t) inv.Stdin = pty.Input() inv.Stdout = pty.Output() @@ -452,11 +489,12 @@ func TestTemplatePush(t *testing.T) { t.Run("VariableIsRequiredButNotProvided", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - templateVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, createEchoResponsesWithTemplateVariables(initialTemplateVariables)) - _ = coderdtest.AwaitTemplateVersionJob(t, client, templateVersion.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, templateVersion.ID) + templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, createEchoResponsesWithTemplateVariables(initialTemplateVariables)) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID) // Test the cli command. modifiedTemplateVariables := append(initialTemplateVariables, @@ -469,7 +507,7 @@ func TestTemplatePush(t *testing.T) { ) source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(modifiedTemplateVariables)) inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example") - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t) inv.Stdin = pty.Input() inv.Stdout = pty.Output() @@ -498,11 +536,12 @@ func TestTemplatePush(t *testing.T) { t.Run("VariableIsOptionalButNotProvided", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - templateVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, createEchoResponsesWithTemplateVariables(initialTemplateVariables)) - _ = coderdtest.AwaitTemplateVersionJob(t, client, templateVersion.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, templateVersion.ID) + templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, createEchoResponsesWithTemplateVariables(initialTemplateVariables)) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID) // Test the cli command. modifiedTemplateVariables := append(initialTemplateVariables, @@ -515,8 +554,12 @@ func TestTemplatePush(t *testing.T) { }, ) source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(modifiedTemplateVariables)) - inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example") - clitest.SetupConfig(t, client, root) + inv, root := clitest.New(t, "templates", "push", template.Name, + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--name", "example", + ) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t) inv.Stdin = pty.Input() inv.Stdout = pty.Output() @@ -559,11 +602,12 @@ func TestTemplatePush(t *testing.T) { t.Run("WithVariableOption", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - templateVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, createEchoResponsesWithTemplateVariables(initialTemplateVariables)) - _ = coderdtest.AwaitTemplateVersionJob(t, client, templateVersion.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, templateVersion.ID) + templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, createEchoResponsesWithTemplateVariables(initialTemplateVariables)) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID) // Test the cli command. modifiedTemplateVariables := append(initialTemplateVariables, @@ -575,8 +619,14 @@ func TestTemplatePush(t *testing.T) { }, ) source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(modifiedTemplateVariables)) - inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example", "--variable", "second_variable=foobar") - clitest.SetupConfig(t, client, root) + inv, root := clitest.New(t, + "templates", "push", template.Name, + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--name", "example", + "--variable", "second_variable=foobar", + ) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t) inv.Stdin = pty.Input() inv.Stdout = pty.Output() @@ -618,11 +668,9 @@ func TestTemplatePush(t *testing.T) { t.Run("CreateTemplate", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) const templateName = "my-template" args := []string{ @@ -634,7 +682,7 @@ func TestTemplatePush(t *testing.T) { "--create", } inv, root := clitest.New(t, args...) - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, templateAdmin, root) pty := ptytest.New(t).Attach(inv) waiter := clitest.StartWithWaiter(t, inv) @@ -655,7 +703,7 @@ func TestTemplatePush(t *testing.T) { waiter.RequireSuccess() - template, err := client.TemplateByName(context.Background(), user.OrganizationID, templateName) + template, err := client.TemplateByName(context.Background(), owner.OrganizationID, templateName) require.NoError(t, err) require.Equal(t, templateName, template.Name) require.NotEqual(t, uuid.Nil, template.ActiveVersionID) @@ -665,16 +713,16 @@ func TestTemplatePush(t *testing.T) { func createEchoResponsesWithTemplateVariables(templateVariables []*proto.TemplateVariable) *echo.Responses { return &echo.Responses{ - Parse: []*proto.Parse_Response{ + Parse: []*proto.Response{ { - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ + Type: &proto.Response_Parse{ + Parse: &proto.ParseComplete{ TemplateVariables: templateVariables, }, }, }, }, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, } } diff --git a/cli/templates.go b/cli/templates.go index ad347a138ba91..4f5b4f8f36d0b 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -4,10 +4,12 @@ import ( "time" "github.com/google/uuid" + "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) func (r *RootCmd) templates() *clibase.Cmd { @@ -37,17 +39,49 @@ func (r *RootCmd) templates() *clibase.Cmd { r.templateEdit(), r.templateInit(), r.templateList(), - r.templatePlan(), r.templatePush(), r.templateVersions(), r.templateDelete(), r.templatePull(), + r.archiveTemplateVersions(), }, } return cmd } +func selectTemplate(inv *clibase.Invocation, client *codersdk.Client, organization codersdk.Organization) (codersdk.Template, error) { + var empty codersdk.Template + ctx := inv.Context() + allTemplates, err := client.TemplatesByOrganization(ctx, organization.ID) + if err != nil { + return empty, xerrors.Errorf("get templates by organization: %w", err) + } + + if len(allTemplates) == 0 { + return empty, xerrors.Errorf("no templates exist in the current organization %q", organization.Name) + } + + opts := make([]string, 0, len(allTemplates)) + for _, template := range allTemplates { + opts = append(opts, template.Name) + } + + selection, err := cliui.Select(inv, cliui.SelectOptions{ + Options: opts, + }) + if err != nil { + return empty, xerrors.Errorf("select template: %w", err) + } + + for _, template := range allTemplates { + if template.Name == selection { + return template, nil + } + } + return empty, xerrors.Errorf("no template selected") +} + type templateTableRow struct { // Used by json format: Template codersdk.Template @@ -76,7 +110,7 @@ func templatesToRows(templates ...codersdk.Template) []templateTableRow { OrganizationID: template.OrganizationID, Provisioner: template.Provisioner, ActiveVersionID: template.ActiveVersionID, - UsedBy: cliui.DefaultStyles.Fuchsia.Render(formatActiveDevelopers(template.ActiveUserCount)), + UsedBy: pretty.Sprint(cliui.DefaultStyles.Fuchsia, formatActiveDevelopers(template.ActiveUserCount)), DefaultTTL: (time.Duration(template.DefaultTTLMillis) * time.Millisecond), } } diff --git a/cli/templatevariables.go b/cli/templatevariables.go index 888b8a04e30f5..801e65cb8d82f 100644 --- a/cli/templatevariables.go +++ b/cli/templatevariables.go @@ -7,7 +7,7 @@ import ( "golang.org/x/xerrors" "gopkg.in/yaml.v3" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" ) func loadVariableValuesFromFile(variablesFile string) ([]codersdk.VariableValue, error) { diff --git a/cli/templateversionarchive.go b/cli/templateversionarchive.go new file mode 100644 index 0000000000000..63c9d8a3de212 --- /dev/null +++ b/cli/templateversionarchive.go @@ -0,0 +1,184 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" +) + +func (r *RootCmd) unarchiveTemplateVersion() *clibase.Cmd { + return r.setArchiveTemplateVersion(false) +} + +func (r *RootCmd) archiveTemplateVersion() *clibase.Cmd { + return r.setArchiveTemplateVersion(true) +} + +//nolint:revive +func (r *RootCmd) setArchiveTemplateVersion(archive bool) *clibase.Cmd { + presentVerb := "archive" + pastVerb := "archived" + if !archive { + presentVerb = "unarchive" + pastVerb = "unarchived" + } + + client := new(codersdk.Client) + cmd := &clibase.Cmd{ + Use: presentVerb + " [template-version-names...] ", + Short: strings.ToUpper(string(presentVerb[0])) + presentVerb[1:] + " a template version(s).", + Middleware: clibase.Chain( + r.InitClient(client), + ), + Options: clibase.OptionSet{ + cliui.SkipPromptOption(), + }, + Handler: func(inv *clibase.Invocation) error { + var ( + ctx = inv.Context() + versions []codersdk.TemplateVersion + ) + + organization, err := CurrentOrganization(inv, client) + if err != nil { + return err + } + + if len(inv.Args) == 0 { + return xerrors.Errorf("missing template name") + } + if len(inv.Args) < 2 { + return xerrors.Errorf("missing template version name(s)") + } + + templateName := inv.Args[0] + template, err := client.TemplateByName(ctx, organization.ID, templateName) + if err != nil { + return xerrors.Errorf("get template by name: %w", err) + } + for _, versionName := range inv.Args[1:] { + version, err := client.TemplateVersionByOrganizationAndName(ctx, organization.ID, template.Name, versionName) + if err != nil { + return xerrors.Errorf("get template version by name %q: %w", versionName, err) + } + versions = append(versions, version) + } + + for _, version := range versions { + if version.Archived == archive { + _, _ = fmt.Fprintln( + inv.Stdout, fmt.Sprintf("Version "+pretty.Sprint(cliui.DefaultStyles.Keyword, version.Name)+" already "+pastVerb), + ) + continue + } + + err := client.SetArchiveTemplateVersion(ctx, version.ID, archive) + if err != nil { + return xerrors.Errorf("%s template version %q: %w", presentVerb, version.Name, err) + } + + _, _ = fmt.Fprintln( + inv.Stdout, fmt.Sprintf("Version "+pretty.Sprint(cliui.DefaultStyles.Keyword, version.Name)+" "+pastVerb+" at "+cliui.Timestamp(time.Now())), + ) + } + return nil + }, + } + + return cmd +} + +func (r *RootCmd) archiveTemplateVersions() *clibase.Cmd { + var all clibase.Bool + client := new(codersdk.Client) + cmd := &clibase.Cmd{ + Use: "archive [template-name...] ", + Short: "Archive unused or failed template versions from a given template(s)", + Middleware: clibase.Chain( + r.InitClient(client), + ), + Options: clibase.OptionSet{ + cliui.SkipPromptOption(), + clibase.Option{ + Name: "all", + Description: "Include all unused template versions. By default, only failed template versions are archived.", + Flag: "all", + Value: &all, + }, + }, + Handler: func(inv *clibase.Invocation) error { + var ( + ctx = inv.Context() + templateNames = []string{} + templates = []codersdk.Template{} + ) + + organization, err := CurrentOrganization(inv, client) + if err != nil { + return err + } + + if len(inv.Args) > 0 { + templateNames = inv.Args + + for _, templateName := range templateNames { + template, err := client.TemplateByName(ctx, organization.ID, templateName) + if err != nil { + return xerrors.Errorf("get template by name: %w", err) + } + templates = append(templates, template) + } + } else { + template, err := selectTemplate(inv, client, organization) + if err != nil { + return err + } + + templates = append(templates, template) + templateNames = append(templateNames, template.Name) + } + + // Confirm archive of the template. + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: fmt.Sprintf("Archive template versions of these templates: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(templateNames, ", "))), + IsConfirm: true, + Default: cliui.ConfirmNo, + }) + if err != nil { + return err + } + + for _, template := range templates { + resp, err := client.ArchiveTemplateVersions(ctx, template.ID, all.Value()) + if err != nil { + return xerrors.Errorf("archive template %q: %w", template.Name, err) + } + + _, _ = fmt.Fprintln( + inv.Stdout, fmt.Sprintf("Archived %d versions from "+pretty.Sprint(cliui.DefaultStyles.Keyword, template.Name)+" at "+cliui.Timestamp(time.Now()), len(resp.ArchivedIDs)), + ) + + if ok, _ := inv.ParsedFlags().GetBool("verbose"); err == nil && ok { + data, err := json.Marshal(resp) + if err != nil { + return xerrors.Errorf("marshal verbose response: %w", err) + } + _, _ = fmt.Fprintln( + inv.Stdout, string(data), + ) + } + } + return nil + }, + } + + return cmd +} diff --git a/cli/templateversionarchive_test.go b/cli/templateversionarchive_test.go new file mode 100644 index 0000000000000..02fb72a6b7b74 --- /dev/null +++ b/cli/templateversionarchive_test.go @@ -0,0 +1,108 @@ +package cli_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/testutil" +) + +func TestTemplateVersionsArchive(t *testing.T) { + t.Parallel() + t.Run("Archive-Unarchive", func(t *testing.T) { + t.Parallel() + ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + + client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + other := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(request *codersdk.CreateTemplateVersionRequest) { + request.TemplateID = template.ID + }) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, other.ID) + + // Archive + inv, root := clitest.New(t, "templates", "versions", "archive", template.Name, other.Name, "-y") + clitest.SetupConfig(t, client, root) + w := clitest.StartWithWaiter(t, inv) + w.RequireSuccess() + + // Verify archived + ctx := testutil.Context(t, testutil.WaitMedium) + found, err := client.TemplateVersion(ctx, other.ID) + require.NoError(t, err) + require.True(t, found.Archived, "expect archived") + + // Unarchive + inv, root = clitest.New(t, "templates", "versions", "unarchive", template.Name, other.Name, "-y") + clitest.SetupConfig(t, client, root) + w = clitest.StartWithWaiter(t, inv) + w.RequireSuccess() + + // Verify unarchived + ctx = testutil.Context(t, testutil.WaitMedium) + found, err = client.TemplateVersion(ctx, other.ID) + require.NoError(t, err) + require.False(t, found.Archived, "expect unarchived") + }) + + t.Run("ArchiveMany", func(t *testing.T) { + t.Parallel() + ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + + client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Add a failed + expArchived := map[uuid.UUID]bool{} + failed := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyFailed, + ProvisionPlan: echo.PlanFailed, + }, func(request *codersdk.CreateTemplateVersionRequest) { + request.TemplateID = template.ID + }) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, failed.ID) + expArchived[failed.ID] = true + // Add unused + unused := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(request *codersdk.CreateTemplateVersionRequest) { + request.TemplateID = template.ID + }) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, unused.ID) + expArchived[unused.ID] = true + + // Archive all unused versions + inv, root := clitest.New(t, "templates", "archive", template.Name, "-y", "--all") + clitest.SetupConfig(t, client, root) + w := clitest.StartWithWaiter(t, inv) + w.RequireSuccess() + + ctx := testutil.Context(t, testutil.WaitMedium) + all, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{ + TemplateID: template.ID, + IncludeArchived: true, + }) + require.NoError(t, err, "query all versions") + for _, v := range all { + if _, ok := expArchived[v.ID]; ok { + require.True(t, v.Archived, "expect archived") + delete(expArchived, v.ID) + } else { + require.False(t, v.Archived, "expect unarchived") + } + } + require.Len(t, expArchived, 0, "expect all archived") + }) +} diff --git a/cli/templateversions.go b/cli/templateversions.go index ed7688d3f3108..a27d6a6d65af3 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -8,9 +8,10 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) func (r *RootCmd) templateVersions() *clibase.Cmd { @@ -29,6 +30,8 @@ func (r *RootCmd) templateVersions() *clibase.Cmd { }, Children: []*clibase.Cmd{ r.templateVersionsList(), + r.archiveTemplateVersion(), + r.unarchiveTemplateVersion(), }, } @@ -36,19 +39,59 @@ func (r *RootCmd) templateVersions() *clibase.Cmd { } func (r *RootCmd) templateVersionsList() *clibase.Cmd { + defaultColumns := []string{ + "Name", + "Created At", + "Created By", + "Status", + "Active", + } formatter := cliui.NewOutputFormatter( - cliui.TableFormat([]templateVersionRow{}, nil), + cliui.TableFormat([]templateVersionRow{}, defaultColumns), cliui.JSONFormat(), ) client := new(codersdk.Client) + var includeArchived clibase.Bool + cmd := &clibase.Cmd{ Use: "list