diff --git a/.github/actions/setup-go/action.yaml b/.github/actions/setup-go/action.yaml index 4d696ef298b12..968f1f1ab8d27 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.7" runs: using: "composite" steps: diff --git a/.github/actions/setup-sqlc/action.yaml b/.github/actions/setup-sqlc/action.yaml index 354e55e8213f6..d109a50f52f75 100644 --- a/.github/actions/setup-sqlc/action.yaml +++ b/.github/actions/setup-sqlc/action.yaml @@ -7,4 +7,4 @@ runs: - name: Setup sqlc uses: sqlc-dev/setup-sqlc@v3 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/dependabot.yaml b/.github/dependabot.yaml index 5b9f7a9c6597a..76048b9fe398d 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 @@ -117,11 +117,6 @@ updates: - "@eslint*" - "@typescript-eslint/eslint-plugin" - "@typescript-eslint/parser" - jest: - patterns: - - "jest*" - - "@swc/jest" - - "@types/jest" - package-ecosystem: "npm" directory: "/offlinedocs/" @@ -146,20 +141,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..bef767547b2a0 --- /dev/null +++ b/.github/pr-deployments/template/main.tf @@ -0,0 +1,313 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 0.11.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.22" + } + } +} + +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" @@ -224,7 +221,7 @@ jobs: with: # This doesn't need caching. It's super fast anyways! cache: false - go-version: 1.20.6 + go-version: 1.20.7 - name: Install shfmt run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.5.0 @@ -532,6 +529,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 @@ -586,6 +601,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 +627,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 @@ -640,9 +657,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 +683,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. @@ -724,6 +740,7 @@ jobs: 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 +755,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..9c601b011c03b 100644 --- a/.github/workflows/contrib.yaml +++ b/.github/workflows/contrib.yaml @@ -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/dogfood.yaml b/.github/workflows/dogfood.yaml index f1a6c2e712fd0..bbed89679f7d1 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@v3 + - name: Get branch name id: branch-name uses: tj-actions/branch-names@v6.5 @@ -30,11 +37,13 @@ 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@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + - name: Run the Magic Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@v2 + + - run: nix build .#devEnvImage && ./result | docker load - name: Login to DockerHub uses: docker/login-action@v2 @@ -42,15 +51,10 @@ jobs: 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 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..4dd2446dcc9aa 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,70 @@ env: permissions: contents: read packages: write - pull-requests: write + pull-requests: write # needed for commenting on PRs concurrency: - group: ${{ github.workflow }}-PR-${{ github.event.pull_request.number || github.event.inputs.pr_number }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + check_pr: + runs-on: ubuntu-latest + outputs: + PR_OPEN: ${{ steps.check_pr.outputs.pr_open }} + steps: + - name: Checkout + uses: actions/checkout@v3 + + - 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 || steps.build_conditionals.outputs.automatic_rebuild }} runs-on: "ubuntu-latest" steps: + - name: Checkout + uses: actions/checkout@v3 + 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,7 +107,7 @@ 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 @@ -82,53 +115,21 @@ jobs: - 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,47 +150,72 @@ 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: [check_pr, 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' }} 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 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 with: registry: ghcr.io @@ -197,9 +223,8 @@ jobs: 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 +242,42 @@ 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 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 +285,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@v3 + - 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 +393,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 +408,24 @@ 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 + terraform init + 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 +433,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..42d6a2f8620fc 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: @@ -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 @@ -341,6 +332,7 @@ jobs: name: Publish to winget-pkgs runs-on: windows-latest needs: release + if: ${{ !inputs.dry_run }} steps: - name: Checkout uses: actions/checkout@v3 @@ -376,12 +368,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 +391,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 +406,65 @@ 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@v3 + 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..04c3b1562147b 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -21,9 +21,6 @@ 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' }} @@ -69,16 +66,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: Setup sqlc uses: ./.github/actions/setup-sqlc diff --git a/.gitignore b/.gitignore index b22db03c2089e..16c4b9a7aef94 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ site/stats/ ./scaletest/terraform/.terraform.lock.hcl scaletest/terraform/secrets.tfvars .terraform.tfstate.* + +# Nix +result diff --git a/.golangci.yaml b/.golangci.yaml index e3f3797d06b81..5f474602b2cfd 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -2,12 +2,16 @@ # 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+' 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 +122,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 @@ -211,6 +211,7 @@ issues: run: skip-dirs: - node_modules + - .git skip-files: - scripts/rules.go timeout: 10m @@ -231,7 +232,11 @@ 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 - goimports - gomodguard - gosec @@ -267,3 +272,4 @@ linters: - typecheck - unconvert - unused + - dupl diff --git a/.prettierignore b/.prettierignore index 9296d15d8802e..29a161fcb86f5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -64,10 +64,13 @@ site/stats/ ./scaletest/terraform/.terraform.lock.hcl scaletest/terraform/secrets.tfvars .terraform.tfstate.* + +# Nix +result # .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 diff --git a/.prettierignore.include b/.prettierignore.include index 1f60eda9c54a7..975c00ca21b84 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 diff --git a/.prettierrc.yaml b/.prettierrc.yaml index 9ba1d2ca9db7a..7fe31e7338ad4 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -2,6 +2,7 @@ # formatting for prettier-supported files. See `.editorconfig` and # `site/.editorconfig`for whitespace formatting options. printWidth: 80 +proseWrap: always semi: false trailingComma: all useTabs: false @@ -9,10 +10,9 @@ tabWidth: 2 overrides: - files: - README.md + - docs/api/**/*.md + - docs/cli/**/*.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/Makefile b/Makefile index c9089a9d4e452..56acd83ff70c8 100644 --- a/Makefile +++ b/Makefile @@ -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' \)) @@ -452,10 +456,10 @@ DB_GEN_FILES := \ # all gen targets should be added here and to gen/mark-fresh gen: \ - coderd/database/dump.sql \ - $(DB_GEN_FILES) \ 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 +470,18 @@ gen: \ .prettierignore \ site/.prettierrc.yaml \ site/.prettierignore \ - site/.eslintignore + site/.eslintignore \ + site/e2e/provisionerGenerated.ts .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) \ 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 +493,7 @@ gen/mark-fresh: site/.prettierrc.yaml \ site/.prettierignore \ site/.eslintignore \ + site/e2e/provisionerGenerated.ts \ " for file in $$files; do echo "$$file" @@ -532,7 +538,12 @@ provisionerd/proto/provisionerd.pb.go: 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 cd site - pnpm run format:types + pnpm run format:types ./src/api/typesGenerated.ts + +site/e2e/provisionerGenerated.ts: + cd site + ../scripts/pnpm_install.sh + pnpm run gen:provisioner 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 @@ -553,7 +564,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 .PHONY: update-golden-files cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go) @@ -564,8 +575,16 @@ 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 "$@" scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go) diff --git a/README.md b/README.md index 9443eb6b701fd..3f7d835125ff9 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ You can run the install script with `--dry-run` to see the commands that will be 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..532e7e5a88392 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -21,7 +21,7 @@ import ( "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 +34,14 @@ 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/agentssh" + "github.com/coder/coder/v2/agent/reconnectingpty" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/tailnet" "github.com/coder/retry" ) @@ -63,7 +63,7 @@ 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 @@ -91,9 +91,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() } @@ -144,7 +141,7 @@ func New(options Options) Agent { reportMetadataInterval: options.ReportMetadataInterval, serviceBannerRefreshInterval: options.ServiceBannerRefreshInterval, sshMaxTimeout: options.SSHMaxTimeout, - subsystem: options.Subsystem, + subsystems: options.Subsystems, addresses: options.Addresses, prometheusRegistry: prometheusRegistry, @@ -166,7 +163,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 @@ -608,7 +605,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) @@ -657,7 +654,7 @@ func (a *agent) run(ctx context.Context) error { select { case err = <-scriptDone: case <-timeout: - a.logger.Warn(ctx, "script timed out", slog.F("lifecycle", "startup"), slog.F("timeout", manifest.ShutdownScriptTimeout)) + a.logger.Warn(ctx, "script timed out", slog.F("lifecycle", "startup"), slog.F("timeout", manifest.StartupScriptTimeout)) a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleStartTimeout) err = <-scriptDone // The script can still complete after a timeout. } @@ -681,7 +678,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 +701,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 +758,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) @@ -1074,8 +1075,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 +1087,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.Debug(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.Debug(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 +1112,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 +1128,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) - } + rpty = reconnectingpty.New(ctx, cmd, &reconnectingpty.Options{ + Timeout: a.reconnectingPTYTimeout, + Metrics: a.metrics.reconnectingPTYErrors, + }, logger.With(slog.F("message_id", msg.ID))) - ptty, process, err := pty.Start(cmd) - if err != nil { - a.metrics.reconnectingPTYErrors.WithLabelValues("start_command").Add(1) - return xerrors.Errorf("start command: %w", err) - } - - 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. @@ -1408,24 +1264,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 { @@ -1507,31 +1396,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..126e0f4fa4c97 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" @@ -42,17 +42,17 @@ import ( "cdr.dev/slog" "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/agentssh" + "github.com/coder/coder/v2/agent/agenttest" + "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" + "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 +102,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() @@ -1587,8 +1587,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 +1596,116 @@ 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) + for _, backendType := range backends { + backendType := backendType + t.Run(backendType, func(t *testing.T) { + if backendType == "Screen" { + t.Parallel() + 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) + } else { + t.Parallel() + } - // Brief pause to reduce the likelihood that we send keystrokes while - // the shell is simultaneously sending a prompt. - time.Sleep(100 * time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() - data, err := json.Marshal(codersdk.ReconnectingPTYRequest{ - Data: "echo test\r\n", - }) - require.NoError(t, err) - _, err = netConn.Write(data) - require.NoError(t, err) + //nolint:dogsled + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) + id := uuid.New() + netConn1, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash") + require.NoError(t, err) + defer netConn1.Close() - expectLine := func(matcher func(string) bool) { - for { - line, err := bufRead.ReadString('\n') + // A second simultaneous connection. + netConn2, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash") + require.NoError(t, err) + defer netConn2.Close() + + // Brief pause to reduce the likelihood that we send keystrokes while + // the shell is simultaneously sending a prompt. + time.Sleep(100 * time.Millisecond) + + data, err := json.Marshal(codersdk.ReconnectingPTYRequest{ + Data: "echo test\r\n", + }) + require.NoError(t, err) + _, err = netConn1.Write(data) require.NoError(t, err) - if matcher(line) { - break + + 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") - } + // Once for typing the command... + require.NoError(t, testutil.ReadUntil(ctx, t, netConn1, matchEchoCommand), "find echo command") + // And another time for the actual output. + require.NoError(t, testutil.ReadUntil(ctx, t, netConn1, matchEchoOutput), "find echo output") - // Once for typing the command... - expectLine(matchEchoCommand) - // And another time for the actual output. - expectLine(matchEchoOutput) + // Same for the other connection. + require.NoError(t, testutil.ReadUntil(ctx, t, netConn2, matchEchoCommand), "find echo command") + require.NoError(t, testutil.ReadUntil(ctx, t, netConn2, matchEchoOutput), "find echo output") - _ = netConn.Close() - netConn, err = conn.ReconnectingPTY(ctx, id, 100, 100, "/bin/bash") - require.NoError(t, err) - defer netConn.Close() + _ = netConn1.Close() + _ = netConn2.Close() + netConn3, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash") + require.NoError(t, err) + defer netConn3.Close() + + // Same output again! + require.NoError(t, testutil.ReadUntil(ctx, t, netConn3, matchEchoCommand), "find echo command") + require.NoError(t, testutil.ReadUntil(ctx, t, netConn3, matchEchoOutput), "find echo output") + + // Exit should cause the connection to close. + data, err = json.Marshal(codersdk.ReconnectingPTYRequest{ + Data: "exit\r\n", + }) + require.NoError(t, err) + _, err = netConn3.Write(data) + require.NoError(t, err) - bufRead = bufio.NewReader(netConn) + // Once for the input and again for the output. + require.NoError(t, testutil.ReadUntil(ctx, t, netConn3, matchExitCommand), "find exit command") + require.NoError(t, testutil.ReadUntil(ctx, t, netConn3, matchExitOutput), "find exit output") + + // Wait for the connection to close. + require.ErrorIs(t, testutil.ReadUntil(ctx, t, netConn3, 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() - // Same output again! - expectLine(matchEchoCommand) - expectLine(matchEchoOutput) + require.NoError(t, testutil.ReadUntil(ctx, t, netConn4, matchEchoOutput), "find echo output") + require.ErrorIs(t, testutil.ReadUntil(ctx, t, netConn3, nil), io.EOF) + }) + } } func TestAgent_Dial(t *testing.T) { @@ -1932,6 +1987,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 +2158,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 { diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index 729cadd423ce2..46dabacd7f2c5 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -28,10 +28,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 ( 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..146da9b4c3bec 100644 --- a/agent/agentssh/agentssh_test.go +++ b/agent/agentssh/agentssh_test.go @@ -20,9 +20,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) { 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/client.go b/agent/agenttest/client.go index cc9d365b5331d..f8c69bf408869 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, 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/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..30b1f44801b9f --- /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) + var req codersdk.ReconnectingPTYRequest + for { + 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..a2db7bb9c001e --- /dev/null +++ b/agent/reconnectingpty/screen.go @@ -0,0 +1,388 @@ +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, + // -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. + "-xRRqc", 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/buildinfo_test.go b/buildinfo/buildinfo_test.go index 12cc8c99a3ee7..2b4b6a3270654 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) { diff --git a/cli/agent.go b/cli/agent.go index 1d9a2ba02d51c..8b77c057ef31a 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -12,6 +12,7 @@ import ( "path/filepath" "runtime" "strconv" + "strings" "sync" "time" @@ -27,12 +28,12 @@ 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/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 { @@ -253,7 +254,19 @@ 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) + } + agnt := agent.New(agent.Options{ Client: client, Logger: logger, @@ -275,7 +288,7 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd { }, IgnorePorts: ignorePorts, SSHMaxTimeout: sshMaxTimeout, - Subsystem: codersdk.AgentSubsystem(subsystem), + Subsystems: subsystems, PrometheusRegistry: prometheusRegistry, }) diff --git a/cli/agent_test.go b/cli/agent_test.go index 462ef3c204541..7073f7c0f18ca 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) { @@ -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", @@ -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", @@ -178,9 +179,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", @@ -264,8 +265,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 +276,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_test.go b/cli/clibase/option_test.go index cacd8d3a10793..f1b881d94408e 100644 --- a/cli/clibase/option_test.go +++ b/cli/clibase/option_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/v2/cli/clibase" ) func TestOptionSet_ParseFlags(t *testing.T) { @@ -72,6 +72,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) { diff --git a/cli/clibase/values.go b/cli/clibase/values.go index 288a7c372b152..6ec67d2d1bc09 100644 --- a/cli/clibase/values.go +++ b/cli/clibase/values.go @@ -7,6 +7,7 @@ import ( "net" "net/url" "reflect" + "regexp" "strconv" "strings" "time" @@ -461,6 +462,43 @@ func (e *Enum) String() string { return *e.Value } +type Regexp regexp.Regexp + +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_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..b1c8cd665d8cb 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -19,12 +19,12 @@ 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 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..8abaaeb3d154f 100644 --- a/cli/clitest/golden.go +++ b/cli/clitest/golden.go @@ -15,12 +15,12 @@ import ( "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. 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..c6cc9f413fe54 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") diff --git a/cli/cliui/agent_test.go b/cli/cliui/agent_test.go index c910bf11c301a..c5b5f9f4a3965 100644 --- a/cli/cliui/agent_test.go +++ b/cli/cliui/agent_test.go @@ -14,12 +14,12 @@ import ( "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) { diff --git a/cli/cliui/gitauth.go b/cli/cliui/gitauth.go index 7b4bd6f30e264..2e9453c1aac9d 100644 --- a/cli/cliui/gitauth.go +++ b/cli/cliui/gitauth.go @@ -8,7 +8,7 @@ import ( "github.com/briandowns/spinner" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" ) type GitAuthOptions struct { diff --git a/cli/cliui/gitauth_test.go b/cli/cliui/gitauth_test.go index dfe142f99be28..22da1b46ca6f9 100644 --- a/cli/cliui/gitauth_test.go +++ b/cli/cliui/gitauth_test.go @@ -8,11 +8,11 @@ import ( "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" + "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 TestGitAuth(t *testing.T) { 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..a4c8d8e817d59 100644 --- a/cli/cliui/parameter.go +++ b/cli/cliui/parameter.go @@ -5,8 +5,8 @@ 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" ) func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.TemplateVersionParameter) (string, error) { diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index f927b60749769..ef859814f6299 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -13,7 +13,7 @@ import ( "github.com/mattn/go-isatty" "golang.org/x/xerrors" - "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/v2/cli/clibase" ) // PromptOptions supply a set of options to the prompt. 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..b09ac6bc73cad 100644 --- a/cli/cliui/provisionerjob.go +++ b/cli/cliui/provisionerjob.go @@ -14,7 +14,7 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" ) func WorkspaceBuild(ctx context.Context, writer io.Writer, client *codersdk.Client, build uuid.UUID) error { diff --git a/cli/cliui/provisionerjob_test.go b/cli/cliui/provisionerjob_test.go index 2aa25b6046517..0cf71d8444b95 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" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" ) // This cannot be ran in parallel because it uses a signal. diff --git a/cli/cliui/resources.go b/cli/cliui/resources.go index 586d37eb5cc81..b1646e22a7b9b 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/v2/coderd/database" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" ) type WorkspaceResourcesOptions struct { diff --git a/cli/cliui/resources_test.go b/cli/cliui/resources_test.go index c9d87c258a6e4..6fc0d7a266c47 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" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" ) func TestWorkspaceResources(t *testing.T) { 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..44246da2596e7 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -21,15 +21,15 @@ import ( "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" + "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/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func sshConfigFileName(t *testing.T) (sshConfig string) { @@ -82,9 +82,9 @@ func TestConfigSSH(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.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", @@ -720,22 +720,11 @@ 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) // authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: provisionResponse, - ProvisionApply: provisionResponse, - }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, + echo.WithResources(resources)) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) diff --git a/cli/create.go b/cli/create.go index 602b7b40a45bc..971fbc27aac36 100644 --- a/cli/create.go +++ b/cli/create.go @@ -10,19 +10,21 @@ 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/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 ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -80,8 +82,8 @@ func (r *RootCmd) create() *clibase.Cmd { 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)) @@ -129,10 +131,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 +166,7 @@ func (r *RootCmd) create() *clibase.Cmd { Name: workspaceName, AutostartSchedule: schedSpec, TTLMillis: ttlMillis, - RichParameterValues: buildParams.richParameters, + RichParameterValues: richParameters, }) if err != nil { return xerrors.Errorf("create workspace: %w", err) @@ -179,12 +189,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", @@ -199,99 +203,59 @@ func (r *RootCmd) create() *clibase.Cmd { }, 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 + + LastBuildParameters []codersdk.WorkspaceBuildParameter + + PromptBuildOptions bool + BuildOptions []codersdk.WorkspaceBuildParameter -type buildParameters struct { - // Rich parameters stores values for build parameters annotated with description, icon, type, etc. - richParameters []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) - if err != nil { - return nil, 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) + parameterFile, err = parseParameterMapFile(args.RichParameterFile) if err != nil { - return nil, err + return nil, xerrors.Errorf("can't parse parameter map file: %w", 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{ @@ -306,7 +270,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 +310,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..bdd229775ec68 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/gitauth" + "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) { @@ -28,11 +29,7 @@ func TestCreate(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, - }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, completeWithAgent()) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) args := []string{ @@ -83,11 +80,7 @@ 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, - }) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent()) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) _, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) @@ -140,11 +133,7 @@ func TestCreate(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, - }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, completeWithAgent()) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { var defaultTTLMillis int64 = 2 * 60 * 60 * 1000 // 2 hours @@ -239,6 +228,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,27 +262,12 @@ 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() @@ -357,6 +347,41 @@ func TestCreateWithRichParameters(t *testing.T) { } <-doneChan }) + + t.Run("ParameterFlags", 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) + + 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, client, 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,28 +416,6 @@ 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() @@ -590,20 +593,16 @@ 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{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ GitAuthProviders: []string{"github"}, }, }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, + ProvisionApply: echo.ApplyComplete, } client := coderdtest.New(t, &coderdtest.Options{ diff --git a/cli/delete.go b/cli/delete.go index 867abe0326a30..760c0c4e77dd0 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,7 @@ 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.DefaultStyles.Keyword.Render(workspace.FullName()), cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))) return nil }, } diff --git a/cli/delete_test.go b/cli/delete_test.go index 40f5b9ac22168..58da1aa9a4efd 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) { @@ -41,7 +41,7 @@ func TestDelete(t *testing.T) { assert.ErrorIs(t, err, io.EOF) } }() - pty.ExpectMatch("workspace has been deleted") + pty.ExpectMatch("has been deleted") <-doneChan }) @@ -68,7 +68,7 @@ func TestDelete(t *testing.T) { assert.ErrorIs(t, err, io.EOF) } }() - pty.ExpectMatch("workspace has been deleted") + pty.ExpectMatch("has been deleted") <-doneChan }) @@ -113,7 +113,7 @@ func TestDelete(t *testing.T) { assert.ErrorIs(t, err, io.EOF) } }() - pty.ExpectMatch("workspace has been deleted") + pty.ExpectMatch("has been deleted") <-doneChan }) @@ -145,7 +145,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..635a3add33c0a 100644 --- a/cli/dotfiles.go +++ b/cli/dotfiles.go @@ -13,8 +13,8 @@ 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" ) func (r *RootCmd) dotfiles() *clibase.Cmd { diff --git a/cli/dotfiles_test.go b/cli/dotfiles_test.go index e979fec3e7980..d5511c986aecc 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) { diff --git a/cli/exp.go b/cli/exp.go index 2513a8fda43ee..815d334256414 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{ diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go index d2ee36c1819eb..5f0dc34bf68bc 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -22,19 +22,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" @@ -427,7 +427,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 +443,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 +460,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 +479,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) diff --git a/cli/exp_scaletest_test.go b/cli/exp_scaletest_test.go index 4c10b722ca357..db58d41e6926d 100644 --- a/cli/exp_scaletest_test.go +++ b/cli/exp_scaletest_test.go @@ -1,17 +1,16 @@ 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" + "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) { @@ -72,9 +71,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,6 +82,9 @@ func TestScaleTestWorkspaceTraffic(t *testing.T) { // This test just validates that the CLI command accepts its known arguments. func TestScaleTestDashboard(t *testing.T) { t.Parallel() + if testutil.RaceEnabled() { + t.Skip("Flakes under race detector, see https://github.com/coder/coder/issues/9168") + } ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitMedium) defer cancelFunc() @@ -98,9 +101,10 @@ func TestScaleTestDashboard(t *testing.T) { "--scaletest-prometheus-wait", "0s", ) 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.NoError(t, err, "") } diff --git a/cli/gitaskpass.go b/cli/gitaskpass.go index 5bb67adf82416..fb41613d26836 100644 --- a/cli/gitaskpass.go +++ b/cli/gitaskpass.go @@ -9,10 +9,10 @@ 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/coderd/gitauth" + "github.com/coder/coder/v2/codersdk" "github.com/coder/retry" ) @@ -51,9 +51,9 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd { } 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); { @@ -61,7 +61,7 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd { 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..5ec7f4c6bb258 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) { diff --git a/cli/gitssh.go b/cli/gitssh.go index 6c4046c03cafe..de5482a8ae387 100644 --- a/cli/gitssh.go +++ b/cli/gitssh.go @@ -14,8 +14,8 @@ 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" ) func (r *RootCmd) gitssh() *clibase.Cmd { diff --git a/cli/gitssh_test.go b/cli/gitssh_test.go index 39daab430c01c..3e5045acf0288 100644 --- a/cli/gitssh_test.go +++ b/cli/gitssh_test.go @@ -20,12 +20,12 @@ 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/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 prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, string, gossh.PublicKey) { @@ -48,7 +48,7 @@ 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) diff --git a/cli/help.go b/cli/help.go index fa813febc53e9..3741dbfc28119 100644 --- a/cli/help.go +++ b/cli/help.go @@ -17,8 +17,8 @@ import ( "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/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" ) //go:embed help.tpl diff --git a/cli/list.go b/cli/list.go index 4b50ba16a7d34..12d0a48149ef7 100644 --- a/cli/list.go +++ b/cli/list.go @@ -7,11 +7,11 @@ 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/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/schedule" + "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 +30,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 { @@ -68,6 +69,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 +80,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(), ) ) diff --git a/cli/list_test.go b/cli/list_test.go index 39567cd6d9167..6f5fb883c06d9 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) { diff --git a/cli/login.go b/cli/login.go index e16118dfec0d6..3fe871ad84136 100644 --- a/cli/login.go +++ b/cli/login.go @@ -16,10 +16,10 @@ 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/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 ( @@ -76,7 +76,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 } diff --git a/cli/login_test.go b/cli/login_test.go index 1bab4721ea181..0837c1e89b401 100644 --- a/cli/login_test.go +++ b/cli/login_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/cliui" - "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/cliui" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" ) func TestLogin(t *testing.T) { 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..32ce77758f8e4 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" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) netcheck() *clibase.Cmd { diff --git a/cli/netcheck_test.go b/cli/netcheck_test.go index 890260c1a704e..aff65d565bd27 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" + "github.com/coder/coder/v2/pty/ptytest" ) func TestNetcheck(t *testing.T) { @@ -31,7 +31,7 @@ func TestNetcheck(t *testing.T) { 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..486188d52a27a --- /dev/null +++ b/cli/parameterresolver.go @@ -0,0 +1,254 @@ +package cli + +import ( + "fmt" + + "golang.org/x/xerrors" + + "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, cliui.DefaultStyles.Warn.Render(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..a3075a85ad3e4 100644 --- a/cli/ping.go +++ b/cli/ping.go @@ -10,9 +10,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) ping() *clibase.Cmd { diff --git a/cli/ping_test.go b/cli/ping_test.go index 959c11c8ed9b4..d054cbf38057a 100644 --- a/cli/ping_test.go +++ b/cli/ping_test.go @@ -8,11 +8,11 @@ import ( "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" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestPing(t *testing.T) { 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..030133a7ae317 100644 --- a/cli/portforward_test.go +++ b/cli/portforward_test.go @@ -13,12 +13,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/codersdk" - "github.com/coder/coder/provisioner/echo" - "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/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestPortForward_None(t *testing.T) { @@ -302,7 +302,7 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk. agentToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(agentToken), }) diff --git a/cli/publickey.go b/cli/publickey.go index 43537eec428a1..c41c5e2fd4d46 100644 --- a/cli/publickey.go +++ b/cli/publickey.go @@ -5,9 +5,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) publickey() *clibase.Cmd { @@ -45,12 +45,12 @@ 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", + "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, cliui.DefaultStyles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n") + cliui.Infof(inv.Stdout, "Add to GitHub and GitLab:") + cliui.Infof(inv.Stdout, cliui.DefaultStyles.Prompt.String()+"https://github.com/settings/ssh/new") + cliui.Infof(inv.Stdout, cliui.DefaultStyles.Prompt.String()+"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..95daa46663ea5 100644 --- a/cli/remoteforward.go +++ b/cli/remoteforward.go @@ -11,7 +11,7 @@ import ( 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 diff --git a/cli/rename.go b/cli/rename.go index d9e2af5316603..94d9fc5517278 100644 --- a/cli/rename.go +++ b/cli/rename.go @@ -5,9 +5,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) rename() *clibase.Cmd { diff --git a/cli/rename_test.go b/cli/rename_test.go index 6cd92ff9e1451..42dd4bd897c0e 100644 --- a/cli/rename_test.go +++ b/cli/rename_test.go @@ -6,10 +6,10 @@ 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) { diff --git a/cli/resetpassword.go b/cli/resetpassword.go index 02a98993368cc..7df9481417b28 100644 --- a/cli/resetpassword.go +++ b/cli/resetpassword.go @@ -6,11 +6,11 @@ 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/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 { 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..2f5ca1fff7a77 100644 --- a/cli/restart.go +++ b/cli/restart.go @@ -4,9 +4,11 @@ import ( "fmt" "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" ) func (r *RootCmd) restart() *clibase.Cmd { @@ -21,7 +23,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 +33,29 @@ func (r *RootCmd) restart() *clibase.Cmd { return err } + lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) + if err != nil { + return err + } + template, err := client.Template(inv.Context(), workspace.TemplateID) 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, + Template: template, + + LastBuildParameters: lastBuildParameters, + + PromptBuildOptions: parameterFlags.promptBuildOptions, + BuildOptions: buildOptions, }) if err != nil { return err @@ -65,7 +82,7 @@ func (r *RootCmd) restart() *clibase.Cmd { build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, - RichParameterValues: buildParams.richParameters, + RichParameterValues: buildParameters, }) if err != nil { return err diff --git a/cli/restart_test.go b/cli/restart_test.go index 83b066e4defc5..43b512c1bc30b 100644 --- a/cli/restart_test.go +++ b/cli/restart_test.go @@ -2,47 +2,32 @@ 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() @@ -126,4 +111,128 @@ func TestRestart(t *testing.T) { Value: ephemeralParameterValue, }) }) + + t.Run("BuildOptionFlags", 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) + + inv, root := clitest.New(t, "restart", workspace.Name, + "--build-option", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue)) + clitest.SetupConfig(t, client, 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, user.UserID.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, + }) + }) +} + +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}) + 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, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + { + Name: immutableParameterName, + Value: immutableParameterValue, + }, + } + }) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // Restart the workspace again + inv, root := clitest.New(t, "restart", workspace.Name, "-y") + clitest.SetupConfig(t, client, 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..3ab2f0d7f33b9 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1,6 +1,8 @@ package cli import ( + "bufio" + "bytes" "context" "encoding/base64" "encoding/json" @@ -13,6 +15,7 @@ import ( "net/http" "net/url" "os" + "os/exec" "os/signal" "path/filepath" "runtime" @@ -28,15 +31,15 @@ import ( "golang.org/x/xerrors" "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/coderd" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" ) var ( @@ -55,6 +58,7 @@ const ( varAgentToken = "agent-token" varAgentURL = "agent-url" varHeader = "header" + varHeaderCommand = "header-command" varNoOpen = "no-open" varNoVersionCheck = "no-version-warning" varNoFeatureWarning = "no-feature-warning" @@ -356,6 +360,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", @@ -437,6 +448,7 @@ type RootCmd struct { token string globalConfig string header []string + headerCommand string agentToken string agentURL *url.URL forceTTY bool @@ -494,6 +506,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 +529,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 +545,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 } @@ -582,12 +602,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 +647,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 } @@ -785,6 +831,13 @@ func (r *RootCmd) checkWarnings(i *clibase.Invocation, client *codersdk.Client) 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 @@ -935,6 +988,8 @@ func (p *prettyErrorFormatter) format(err error) { msg = sdkError.Message if sdkError.Helper != "" { msg = msg + "\n" + sdkError.Helper + } else if sdkError.Detail != "" { + msg = msg + "\n" + sdkError.Detail } // The SDK error is usually good enough, and we don't want to overwhelm // the user with output. 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..68336ba23a599 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) @@ -129,8 +143,9 @@ func TestDERPHeaders(t *testing.T) { // 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 ) @@ -159,9 +174,12 @@ func TestDERPHeaders(t *testing.T) { "--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) diff --git a/cli/schedule.go b/cli/schedule.go index 8fff0121ae8db..629d6160fa3ee 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" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/util/tz" + "github.com/coder/coder/v2/codersdk" ) const ( diff --git a/cli/schedule_test.go b/cli/schedule_test.go index d1e6fe2da543f..65e2b23ec5db9 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) { diff --git a/cli/server.go b/cli/server.go index 170b7c5eb9f00..779215f0fce35 100644 --- a/cli/server.go +++ b/cli/server.go @@ -41,7 +41,6 @@ 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/trace" "golang.org/x/mod/semver" "golang.org/x/oauth2" @@ -57,41 +56,43 @@ import ( "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/dbfake" + "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/dormancy" + "github.com/coder/coder/v2/coderd/gitauth" + "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/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" ) @@ -175,18 +176,147 @@ func ReadGitAuthProvidersFromEnv(environ []string) ([]codersdk.GitAuthConfig, er return providers, nil } -// nolint:gocyclo +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!") + } + + 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") + } + + pkiCfg, err := configureOIDCPKI(oauthCfg, vals.OIDC.ClientKeyFile.Value(), vals.OIDC.ClientCertFile.Value()) + if err != nil { + 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 +} + +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) + + 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) + + 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) + } + } + + //nolint:revive + return ServeHandler( + ctx, logger, promhttp.InstrumentMetricHandler( + options.PrometheusRegistry, promhttp.HandlerFor(options.PrometheusRegistry, promhttp.HandlerOpts{}), + ), vals.Prometheus.Address.String(), "prometheus", + ), nil +} + func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, io.Closer, error)) *clibase.Cmd { 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 +326,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 +359,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 +389,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 +400,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, inv, 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 +417,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 +425,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 +449,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 +463,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 +496,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 +506,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, + cliui.DefaultStyles.Field.Render(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 +519,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) @@ -447,10 +565,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("read git auth providers from env: %w", err) } - cfg.GitAuthProviders.Value = append(cfg.GitAuthProviders.Value, gitAuthEnv...) + vals.GitAuthProviders.Value = append(vals.GitAuthProviders.Value, gitAuthEnv...) gitAuthConfigs, err := gitauth.ConvertConfig( - cfg.GitAuthProviders.Value, - cfg.AccessURL.Value(), + vals.GitAuthProviders.Value, + vals.AccessURL.Value(), ) if err != nil { return xerrors.Errorf("convert git auth config: %w", err) @@ -462,18 +580,18 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. ) } - 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"), @@ -484,22 +602,22 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. GoogleTokenValidator: googleTokenValidator, GitAuthConfigs: gitAuthConfigs, 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, 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 +625,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 +653,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(), - ) + oc, err := createOIDCConfig(ctx, vals) if err != nil { - return xerrors.Errorf("configure oidc provider: %w", err) - } - redirectURL, err := cfg.AccessURL.Value().Parse("/api/v2/users/oidc/callback") - 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.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 +694,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,7 +801,7 @@ 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 @@ -742,15 +816,15 @@ 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 { @@ -761,57 +835,36 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // 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) } + defer closeFn() + } - //nolint:revive - defer ServeHandler(ctx, logger, promhttp.InstrumentMetricHandler( - options.PrometheusRegistry, promhttp.HandlerFor(options.PrometheusRegistry, promhttp.HandlerOpts{}), - ), cfg.Prometheus.Address.String(), "prometheus")() + if vals.Swagger.Enable { + options.SwaggerEndpoint = vals.Swagger.Enable.Value() } - if cfg.Swagger.Enable { - options.SwaggerEndpoint = cfg.Swagger.Enable.Value() + 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() closeCheckInactiveUsersFunc := dormancy.CheckInactiveUsers(ctx, logger, options.Database) defer closeCheckInactiveUsersFunc() @@ -824,7 +877,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 +925,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 +947,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 +1005,12 @@ 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.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() @@ -1016,9 +1069,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 +1080,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() @@ -1272,7 +1321,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: @@ -1304,10 +1357,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) { @@ -1327,14 +1381,13 @@ func newProvisionerDaemon( // in provisionerdserver.go to learn more! return coderAPI.CreateInMemoryProvisionerDaemon(ctx, debounce) }, &provisionerd.Options{ - Logger: logger, + Logger: logger.Named("provisionerd"), JobPollInterval: cfg.Provisioner.DaemonPollInterval.Value(), JobPollJitter: cfg.Provisioner.DaemonPollJitter.Value(), JobPollDebounce: debounce, UpdateInterval: time.Second, ForceCancelInterval: cfg.Provisioner.ForceCancelInterval.Value(), Provisioners: provisioners, - WorkDirectory: workDir, TracerProvider: coderAPI.TracerProvider, Metrics: &metrics, }), nil @@ -1481,6 +1534,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 +1866,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 } } diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index fbdfed6b8016e..8f146f8f95ead 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -12,14 +12,14 @@ 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/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 +51,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 diff --git a/cli/server_createadminuser_test.go b/cli/server_createadminuser_test.go index 4daca225d71f7..8859a3df806c3 100644 --- a/cli/server_createadminuser_test.go +++ b/cli/server_createadminuser_test.go @@ -11,13 +11,13 @@ 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/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 diff --git a/cli/server_slim.go b/cli/server_slim.go index 4703f20b7669f..f5f17794f3f56 100644 --- a/cli/server_slim.go +++ b/cli/server_slim.go @@ -8,9 +8,9 @@ import ( "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" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd" ) func (r *RootCmd) Server(_ func(context.Context, *coderd.Options) (*coderd.API, io.Closer, error)) *clibase.Cmd { diff --git a/cli/server_test.go b/cli/server_test.go index ee00499c4d2a6..7b38bb76f9e15 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -34,16 +34,16 @@ 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" + "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/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 TestReadGitAuthProvidersFromEnv(t *testing.T) { @@ -1309,6 +1309,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 +1327,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 +1345,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 +1366,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 +1401,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, @@ -1491,31 +1496,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) { 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..ccbe182cc7ed9 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) { @@ -17,11 +16,7 @@ func TestShow(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, - }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, completeWithAgent()) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) 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..5fc9aedbe2e79 100644 --- a/cli/speedtest_test.go +++ b/cli/speedtest_test.go @@ -9,14 +9,14 @@ 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" + "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/codersdk/agentsdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestSpeedtest(t *testing.T) { diff --git a/cli/ssh.go b/cli/ssh.go index 2db1f4b4e2cb4..4455b8987cc5f 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 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..971dc2873ffdc 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -29,18 +29,18 @@ 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/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/codersdk/agentsdk" + "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" ) func setupWorkspaceForAgent(t *testing.T, mutate func([]*proto.Agent) []*proto.Agent) (*codersdk.Client, codersdk.Workspace, string) { @@ -56,10 +56,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", diff --git a/cli/start.go b/cli/start.go index 5bd35867fd105..cde5152e14dc2 100644 --- a/cli/start.go +++ b/cli/start.go @@ -6,26 +6,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/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,21 +23,36 @@ 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 } + lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) + if err != nil { + return err + } + template, err := client.Template(inv.Context(), workspace.TemplateID) 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, + Template: template, + + LastBuildParameters: lastBuildParameters, + + PromptBuildOptions: parameterFlags.promptBuildOptions, + BuildOptions: buildOptions, }) if err != nil { return err @@ -60,7 +60,7 @@ func (r *RootCmd) start() *clibase.Cmd { build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, - RichParameterValues: buildParams.richParameters, + RichParameterValues: buildParameters, }) if err != nil { return err @@ -79,16 +79,21 @@ func (r *RootCmd) start() *clibase.Cmd { } type prepStartWorkspaceArgs struct { - Template codersdk.Template - BuildOptions bool + Action WorkspaceCLIAction + Template codersdk.Template + + 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) 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 +101,9 @@ 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 - } - - for _, templateVersionParameter := range templateVersionParameters { - if !templateVersionParameter.Ephemeral { - continue - } - - parameterValue, err := cliui.RichParameter(inv, templateVersionParameter) - if err != nil { - return nil, err - } - - richParameters = append(richParameters, codersdk.WorkspaceBuildParameter{ - Name: templateVersionParameter.Name, - Value: parameterValue, - }) - } - - return &buildParameters{ - richParameters: richParameters, - }, nil + resolver := new(ParameterResolver). + WithLastBuildParameters(args.LastBuildParameters). + WithPromptBuildOptions(args.PromptBuildOptions). + WithBuildOptions(args.BuildOptions) + return resolver.Resolve(inv, args.Action, templateVersionParameters) } diff --git a/cli/start_test.go b/cli/start_test.go index a302fe2ac1c40..dff4048f3e765 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,11 +49,7 @@ 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) { @@ -99,4 +101,118 @@ func TestStart(t *testing.T) { Value: ephemeralParameterValue, }) }) + + t.Run("BuildOptionFlags", 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) + + inv, root := clitest.New(t, "start", workspace.Name, + "--build-option", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue)) + clitest.SetupConfig(t, client, 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}) + 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, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + { + Name: immutableParameterName, + Value: immutableParameterValue, + }, + } + }) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // Stop the workspace + workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspaceBuild.ID) + + // Start the workspace again + inv, root := clitest.New(t, "start", workspace.Name) + clitest.SetupConfig(t, client, 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..a240a6d2c81ae 100644 --- a/cli/state_test.go +++ b/cli/state_test.go @@ -10,10 +10,10 @@ 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/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" ) func TestStatePull(t *testing.T) { @@ -25,9 +25,9 @@ func TestStatePull(t *testing.T) { wantState := []byte("some state") 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{ State: wantState, }, }, @@ -53,9 +53,9 @@ func TestStatePull(t *testing.T) { wantState := []byte("some state") 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{ State: wantState, }, }, @@ -83,7 +83,7 @@ func TestStatePush(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.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) @@ -108,7 +108,7 @@ func TestStatePush(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.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) diff --git a/cli/stop.go b/cli/stop.go index 1dbf446ed2979..41265a859f489 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 { diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 77a869bdc0518..638f790dd811d 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -14,12 +14,12 @@ 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/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionerd" ) func (r *RootCmd) templateCreate() *clibase.Cmd { @@ -29,9 +29,11 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { variablesFile string variables []string disableEveryone bool - defaultTTL time.Duration - failureTTL time.Duration - inactivityTTL time.Duration + + defaultTTL time.Duration + failureTTL time.Duration + inactivityTTL time.Duration + maxTTL time.Duration uploadFlags templateUploadFlags ) @@ -44,7 +46,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { - if failureTTL != 0 || inactivityTTL != 0 { + if failureTTL != 0 || inactivityTTL != 0 || maxTTL != 0 { // This call can be removed when workspace_actions is no longer experimental experiments, exErr := client.Experiments(inv.Context()) if exErr != nil { @@ -134,7 +136,8 @@ 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, } @@ -182,22 +185,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.", diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index 06e180f7dcd6c..ba5dad7b4ac6a 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -10,35 +10,61 @@ 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/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 +73,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 +108,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 +148,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 +184,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 +210,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", diff --git a/cli/templatedelete.go b/cli/templatedelete.go index d954dbf44c081..9380279d6af96 100644 --- a/cli/templatedelete.go +++ b/cli/templatedelete.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) templateDelete() *clibase.Cmd { diff --git a/cli/templatedelete_test.go b/cli/templatedelete_test.go index 1f7c032b11d59..963ece08ab4dc 100644 --- a/cli/templatedelete_test.go +++ b/cli/templatedelete_test.go @@ -8,11 +8,11 @@ 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/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" ) func TestTemplateDelete(t *testing.T) { diff --git a/cli/templateedit.go b/cli/templateedit.go index 6c8173c452817..329187ef7ae7c 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -8,9 +8,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) templateEdit() *clibase.Cmd { @@ -104,7 +104,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Weeks: restartRequirementWeeks, }, FailureTTLMillis: failureTTL.Milliseconds(), - InactivityTTLMillis: inactivityTTL.Milliseconds(), + TimeTilDormantMillis: inactivityTTL.Milliseconds(), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, AllowUserAutostart: allowUserAutostart, AllowUserAutostop: allowUserAutostop, @@ -142,12 +142,12 @@ 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), }, { @@ -176,13 +176,13 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { }, { 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), }, diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 87944cd5a0f60..0aff5166e9ca8 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -18,11 +18,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/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/codersdk" + "github.com/coder/coder/v2/testutil" ) func TestTemplateEdit(t *testing.T) { @@ -752,7 +752,7 @@ func TestTemplateEdit(t *testing.T) { ctr.DefaultTTLMillis = nil ctr.RestartRequirement = nil ctr.FailureTTLMillis = nil - ctr.InactivityTTLMillis = nil + ctr.TimeTilDormantMillis = nil }) // Test the cli command with --allow-user-autostart. @@ -798,7 +798,7 @@ func TestTemplateEdit(t *testing.T) { 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) { @@ -892,7 +892,7 @@ func TestTemplateEdit(t *testing.T) { 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() @@ -990,7 +990,7 @@ func TestTemplateEdit(t *testing.T) { 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) }) }) } diff --git a/cli/templateinit.go b/cli/templateinit.go index b42e555fde074..47addbf05e347 100644 --- a/cli/templateinit.go +++ b/cli/templateinit.go @@ -12,11 +12,11 @@ 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" ) func (*RootCmd) templateInit() *clibase.Cmd { 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..d639b7b1ebfe2 100644 --- a/cli/templatelist_test.go +++ b/cli/templatelist_test.go @@ -9,11 +9,11 @@ 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/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) func TestTemplateList(t *testing.T) { 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..eb772379b9611 100644 --- a/cli/templatepull.go +++ b/cli/templatepull.go @@ -9,9 +9,9 @@ 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 { @@ -83,7 +83,7 @@ func (r *RootCmd) templatePull() *clibase.Cmd { } if dest == "" { - dest = templateName + "/" + dest = templateName } err = os.MkdirAll(dest, 0o750) diff --git a/cli/templatepull_test.go b/cli/templatepull_test.go index 1fdfe80d6ef50..5a5e7bc6c9e06 100644 --- a/cli/templatepull_test.go +++ b/cli/templatepull_test.go @@ -13,11 +13,11 @@ import ( "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/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 +40,227 @@ 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) +} - inv, _ := clitest.New(t, "templates", "pull") - err := inv.Run() - require.Error(t, err) - }) +// Stdout tests that 'templates pull' pulls down the latest template +// and writes it to stdout. +func TestTemplatePull_Stdout(t *testing.T) { + t.Parallel() - // Stdout tests that 'templates pull' pulls down the latest template - // and writes it to stdout. - t.Run("Stdout", func(t *testing.T) { - t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + // 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() - // 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) - expected, err := echo.Tar(source2) - require.NoError(t, err) + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) - version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + // Update the template version so that we can assert that templates + // are being sorted correctly. + _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) - // Update the template version so that we can assert that templates - // are being sorted correctly. - _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name) + clitest.SetupConfig(t, client, root) - inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name) - clitest.SetupConfig(t, client, root) + var buf bytes.Buffer + inv.Stdout = &buf - var buf bytes.Buffer - inv.Stdout = &buf + err = inv.Run() + require.NoError(t, err) - err = inv.Run() - require.NoError(t, err) + require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ") +} - require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ") - }) +// ToDir tests that 'templates pull' pulls down the latest template +// and writes it to the correct directory. +func TestTemplatePull_ToDir(t *testing.T) { + t.Parallel() - // 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() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + // 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() - // 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) - expected, err := echo.Tar(source2) - require.NoError(t, err) + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) - version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + // Update the template version so that we can assert that templates + // are being sorted correctly. + _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) - // Update the template version so that we can assert that templates - // are being sorted correctly. - _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + dir := t.TempDir() - dir := t.TempDir() + expectedDest := filepath.Join(dir, "expected") + actualDest := filepath.Join(dir, "actual") + ctx := context.Background() - expectedDest := filepath.Join(dir, "expected") - actualDest := filepath.Join(dir, "actual") - ctx := context.Background() + err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) + 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, client, root) - inv, root := clitest.New(t, "templates", "pull", template.Name, actualDest) - clitest.SetupConfig(t, client, root) + ptytest.New(t).Attach(inv) - ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) - require.NoError(t, inv.Run()) + require.Equal(t, + dirSum(t, expectedDest), + dirSum(t, actualDest), + ) +} - require.Equal(t, - dirSum(t, expectedDest), - dirSum(t, actualDest), - ) - }) +// ToDir tests that 'templates pull' pulls down the latest 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}) + user := coderdtest.CreateFirstUser(t, client) - // FolderConflict tests that 'templates pull' fails when a folder with has - // existing - t.Run("FolderConflict", 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, user.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) - expected, err := echo.Tar(source2) - require.NoError(t, err) + template := coderdtest.CreateTemplate(t, client, user.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. + _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.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") + }() - // Update the template version so that we can assert that templates - // are being sorted correctly. - _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + expectedDest := filepath.Join(dir, "expected") + actualDest := filepath.Join(dir, template.Name) - dir := t.TempDir() + ctx := context.Background() - expectedDest := filepath.Join(dir, "expected") - conflictDest := filepath.Join(dir, "conflict") + err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) + require.NoError(t, err) - err = os.MkdirAll(conflictDest, 0o700) - require.NoError(t, err) + inv, root := clitest.New(t, "templates", "pull", template.Name) + clitest.SetupConfig(t, client, root) - err = os.WriteFile( - filepath.Join(conflictDest, "conflict-file"), - []byte("conflict"), 0o600, - ) - require.NoError(t, err) + ptytest.New(t).Attach(inv) - ctx := context.Background() + require.NoError(t, inv.Run()) - err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) - require.NoError(t, err) + 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() - inv, root := clitest.New(t, "templates", "pull", template.Name, conflictDest) - clitest.SetupConfig(t, client, root) + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) - pty := ptytest.New(t).Attach(inv) + // 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() - waiter := clitest.StartWithWaiter(t, inv) + expected, err := echo.Tar(source2) + require.NoError(t, err) - pty.ExpectMatch("not empty") - pty.WriteLine("no") + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) - waiter.RequireError() + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) - ents, err := os.ReadDir(conflictDest) - require.NoError(t, err) + // Update the template version so that we can assert that templates + // are being sorted correctly. + _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.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, client, 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") - }) + 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 +268,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..7e676780f7a82 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -11,11 +11,11 @@ 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/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionersdk" ) // templateUploadFlags is shared by `templates create` and `templates push`. diff --git a/cli/templatepush_test.go b/cli/templatepush_test.go index 88a1ce250543c..4c41597802bb2 100644 --- a/cli/templatepush_test.go +++ b/cli/templatepush_test.go @@ -13,14 +13,14 @@ 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/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) { @@ -38,7 +38,7 @@ func TestTemplatePush(t *testing.T) { // 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) @@ -82,7 +82,7 @@ func TestTemplatePush(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) wantMessage := strings.Repeat("a", 72) @@ -121,7 +121,7 @@ func TestTemplatePush(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.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) @@ -168,7 +168,7 @@ func TestTemplatePush(t *testing.T) { // 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"))) @@ -211,7 +211,7 @@ func TestTemplatePush(t *testing.T) { // 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"))) @@ -248,7 +248,7 @@ func TestTemplatePush(t *testing.T) { // 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) @@ -293,7 +293,7 @@ func TestTemplatePush(t *testing.T) { // 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, @@ -340,7 +340,7 @@ func TestTemplatePush(t *testing.T) { source, err := echo.Tar(&echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) require.NoError(t, err) @@ -619,10 +619,7 @@ func TestTemplatePush(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, - }) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) const templateName = "my-template" args := []string{ @@ -665,16 +662,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..7ded6a7e5ee2b 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -5,9 +5,9 @@ import ( "github.com/google/uuid" - "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) templates() *clibase.Cmd { @@ -37,7 +37,6 @@ func (r *RootCmd) templates() *clibase.Cmd { r.templateEdit(), r.templateInit(), r.templateList(), - r.templatePlan(), r.templatePush(), r.templateVersions(), r.templateDelete(), 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/templateversions.go b/cli/templateversions.go index ed7688d3f3108..622854c4afedc 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -8,9 +8,9 @@ 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" ) func (r *RootCmd) templateVersions() *clibase.Cmd { diff --git a/cli/templateversions_test.go b/cli/templateversions_test.go index f3be61322c29a..c7c4da549528c 100644 --- a/cli/templateversions_test.go +++ b/cli/templateversions_test.go @@ -5,9 +5,9 @@ import ( "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/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" ) func TestTemplateVersions(t *testing.T) { diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index e074756680e84..6e988a9f568fd 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -62,6 +62,11 @@ variables or flags. Additional HTTP headers added to all requests. Provide as key=value. Can be specified multiple times. + --header-command string, $CODER_HEADER_COMMAND + 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. + --no-feature-warning bool, $CODER_NO_FEATURE_WARNING Suppress warnings about unlicensed features. diff --git a/cli/testdata/coder_create_--help.golden b/cli/testdata/coder_create_--help.golden index 6c8bcc46908c9..2e080b6e85ca7 100644 --- a/cli/testdata/coder_create_--help.golden +++ b/cli/testdata/coder_create_--help.golden @@ -7,6 +7,9 @@ Create a workspace  $ coder create /  Options + --parameter string-array, $CODER_RICH_PARAMETER + Rich parameter value in the format "name=value". + --rich-parameter-file string, $CODER_RICH_PARAMETER_FILE Specify a file path with values for rich parameters defined in the template. diff --git a/cli/testdata/coder_list_--help.golden b/cli/testdata/coder_list_--help.golden index a9bb8218ba1c0..e4e6f5dd3524a 100644 --- a/cli/testdata/coder_list_--help.golden +++ b/cli/testdata/coder_list_--help.golden @@ -11,7 +11,7 @@ Aliases: ls -c, --column string-array (default: workspace,template,status,healthy,last built,outdated,starts at,stops after) Columns to display in table output. Available columns: workspace, template, status, healthy, last built, outdated, starts at, stops - after. + after, daily cost. -o, --output string (default: table) Output format. Available formats: table, json. diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 49e51d408285c..2e317f996047b 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -11,6 +11,7 @@ "template_display_name": "", "template_icon": "", "template_allow_user_cancel_workspace_jobs": false, + "template_active_version_id": "[version ID]", "latest_build": { "id": "[workspace build ID]", "created_at": "[timestamp]", @@ -52,7 +53,7 @@ "ttl_ms": 28800000, "last_used_at": "[timestamp]", "deleting_at": null, - "locked_at": null, + "dormant_at": null, "health": { "healthy": true, "failing_agents": [] diff --git a/cli/testdata/coder_restart_--help.golden b/cli/testdata/coder_restart_--help.golden index c2079b9065dca..e16a6f9ff7e99 100644 --- a/cli/testdata/coder_restart_--help.golden +++ b/cli/testdata/coder_restart_--help.golden @@ -3,6 +3,9 @@ Usage: coder restart [flags] Restart a workspace Options + --build-option string-array, $CODER_BUILD_OPTION + Build option value in the format "name=value". + --build-options bool Prompt for one-time build options defined with ephemeral parameters. diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index cb7ca61b4913a..d3a5d74bcddbe 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -172,21 +172,25 @@ backed by Tailscale and WireGuard. URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/. + --derp-force-websockets bool, $CODER_DERP_FORCE_WEBSOCKETS + Force clients and agents to always use WebSocket to connect to DERP + relay servers. By default, DERP uses `Upgrade: derp`, which may cause + issues with some reverse proxies. Clients may automatically fallback + to WebSocket if they detect an issue with `Upgrade: derp`, but this + does not work in all situations. + --derp-server-enable bool, $CODER_DERP_SERVER_ENABLE (default: true) Whether to enable or disable the embedded DERP relay server. - --derp-server-region-code string, $CODER_DERP_SERVER_REGION_CODE (default: coder) - Region code to use for the embedded DERP server. - - --derp-server-region-id int, $CODER_DERP_SERVER_REGION_ID (default: 999) - Region ID to use for the embedded DERP server. - --derp-server-region-name string, $CODER_DERP_SERVER_REGION_NAME (default: Coder Embedded Relay) Region name that for the embedded DERP server. - --derp-server-stun-addresses string-array, $CODER_DERP_SERVER_STUN_ADDRESSES (default: stun.l.google.com:19302) - Addresses for STUN servers to establish P2P connections. Use special - value 'disable' to turn off STUN. + --derp-server-stun-addresses string-array, $CODER_DERP_SERVER_STUN_ADDRESSES (default: stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302) + Addresses for STUN servers to establish P2P connections. It's + recommended to have at least two STUN servers to give users the best + chance of connecting P2P to workspaces. Each STUN server will get it's + own DERP region, with region IDs starting at `--derp-server-region-id + + 1`. Use special value 'disable' to turn off STUN completely. Networking / HTTP Options --disable-password-auth bool, $CODER_DISABLE_PASSWORD_AUTH @@ -298,15 +302,28 @@ can safely ignore these settings. GitHub. OIDC Options + --oidc-group-auto-create bool, $CODER_OIDC_GROUP_AUTO_CREATE (default: false) + Automatically creates missing groups from a user's groups claim. + --oidc-allow-signups bool, $CODER_OIDC_ALLOW_SIGNUPS (default: true) Whether new users can sign up with OIDC. --oidc-auth-url-params struct[map[string]string], $CODER_OIDC_AUTH_URL_PARAMS (default: {"access_type": "offline"}) OIDC auth URL parameters to pass to the upstream provider. + --oidc-client-cert-file string, $CODER_OIDC_CLIENT_CERT_FILE + Pem encoded certificate file to use for oauth2 PKI/JWT authorization. + The public certificate that accompanies oidc-client-key-file. A + standard x509 certificate is expected. + --oidc-client-id string, $CODER_OIDC_CLIENT_ID Client ID to use for Login with OIDC. + --oidc-client-key-file string, $CODER_OIDC_CLIENT_KEY_FILE + Pem encoded RSA private key to use for oauth2 PKI/JWT authorization. + This can be used instead of oidc-client-secret if your IDP supports + it. + --oidc-client-secret string, $CODER_OIDC_CLIENT_SECRET Client secret to use for Login with OIDC. @@ -334,6 +351,11 @@ can safely ignore these settings. --oidc-issuer-url string, $CODER_OIDC_ISSUER_URL Issuer URL to use for Login with OIDC. + --oidc-group-regex-filter regexp, $CODER_OIDC_GROUP_REGEX_FILTER (default: .*) + If provided any group name not matching the regex is ignored. This + allows for filtering out groups that are not needed. This filter is + applied after the group mapping. + --oidc-scopes string-array, $CODER_OIDC_SCOPES (default: openid,profile,email) Scopes to grant when authenticating with OIDC. @@ -373,6 +395,10 @@ updating, and deleting workspace resources. --provisioner-daemon-poll-jitter duration, $CODER_PROVISIONER_DAEMON_POLL_JITTER (default: 100ms) Random jitter added to the poll interval. + --provisioner-daemon-psk string, $CODER_PROVISIONER_DAEMON_PSK + Pre-shared key to authenticate external provisioner daemons to Coder + server. + --provisioner-daemons int, $CODER_PROVISIONER_DAEMONS (default: 3) Number of provisioner daemons to create on start. If builds are stuck in queued state for a long time, consider increasing this. diff --git a/cli/testdata/coder_start_--help.golden b/cli/testdata/coder_start_--help.golden index aa447240e9bbb..b03c9975925f4 100644 --- a/cli/testdata/coder_start_--help.golden +++ b/cli/testdata/coder_start_--help.golden @@ -3,6 +3,9 @@ Usage: coder start [flags] Start a workspace Options + --build-option string-array, $CODER_BUILD_OPTION + Build option value in the format "name=value". + --build-options bool Prompt for one-time build options defined with ephemeral parameters. diff --git a/cli/testdata/coder_templates_--help.golden b/cli/testdata/coder_templates_--help.golden index 0bcc6c7978df7..352695e26fb57 100644 --- a/cli/testdata/coder_templates_--help.golden +++ b/cli/testdata/coder_templates_--help.golden @@ -24,7 +24,6 @@ Templates are written in standard Terraform and describe the infrastructure for edit Edit the metadata of a template by name. init Get started with a templated template. list List all the templates available for the organization - plan Plan a template push from the current directory pull Download the latest version of a template to a path. push Push a new template version from the current directory or as specified by flag diff --git a/cli/testdata/coder_templates_create_--help.golden b/cli/testdata/coder_templates_create_--help.golden index a88fe64bdeba3..ce71793cebc27 100644 --- a/cli/testdata/coder_templates_create_--help.golden +++ b/cli/testdata/coder_templates_create_--help.golden @@ -4,14 +4,18 @@ Create a template from the current directory or as specified by flag Options --default-ttl duration (default: 24h) - Specify a default TTL for workspaces created from this template. + 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. -d, --directory string (default: .) Specify the directory to create from, use '-' to read tar from stdin. --failure-ttl duration (default: 0h) - Specify a failure TTL for workspaces created from this template. This - licensed feature's default is 0h (off). + 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. --ignore-lockfile bool (default: false) Ignore warnings about not having a .terraform.lock.hcl file present in @@ -19,7 +23,15 @@ Create a template from the current directory or as specified by flag --inactivity-ttl duration (default: 0h) Specify an inactivity TTL for workspaces created from this template. - This licensed feature's default is 0h (off). + 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. + + --max-ttl duration + 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. -m, --message string Specify a message describing the changes in this version of the diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index 09c0b7209e78a..19dfcd2953c33 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -16,7 +16,8 @@ Edit the metadata of a template by name. --default-ttl duration Edit the template default time before shutdown - workspaces created - from this template default to this value. + from this template default to this value. Maps to "Default autostop" + in the UI. --description string Edit the template description. @@ -25,20 +26,26 @@ Edit the metadata of a template by name. Edit the template display name. --failure-ttl duration (default: 0h) - Specify a failure TTL for workspaces created from this template. This - licensed feature's default is 0h (off). + 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. --icon string Edit the template icon path. --inactivity-ttl duration (default: 0h) Specify an inactivity TTL for workspaces created from this template. - This licensed feature's default is 0h (off). + 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. --max-ttl duration 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. + starting, regardless of user activity. This is an enterprise-only + feature. Maps to "Max lifetime" in the UI. --name string Edit the template name. diff --git a/cli/testdata/coder_update_--help.golden b/cli/testdata/coder_update_--help.golden index 40e899cd37348..669bda831caa6 100644 --- a/cli/testdata/coder_update_--help.golden +++ b/cli/testdata/coder_update_--help.golden @@ -9,9 +9,15 @@ Use --always-prompt to change the parameter values of the workspace. Always prompt all parameters. Does not pull parameter values from existing workspace. + --build-option string-array, $CODER_BUILD_OPTION + Build option value in the format "name=value". + --build-options bool Prompt for one-time build options defined with ephemeral parameters. + --parameter string-array, $CODER_RICH_PARAMETER + Rich parameter value in the format "name=value". + --rich-parameter-file string, $CODER_RICH_PARAMETER_FILE Specify a file path with values for rich parameters defined in the template. diff --git a/cli/testdata/coder_users_create_--help.golden b/cli/testdata/coder_users_create_--help.golden index bb94cac633bc0..275e89803d4c6 100644 --- a/cli/testdata/coder_users_create_--help.golden +++ b/cli/testdata/coder_users_create_--help.golden @@ -1,15 +1,15 @@ Usage: coder users create [flags] Options - --disable-login bool - Disabling login for a user prevents the user from authenticating via - password or IdP login. Authentication requires an API key/token - generated by an admin. Be careful when using this flag as it can lock - the user out of their account. - -e, --email string Specifies an email address for the new user. + --login-type string + Optionally specify the login type for the user. Valid values are: + password, none, github, oidc. Using 'none' prevents the user from + authenticating and requires an API key/token to be generated by an + admin. + -p, --password string Specifies a password for the new user. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index c7a8df03414e6..166e9f02d9465 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -111,11 +111,20 @@ networking: # Region name that for the embedded DERP server. # (default: Coder Embedded Relay, type: string) regionName: Coder Embedded Relay - # Addresses for STUN servers to establish P2P connections. Use special value - # 'disable' to turn off STUN. - # (default: stun.l.google.com:19302, type: string-array) + # Addresses for STUN servers to establish P2P connections. It's recommended to + # have at least two STUN servers to give users the best chance of connecting P2P + # to workspaces. Each STUN server will get it's own DERP region, with region IDs + # starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn + # off STUN completely. + # (default: + # stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302, + # type: string-array) stunAddresses: - stun.l.google.com:19302 + - stun1.l.google.com:19302 + - stun2.l.google.com:19302 + - stun3.l.google.com:19302 + - stun4.l.google.com:19302 # An HTTP URL that is accessible by other replicas to relay DERP traffic. Required # for high availability. # (default: , type: url) @@ -127,6 +136,12 @@ networking: # this change has been made, but new connections will still be proxied regardless. # (default: , type: bool) blockDirect: false + # Force clients and agents to always use WebSocket to connect to DERP relay + # servers. By default, DERP uses `Upgrade: derp`, which may cause issues with some + # reverse proxies. Clients may automatically fallback to WebSocket if they detect + # an issue with `Upgrade: derp`, but this does not work in all situations. + # (default: , type: bool) + forceWebSockets: false # URL to fetch a DERP mapping on startup. See: # https://tailscale.com/kb/1118/custom-derp-servers/. # (default: , type: string) @@ -235,6 +250,15 @@ oidc: # Client ID to use for Login with OIDC. # (default: , type: string) clientID: "" + # Pem encoded RSA private key to use for oauth2 PKI/JWT authorization. This can be + # used instead of oidc-client-secret if your IDP supports it. + # (default: , type: string) + oidcClientKeyFile: "" + # Pem encoded certificate file to use for oauth2 PKI/JWT authorization. The public + # certificate that accompanies oidc-client-key-file. A standard x509 certificate + # is expected. + # (default: , type: string) + oidcClientCertFile: "" # Email domains that clients logging in with OIDC must match. # (default: , type: string-array) emailDomain: [] @@ -271,6 +295,14 @@ oidc: # for when OIDC providers only return group IDs. # (default: {}, type: struct[map[string]string]) groupMapping: {} + # Automatically creates missing groups from a user's groups claim. + # (default: false, type: bool) + enableGroupAutoCreate: false + # If provided any group name not matching the regex is ignored. This allows for + # filtering out groups that are not needed. This filter is applied after the group + # mapping. + # (default: .*, type: regexp) + groupRegexFilter: .* # This field must be set if using the user roles sync feature. Set this to the # name of the claim used to store the user's role. The roles should be sent as an # array of strings. @@ -327,6 +359,9 @@ provisioning: # Time to force cancel provisioning tasks that are stuck. # (default: 10m0s, type: duration) forceCancelInterval: 10m0s + # Pre-shared key to authenticate external provisioner daemons to Coder server. + # (default: , type: string) + daemonPSK: "" # Enable one or more experiments. These are not ready for production. Separate # multiple experiments with commas, or enter '*' to opt-in to all available # experiments. diff --git a/cli/tokens.go b/cli/tokens.go index 1f34287c4e9a3..579a15fc5f1fe 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -8,9 +8,9 @@ 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/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) tokens() *clibase.Cmd { diff --git a/cli/tokens_test.go b/cli/tokens_test.go index f31dd847e0396..fdb062b959a3b 100644 --- a/cli/tokens_test.go +++ b/cli/tokens_test.go @@ -8,10 +8,10 @@ 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/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/testutil" ) func TestTokens(t *testing.T) { diff --git a/cli/update.go b/cli/update.go index 64710217bb996..cdff4b4a8df26 100644 --- a/cli/update.go +++ b/cli/update.go @@ -3,14 +3,15 @@ package cli import ( "fmt" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/codersdk" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) update() *clibase.Cmd { var ( - richParameterFile string - alwaysPrompt bool + alwaysPrompt bool parameterFlags workspaceParameterFlags ) @@ -30,33 +31,45 @@ func (r *RootCmd) update() *clibase.Cmd { if err != nil { return err } - if !workspace.Outdated && !alwaysPrompt && !parameterFlags.buildOptions { + if !workspace.Outdated && !alwaysPrompt && !parameterFlags.promptBuildOptions && len(parameterFlags.buildOptions) == 0 { _, _ = fmt.Fprintf(inv.Stdout, "Workspace isn't outdated!\n") return nil } + + buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) + if err != nil { + return err + } + template, err := client.Template(inv.Context(), workspace.TemplateID) if err != nil { return err } - var existingRichParams []codersdk.WorkspaceBuildParameter - if !alwaysPrompt { - existingRichParams, err = client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) - if err != nil { - return err - } + lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) + if err != nil { + return err + } + + cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters) + if err != nil { + return xerrors.Errorf("can't parse given parameter values: %w", err) } - buildParams, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ - Template: template, - ExistingRichParams: existingRichParams, - RichParameterFile: richParameterFile, - NewWorkspaceName: workspace.Name, + buildParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ + Action: WorkspaceUpdate, + Template: template, + NewWorkspaceName: workspace.Name, + WorkspaceID: workspace.LatestBuild.ID, - UpdateWorkspace: true, - WorkspaceID: workspace.LatestBuild.ID, + LastBuildParameters: lastBuildParameters, - BuildOptions: parameterFlags.buildOptions, + PromptBuildOptions: parameterFlags.promptBuildOptions, + BuildOptions: buildOptions, + + PromptRichParameters: alwaysPrompt, + RichParameters: cliRichParameters, + RichParameterFile: parameterFlags.richParameterFile, }) if err != nil { return err @@ -65,7 +78,7 @@ func (r *RootCmd) update() *clibase.Cmd { build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: template.ActiveVersionID, Transition: codersdk.WorkspaceTransitionStart, - RichParameterValues: buildParams.richParameters, + RichParameterValues: buildParameters, }) if err != nil { return err @@ -92,13 +105,8 @@ func (r *RootCmd) update() *clibase.Cmd { Description: "Always prompt all parameters. Does not pull parameter values from existing workspace.", Value: clibase.BoolOf(&alwaysPrompt), }, - { - Flag: "rich-parameter-file", - Description: "Specify a file path with values for rich parameters defined in the template.", - Env: "CODER_RICH_PARAMETER_FILE", - Value: clibase.StringOf(&richParameterFile), - }, } - cmd.Options = append(cmd.Options, parameterFlags.options()...) + cmd.Options = append(cmd.Options, parameterFlags.cliBuildOptions()...) + cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...) return cmd } diff --git a/cli/update_test.go b/cli/update_test.go index 886adf9bea264..38b042d2813f0 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -9,14 +9,14 @@ 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/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/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 TestUpdate(t *testing.T) { @@ -57,8 +57,8 @@ func TestUpdate(t *testing.T) { version2 := coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: echo.PlanComplete, }, template.ID) _ = coderdtest.AwaitTemplateVersionJob(t, client, version2.ID) @@ -100,28 +100,13 @@ func TestUpdateWithRichParameters(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: immutableParameterName, Description: immutableParameterDescription, Mutable: false}, - {Name: secondParameterName, Description: secondParameterDescription, Mutable: true}, - {Name: ephemeralParameterName, Description: ephemeralParameterDescription, Mutable: true, Ephemeral: true}, - }, - }, - }, - }, - }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, - } + echoResponses := prepareEchoResponses([]*proto.RichParameter{ + {Name: firstParameterName, Description: firstParameterDescription, Mutable: true}, + {Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false}, + {Name: secondParameterName, Description: secondParameterDescription, Mutable: true}, + {Name: ephemeralParameterName, Description: ephemeralParameterDescription, Mutable: true, Ephemeral: true}, + }, + ) t.Run("ImmutableCannotBeCustomized", func(t *testing.T) { t.Parallel() @@ -159,7 +144,7 @@ func TestUpdateWithRichParameters(t *testing.T) { matches := []string{ firstParameterDescription, firstParameterValue, - fmt.Sprintf("Parameter %q is not mutable, so can't be customized after workspace creation.", immutableParameterName), "", + fmt.Sprintf("Parameter %q is not mutable, and cannot be customized after workspace creation.", immutableParameterName), "", secondParameterDescription, secondParameterValue, } for i := 0; i < len(matches); i += 2 { @@ -236,6 +221,55 @@ func TestUpdateWithRichParameters(t *testing.T) { Value: ephemeralParameterValue, }) }) + + t.Run("BuildOptionFlags", 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) + + const workspaceName = "my-workspace" + + inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", + "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue), + "--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue), + "--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue)) + clitest.SetupConfig(t, client, root) + err := inv.Run() + assert.NoError(t, err) + + inv, root = clitest.New(t, "update", workspaceName, + "--build-option", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue)) + clitest.SetupConfig(t, client, 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("Planning workspace") + <-doneChan + + // Verify if build option is set + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspace, err := client.WorkspaceByOwnerAndName(ctx, user.UserID.String(), workspaceName, 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 TestUpdateValidateRichParameters(t *testing.T) { @@ -264,28 +298,6 @@ func TestUpdateValidateRichParameters(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() @@ -544,15 +556,258 @@ func TestUpdateValidateRichParameters(t *testing.T) { assert.NoError(t, err) }() + pty.ExpectMatch("Planning workspace...") + <-doneChan + }) + + t.Run("ParameterOptionChanged", func(t *testing.T) { + t.Parallel() + + // Create template and workspace + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + templateParameters := []*proto.RichParameter{ + {Name: stringParameterName, Type: "string", Mutable: true, Required: true, Options: []*proto.RichParameterOption{ + {Name: "First option", Description: "This is first option", Value: "1st"}, + {Name: "Second option", Description: "This is second option", Value: "2nd"}, + {Name: "Third option", Description: "This is third option", Value: "3rd"}, + }}, + } + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(templateParameters)) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + inv, root := clitest.New(t, "create", "my-workspace", "--yes", "--template", template.Name, "--parameter", fmt.Sprintf("%s=%s", stringParameterName, "2nd")) + clitest.SetupConfig(t, client, root) + err := inv.Run() + require.NoError(t, err) + + // Update template + updatedTemplateParameters := []*proto.RichParameter{ + {Name: stringParameterName, Type: "string", Mutable: true, Required: true, Options: []*proto.RichParameterOption{ + {Name: "first_option", Description: "This is first option", Value: "1"}, + {Name: "second_option", Description: "This is second option", Value: "2"}, + {Name: "third_option", Description: "This is third option", Value: "3"}, + }}, + } + + updatedVersion := coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(updatedTemplateParameters), template.ID) + coderdtest.AwaitTemplateVersionJob(t, client, updatedVersion.ID) + err = client.UpdateActiveTemplateVersion(context.Background(), template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: updatedVersion.ID, + }) + require.NoError(t, err) + + // Update the workspace + inv, root = clitest.New(t, "update", "my-workspace") + clitest.SetupConfig(t, client, root) + + pty := ptytest.New(t).Attach(inv) + clitest.Start(t, inv) + matches := []string{ - "added_parameter", "", - `Enter a value (default: "foobar")`, "abc", + stringParameterName, "second_option", + "Planning workspace...", "", } for i := 0; i < len(matches); i += 2 { match := matches[i] value := matches[i+1] pty.ExpectMatch(match) - pty.WriteLine(value) + + if value != "" { + pty.WriteLine(value) + } + } + }) + + t.Run("ParameterOptionDisappeared", func(t *testing.T) { + t.Parallel() + + // Create template and workspace + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + templateParameters := []*proto.RichParameter{ + {Name: stringParameterName, Type: "string", Mutable: true, Required: true, Options: []*proto.RichParameterOption{ + {Name: "First option", Description: "This is first option", Value: "1st"}, + {Name: "Second option", Description: "This is second option", Value: "2nd"}, + {Name: "Third option", Description: "This is third option", Value: "3rd"}, + }}, + } + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(templateParameters)) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + inv, root := clitest.New(t, "create", "my-workspace", "--yes", "--template", template.Name, "--parameter", fmt.Sprintf("%s=%s", stringParameterName, "2nd")) + clitest.SetupConfig(t, client, root) + err := inv.Run() + require.NoError(t, err) + + // Update template - 2nd option disappeared, 4th option added + updatedTemplateParameters := []*proto.RichParameter{ + {Name: stringParameterName, Type: "string", Mutable: true, Required: true, Options: []*proto.RichParameterOption{ + {Name: "First option", Description: "This is first option", Value: "1st"}, + {Name: "Third option", Description: "This is third option", Value: "3rd"}, + {Name: "Fourth option", Description: "This is fourth option", Value: "4th"}, + }}, + } + + updatedVersion := coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(updatedTemplateParameters), template.ID) + coderdtest.AwaitTemplateVersionJob(t, client, updatedVersion.ID) + err = client.UpdateActiveTemplateVersion(context.Background(), template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: updatedVersion.ID, + }) + require.NoError(t, err) + + // Update the workspace + inv, root = clitest.New(t, "update", "my-workspace") + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t).Attach(inv) + clitest.Start(t, inv) + + matches := []string{ + stringParameterName, "Third option", + "Planning workspace...", "", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + + if value != "" { + pty.WriteLine(value) + } + } + }) + + t.Run("ImmutableRequiredParameterExists_MutableRequiredParameterAdded", func(t *testing.T) { + t.Parallel() + + // Create template and workspace + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + templateParameters := []*proto.RichParameter{ + {Name: stringParameterName, Type: "string", Mutable: false, Required: true, Options: []*proto.RichParameterOption{ + {Name: "First option", Description: "This is first option", Value: "1st"}, + {Name: "Second option", Description: "This is second option", Value: "2nd"}, + {Name: "Third option", Description: "This is third option", Value: "3rd"}, + }}, + } + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(templateParameters)) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + inv, root := clitest.New(t, "create", "my-workspace", "--yes", "--template", template.Name, "--parameter", fmt.Sprintf("%s=%s", stringParameterName, "2nd")) + clitest.SetupConfig(t, client, root) + err := inv.Run() + require.NoError(t, err) + + // Update template: add required, mutable parameter + const mutableParameterName = "foobar" + updatedTemplateParameters := []*proto.RichParameter{ + templateParameters[0], + {Name: mutableParameterName, Type: "string", Mutable: true, Required: true}, + } + + updatedVersion := coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(updatedTemplateParameters), template.ID) + coderdtest.AwaitTemplateVersionJob(t, client, updatedVersion.ID) + err = client.UpdateActiveTemplateVersion(context.Background(), template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: updatedVersion.ID, + }) + require.NoError(t, err) + + // Update the workspace + inv, root = clitest.New(t, "update", "my-workspace") + clitest.SetupConfig(t, client, 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{ + mutableParameterName, "hello", + "Planning workspace...", "", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + + if value != "" { + pty.WriteLine(value) + } + } + <-doneChan + }) + + t.Run("MutableRequiredParameterExists_ImmutableRequiredParameterAdded", func(t *testing.T) { + t.Parallel() + + // Create template and workspace + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + templateParameters := []*proto.RichParameter{ + {Name: stringParameterName, Type: "string", Mutable: true, Required: true, Options: []*proto.RichParameterOption{ + {Name: "First option", Description: "This is first option", Value: "1st"}, + {Name: "Second option", Description: "This is second option", Value: "2nd"}, + {Name: "Third option", Description: "This is third option", Value: "3rd"}, + }}, + } + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(templateParameters)) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + inv, root := clitest.New(t, "create", "my-workspace", "--yes", "--template", template.Name, "--parameter", fmt.Sprintf("%s=%s", stringParameterName, "2nd")) + clitest.SetupConfig(t, client, root) + err := inv.Run() + require.NoError(t, err) + + // Update template: add required, immutable parameter + updatedTemplateParameters := []*proto.RichParameter{ + templateParameters[0], + {Name: immutableParameterName, Type: "string", Mutable: false, Required: true, Options: []*proto.RichParameterOption{ + {Name: "fir", Description: "This is first option for immutable parameter", Value: "I"}, + {Name: "sec", Description: "This is second option for immutable parameter", Value: "II"}, + {Name: "thi", Description: "This is third option for immutable parameter", Value: "III"}, + }}, + } + + updatedVersion := coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(updatedTemplateParameters), template.ID) + coderdtest.AwaitTemplateVersionJob(t, client, updatedVersion.ID) + err = client.UpdateActiveTemplateVersion(context.Background(), template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: updatedVersion.ID, + }) + require.NoError(t, err) + + // Update the workspace + inv, root = clitest.New(t, "update", "my-workspace") + clitest.SetupConfig(t, client, 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{ + immutableParameterName, "thi", + "Planning workspace...", "", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + + if value != "" { + pty.WriteLine(value) + } } <-doneChan }) diff --git a/cli/usercreate.go b/cli/usercreate.go index b38bbb2d6401f..768e87d826783 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -2,14 +2,15 @@ package cli import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "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/cryptorand" + "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/cryptorand" ) func (r *RootCmd) userCreate() *clibase.Cmd { @@ -18,6 +19,7 @@ func (r *RootCmd) userCreate() *clibase.Cmd { username string password string disableLogin bool + loginType string ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -54,7 +56,18 @@ func (r *RootCmd) userCreate() *clibase.Cmd { return err } } - if password == "" && !disableLogin { + userLoginType := codersdk.LoginTypePassword + if disableLogin && loginType != "" { + return xerrors.New("You cannot specify both --disable-login and --login-type") + } + if disableLogin { + userLoginType = codersdk.LoginTypeNone + } else if loginType != "" { + userLoginType = codersdk.LoginType(loginType) + } + + if password == "" && userLoginType == codersdk.LoginTypePassword { + // Generate a random password password, err = cryptorand.StringCharset(cryptorand.Human, 20) if err != nil { return err @@ -66,14 +79,22 @@ func (r *RootCmd) userCreate() *clibase.Cmd { Username: username, Password: password, OrganizationID: organization.ID, - DisableLogin: disableLogin, + UserLoginType: userLoginType, }) if err != nil { return err } - authenticationMethod := `Your password is: ` + cliui.DefaultStyles.Field.Render(password) - if disableLogin { + + authenticationMethod := "" + switch codersdk.LoginType(strings.ToLower(string(userLoginType))) { + case codersdk.LoginTypePassword: + authenticationMethod = `Your password is: ` + cliui.DefaultStyles.Field.Render(password) + case codersdk.LoginTypeNone: authenticationMethod = "Login has been disabled for this user. Contact your administrator to authenticate." + case codersdk.LoginTypeGithub: + authenticationMethod = `Login is authenticated through GitHub.` + case codersdk.LoginTypeOIDC: + authenticationMethod = `Login is authenticated through the configured OIDC provider.` } _, _ = fmt.Fprintln(inv.Stderr, `A new user has been created! @@ -111,11 +132,22 @@ Create a workspace `+cliui.DefaultStyles.Code.Render("coder create")+`!`) Value: clibase.StringOf(&password), }, { - Flag: "disable-login", - Description: "Disabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. " + + Flag: "disable-login", + Hidden: true, + Description: "Deprecated: Use '--login-type=none'. \nDisabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. " + "Be careful when using this flag as it can lock the user out of their account.", Value: clibase.BoolOf(&disableLogin), }, + { + Flag: "login-type", + Description: fmt.Sprintf("Optionally specify the login type for the user. Valid values are: %s. "+ + "Using 'none' prevents the user from authenticating and requires an API key/token to be generated by an admin.", + strings.Join([]string{ + string(codersdk.LoginTypePassword), string(codersdk.LoginTypeNone), string(codersdk.LoginTypeGithub), string(codersdk.LoginTypeOIDC), + }, ", ", + )), + Value: clibase.StringOf(&loginType), + }, } return cmd } diff --git a/cli/usercreate_test.go b/cli/usercreate_test.go index 01e2137a9e53b..5726cc84d25b5 100644 --- a/cli/usercreate_test.go +++ b/cli/usercreate_test.go @@ -5,9 +5,9 @@ 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/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" ) func TestUserCreate(t *testing.T) { diff --git a/cli/userlist.go b/cli/userlist.go index 0f05578a9fe61..ce50a12849fa3 100644 --- a/cli/userlist.go +++ b/cli/userlist.go @@ -8,9 +8,9 @@ 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/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" ) func (r *RootCmd) userList() *clibase.Cmd { diff --git a/cli/userlist_test.go b/cli/userlist_test.go index 71c58f38147a7..d6c80d0b7c95f 100644 --- a/cli/userlist_test.go +++ b/cli/userlist_test.go @@ -9,10 +9,10 @@ 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/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" ) func TestUserList(t *testing.T) { diff --git a/cli/users.go b/cli/users.go index 76615d05d7f04..92d79635cf1ba 100644 --- a/cli/users.go +++ b/cli/users.go @@ -1,8 +1,8 @@ package cli import ( - "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 (r *RootCmd) users() *clibase.Cmd { diff --git a/cli/userstatus.go b/cli/userstatus.go index 6a2ada1a7cd19..ac3bbaa0929a6 100644 --- a/cli/userstatus.go +++ b/cli/userstatus.go @@ -6,9 +6,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" ) // createUserStatusCommand sets a user status. diff --git a/cli/userstatus_test.go b/cli/userstatus_test.go index 348559e10de5d..b288a483e0117 100644 --- a/cli/userstatus_test.go +++ b/cli/userstatus_test.go @@ -7,9 +7,9 @@ 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/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" ) func TestUserStatus(t *testing.T) { diff --git a/cli/util.go b/cli/util.go index 777335d0a7d80..e0fe340e45ca2 100644 --- a/cli/util.go +++ b/cli/util.go @@ -8,8 +8,8 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/util/tz" + "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/util/tz" ) var ( diff --git a/cli/version.go b/cli/version.go index fb33749f004f9..84e45fb74fe22 100644 --- a/cli/version.go +++ b/cli/version.go @@ -5,9 +5,9 @@ import ( "strings" "time" - "github.com/coder/coder/buildinfo" - "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" ) // versionInfo wraps the stuff we get from buildinfo so that it's diff --git a/cli/version_test.go b/cli/version_test.go index 4267e46f9ad69..20068d29bf124 100644 --- a/cli/version_test.go +++ b/cli/version_test.go @@ -10,8 +10,8 @@ import ( "github.com/muesli/termenv" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/testutil" ) // We need to override the global color profile to test escape codes. diff --git a/cli/vscodessh.go b/cli/vscodessh.go index 136a0d727c17a..7e856df96983b 100644 --- a/cli/vscodessh.go +++ b/cli/vscodessh.go @@ -20,8 +20,8 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/codersdk" ) // vscodeSSH is used by the Coder VS Code extension to establish @@ -86,7 +86,7 @@ func (r *RootCmd) vscodeSSH() *clibase.Cmd { client.SetSessionToken(string(sessionToken)) // This adds custom headers to the request! - err = r.setClient(client, serverURL) + err = r.setClient(ctx, client, serverURL) if err != nil { return xerrors.Errorf("set client: %w", err) } diff --git a/cli/vscodessh_test.go b/cli/vscodessh_test.go index a134903005c4a..2c1afd6135587 100644 --- a/cli/vscodessh_test.go +++ b/cli/vscodessh_test.go @@ -11,13 +11,13 @@ import ( "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/pty/ptytest" - "github.com/coder/coder/testutil" + "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/codersdk/agentsdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" ) // TestVSCodeSSH ensures the agent connects properly with SSH diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index 60e8f6536f7a8..a3badedee9f01 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -15,10 +15,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/coderd/database" - "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/codersdk" ) func main() { diff --git a/cmd/coder/main.go b/cmd/coder/main.go index 7ca0f63d0e70a..5d1cea2f8097d 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -3,7 +3,7 @@ package main import ( _ "time/tzdata" - "github.com/coder/coder/cli" + "github.com/coder/coder/v2/cli" ) func main() { diff --git a/coderd/activitybump.go b/coderd/activitybump.go index 972d59a31f93b..6abc73ebdc0e4 100644 --- a/coderd/activitybump.go +++ b/coderd/activitybump.go @@ -10,7 +10,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/database" + "github.com/coder/coder/v2/coderd/database" ) // activityBumpWorkspace automatically bumps the workspace's auto-off timer diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index b9c757e98986e..8ce018a4e9a20 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -9,16 +9,15 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbtestutil" - "github.com/coder/coder/coderd/schedule" - "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/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/schedule" + "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/testutil" ) func TestWorkspaceActivityBump(t *testing.T) { @@ -60,25 +59,9 @@ func TestWorkspaceActivityBump(t *testing.T) { ttlMillis := int64(ttl / time.Millisecond) 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{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - Agents: []*proto.Agent{{ - Id: uuid.NewString(), - Name: "agent", - Auth: &proto.Agent_Token{ - Token: agentToken, - }, - }}, - }}, - }, - }, - }}, + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(agentToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2c115a30c3261..58624b22a908f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -787,12 +787,12 @@ const docTemplate = `{ "tags": [ "Enterprise" ], - "summary": "Get group by name", - "operationId": "get-group-by-name", + "summary": "Get group by ID", + "operationId": "get-group-by-id", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group id", "name": "group", "in": "path", "required": true @@ -845,6 +845,9 @@ const docTemplate = `{ "CoderSessionToken": [] } ], + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], @@ -860,6 +863,15 @@ const docTemplate = `{ "name": "group", "in": "path", "required": true + }, + { + "description": "Patch group request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PatchGroupRequest" + } } ], "responses": { @@ -1012,6 +1024,31 @@ const docTemplate = `{ } } }, + "/licenses/refresh-entitlements": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Organizations" + ], + "summary": "Update license entitlements", + "operationId": "update-license-entitlements", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/licenses/{id}": { "delete": { "security": [ @@ -5479,6 +5516,42 @@ const docTemplate = `{ } } }, + "/workspaceproxies/me/app-stats": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Report workspace app stats", + "operationId": "report-workspace-app-stats", + "parameters": [ + { + "description": "Report app stats request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/wsproxysdk.ReportAppStatsRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspaceproxies/me/coordinate": { "get": { "security": [ @@ -6009,7 +6082,7 @@ const docTemplate = `{ } } }, - "/workspaces/{workspace}/extend": { + "/workspaces/{workspace}/dormant": { "put": { "security": [ { @@ -6025,8 +6098,8 @@ const docTemplate = `{ "tags": [ "Workspaces" ], - "summary": "Extend workspace deadline by ID", - "operationId": "extend-workspace-deadline-by-id", + "summary": "Update workspace dormancy status by id.", + "operationId": "update-workspace-dormancy-status-by-id", "parameters": [ { "type": "string", @@ -6037,12 +6110,12 @@ const docTemplate = `{ "required": true }, { - "description": "Extend deadline update request", + "description": "Make a workspace dormant or active", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.PutExtendWorkspaceRequest" + "$ref": "#/definitions/codersdk.UpdateWorkspaceDormancy" } } ], @@ -6050,13 +6123,13 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Response" + "$ref": "#/definitions/codersdk.Workspace" } } } } }, - "/workspaces/{workspace}/lock": { + "/workspaces/{workspace}/extend": { "put": { "security": [ { @@ -6072,8 +6145,8 @@ const docTemplate = `{ "tags": [ "Workspaces" ], - "summary": "Update workspace lock by id.", - "operationId": "update-workspace-lock-by-id", + "summary": "Extend workspace deadline by ID", + "operationId": "extend-workspace-deadline-by-id", "parameters": [ { "type": "string", @@ -6084,12 +6157,12 @@ const docTemplate = `{ "required": true }, { - "description": "Lock or unlock a workspace", + "description": "Extend deadline update request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.UpdateWorkspaceLock" + "$ref": "#/definitions/codersdk.PutExtendWorkspaceRequest" } } ], @@ -6343,6 +6416,9 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.WorkspaceApp" } }, + "derp_force_websockets": { + "type": "boolean" + }, "derpmap": { "$ref": "#/definitions/tailcfg.DERPMap" }, @@ -6447,8 +6523,11 @@ const docTemplate = `{ "expanded_directory": { "type": "string" }, - "subsystem": { - "$ref": "#/definitions/codersdk.AgentSubsystem" + "subsystems": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentSubsystem" + } }, "version": { "type": "string" @@ -6624,6 +6703,9 @@ const docTemplate = `{ } } }, + "clibase.Regexp": { + "type": "object" + }, "clibase.Struct-array_codersdk_GitAuthConfig": { "type": "object", "properties": { @@ -6900,10 +6982,14 @@ const docTemplate = `{ "codersdk.AgentSubsystem": { "type": "string", "enum": [ - "envbox" + "envbox", + "envbuilder", + "exectrace" ], "x-enum-varnames": [ - "AgentSubsystemEnvbox" + "AgentSubsystemEnvbox", + "AgentSubsystemEnvbuilder", + "AgentSubsystemExectrace" ] }, "codersdk.AppHostResponse": { @@ -7308,6 +7394,10 @@ const docTemplate = `{ "description": "DefaultTTLMillis allows optionally specifying the default TTL\nfor all workspaces created from this template.", "type": "integer" }, + "delete_ttl_ms": { + "description": "TimeTilDormantAutoDeleteMillis allows optionally specifying the max lifetime before Coder\npermanently deletes dormant workspaces created from this template.", + "type": "integer" + }, "description": { "description": "Description is a description of what the template contains. It must be\nless than 128 bytes.", "type": "string" @@ -7320,6 +7410,10 @@ const docTemplate = `{ "description": "DisplayName is the displayed name of the template.", "type": "string" }, + "dormant_ttl_ms": { + "description": "TimeTilDormantMillis allows optionally specifying the max lifetime before Coder\nlocks inactive workspaces created from this template.", + "type": "integer" + }, "failure_ttl_ms": { "description": "FailureTTLMillis allows optionally specifying the max lifetime before Coder\nstops all resources for failed workspaces created from this template.", "type": "integer" @@ -7328,14 +7422,6 @@ const docTemplate = `{ "description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.", "type": "string" }, - "inactivity_ttl_ms": { - "description": "InactivityTTLMillis allows optionally specifying the max lifetime before Coder\nlocks inactive workspaces created from this template.", - "type": "integer" - }, - "locked_ttl_ms": { - "description": "LockedTTLMillis allows optionally specifying the max lifetime before Coder\npermanently deletes locked workspaces created from this template.", - "type": "integer" - }, "max_ttl_ms": { "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" @@ -7526,13 +7612,21 @@ const docTemplate = `{ ], "properties": { "disable_login": { - "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.", + "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.\nDeprecated: Set UserLoginType=LoginTypeDisabled instead.", "type": "boolean" }, "email": { "type": "string", "format": "email" }, + "login_type": { + "description": "UserLoginType defaults to LoginTypePassword.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.LoginType" + } + ] + }, "organization_id": { "type": "string", "format": "uuid" @@ -7690,6 +7784,9 @@ const docTemplate = `{ "block_direct": { "type": "boolean" }, + "force_websockets": { + "type": "boolean" + }, "path": { "type": "string" }, @@ -8002,6 +8099,10 @@ const docTemplate = `{ "has_license": { "type": "boolean" }, + "refreshed_at": { + "type": "string", + "format": "date-time" + }, "require_telemetry": { "type": "boolean" }, @@ -8024,7 +8125,8 @@ const docTemplate = `{ "tailnet_pg_coordinator", "single_tailnet", "template_restart_requirement", - "deployment_health_page" + "deployment_health_page", + "workspaces_batch_actions" ], "x-enum-varnames": [ "ExperimentMoons", @@ -8032,7 +8134,8 @@ const docTemplate = `{ "ExperimentTailnetPGCoordinator", "ExperimentSingleTailnet", "ExperimentTemplateRestartRequirement", - "ExperimentDeploymentHealthPage" + "ExperimentDeploymentHealthPage", + "ExperimentWorkspacesBatchActions" ] }, "codersdk.Feature": { @@ -8272,9 +8375,23 @@ const docTemplate = `{ }, "quota_allowance": { "type": "integer" + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" } } }, + "codersdk.GroupSource": { + "type": "string", + "enum": [ + "user", + "oidc" + ], + "x-enum-varnames": [ + "GroupSourceUser", + "GroupSourceOIDC" + ] + }, "codersdk.Healthcheck": { "type": "object", "properties": { @@ -8329,11 +8446,9 @@ const docTemplate = `{ "codersdk.JobErrorCode": { "type": "string", "enum": [ - "MISSING_TEMPLATE_PARAMETER", "REQUIRED_TEMPLATE_VARIABLES" ], "x-enum-varnames": [ - "MissingTemplateParameter", "RequiredTemplateVariables" ] }, @@ -8423,6 +8538,7 @@ const docTemplate = `{ "codersdk.LoginType": { "type": "string", "enum": [ + "", "password", "github", "oidc", @@ -8430,6 +8546,7 @@ const docTemplate = `{ "none" ], "x-enum-varnames": [ + "LoginTypeUnknown", "LoginTypePassword", "LoginTypeGithub", "LoginTypeOIDC", @@ -8566,9 +8683,16 @@ const docTemplate = `{ "auth_url_params": { "type": "object" }, + "client_cert_file": { + "type": "string" + }, "client_id": { "type": "string" }, + "client_key_file": { + "description": "ClientKeyFile \u0026 ClientCertFile are used in place of ClientSecret for PKI auth.", + "type": "string" + }, "client_secret": { "type": "string" }, @@ -8581,9 +8705,15 @@ const docTemplate = `{ "email_field": { "type": "string" }, + "group_auto_create": { + "type": "boolean" + }, "group_mapping": { "type": "object" }, + "group_regex_filter": { + "$ref": "#/definitions/clibase.Regexp" + }, "groups_field": { "type": "string" }, @@ -8678,6 +8808,35 @@ const docTemplate = `{ } } }, + "codersdk.PatchGroupRequest": { + "type": "object", + "properties": { + "add_users": { + "type": "array", + "items": { + "type": "string" + } + }, + "avatar_url": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "remove_users": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.PatchTemplateVersionRequest": { "type": "object", "properties": { @@ -8753,6 +8912,9 @@ const docTemplate = `{ "daemon_poll_jitter": { "type": "integer" }, + "daemon_psk": { + "type": "string" + }, "daemons": { "type": "integer" }, @@ -8820,7 +8982,6 @@ const docTemplate = `{ }, "error_code": { "enum": [ - "MISSING_TEMPLATE_PARAMETER", "REQUIRED_TEMPLATE_VARIABLES" ], "allOf": [ @@ -9152,7 +9313,9 @@ const docTemplate = `{ "api_key", "group", "license", - "convert_login" + "convert_login", + "workspace_proxy", + "organization" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -9164,7 +9327,9 @@ const docTemplate = `{ "ResourceTypeAPIKey", "ResourceTypeGroup", "ResourceTypeLicense", - "ResourceTypeConvertLogin" + "ResourceTypeConvertLogin", + "ResourceTypeWorkspaceProxy", + "ResourceTypeOrganization" ] }, "codersdk.Response": { @@ -9375,7 +9540,7 @@ const docTemplate = `{ "type": "string" }, "failure_ttl_ms": { - "description": "FailureTTLMillis, InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their\nvalues are used if your license is entitled to use the advanced\ntemplate scheduling feature.", + "description": "FailureTTLMillis, TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their\nvalues are used if your license is entitled to use the advanced\ntemplate scheduling feature.", "type": "integer" }, "icon": { @@ -9385,12 +9550,6 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, - "inactivity_ttl_ms": { - "type": "integer" - }, - "locked_ttl_ms": { - "type": "integer" - }, "max_ttl_ms": { "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" @@ -9416,6 +9575,12 @@ const docTemplate = `{ } ] }, + "time_til_dormant_autodelete_ms": { + "type": "integer" + }, + "time_til_dormant_ms": { + "type": "integer" + }, "updated_at": { "type": "string", "format": "date-time" @@ -9460,10 +9625,12 @@ const docTemplate = `{ "codersdk.TemplateAppsType": { "type": "string", "enum": [ - "builtin" + "builtin", + "app" ], "x-enum-varnames": [ - "TemplateAppsTypeBuiltin" + "TemplateAppsTypeBuiltin", + "TemplateAppsTypeApp" ] }, "codersdk.TemplateBuildTimeStats": { @@ -9582,6 +9749,9 @@ const docTemplate = `{ "codersdk.TemplateParameterUsage": { "type": "object", "properties": { + "description": { + "type": "string" + }, "display_name": { "type": "string" }, @@ -9601,6 +9771,9 @@ const docTemplate = `{ "format": "uuid" } }, + "type": { + "type": "string" + }, "values": { "type": "array", "items": { @@ -10081,10 +10254,10 @@ const docTemplate = `{ } } }, - "codersdk.UpdateWorkspaceLock": { + "codersdk.UpdateWorkspaceDormancy": { "type": "object", "properties": { - "lock": { + "dormant": { "type": "boolean" } } @@ -10337,7 +10510,12 @@ const docTemplate = `{ "format": "date-time" }, "deleting_at": { - "description": "DeletingAt indicates the time of the upcoming workspace deletion, if applicable; otherwise it is nil.\nWorkspaces may have impending deletions if Template.InactivityTTL feature is turned on and the workspace is inactive.", + "description": "DeletingAt indicates the time at which the workspace will be permanently deleted.\nA workspace is eligible for deletion if it is dormant (a non-nil dormant_at value)\nand a value has been specified for time_til_dormant_autodelete on its template.", + "type": "string", + "format": "date-time" + }, + "dormant_at": { + "description": "DormantAt being non-nil indicates a workspace that is dormant.\nA dormant workspace is no longer accessible must be activated.\nIt is subject to deletion if it breaches\nthe duration of the time_til_ field on its template.", "type": "string", "format": "date-time" }, @@ -10360,11 +10538,6 @@ const docTemplate = `{ "latest_build": { "$ref": "#/definitions/codersdk.WorkspaceBuild" }, - "locked_at": { - "description": "LockedAt being non-nil indicates a workspace that has been locked.\nA locked workspace is no longer accessible by a user and must be\nunlocked by an admin. It is subject to deletion if it breaches\nthe duration of the locked_ttl field on its template.", - "type": "string", - "format": "date-time" - }, "name": { "type": "string" }, @@ -10382,6 +10555,10 @@ const docTemplate = `{ "owner_name": { "type": "string" }, + "template_active_version_id": { + "type": "string", + "format": "uuid" + }, "template_allow_user_cancel_workspace_jobs": { "type": "boolean" }, @@ -10522,8 +10699,11 @@ const docTemplate = `{ "status": { "$ref": "#/definitions/codersdk.WorkspaceAgentStatus" }, - "subsystem": { - "$ref": "#/definitions/codersdk.AgentSubsystem" + "subsystems": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentSubsystem" + } }, "troubleshooting_url": { "type": "string" @@ -10540,6 +10720,9 @@ const docTemplate = `{ "codersdk.WorkspaceAgentConnectionInfo": { "type": "object", "properties": { + "derp_force_websockets": { + "type": "boolean" + }, "derp_map": { "$ref": "#/definitions/tailcfg.DERPMap" }, @@ -11499,11 +11682,31 @@ const docTemplate = `{ } } }, + "tailcfg.DERPHomeParams": { + "type": "object", + "properties": { + "regionScore": { + "description": "RegionScore scales latencies of DERP regions by a given scaling\nfactor when determining which region to use as the home\n(\"preferred\") DERP. Scores in the range (0, 1) will cause this\nregion to be proportionally more preferred, and scores in the range\n(1, ∞) will penalize a region.\n\nIf a region is not present in this map, it is treated as having a\nscore of 1.0.\n\nScores should not be 0 or negative; such scores will be ignored.\n\nA nil map means no change from the previous value (if any); an empty\nnon-nil map can be sent to reset all scores back to 1.0.", + "type": "object", + "additionalProperties": { + "type": "number" + } + } + } + }, "tailcfg.DERPMap": { "type": "object", "properties": { + "homeParams": { + "description": "HomeParams, if non-nil, is a change in home parameters.\n\nThe rest of the DEPRMap fields, if zero, means unchanged.", + "allOf": [ + { + "$ref": "#/definitions/tailcfg.DERPHomeParams" + } + ] + }, "omitDefaultRegions": { - "description": "OmitDefaultRegions specifies to not use Tailscale's DERP servers, and only use those\nspecified in this DERPMap. If there are none set outside of the defaults, this is a noop.", + "description": "OmitDefaultRegions specifies to not use Tailscale's DERP servers, and only use those\nspecified in this DERPMap. If there are none set outside of the defaults, this is a noop.\n\nThis field is only meaningful if the Regions map is non-nil (indicating a change).", "type": "boolean" }, "regions": { @@ -11518,6 +11721,10 @@ const docTemplate = `{ "tailcfg.DERPNode": { "type": "object", "properties": { + "canPort80": { + "description": "CanPort80 specifies whether this DERP node is accessible over HTTP\non port 80 specifically. This is used for captive portal checks.", + "type": "boolean" + }, "certName": { "description": "CertName optionally specifies the expected TLS cert common\nname. If empty, HostName is used. If CertName is non-empty,\nHostName is only used for the TCP dial (if IPv4/IPv6 are\nnot present) + TLS ClientHello.", "type": "string" @@ -11670,6 +11877,39 @@ const docTemplate = `{ } } }, + "workspaceapps.StatsReport": { + "type": "object", + "properties": { + "access_method": { + "$ref": "#/definitions/workspaceapps.AccessMethod" + }, + "agent_id": { + "type": "string" + }, + "requests": { + "type": "integer" + }, + "session_ended_at": { + "description": "Updated periodically while app is in use active and when the last connection is closed.", + "type": "string" + }, + "session_id": { + "type": "string" + }, + "session_started_at": { + "type": "string" + }, + "slug_or_port": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + } + }, "wsproxysdk.AgentIsLegacyResponse": { "type": "object", "properties": { @@ -11746,6 +11986,12 @@ const docTemplate = `{ "app_security_key": { "type": "string" }, + "derp_force_websockets": { + "type": "boolean" + }, + "derp_map": { + "$ref": "#/definitions/tailcfg.DERPMap" + }, "derp_mesh_key": { "type": "string" }, @@ -11760,6 +12006,17 @@ const docTemplate = `{ } } } + }, + "wsproxysdk.ReportAppStatsRequest": { + "type": "object", + "properties": { + "stats": { + "type": "array", + "items": { + "$ref": "#/definitions/workspaceapps.StatsReport" + } + } + } } }, "securityDefinitions": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7f7d12a51a53a..7342e5140598e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -675,12 +675,12 @@ ], "produces": ["application/json"], "tags": ["Enterprise"], - "summary": "Get group by name", - "operationId": "get-group-by-name", + "summary": "Get group by ID", + "operationId": "get-group-by-id", "parameters": [ { "type": "string", - "description": "Group name", + "description": "Group id", "name": "group", "in": "path", "required": true @@ -729,6 +729,7 @@ "CoderSessionToken": [] } ], + "consumes": ["application/json"], "produces": ["application/json"], "tags": ["Enterprise"], "summary": "Update group by name", @@ -740,6 +741,15 @@ "name": "group", "in": "path", "required": true + }, + { + "description": "Patch group request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PatchGroupRequest" + } } ], "responses": { @@ -870,6 +880,27 @@ } } }, + "/licenses/refresh-entitlements": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Organizations"], + "summary": "Update license entitlements", + "operationId": "update-license-entitlements", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/licenses/{id}": { "delete": { "security": [ @@ -4831,6 +4862,38 @@ } } }, + "/workspaceproxies/me/app-stats": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "tags": ["Enterprise"], + "summary": "Report workspace app stats", + "operationId": "report-workspace-app-stats", + "parameters": [ + { + "description": "Report app stats request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/wsproxysdk.ReportAppStatsRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspaceproxies/me/coordinate": { "get": { "security": [ @@ -5303,7 +5366,7 @@ } } }, - "/workspaces/{workspace}/extend": { + "/workspaces/{workspace}/dormant": { "put": { "security": [ { @@ -5313,8 +5376,8 @@ "consumes": ["application/json"], "produces": ["application/json"], "tags": ["Workspaces"], - "summary": "Extend workspace deadline by ID", - "operationId": "extend-workspace-deadline-by-id", + "summary": "Update workspace dormancy status by id.", + "operationId": "update-workspace-dormancy-status-by-id", "parameters": [ { "type": "string", @@ -5325,12 +5388,12 @@ "required": true }, { - "description": "Extend deadline update request", + "description": "Make a workspace dormant or active", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.PutExtendWorkspaceRequest" + "$ref": "#/definitions/codersdk.UpdateWorkspaceDormancy" } } ], @@ -5338,13 +5401,13 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Response" + "$ref": "#/definitions/codersdk.Workspace" } } } } }, - "/workspaces/{workspace}/lock": { + "/workspaces/{workspace}/extend": { "put": { "security": [ { @@ -5354,8 +5417,8 @@ "consumes": ["application/json"], "produces": ["application/json"], "tags": ["Workspaces"], - "summary": "Update workspace lock by id.", - "operationId": "update-workspace-lock-by-id", + "summary": "Extend workspace deadline by ID", + "operationId": "extend-workspace-deadline-by-id", "parameters": [ { "type": "string", @@ -5366,12 +5429,12 @@ "required": true }, { - "description": "Lock or unlock a workspace", + "description": "Extend deadline update request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.UpdateWorkspaceLock" + "$ref": "#/definitions/codersdk.PutExtendWorkspaceRequest" } } ], @@ -5593,6 +5656,9 @@ "$ref": "#/definitions/codersdk.WorkspaceApp" } }, + "derp_force_websockets": { + "type": "boolean" + }, "derpmap": { "$ref": "#/definitions/tailcfg.DERPMap" }, @@ -5697,8 +5763,11 @@ "expanded_directory": { "type": "string" }, - "subsystem": { - "$ref": "#/definitions/codersdk.AgentSubsystem" + "subsystems": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentSubsystem" + } }, "version": { "type": "string" @@ -5874,6 +5943,9 @@ } } }, + "clibase.Regexp": { + "type": "object" + }, "clibase.Struct-array_codersdk_GitAuthConfig": { "type": "object", "properties": { @@ -6127,8 +6199,12 @@ }, "codersdk.AgentSubsystem": { "type": "string", - "enum": ["envbox"], - "x-enum-varnames": ["AgentSubsystemEnvbox"] + "enum": ["envbox", "envbuilder", "exectrace"], + "x-enum-varnames": [ + "AgentSubsystemEnvbox", + "AgentSubsystemEnvbuilder", + "AgentSubsystemExectrace" + ] }, "codersdk.AppHostResponse": { "type": "object", @@ -6511,6 +6587,10 @@ "description": "DefaultTTLMillis allows optionally specifying the default TTL\nfor all workspaces created from this template.", "type": "integer" }, + "delete_ttl_ms": { + "description": "TimeTilDormantAutoDeleteMillis allows optionally specifying the max lifetime before Coder\npermanently deletes dormant workspaces created from this template.", + "type": "integer" + }, "description": { "description": "Description is a description of what the template contains. It must be\nless than 128 bytes.", "type": "string" @@ -6523,6 +6603,10 @@ "description": "DisplayName is the displayed name of the template.", "type": "string" }, + "dormant_ttl_ms": { + "description": "TimeTilDormantMillis allows optionally specifying the max lifetime before Coder\nlocks inactive workspaces created from this template.", + "type": "integer" + }, "failure_ttl_ms": { "description": "FailureTTLMillis allows optionally specifying the max lifetime before Coder\nstops all resources for failed workspaces created from this template.", "type": "integer" @@ -6531,14 +6615,6 @@ "description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.", "type": "string" }, - "inactivity_ttl_ms": { - "description": "InactivityTTLMillis allows optionally specifying the max lifetime before Coder\nlocks inactive workspaces created from this template.", - "type": "integer" - }, - "locked_ttl_ms": { - "description": "LockedTTLMillis allows optionally specifying the max lifetime before Coder\npermanently deletes locked workspaces created from this template.", - "type": "integer" - }, "max_ttl_ms": { "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" @@ -6705,13 +6781,21 @@ "required": ["email", "username"], "properties": { "disable_login": { - "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.", + "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.\nDeprecated: Set UserLoginType=LoginTypeDisabled instead.", "type": "boolean" }, "email": { "type": "string", "format": "email" }, + "login_type": { + "description": "UserLoginType defaults to LoginTypePassword.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.LoginType" + } + ] + }, "organization_id": { "type": "string", "format": "uuid" @@ -6855,6 +6939,9 @@ "block_direct": { "type": "boolean" }, + "force_websockets": { + "type": "boolean" + }, "path": { "type": "string" }, @@ -7163,6 +7250,10 @@ "has_license": { "type": "boolean" }, + "refreshed_at": { + "type": "string", + "format": "date-time" + }, "require_telemetry": { "type": "boolean" }, @@ -7185,7 +7276,8 @@ "tailnet_pg_coordinator", "single_tailnet", "template_restart_requirement", - "deployment_health_page" + "deployment_health_page", + "workspaces_batch_actions" ], "x-enum-varnames": [ "ExperimentMoons", @@ -7193,7 +7285,8 @@ "ExperimentTailnetPGCoordinator", "ExperimentSingleTailnet", "ExperimentTemplateRestartRequirement", - "ExperimentDeploymentHealthPage" + "ExperimentDeploymentHealthPage", + "ExperimentWorkspacesBatchActions" ] }, "codersdk.Feature": { @@ -7428,9 +7521,17 @@ }, "quota_allowance": { "type": "integer" + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" } } }, + "codersdk.GroupSource": { + "type": "string", + "enum": ["user", "oidc"], + "x-enum-varnames": ["GroupSourceUser", "GroupSourceOIDC"] + }, "codersdk.Healthcheck": { "type": "object", "properties": { @@ -7477,11 +7578,8 @@ }, "codersdk.JobErrorCode": { "type": "string", - "enum": ["MISSING_TEMPLATE_PARAMETER", "REQUIRED_TEMPLATE_VARIABLES"], - "x-enum-varnames": [ - "MissingTemplateParameter", - "RequiredTemplateVariables" - ] + "enum": ["REQUIRED_TEMPLATE_VARIABLES"], + "x-enum-varnames": ["RequiredTemplateVariables"] }, "codersdk.License": { "type": "object", @@ -7556,8 +7654,9 @@ }, "codersdk.LoginType": { "type": "string", - "enum": ["password", "github", "oidc", "token", "none"], + "enum": ["", "password", "github", "oidc", "token", "none"], "x-enum-varnames": [ + "LoginTypeUnknown", "LoginTypePassword", "LoginTypeGithub", "LoginTypeOIDC", @@ -7686,9 +7785,16 @@ "auth_url_params": { "type": "object" }, + "client_cert_file": { + "type": "string" + }, "client_id": { "type": "string" }, + "client_key_file": { + "description": "ClientKeyFile \u0026 ClientCertFile are used in place of ClientSecret for PKI auth.", + "type": "string" + }, "client_secret": { "type": "string" }, @@ -7701,9 +7807,15 @@ "email_field": { "type": "string" }, + "group_auto_create": { + "type": "boolean" + }, "group_mapping": { "type": "object" }, + "group_regex_filter": { + "$ref": "#/definitions/clibase.Regexp" + }, "groups_field": { "type": "string" }, @@ -7793,6 +7905,35 @@ } } }, + "codersdk.PatchGroupRequest": { + "type": "object", + "properties": { + "add_users": { + "type": "array", + "items": { + "type": "string" + } + }, + "avatar_url": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "remove_users": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.PatchTemplateVersionRequest": { "type": "object", "properties": { @@ -7863,6 +8004,9 @@ "daemon_poll_jitter": { "type": "integer" }, + "daemon_psk": { + "type": "string" + }, "daemons": { "type": "integer" }, @@ -7929,7 +8073,7 @@ "type": "string" }, "error_code": { - "enum": ["MISSING_TEMPLATE_PARAMETER", "REQUIRED_TEMPLATE_VARIABLES"], + "enum": ["REQUIRED_TEMPLATE_VARIABLES"], "allOf": [ { "$ref": "#/definitions/codersdk.JobErrorCode" @@ -8238,7 +8382,9 @@ "api_key", "group", "license", - "convert_login" + "convert_login", + "workspace_proxy", + "organization" ], "x-enum-varnames": [ "ResourceTypeTemplate", @@ -8250,7 +8396,9 @@ "ResourceTypeAPIKey", "ResourceTypeGroup", "ResourceTypeLicense", - "ResourceTypeConvertLogin" + "ResourceTypeConvertLogin", + "ResourceTypeWorkspaceProxy", + "ResourceTypeOrganization" ] }, "codersdk.Response": { @@ -8461,7 +8609,7 @@ "type": "string" }, "failure_ttl_ms": { - "description": "FailureTTLMillis, InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their\nvalues are used if your license is entitled to use the advanced\ntemplate scheduling feature.", + "description": "FailureTTLMillis, TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their\nvalues are used if your license is entitled to use the advanced\ntemplate scheduling feature.", "type": "integer" }, "icon": { @@ -8471,12 +8619,6 @@ "type": "string", "format": "uuid" }, - "inactivity_ttl_ms": { - "type": "integer" - }, - "locked_ttl_ms": { - "type": "integer" - }, "max_ttl_ms": { "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" @@ -8500,6 +8642,12 @@ } ] }, + "time_til_dormant_autodelete_ms": { + "type": "integer" + }, + "time_til_dormant_ms": { + "type": "integer" + }, "updated_at": { "type": "string", "format": "date-time" @@ -8543,8 +8691,8 @@ }, "codersdk.TemplateAppsType": { "type": "string", - "enum": ["builtin"], - "x-enum-varnames": ["TemplateAppsTypeBuiltin"] + "enum": ["builtin", "app"], + "x-enum-varnames": ["TemplateAppsTypeBuiltin", "TemplateAppsTypeApp"] }, "codersdk.TemplateBuildTimeStats": { "type": "object", @@ -8662,6 +8810,9 @@ "codersdk.TemplateParameterUsage": { "type": "object", "properties": { + "description": { + "type": "string" + }, "display_name": { "type": "string" }, @@ -8681,6 +8832,9 @@ "format": "uuid" } }, + "type": { + "type": "string" + }, "values": { "type": "array", "items": { @@ -9120,10 +9274,10 @@ } } }, - "codersdk.UpdateWorkspaceLock": { + "codersdk.UpdateWorkspaceDormancy": { "type": "object", "properties": { - "lock": { + "dormant": { "type": "boolean" } } @@ -9358,7 +9512,12 @@ "format": "date-time" }, "deleting_at": { - "description": "DeletingAt indicates the time of the upcoming workspace deletion, if applicable; otherwise it is nil.\nWorkspaces may have impending deletions if Template.InactivityTTL feature is turned on and the workspace is inactive.", + "description": "DeletingAt indicates the time at which the workspace will be permanently deleted.\nA workspace is eligible for deletion if it is dormant (a non-nil dormant_at value)\nand a value has been specified for time_til_dormant_autodelete on its template.", + "type": "string", + "format": "date-time" + }, + "dormant_at": { + "description": "DormantAt being non-nil indicates a workspace that is dormant.\nA dormant workspace is no longer accessible must be activated.\nIt is subject to deletion if it breaches\nthe duration of the time_til_ field on its template.", "type": "string", "format": "date-time" }, @@ -9381,11 +9540,6 @@ "latest_build": { "$ref": "#/definitions/codersdk.WorkspaceBuild" }, - "locked_at": { - "description": "LockedAt being non-nil indicates a workspace that has been locked.\nA locked workspace is no longer accessible by a user and must be\nunlocked by an admin. It is subject to deletion if it breaches\nthe duration of the locked_ttl field on its template.", - "type": "string", - "format": "date-time" - }, "name": { "type": "string" }, @@ -9403,6 +9557,10 @@ "owner_name": { "type": "string" }, + "template_active_version_id": { + "type": "string", + "format": "uuid" + }, "template_allow_user_cancel_workspace_jobs": { "type": "boolean" }, @@ -9543,8 +9701,11 @@ "status": { "$ref": "#/definitions/codersdk.WorkspaceAgentStatus" }, - "subsystem": { - "$ref": "#/definitions/codersdk.AgentSubsystem" + "subsystems": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentSubsystem" + } }, "troubleshooting_url": { "type": "string" @@ -9561,6 +9722,9 @@ "codersdk.WorkspaceAgentConnectionInfo": { "type": "object", "properties": { + "derp_force_websockets": { + "type": "boolean" + }, "derp_map": { "$ref": "#/definitions/tailcfg.DERPMap" }, @@ -10483,11 +10647,31 @@ } } }, + "tailcfg.DERPHomeParams": { + "type": "object", + "properties": { + "regionScore": { + "description": "RegionScore scales latencies of DERP regions by a given scaling\nfactor when determining which region to use as the home\n(\"preferred\") DERP. Scores in the range (0, 1) will cause this\nregion to be proportionally more preferred, and scores in the range\n(1, ∞) will penalize a region.\n\nIf a region is not present in this map, it is treated as having a\nscore of 1.0.\n\nScores should not be 0 or negative; such scores will be ignored.\n\nA nil map means no change from the previous value (if any); an empty\nnon-nil map can be sent to reset all scores back to 1.0.", + "type": "object", + "additionalProperties": { + "type": "number" + } + } + } + }, "tailcfg.DERPMap": { "type": "object", "properties": { + "homeParams": { + "description": "HomeParams, if non-nil, is a change in home parameters.\n\nThe rest of the DEPRMap fields, if zero, means unchanged.", + "allOf": [ + { + "$ref": "#/definitions/tailcfg.DERPHomeParams" + } + ] + }, "omitDefaultRegions": { - "description": "OmitDefaultRegions specifies to not use Tailscale's DERP servers, and only use those\nspecified in this DERPMap. If there are none set outside of the defaults, this is a noop.", + "description": "OmitDefaultRegions specifies to not use Tailscale's DERP servers, and only use those\nspecified in this DERPMap. If there are none set outside of the defaults, this is a noop.\n\nThis field is only meaningful if the Regions map is non-nil (indicating a change).", "type": "boolean" }, "regions": { @@ -10502,6 +10686,10 @@ "tailcfg.DERPNode": { "type": "object", "properties": { + "canPort80": { + "description": "CanPort80 specifies whether this DERP node is accessible over HTTP\non port 80 specifically. This is used for captive portal checks.", + "type": "boolean" + }, "certName": { "description": "CertName optionally specifies the expected TLS cert common\nname. If empty, HostName is used. If CertName is non-empty,\nHostName is only used for the TCP dial (if IPv4/IPv6 are\nnot present) + TLS ClientHello.", "type": "string" @@ -10650,6 +10838,39 @@ } } }, + "workspaceapps.StatsReport": { + "type": "object", + "properties": { + "access_method": { + "$ref": "#/definitions/workspaceapps.AccessMethod" + }, + "agent_id": { + "type": "string" + }, + "requests": { + "type": "integer" + }, + "session_ended_at": { + "description": "Updated periodically while app is in use active and when the last connection is closed.", + "type": "string" + }, + "session_id": { + "type": "string" + }, + "session_started_at": { + "type": "string" + }, + "slug_or_port": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + } + }, "wsproxysdk.AgentIsLegacyResponse": { "type": "object", "properties": { @@ -10726,6 +10947,12 @@ "app_security_key": { "type": "string" }, + "derp_force_websockets": { + "type": "boolean" + }, + "derp_map": { + "$ref": "#/definitions/tailcfg.DERPMap" + }, "derp_mesh_key": { "type": "string" }, @@ -10740,6 +10967,17 @@ } } } + }, + "wsproxysdk.ReportAppStatsRequest": { + "type": "object", + "properties": { + "stats": { + "type": "array", + "items": { + "$ref": "#/definitions/workspaceapps.StatsReport" + } + } + } } }, "securityDefinitions": { diff --git a/coderd/apikey.go b/coderd/apikey.go index c3934d076cbdc..ba017819773cf 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -12,14 +12,14 @@ import ( "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/apikey" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/apikey" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/codersdk" ) // Creates a new token API key that effectively doesn't expire. diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index 8abba96421914..c21c28dd16967 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -10,9 +10,9 @@ import ( "github.com/sqlc-dev/pqtype" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" ) type CreateParams struct { diff --git a/coderd/apikey/apikey_test.go b/coderd/apikey/apikey_test.go index 89fc3a6fade02..eccb0cb81af6e 100644 --- a/coderd/apikey/apikey_test.go +++ b/coderd/apikey/apikey_test.go @@ -10,10 +10,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/coderd/apikey" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/coderd/apikey" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" ) func TestGenerate(t *testing.T) { diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index 412f7bebae660..c77d396d3e43e 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -10,13 +10,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "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/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func TestTokenCRUD(t *testing.T) { diff --git a/coderd/apiroot.go b/coderd/apiroot.go index 6974ca5133e24..a0dee428e3970 100644 --- a/coderd/apiroot.go +++ b/coderd/apiroot.go @@ -3,8 +3,8 @@ package coderd import ( "net/http" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) // @Summary API root handler diff --git a/coderd/audit.go b/coderd/audit.go index 18eb155743c0e..e898e343b1e9f 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -15,14 +15,14 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/db2sdk" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/searchquery" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/searchquery" + "github.com/coder/coder/v2/codersdk" ) // @Summary Get audit logs diff --git a/coderd/audit/audit.go b/coderd/audit/audit.go index d3e83d19e85fb..4d256541d05f6 100644 --- a/coderd/audit/audit.go +++ b/coderd/audit/audit.go @@ -4,7 +4,7 @@ import ( "context" "sync" - "github.com/coder/coder/coderd/database" + "github.com/coder/coder/v2/coderd/database" ) type Auditor interface { diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 858334493cf8d..8cf0a1d0ddaf3 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -1,7 +1,7 @@ package audit import ( - "github.com/coder/coder/coderd/database" + "github.com/coder/coder/v2/coderd/database" ) // Auditable is mostly a marker interface. It contains a definitive list of all diff --git a/coderd/audit/request.go b/coderd/audit/request.go index aec89ef3d308e..434ff401f3339 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -13,9 +13,9 @@ import ( "github.com/sqlc-dev/pqtype" "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/tracing" ) type RequestParams struct { diff --git a/coderd/audit_test.go b/coderd/audit_test.go index cc8698775bd22..8d7ada74b3bef 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -10,10 +10,10 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" ) func TestAuditLogs(t *testing.T) { diff --git a/coderd/authorize.go b/coderd/authorize.go index 229c7e4624655..e8d4274ab89a0 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -8,10 +8,10 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" ) // AuthorizeFilter takes a list of objects and returns the filtered list of diff --git a/coderd/authorize_test.go b/coderd/authorize_test.go index 55214d10473a7..54462690be516 100644 --- a/coderd/authorize_test.go +++ b/coderd/authorize_test.go @@ -7,10 +7,10 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "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/testutil" ) func TestCheckPermissions(t *testing.T) { diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index f7176ae8cd721..f603d7895531d 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -12,12 +12,12 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/db2sdk" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/wsbuilder" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/wsbuilder" + "github.com/coder/coder/v2/codersdk" ) // Executor automatically starts or stops workspaces. @@ -175,35 +175,35 @@ func (e *Executor) runOnce(t time.Time) Stats { } } - // Lock the workspace if it has breached the template's + // Transition the workspace to dormant if it has breached the template's // threshold for inactivity. if reason == database.BuildReasonAutolock { - err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{ + ws, err = tx.UpdateWorkspaceDormantDeletingAt(e.ctx, database.UpdateWorkspaceDormantDeletingAtParams{ ID: ws.ID, - LockedAt: sql.NullTime{ + DormantAt: sql.NullTime{ Time: database.Now(), Valid: true, }, }) if err != nil { - log.Error(e.ctx, "unable to lock workspace", + log.Error(e.ctx, "unable to transition workspace to dormant", slog.F("transition", nextTransition), slog.Error(err), ) return nil } - log.Info(e.ctx, "locked workspace", + log.Info(e.ctx, "dormant workspace", slog.F("last_used_at", ws.LastUsedAt), - slog.F("inactivity_ttl", templateSchedule.InactivityTTL), + slog.F("time_til_dormant", templateSchedule.TimeTilDormant), slog.F("since_last_used_at", time.Since(ws.LastUsedAt)), ) } if reason == database.BuildReasonAutodelete { log.Info(e.ctx, "deleted workspace", - slog.F("locked_at", ws.LockedAt.Time), - slog.F("locked_ttl", templateSchedule.LockedTTL), + slog.F("dormant_at", ws.DormantAt.Time), + slog.F("time_til_dormant_autodelete", templateSchedule.TimeTilDormantAutoDelete), ) } @@ -246,7 +246,7 @@ func (e *Executor) runOnce(t time.Time) Stats { // for this function to return a nil error as well as an empty transition. // In such cases it means no provisioning should occur but the workspace // may be "transitioning" to a new state (such as an inactive, stopped -// workspace transitioning to the locked state). +// workspace transitioning to the dormant state). func getNextTransition( ws database.Workspace, latestBuild database.WorkspaceBuild, @@ -265,13 +265,13 @@ func getNextTransition( return database.WorkspaceTransitionStart, database.BuildReasonAutostart, nil case isEligibleForFailedStop(latestBuild, latestJob, templateSchedule, currentTick): return database.WorkspaceTransitionStop, database.BuildReasonAutostop, nil - case isEligibleForLockedStop(ws, templateSchedule, currentTick): + case isEligibleForDormantStop(ws, templateSchedule, currentTick): // Only stop started workspaces. if latestBuild.Transition == database.WorkspaceTransitionStart { return database.WorkspaceTransitionStop, database.BuildReasonAutolock, nil } // We shouldn't transition the workspace but we should still - // lock it. + // make it dormant. return "", database.BuildReasonAutolock, nil case isEligibleForDelete(ws, templateSchedule, currentTick): @@ -288,8 +288,8 @@ func isEligibleForAutostart(ws database.Workspace, build database.WorkspaceBuild return false } - // If the workspace is locked we should not autostart it. - if ws.LockedAt.Valid { + // If the workspace is dormant we should not autostart it. + if ws.DormantAt.Valid { return false } @@ -322,8 +322,8 @@ func isEligibleForAutostop(ws database.Workspace, build database.WorkspaceBuild, return false } - // If the workspace is locked we should not autostop it. - if ws.LockedAt.Valid { + // If the workspace is dormant we should not autostop it. + if ws.DormantAt.Valid { return false } @@ -334,23 +334,23 @@ func isEligibleForAutostop(ws database.Workspace, build database.WorkspaceBuild, !currentTick.Before(build.Deadline) } -// isEligibleForLockedStop returns true if the workspace should be locked +// isEligibleForDormantStop returns true if the workspace should be dormant // for breaching the inactivity threshold of the template. -func isEligibleForLockedStop(ws database.Workspace, templateSchedule schedule.TemplateScheduleOptions, currentTick time.Time) bool { - // Only attempt to lock workspaces not already locked. - return !ws.LockedAt.Valid && - // The template must specify an inactivity TTL. - templateSchedule.InactivityTTL > 0 && - // The workspace must breach the inactivity TTL. - currentTick.Sub(ws.LastUsedAt) > templateSchedule.InactivityTTL +func isEligibleForDormantStop(ws database.Workspace, templateSchedule schedule.TemplateScheduleOptions, currentTick time.Time) bool { + // Only attempt against workspaces not already dormant. + return !ws.DormantAt.Valid && + // The template must specify an time_til_dormant value. + templateSchedule.TimeTilDormant > 0 && + // The workspace must breach the time_til_dormant value. + currentTick.Sub(ws.LastUsedAt) > templateSchedule.TimeTilDormant } func isEligibleForDelete(ws database.Workspace, templateSchedule schedule.TemplateScheduleOptions, currentTick time.Time) bool { - // Only attempt to delete locked workspaces. - return ws.LockedAt.Valid && ws.DeletingAt.Valid && - // Locked workspaces should only be deleted if a locked_ttl is specified. - templateSchedule.LockedTTL > 0 && - // The workspace must breach the locked_ttl. + // Only attempt to delete dormant workspaces. + return ws.DormantAt.Valid && ws.DeletingAt.Valid && + // Dormant workspaces should only be deleted if a time_til_dormant_autodelete value is specified. + templateSchedule.TimeTilDormantAutoDelete > 0 && + // The workspace must breach the time_til_dormant_autodelete value. currentTick.After(ws.DeletingAt.Time) } diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 7803c9fcd41da..7159f3b7d5665 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -13,14 +13,14 @@ import ( "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/autobuild" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/schedule" - "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/v2/coderd/autobuild" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/schedule" + "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" ) func TestExecutorAutostartOK(t *testing.T) { @@ -683,8 +683,8 @@ func TestExecutorFailedWorkspace(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionFailed, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyFailed, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.FailureTTLMillis = ptr.Ref[int64](failureTTL.Milliseconds()) @@ -733,11 +733,11 @@ func TestExecutorInactiveWorkspace(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.InactivityTTLMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) + ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) @@ -766,22 +766,16 @@ func mustProvisionWorkspaceWithParameters(t *testing.T, client *codersdk.Client, user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &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: richParameters, }, }, }, }, - ProvisionApply: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }, - }, + ProvisionApply: echo.ApplyComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) diff --git a/coderd/autobuild/notify/notifier_test.go b/coderd/autobuild/notify/notifier_test.go index fb547defd5b36..09e8158abaa99 100644 --- a/coderd/autobuild/notify/notifier_test.go +++ b/coderd/autobuild/notify/notifier_test.go @@ -9,7 +9,7 @@ import ( "go.uber.org/atomic" "go.uber.org/goleak" - "github.com/coder/coder/coderd/autobuild/notify" + "github.com/coder/coder/v2/coderd/autobuild/notify" ) func TestNotifier(t *testing.T) { diff --git a/coderd/awsidentity/awsidentity_test.go b/coderd/awsidentity/awsidentity_test.go index 755079fc87b80..50aeec98aea22 100644 --- a/coderd/awsidentity/awsidentity_test.go +++ b/coderd/awsidentity/awsidentity_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/awsidentity" + "github.com/coder/coder/v2/coderd/awsidentity" ) const ( diff --git a/coderd/azureidentity/azureidentity_test.go b/coderd/azureidentity/azureidentity_test.go index 08dda41976166..1ae35d0385429 100644 --- a/coderd/azureidentity/azureidentity_test.go +++ b/coderd/azureidentity/azureidentity_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/azureidentity" + "github.com/coder/coder/v2/coderd/azureidentity" ) func TestValidate(t *testing.T) { diff --git a/coderd/batchstats/batcher.go b/coderd/batchstats/batcher.go new file mode 100644 index 0000000000000..b3b881f2133e9 --- /dev/null +++ b/coderd/batchstats/batcher.go @@ -0,0 +1,296 @@ +package batchstats + +import ( + "context" + "encoding/json" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/codersdk/agentsdk" +) + +const ( + defaultBufferSize = 1024 + defaultFlushInterval = time.Second +) + +// Batcher holds a buffer of agent stats and periodically flushes them to +// its configured store. It also updates the workspace's last used time. +type Batcher struct { + store database.Store + log slog.Logger + + mu sync.Mutex + // TODO: make this a buffered chan instead? + buf *database.InsertWorkspaceAgentStatsParams + // NOTE: we batch this separately as it's a jsonb field and + // pq.Array + unnest doesn't play nicely with this. + connectionsByProto []map[string]int64 + batchSize int + + // tickCh is used to periodically flush the buffer. + tickCh <-chan time.Time + ticker *time.Ticker + interval time.Duration + // flushLever is used to signal the flusher to flush the buffer immediately. + flushLever chan struct{} + flushForced atomic.Bool + // flushed is used during testing to signal that a flush has completed. + flushed chan<- int +} + +// Option is a functional option for configuring a Batcher. +type Option func(b *Batcher) + +// WithStore sets the store to use for storing stats. +func WithStore(store database.Store) Option { + return func(b *Batcher) { + b.store = store + } +} + +// WithBatchSize sets the number of stats to store in a batch. +func WithBatchSize(size int) Option { + return func(b *Batcher) { + b.batchSize = size + } +} + +// WithInterval sets the interval for flushes. +func WithInterval(d time.Duration) Option { + return func(b *Batcher) { + b.interval = d + } +} + +// WithLogger sets the logger to use for logging. +func WithLogger(log slog.Logger) Option { + return func(b *Batcher) { + b.log = log + } +} + +// New creates a new Batcher and starts it. +func New(ctx context.Context, opts ...Option) (*Batcher, func(), error) { + b := &Batcher{} + b.log = slog.Make(sloghuman.Sink(os.Stderr)) + b.flushLever = make(chan struct{}, 1) // Buffered so that it doesn't block. + for _, opt := range opts { + opt(b) + } + + if b.store == nil { + return nil, nil, xerrors.Errorf("no store configured for batcher") + } + + if b.interval == 0 { + b.interval = defaultFlushInterval + } + + if b.batchSize == 0 { + b.batchSize = defaultBufferSize + } + + if b.tickCh == nil { + b.ticker = time.NewTicker(b.interval) + b.tickCh = b.ticker.C + } + + b.initBuf(b.batchSize) + + cancelCtx, cancelFunc := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { + b.run(cancelCtx) + close(done) + }() + + closer := func() { + cancelFunc() + if b.ticker != nil { + b.ticker.Stop() + } + <-done + } + + return b, closer, nil +} + +// Add adds a stat to the batcher for the given workspace and agent. +func (b *Batcher) Add( + now time.Time, + agentID uuid.UUID, + templateID uuid.UUID, + userID uuid.UUID, + workspaceID uuid.UUID, + st agentsdk.Stats, +) error { + b.mu.Lock() + defer b.mu.Unlock() + + now = database.Time(now) + + b.buf.ID = append(b.buf.ID, uuid.New()) + b.buf.CreatedAt = append(b.buf.CreatedAt, now) + b.buf.AgentID = append(b.buf.AgentID, agentID) + b.buf.UserID = append(b.buf.UserID, userID) + b.buf.TemplateID = append(b.buf.TemplateID, templateID) + b.buf.WorkspaceID = append(b.buf.WorkspaceID, workspaceID) + + // Store the connections by proto separately as it's a jsonb field. We marshal on flush. + // b.buf.ConnectionsByProto = append(b.buf.ConnectionsByProto, st.ConnectionsByProto) + b.connectionsByProto = append(b.connectionsByProto, st.ConnectionsByProto) + + b.buf.ConnectionCount = append(b.buf.ConnectionCount, st.ConnectionCount) + b.buf.RxPackets = append(b.buf.RxPackets, st.RxPackets) + b.buf.RxBytes = append(b.buf.RxBytes, st.RxBytes) + b.buf.TxPackets = append(b.buf.TxPackets, st.TxPackets) + b.buf.TxBytes = append(b.buf.TxBytes, st.TxBytes) + b.buf.SessionCountVSCode = append(b.buf.SessionCountVSCode, st.SessionCountVSCode) + b.buf.SessionCountJetBrains = append(b.buf.SessionCountJetBrains, st.SessionCountJetBrains) + b.buf.SessionCountReconnectingPTY = append(b.buf.SessionCountReconnectingPTY, st.SessionCountReconnectingPTY) + b.buf.SessionCountSSH = append(b.buf.SessionCountSSH, st.SessionCountSSH) + b.buf.ConnectionMedianLatencyMS = append(b.buf.ConnectionMedianLatencyMS, st.ConnectionMedianLatencyMS) + + // If the buffer is over 80% full, signal the flusher to flush immediately. + // We want to trigger flushes early to reduce the likelihood of + // accidentally growing the buffer over batchSize. + filled := float64(len(b.buf.ID)) / float64(b.batchSize) + if filled >= 0.8 && !b.flushForced.Load() { + b.flushLever <- struct{}{} + b.flushForced.Store(true) + } + return nil +} + +// Run runs the batcher. +func (b *Batcher) run(ctx context.Context) { + // nolint:gocritic // This is only ever used for one thing - inserting agent stats. + authCtx := dbauthz.AsSystemRestricted(ctx) + for { + select { + case <-b.tickCh: + b.flush(authCtx, false, "scheduled") + case <-b.flushLever: + // If the flush lever is depressed, flush the buffer immediately. + b.flush(authCtx, true, "reaching capacity") + case <-ctx.Done(): + b.log.Debug(ctx, "context done, flushing before exit") + + // We must create a new context here as the parent context is done. + ctxTimeout, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() //nolint:revive // We're returning, defer is fine. + + // nolint:gocritic // This is only ever used for one thing - inserting agent stats. + b.flush(dbauthz.AsSystemRestricted(ctxTimeout), true, "exit") + return + } + } +} + +// flush flushes the batcher's buffer. +func (b *Batcher) flush(ctx context.Context, forced bool, reason string) { + b.mu.Lock() + b.flushForced.Store(true) + start := time.Now() + count := len(b.buf.ID) + defer func() { + b.flushForced.Store(false) + b.mu.Unlock() + if count > 0 { + elapsed := time.Since(start) + b.log.Debug(ctx, "flush complete", + slog.F("count", count), + slog.F("elapsed", elapsed), + slog.F("forced", forced), + slog.F("reason", reason), + ) + } + // Notify that a flush has completed. This only happens in tests. + if b.flushed != nil { + select { + case <-ctx.Done(): + close(b.flushed) + default: + b.flushed <- count + } + } + }() + + if len(b.buf.ID) == 0 { + return + } + + // marshal connections by proto + payload, err := json.Marshal(b.connectionsByProto) + if err != nil { + b.log.Error(ctx, "unable to marshal agent connections by proto, dropping data", slog.Error(err)) + b.buf.ConnectionsByProto = json.RawMessage(`[]`) + } else { + b.buf.ConnectionsByProto = payload + } + + err = b.store.InsertWorkspaceAgentStats(ctx, *b.buf) + elapsed := time.Since(start) + if err != nil { + b.log.Error(ctx, "error inserting workspace agent stats", slog.Error(err), slog.F("elapsed", elapsed)) + return + } + + b.resetBuf() +} + +// initBuf resets the buffer. b MUST be locked. +func (b *Batcher) initBuf(size int) { + b.buf = &database.InsertWorkspaceAgentStatsParams{ + ID: make([]uuid.UUID, 0, b.batchSize), + CreatedAt: make([]time.Time, 0, b.batchSize), + UserID: make([]uuid.UUID, 0, b.batchSize), + WorkspaceID: make([]uuid.UUID, 0, b.batchSize), + TemplateID: make([]uuid.UUID, 0, b.batchSize), + AgentID: make([]uuid.UUID, 0, b.batchSize), + ConnectionsByProto: json.RawMessage("[]"), + ConnectionCount: make([]int64, 0, b.batchSize), + RxPackets: make([]int64, 0, b.batchSize), + RxBytes: make([]int64, 0, b.batchSize), + TxPackets: make([]int64, 0, b.batchSize), + TxBytes: make([]int64, 0, b.batchSize), + SessionCountVSCode: make([]int64, 0, b.batchSize), + SessionCountJetBrains: make([]int64, 0, b.batchSize), + SessionCountReconnectingPTY: make([]int64, 0, b.batchSize), + SessionCountSSH: make([]int64, 0, b.batchSize), + ConnectionMedianLatencyMS: make([]float64, 0, b.batchSize), + } + + b.connectionsByProto = make([]map[string]int64, 0, size) +} + +func (b *Batcher) resetBuf() { + b.buf.ID = b.buf.ID[:0] + b.buf.CreatedAt = b.buf.CreatedAt[:0] + b.buf.UserID = b.buf.UserID[:0] + b.buf.WorkspaceID = b.buf.WorkspaceID[:0] + b.buf.TemplateID = b.buf.TemplateID[:0] + b.buf.AgentID = b.buf.AgentID[:0] + b.buf.ConnectionsByProto = json.RawMessage(`[]`) + b.buf.ConnectionCount = b.buf.ConnectionCount[:0] + b.buf.RxPackets = b.buf.RxPackets[:0] + b.buf.RxBytes = b.buf.RxBytes[:0] + b.buf.TxPackets = b.buf.TxPackets[:0] + b.buf.TxBytes = b.buf.TxBytes[:0] + b.buf.SessionCountVSCode = b.buf.SessionCountVSCode[:0] + b.buf.SessionCountJetBrains = b.buf.SessionCountJetBrains[:0] + b.buf.SessionCountReconnectingPTY = b.buf.SessionCountReconnectingPTY[:0] + b.buf.SessionCountSSH = b.buf.SessionCountSSH[:0] + b.buf.ConnectionMedianLatencyMS = b.buf.ConnectionMedianLatencyMS[:0] + b.connectionsByProto = b.connectionsByProto[:0] +} diff --git a/coderd/batchstats/batcher_internal_test.go b/coderd/batchstats/batcher_internal_test.go new file mode 100644 index 0000000000000..8c1c367f7db5b --- /dev/null +++ b/coderd/batchstats/batcher_internal_test.go @@ -0,0 +1,226 @@ +package batchstats + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/cryptorand" +) + +func TestBatchStats(t *testing.T) { + t.Parallel() + + // Given: a fresh batcher with no data + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + store, _ := dbtestutil.NewDB(t) + + // Set up some test dependencies. + deps1 := setupDeps(t, store) + deps2 := setupDeps(t, store) + tick := make(chan time.Time) + flushed := make(chan int, 1) + + b, closer, err := New(ctx, + WithStore(store), + WithLogger(log), + func(b *Batcher) { + b.tickCh = tick + b.flushed = flushed + }, + ) + require.NoError(t, err) + t.Cleanup(closer) + + // Given: no data points are added for workspace + // When: it becomes time to report stats + t1 := database.Now() + // Signal a tick and wait for a flush to complete. + tick <- t1 + f := <-flushed + require.Equal(t, 0, f, "expected no data to be flushed") + t.Logf("flush 1 completed") + + // Then: it should report no stats. + stats, err := store.GetWorkspaceAgentStats(ctx, t1) + require.NoError(t, err, "should not error getting stats") + require.Empty(t, stats, "should have no stats for workspace") + + // Given: a single data point is added for workspace + t2 := t1.Add(time.Second) + t.Logf("inserting 1 stat") + require.NoError(t, b.Add(t2.Add(time.Millisecond), deps1.Agent.ID, deps1.User.ID, deps1.Template.ID, deps1.Workspace.ID, randAgentSDKStats(t))) + + // When: it becomes time to report stats + // Signal a tick and wait for a flush to complete. + tick <- t2 + f = <-flushed // Wait for a flush to complete. + require.Equal(t, 1, f, "expected one stat to be flushed") + t.Logf("flush 2 completed") + + // Then: it should report a single stat. + stats, err = store.GetWorkspaceAgentStats(ctx, t2) + require.NoError(t, err, "should not error getting stats") + require.Len(t, stats, 1, "should have stats for workspace") + + // Given: a lot of data points are added for both workspaces + // (equal to batch size) + t3 := t2.Add(time.Second) + done := make(chan struct{}) + + go func() { + defer close(done) + t.Logf("inserting %d stats", defaultBufferSize) + for i := 0; i < defaultBufferSize; i++ { + if i%2 == 0 { + require.NoError(t, b.Add(t3.Add(time.Millisecond), deps1.Agent.ID, deps1.User.ID, deps1.Template.ID, deps1.Workspace.ID, randAgentSDKStats(t))) + } else { + require.NoError(t, b.Add(t3.Add(time.Millisecond), deps2.Agent.ID, deps2.User.ID, deps2.Template.ID, deps2.Workspace.ID, randAgentSDKStats(t))) + } + } + }() + + // When: the buffer comes close to capacity + // Then: The buffer will force-flush once. + f = <-flushed + t.Logf("flush 3 completed") + require.Greater(t, f, 819, "expected at least 819 stats to be flushed (>=80% of buffer)") + // And we should finish inserting the stats + <-done + + stats, err = store.GetWorkspaceAgentStats(ctx, t3) + require.NoError(t, err, "should not error getting stats") + require.Len(t, stats, 2, "should have stats for both workspaces") + + // Ensures that a subsequent flush pushes all the remaining data + t4 := t3.Add(time.Second) + tick <- t4 + f2 := <-flushed + t.Logf("flush 4 completed") + expectedCount := defaultBufferSize - f + require.Equal(t, expectedCount, f2, "did not flush expected remaining rows") + + // Ensure that a subsequent flush does not push stale data. + t5 := t4.Add(time.Second) + tick <- t5 + f = <-flushed + require.Zero(t, f, "expected zero stats to have been flushed") + t.Logf("flush 5 completed") + + stats, err = store.GetWorkspaceAgentStats(ctx, t5) + require.NoError(t, err, "should not error getting stats") + require.Len(t, stats, 0, "should have no stats for workspace") + + // Ensure that buf never grew beyond what we expect + require.Equal(t, defaultBufferSize, cap(b.buf.ID), "buffer grew beyond expected capacity") +} + +// randAgentSDKStats returns a random agentsdk.Stats +func randAgentSDKStats(t *testing.T, opts ...func(*agentsdk.Stats)) agentsdk.Stats { + t.Helper() + s := agentsdk.Stats{ + ConnectionsByProto: map[string]int64{ + "ssh": mustRandInt64n(t, 9) + 1, + "vscode": mustRandInt64n(t, 9) + 1, + "jetbrains": mustRandInt64n(t, 9) + 1, + "reconnecting_pty": mustRandInt64n(t, 9) + 1, + }, + ConnectionCount: mustRandInt64n(t, 99) + 1, + ConnectionMedianLatencyMS: float64(mustRandInt64n(t, 99) + 1), + RxPackets: mustRandInt64n(t, 99) + 1, + RxBytes: mustRandInt64n(t, 99) + 1, + TxPackets: mustRandInt64n(t, 99) + 1, + TxBytes: mustRandInt64n(t, 99) + 1, + SessionCountVSCode: mustRandInt64n(t, 9) + 1, + SessionCountJetBrains: mustRandInt64n(t, 9) + 1, + SessionCountReconnectingPTY: mustRandInt64n(t, 9) + 1, + SessionCountSSH: mustRandInt64n(t, 9) + 1, + Metrics: []agentsdk.AgentMetric{}, + } + for _, opt := range opts { + opt(&s) + } + return s +} + +// deps is a set of test dependencies. +type deps struct { + Agent database.WorkspaceAgent + Template database.Template + User database.User + Workspace database.Workspace +} + +// setupDeps sets up a set of test dependencies. +// It creates an organization, user, template, workspace, and agent +// along with all the other miscellaneous plumbing required to link +// them together. +func setupDeps(t *testing.T, store database.Store) deps { + t.Helper() + + org := dbgen.Organization(t, store, database.Organization{}) + user := dbgen.User(t, store, database.User{}) + _, err := store.InsertOrganizationMember(context.Background(), database.InsertOrganizationMemberParams{ + OrganizationID: org.ID, + UserID: user.ID, + Roles: []string{rbac.RoleOrgMember(org.ID)}, + }) + require.NoError(t, err) + tv := dbgen.TemplateVersion(t, store, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + tpl := dbgen.Template(t, store, database.Template{ + CreatedBy: user.ID, + OrganizationID: org.ID, + ActiveVersionID: tv.ID, + }) + ws := dbgen.Workspace(t, store, database.Workspace{ + TemplateID: tpl.ID, + OwnerID: user.ID, + OrganizationID: org.ID, + LastUsedAt: time.Now().Add(-time.Hour), + }) + pj := dbgen.ProvisionerJob(t, store, database.ProvisionerJob{ + InitiatorID: user.ID, + OrganizationID: org.ID, + }) + _ = dbgen.WorkspaceBuild(t, store, database.WorkspaceBuild{ + TemplateVersionID: tv.ID, + WorkspaceID: ws.ID, + JobID: pj.ID, + }) + res := dbgen.WorkspaceResource(t, store, database.WorkspaceResource{ + Transition: database.WorkspaceTransitionStart, + JobID: pj.ID, + }) + agt := dbgen.WorkspaceAgent(t, store, database.WorkspaceAgent{ + ResourceID: res.ID, + }) + return deps{ + Agent: agt, + Template: tpl, + User: user, + Workspace: ws, + } +} + +// mustRandInt64n returns a random int64 in the range [0, n). +func mustRandInt64n(t *testing.T, n int64) int64 { + t.Helper() + i, err := cryptorand.Intn(int(n)) + require.NoError(t, err) + return int64(i) +} diff --git a/coderd/client_test.go b/coderd/client_test.go index 0ba31c8d32014..79002e767fb5d 100644 --- a/coderd/client_test.go +++ b/coderd/client_test.go @@ -6,8 +6,8 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) // Issue: https://github.com/coder/coder/issues/5249 diff --git a/coderd/coderd.go b/coderd/coderd.go index d7b80ff273097..0338a020eae36 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -37,36 +37,37 @@ import ( "tailscale.com/util/singleflight" // Used for swagger docs. - _ "github.com/coder/coder/coderd/apidoc" + _ "github.com/coder/coder/v2/coderd/apidoc" "cdr.dev/slog" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/awsidentity" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/database/pubsub" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/gitsshkey" - "github.com/coder/coder/coderd/healthcheck" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/metricscache" - "github.com/coder/coder/coderd/provisionerdserver" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/coderd/updatecheck" - "github.com/coder/coder/coderd/util/slice" - "github.com/coder/coder/coderd/workspaceapps" - "github.com/coder/coder/coderd/wsconncache" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/provisionerd/proto" - "github.com/coder/coder/provisionersdk" - "github.com/coder/coder/site" - "github.com/coder/coder/tailnet" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/awsidentity" + "github.com/coder/coder/v2/coderd/batchstats" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/coderd/gitsshkey" + "github.com/coder/coder/v2/coderd/healthcheck" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/metricscache" + "github.com/coder/coder/v2/coderd/provisionerdserver" + "github.com/coder/coder/v2/coderd/rbac" + "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/updatecheck" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/coderd/workspaceapps" + "github.com/coder/coder/v2/coderd/wsconncache" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/provisionerd/proto" + "github.com/coder/coder/v2/provisionersdk" + "github.com/coder/coder/v2/site" + "github.com/coder/coder/v2/tailnet" ) // We must only ever instantiate one httpSwagger.Handler because of a data race @@ -126,8 +127,8 @@ type Options struct { BaseDERPMap *tailcfg.DERPMap DERPMapUpdateFrequency time.Duration SwaggerEndpoint bool - SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error - SetUserSiteRoles func(ctx context.Context, tx database.Store, userID uuid.UUID, roles []string) error + SetUserGroups func(ctx context.Context, logger slog.Logger, tx database.Store, userID uuid.UUID, groupNames []string, createMissingGroups bool) error + SetUserSiteRoles func(ctx context.Context, logger slog.Logger, tx database.Store, userID uuid.UUID, roles []string) error TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] // AppSecurityKey is the crypto key used to sign and encrypt tokens related to @@ -160,6 +161,9 @@ type Options struct { HTTPClient *http.Client UpdateAgentMetrics func(ctx context.Context, username, workspaceName, agentName string, metrics []agentsdk.AgentMetric) + StatsBatcher *batchstats.Batcher + + WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions } // @title Coder API @@ -258,16 +262,16 @@ func New(options *Options) *API { options.TracerProvider = trace.NewNoopTracerProvider() } if options.SetUserGroups == nil { - options.SetUserGroups = func(ctx context.Context, _ database.Store, userID uuid.UUID, groups []string) error { - options.Logger.Warn(ctx, "attempted to assign OIDC groups without enterprise license", - slog.F("user_id", userID), slog.F("groups", groups), + options.SetUserGroups = func(ctx context.Context, logger slog.Logger, _ database.Store, userID uuid.UUID, groups []string, createMissingGroups bool) error { + logger.Warn(ctx, "attempted to assign OIDC groups without enterprise license", + slog.F("user_id", userID), slog.F("groups", groups), slog.F("create_missing_groups", createMissingGroups), ) return nil } } if options.SetUserSiteRoles == nil { - options.SetUserSiteRoles = func(ctx context.Context, _ database.Store, userID uuid.UUID, roles []string) error { - options.Logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise license", + options.SetUserSiteRoles = func(ctx context.Context, logger slog.Logger, _ database.Store, userID uuid.UUID, roles []string) error { + logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise license", slog.F("user_id", userID), slog.F("roles", roles), ) return nil @@ -288,6 +292,10 @@ func New(options *Options) *API { options.UserQuietHoursScheduleStore.Store(&v) } + if options.StatsBatcher == nil { + panic("developer error: options.StatsBatcher is nil") + } + siteCacheDir := options.CacheDir if siteCacheDir != "" { siteCacheDir = filepath.Join(siteCacheDir, "site") @@ -394,11 +402,13 @@ func New(options *Options) *API { api.agentProvider, err = NewServerTailnet(api.ctx, options.Logger, options.DERPServer, - options.BaseDERPMap, + api.DERPMap, + options.DeploymentValues.DERP.Config.ForceWebSockets.Value(), func(context.Context) (tailnet.MultiAgentConn, error) { return (*api.TailnetCoordinator.Load()).ServeMultiAgent(uuid.New()), nil }, wsconncache.New(api._dialWorkspaceAgentTailnet, 0), + api.TracerProvider, ) if err != nil { panic("failed to setup server tailnet: " + err.Error()) @@ -409,8 +419,17 @@ func New(options *Options) *API { } } + workspaceAppsLogger := options.Logger.Named("workspaceapps") + if options.WorkspaceAppsStatsCollectorOptions.Logger == nil { + named := workspaceAppsLogger.Named("stats_collector") + options.WorkspaceAppsStatsCollectorOptions.Logger = &named + } + if options.WorkspaceAppsStatsCollectorOptions.Reporter == nil { + options.WorkspaceAppsStatsCollectorOptions.Reporter = workspaceapps.NewStatsDBReporter(options.Database, workspaceapps.DefaultStatsDBReporterBatchSize) + } + api.workspaceAppServer = &workspaceapps.Server{ - Logger: options.Logger.Named("workspaceapps"), + Logger: workspaceAppsLogger, DashboardURL: api.AccessURL, AccessURL: api.AccessURL, @@ -421,6 +440,7 @@ func New(options *Options) *API { SignedTokenProvider: api.WorkspaceAppsProvider, AgentProvider: api.agentProvider, AppSecurityKey: options.AppSecurityKey, + StatsCollector: workspaceapps.NewStatsCollector(options.WorkspaceAppsStatsCollectorOptions), DisablePathApps: options.DeploymentValues.DisablePathApps.Value(), SecureAuthCookie: options.DeploymentValues.SecureAuthCookie.Value(), @@ -462,6 +482,8 @@ func New(options *Options) *API { cors := httpmw.Cors(options.DeploymentValues.Dangerous.AllowAllCors.Value()) prometheusMW := httpmw.Prometheus(options.PrometheusRegistry) + api.statsBatcher = options.StatsBatcher + r.Use( httpmw.Recover(api.Logger), tracing.StatusWriterMiddleware, @@ -683,7 +705,6 @@ func New(options *Options) *API { r.Route("/github", func(r chi.Router) { r.Use( httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, nil), - apiKeyMiddlewareOptional, ) r.Get("/callback", api.userOAuth2Github) }) @@ -691,7 +712,6 @@ func New(options *Options) *API { r.Route("/oidc/callback", func(r chi.Router) { r.Use( httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient, oidcAuthURLParams), - apiKeyMiddlewareOptional, ) r.Get("/", api.userOIDC) }) @@ -833,7 +853,7 @@ func New(options *Options) *API { }) r.Get("/watch", api.watchWorkspace) r.Put("/extend", api.putExtendWorkspace) - r.Put("/lock", api.putWorkspaceLock) + r.Put("/dormant", api.putWorkspaceDormant) }) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { @@ -994,6 +1014,8 @@ type API struct { healthCheckGroup *singleflight.Group[string, *healthcheck.Report] healthCheckCache atomic.Pointer[healthcheck.Report] + + statsBatcher *batchstats.Batcher } // Close waits for all WebSocket connections to drain before returning. @@ -1009,6 +1031,7 @@ func (api *API) Close() error { if api.updateChecker != nil { api.updateChecker.Close() } + _ = api.workspaceAppServer.Close() coordinator := api.TailnetCoordinator.Load() if coordinator != nil { _ = (*coordinator).Close() diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index 0cd69915d13dc..6edf4657cc903 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -2,13 +2,18 @@ package coderd_test import ( "context" + "flag" "io" "net/http" "net/netip" "strconv" + "strings" "sync" + "sync/atomic" "testing" + "github.com/davecgh/go-spew/spew" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" @@ -17,12 +22,20 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/tailnet" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd" + "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/tailnet" + "github.com/coder/coder/v2/testutil" ) +// updateGoldenFiles is a flag that can be set to update golden files. +var updateGoldenFiles = flag.Bool("update", false, "Update golden files") + func TestMain(m *testing.M) { goleak.VerifyTestMain(m) } @@ -115,6 +128,91 @@ func TestDERP(t *testing.T) { w2.Close() } +func TestDERPForceWebSockets(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.DERP.Config.ForceWebSockets = true + dv.DERP.Config.BlockDirect = true // to ensure the test always uses DERP + + // Manually create a server so we can influence the HTTP handler. + options := &coderdtest.Options{ + DeploymentValues: dv, + } + setHandler, cancelFunc, serverURL, newOptions := coderdtest.NewOptions(t, options) + coderAPI := coderd.New(newOptions) + t.Cleanup(func() { + cancelFunc() + _ = coderAPI.Close() + }) + + // Set the HTTP handler to a custom one that ensures all /derp calls are + // WebSockets and not `Upgrade: derp`. + var upgradeCount int64 + setHandler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/derp") { + up := r.Header.Get("Upgrade") + if up != "" && up != "websocket" { + t.Errorf("expected Upgrade: websocket, got %q", up) + } else { + atomic.AddInt64(&upgradeCount, 1) + } + } + + coderAPI.RootHandler.ServeHTTP(rw, r) + })) + + // Start a provisioner daemon. + provisionerCloser := coderdtest.NewProvisionerDaemon(t, coderAPI) + t.Cleanup(func() { + _ = provisionerCloser.Close() + }) + + client := codersdk.New(serverURL) + t.Cleanup(func() { + client.HTTPClient.CloseIdleConnections() + }) + user := coderdtest.CreateFirstUser(t, client) + + gen, err := client.WorkspaceAgentConnectionInfoGeneric(context.Background()) + require.NoError(t, err) + t.Log(spew.Sdump(gen)) + + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + 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) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + agentCloser := agent.New(agent.Options{ + Client: agentClient, + Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), + }) + defer func() { + _ = agentCloser.Close() + }() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) + require.NoError(t, err) + defer func() { + _ = conn.Close() + }() + conn.AwaitReachable(ctx) + + require.GreaterOrEqual(t, atomic.LoadInt64(&upgradeCount), int64(1), "expected at least one /derp call") +} + func TestDERPLatencyCheck(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 889e8f1ee485a..ce9faf1ace16f 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -15,12 +15,12 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/coderd" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/rbac/regosql" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/regosql" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" ) // RBACAsserter is a helper for asserting that the correct RBAC checks are diff --git a/coderd/coderdtest/authorize_test.go b/coderd/coderdtest/authorize_test.go index 67bcf482def75..13a04200a9d2f 100644 --- a/coderd/coderdtest/authorize_test.go +++ b/coderd/coderdtest/authorize_test.go @@ -7,8 +7,8 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" ) func TestAuthzRecorder(t *testing.T) { diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 4d4fbb8c5e78e..5ef17af359ca7 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -31,16 +31,13 @@ import ( "time" "cloud.google.com/go/compute/metadata" - "github.com/coreos/go-oidc/v3/oidc" "github.com/fullsailor/pkcs7" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" "github.com/moby/moby/pkg/namesgenerator" "github.com/prometheus/client_golang/prometheus" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/oauth2" "golang.org/x/xerrors" "google.golang.org/api/idtoken" "google.golang.org/api/option" @@ -51,37 +48,39 @@ import ( "tailscale.com/types/nettype" "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/autobuild" - "github.com/coder/coder/coderd/awsidentity" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/database/dbtestutil" - "github.com/coder/coder/coderd/database/pubsub" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/gitsshkey" - "github.com/coder/coder/coderd/healthcheck" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/coderd/unhanger" - "github.com/coder/coder/coderd/updatecheck" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/coderd/workspaceapps" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/cryptorand" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionerd" - provisionerdproto "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/testutil" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/autobuild" + "github.com/coder/coder/v2/coderd/awsidentity" + "github.com/coder/coder/v2/coderd/batchstats" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/coderd/gitsshkey" + "github.com/coder/coder/v2/coderd/healthcheck" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/coderd/unhanger" + "github.com/coder/coder/v2/coderd/updatecheck" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/workspaceapps" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionerd" + provisionerdproto "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/coder/v2/testutil" ) // AppSecurityKey is a 96-byte key used to sign JWTs and encrypt JWEs for @@ -140,7 +139,10 @@ type Options struct { SwaggerEndpoint bool // Logger should only be overridden if you expect errors // as part of your test. - Logger *slog.Logger + Logger *slog.Logger + StatsBatcher *batchstats.Batcher + + WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions } // New constructs a codersdk client connected to an in-memory API instance. @@ -241,6 +243,18 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can if options.FilesRateLimit == 0 { options.FilesRateLimit = -1 } + if options.StatsBatcher == nil { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + batcher, closeBatcher, err := batchstats.New(ctx, + batchstats.WithStore(options.Database), + // Avoid cluttering up test output. + batchstats.WithLogger(slog.Make(sloghuman.Sink(io.Discard))), + ) + require.NoError(t, err, "create stats batcher") + options.StatsBatcher = batcher + t.Cleanup(closeBatcher) + } var templateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore] if options.TemplateScheduleStore == nil { @@ -309,13 +323,13 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can stunAddresses []string dvStunAddresses = options.DeploymentValues.DERP.Server.STUNAddresses.Value() ) - if len(dvStunAddresses) == 0 || (len(dvStunAddresses) == 1 && dvStunAddresses[0] == "stun.l.google.com:19302") { + if len(dvStunAddresses) == 0 || dvStunAddresses[0] == "stun.l.google.com:19302" { stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{}) stunAddr.IP = net.ParseIP("127.0.0.1") t.Cleanup(stunCleanup) stunAddresses = []string{stunAddr.String()} options.DeploymentValues.DERP.Server.STUNAddresses = stunAddresses - } else if dvStunAddresses[0] != "disable" { + } else if dvStunAddresses[0] != tailnet.DisableSTUN { stunAddresses = options.DeploymentValues.DERP.Server.STUNAddresses.Value() } @@ -379,36 +393,38 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can Pubsub: options.Pubsub, GitAuthConfigs: options.GitAuthConfigs, - Auditor: options.Auditor, - AWSCertificates: options.AWSCertificates, - AzureCertificates: options.AzureCertificates, - GithubOAuth2Config: options.GithubOAuth2Config, - RealIPConfig: options.RealIPConfig, - OIDCConfig: options.OIDCConfig, - GoogleTokenValidator: options.GoogleTokenValidator, - SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, - DERPServer: derpServer, - APIRateLimit: options.APIRateLimit, - LoginRateLimit: options.LoginRateLimit, - FilesRateLimit: options.FilesRateLimit, - Authorizer: options.Authorizer, - Telemetry: telemetry.NewNoop(), - TemplateScheduleStore: &templateScheduleStore, - TLSCertificates: options.TLSCertificates, - TrialGenerator: options.TrialGenerator, - TailnetCoordinator: options.Coordinator, - BaseDERPMap: derpMap, - DERPMapUpdateFrequency: 150 * time.Millisecond, - MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval, - AgentStatsRefreshInterval: options.AgentStatsRefreshInterval, - DeploymentValues: options.DeploymentValues, - UpdateCheckOptions: options.UpdateCheckOptions, - SwaggerEndpoint: options.SwaggerEndpoint, - AppSecurityKey: AppSecurityKey, - SSHConfig: options.ConfigSSH, - HealthcheckFunc: options.HealthcheckFunc, - HealthcheckTimeout: options.HealthcheckTimeout, - HealthcheckRefresh: options.HealthcheckRefresh, + Auditor: options.Auditor, + AWSCertificates: options.AWSCertificates, + AzureCertificates: options.AzureCertificates, + GithubOAuth2Config: options.GithubOAuth2Config, + RealIPConfig: options.RealIPConfig, + OIDCConfig: options.OIDCConfig, + GoogleTokenValidator: options.GoogleTokenValidator, + SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, + DERPServer: derpServer, + APIRateLimit: options.APIRateLimit, + LoginRateLimit: options.LoginRateLimit, + FilesRateLimit: options.FilesRateLimit, + Authorizer: options.Authorizer, + Telemetry: telemetry.NewNoop(), + TemplateScheduleStore: &templateScheduleStore, + TLSCertificates: options.TLSCertificates, + TrialGenerator: options.TrialGenerator, + TailnetCoordinator: options.Coordinator, + BaseDERPMap: derpMap, + DERPMapUpdateFrequency: 150 * time.Millisecond, + MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval, + AgentStatsRefreshInterval: options.AgentStatsRefreshInterval, + DeploymentValues: options.DeploymentValues, + UpdateCheckOptions: options.UpdateCheckOptions, + SwaggerEndpoint: options.SwaggerEndpoint, + AppSecurityKey: AppSecurityKey, + SSHConfig: options.ConfigSSH, + HealthcheckFunc: options.HealthcheckFunc, + HealthcheckTimeout: options.HealthcheckTimeout, + HealthcheckRefresh: options.HealthcheckRefresh, + StatsBatcher: options.StatsBatcher, + WorkspaceAppsStatsCollectorOptions: options.WorkspaceAppsStatsCollectorOptions, } } @@ -450,10 +466,13 @@ func NewProvisionerDaemon(t testing.TB, coderAPI *coderd.API) io.Closer { _ = echoServer.Close() cancelFunc() }) - fs := afero.NewMemMapFs() + // seems t.TempDir() is not safe to call from a different goroutine + workDir := t.TempDir() go func() { - err := echo.Serve(ctx, fs, &provisionersdk.ServeOptions{ - Listener: echoServer, + err := echo.Serve(ctx, &provisionersdk.ServeOptions{ + Listener: echoServer, + WorkDirectory: workDir, + Logger: coderAPI.Logger.Named("echo").Leveled(slog.LevelDebug), }) assert.NoError(t, err) }() @@ -461,7 +480,6 @@ func NewProvisionerDaemon(t testing.TB, coderAPI *coderd.API) io.Closer { closer := provisionerd.New(func(ctx context.Context) (provisionerdproto.DRPCProvisionerDaemonClient, error) { return coderAPI.CreateInMemoryProvisionerDaemon(ctx, 0) }, &provisionerd.Options{ - Filesystem: fs, Logger: coderAPI.Logger.Named("provisionerd").Leveled(slog.LevelDebug), JobPollInterval: 50 * time.Millisecond, UpdateInterval: 250 * time.Millisecond, @@ -469,7 +487,6 @@ func NewProvisionerDaemon(t testing.TB, coderAPI *coderd.API) io.Closer { Provisioners: provisionerd.Provisioners{ string(database.ProvisionerTypeEcho): sdkproto.NewDRPCProvisionerClient(echoClient), }, - WorkDirectory: t.TempDir(), }) t.Cleanup(func() { _ = closer.Close() @@ -487,19 +504,22 @@ func NewExternalProvisionerDaemon(t *testing.T, client *codersdk.Client, org uui cancelFunc() <-serveDone }) - fs := afero.NewMemMapFs() go func() { defer close(serveDone) - err := echo.Serve(ctx, fs, &provisionersdk.ServeOptions{ - Listener: echoServer, + err := echo.Serve(ctx, &provisionersdk.ServeOptions{ + Listener: echoServer, + WorkDirectory: t.TempDir(), }) assert.NoError(t, err) }() closer := provisionerd.New(func(ctx context.Context) (provisionerdproto.DRPCProvisionerDaemonClient, error) { - return client.ServeProvisionerDaemon(ctx, org, []codersdk.ProvisionerType{codersdk.ProvisionerTypeEcho}, tags) + return client.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: org, + Provisioners: []codersdk.ProvisionerType{codersdk.ProvisionerTypeEcho}, + Tags: tags, + }) }, &provisionerd.Options{ - Filesystem: fs, Logger: slogtest.Make(t, nil).Named("provisionerd").Leveled(slog.LevelDebug), JobPollInterval: 50 * time.Millisecond, UpdateInterval: 250 * time.Millisecond, @@ -507,7 +527,6 @@ func NewExternalProvisionerDaemon(t *testing.T, client *codersdk.Client, org uui Provisioners: provisionerd.Provisioners{ string(database.ProvisionerTypeEcho): sdkproto.NewDRPCProvisionerClient(echoClient), }, - WorkDirectory: t.TempDir(), }) t.Cleanup(func() { _ = closer.Close() @@ -568,14 +587,7 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI require.NoError(t, err) var sessionToken string - if !req.DisableLogin { - login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ - Email: req.Email, - Password: req.Password, - }) - require.NoError(t, err) - sessionToken = login.SessionToken - } else { + if req.DisableLogin || req.UserLoginType == codersdk.LoginTypeNone { // Cannot log in with a disabled login user. So make it an api key from // the client making this user. token, err := client.CreateToken(context.Background(), user.ID.String(), codersdk.CreateTokenRequest{ @@ -585,6 +597,13 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI }) require.NoError(t, err) sessionToken = token.Key + } else { + login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ + Email: req.Email, + Password: req.Password, + }) + require.NoError(t, err) + sessionToken = login.SessionToken } if user.Status == codersdk.UserStatusDormant { @@ -999,105 +1018,6 @@ func NewAWSInstanceIdentity(t *testing.T, instanceID string) (awsidentity.Certif } } -type OIDCConfig struct { - key *rsa.PrivateKey - issuer string -} - -func NewOIDCConfig(t *testing.T, issuer string) *OIDCConfig { - t.Helper() - - block, _ := pem.Decode([]byte(testRSAPrivateKey)) - pkey, err := x509.ParsePKCS1PrivateKey(block.Bytes) - require.NoError(t, err) - - if issuer == "" { - issuer = "https://coder.com" - } - - return &OIDCConfig{ - key: pkey, - issuer: issuer, - } -} - -func (*OIDCConfig) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string { - return "/?state=" + url.QueryEscape(state) -} - -func (*OIDCConfig) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { - return nil -} - -func (*OIDCConfig) Exchange(_ context.Context, code string, _ ...oauth2.AuthCodeOption) (*oauth2.Token, error) { - token, err := base64.StdEncoding.DecodeString(code) - if err != nil { - return nil, xerrors.Errorf("decode code: %w", err) - } - return (&oauth2.Token{ - AccessToken: "token", - }).WithExtra(map[string]interface{}{ - "id_token": string(token), - }), nil -} - -func (o *OIDCConfig) EncodeClaims(t *testing.T, claims jwt.MapClaims) string { - t.Helper() - - if _, ok := claims["exp"]; !ok { - claims["exp"] = time.Now().Add(time.Hour).UnixMilli() - } - - if _, ok := claims["iss"]; !ok { - claims["iss"] = o.issuer - } - - if _, ok := claims["sub"]; !ok { - claims["sub"] = "testme" - } - - signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(o.key) - require.NoError(t, err) - - return base64.StdEncoding.EncodeToString([]byte(signed)) -} - -func (o *OIDCConfig) OIDCConfig(t *testing.T, userInfoClaims jwt.MapClaims, opts ...func(cfg *coderd.OIDCConfig)) *coderd.OIDCConfig { - // By default, the provider can be empty. - // This means it won't support any endpoints! - provider := &oidc.Provider{} - if userInfoClaims != nil { - resp, err := json.Marshal(userInfoClaims) - require.NoError(t, err) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(resp) - })) - t.Cleanup(srv.Close) - cfg := &oidc.ProviderConfig{ - UserInfoURL: srv.URL, - } - provider = cfg.NewProvider(context.Background()) - } - cfg := &coderd.OIDCConfig{ - OAuth2Config: o, - Verifier: oidc.NewVerifier(o.issuer, &oidc.StaticKeySet{ - PublicKeys: []crypto.PublicKey{o.key.Public()}, - }, &oidc.Config{ - SkipClientIDCheck: true, - }), - Provider: provider, - UsernameField: "preferred_username", - EmailField: "email", - AuthURLParams: map[string]string{"access_type": "offline"}, - GroupField: "groups", - } - for _, opt := range opts { - opt(cfg) - } - return cfg -} - // NewAzureInstanceIdentity returns a metadata client and ID token validator for faking // instance authentication for Azure. func NewAzureInstanceIdentity(t *testing.T, instanceID string) (x509.VerifyOptions, *http.Client) { @@ -1186,22 +1106,6 @@ func SDKError(t *testing.T, err error) *codersdk.Error { return cerr } -const testRSAPrivateKey = `-----BEGIN RSA PRIVATE KEY----- -MIICXQIBAAKBgQDLets8+7M+iAQAqN/5BVyCIjhTQ4cmXulL+gm3v0oGMWzLupUS -v8KPA+Tp7dgC/DZPfMLaNH1obBBhJ9DhS6RdS3AS3kzeFrdu8zFHLWF53DUBhS92 -5dCAEuJpDnNizdEhxTfoHrhuCmz8l2nt1pe5eUK2XWgd08Uc93h5ij098wIDAQAB -AoGAHLaZeWGLSaen6O/rqxg2laZ+jEFbMO7zvOTruiIkL/uJfrY1kw+8RLIn+1q0 -wLcWcuEIHgKKL9IP/aXAtAoYh1FBvRPLkovF1NZB0Je/+CSGka6wvc3TGdvppZJe -rKNcUvuOYLxkmLy4g9zuY5qrxFyhtIn2qZzXEtLaVOHzPQECQQDvN0mSajpU7dTB -w4jwx7IRXGSSx65c+AsHSc1Rj++9qtPC6WsFgAfFN2CEmqhMbEUVGPv/aPjdyWk9 -pyLE9xR/AkEA2cGwyIunijE5v2rlZAD7C4vRgdcMyCf3uuPcgzFtsR6ZhyQSgLZ8 -YRPuvwm4cdPJMmO3YwBfxT6XGuSc2k8MjQJBAI0+b8prvpV2+DCQa8L/pjxp+VhR -Xrq2GozrHrgR7NRokTB88hwFRJFF6U9iogy9wOx8HA7qxEbwLZuhm/4AhbECQC2a -d8h4Ht09E+f3nhTEc87mODkl7WJZpHL6V2sORfeq/eIkds+H6CJ4hy5w/bSw8tjf -sz9Di8sGIaUbLZI2rd0CQQCzlVwEtRtoNCyMJTTrkgUuNufLP19RZ5FpyXxBO5/u -QastnN77KfUwdj3SJt44U/uh1jAIv4oSLBr8HYUkbnI8 ------END RSA PRIVATE KEY-----` - func DeploymentValues(t testing.TB) *codersdk.DeploymentValues { var cfg codersdk.DeploymentValues opts := cfg.Options() diff --git a/coderd/coderdtest/coderdtest_test.go b/coderd/coderdtest/coderdtest_test.go index c0187fc94d661..780b58a569478 100644 --- a/coderd/coderdtest/coderdtest_test.go +++ b/coderd/coderdtest/coderdtest_test.go @@ -5,7 +5,7 @@ import ( "go.uber.org/goleak" - "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest" ) func TestMain(m *testing.M) { diff --git a/coderd/coderdtest/oidctest/helper.go b/coderd/coderdtest/oidctest/helper.go new file mode 100644 index 0000000000000..11d9114be2ce8 --- /dev/null +++ b/coderd/coderdtest/oidctest/helper.go @@ -0,0 +1,103 @@ +package oidctest + +import ( + "net/http" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +// LoginHelper helps with logging in a user and refreshing their oauth tokens. +// It is mainly because refreshing oauth tokens is a bit tricky and requires +// some database manipulation. +type LoginHelper struct { + fake *FakeIDP + client *codersdk.Client +} + +func NewLoginHelper(client *codersdk.Client, fake *FakeIDP) *LoginHelper { + if client == nil { + panic("client must not be nil") + } + if fake == nil { + panic("fake must not be nil") + } + return &LoginHelper{ + fake: fake, + client: client, + } +} + +// Login just helps by making an unauthenticated client and logging in with +// the given claims. All Logins should be unauthenticated, so this is a +// convenience method. +func (h *LoginHelper) Login(t *testing.T, idTokenClaims jwt.MapClaims) (*codersdk.Client, *http.Response) { + t.Helper() + unauthenticatedClient := codersdk.New(h.client.URL) + + return h.fake.Login(t, unauthenticatedClient, idTokenClaims) +} + +// ExpireOauthToken expires the oauth token for the given user. +func (*LoginHelper) ExpireOauthToken(t *testing.T, db database.Store, user *codersdk.Client) database.UserLink { + t.Helper() + + //nolint:gocritic // Testing + ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitMedium)) + + id, _, err := httpmw.SplitAPIToken(user.SessionToken()) + require.NoError(t, err) + + // We need to get the OIDC link and update it in the database to force + // it to be expired. + key, err := db.GetAPIKeyByID(ctx, id) + require.NoError(t, err, "get api key") + + link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: key.UserID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err, "get user link") + + // Expire the oauth link for the given user. + updated, err := db.UpdateUserLink(ctx, database.UpdateUserLinkParams{ + OAuthAccessToken: link.OAuthAccessToken, + OAuthRefreshToken: link.OAuthRefreshToken, + OAuthExpiry: time.Now().Add(time.Hour * -1), + UserID: link.UserID, + LoginType: link.LoginType, + }) + require.NoError(t, err, "expire user link") + + return updated +} + +// ForceRefresh forces the client to refresh its oauth token. It does this by +// expiring the oauth token, then doing an authenticated call. This will force +// the API Key middleware to refresh the oauth token. +// +// A unit test assertion makes sure the refresh token is used. +func (h *LoginHelper) ForceRefresh(t *testing.T, db database.Store, user *codersdk.Client, idToken jwt.MapClaims) { + t.Helper() + + link := h.ExpireOauthToken(t, db, user) + // Updates the claims that the IDP will return. By default, it always + // uses the original claims for the original oauth token. + h.fake.UpdateRefreshClaims(link.OAuthRefreshToken, idToken) + + t.Cleanup(func() { + require.True(t, h.fake.RefreshUsed(link.OAuthRefreshToken), "refresh token must be used, but has not. Did you forget to call the returned function from this call?") + }) + + // Do any authenticated call to force the refresh + _, err := user.User(testutil.Context(t, testutil.WaitShort), "me") + require.NoError(t, err, "user must be able to be fetched") +} diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go new file mode 100644 index 0000000000000..3ca8cadbc9ff9 --- /dev/null +++ b/coderd/coderdtest/oidctest/idp.go @@ -0,0 +1,793 @@ +package oidctest + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/coder/coder/v2/coderd/util/syncmap" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-chi/chi/v5" + "github.com/go-jose/go-jose/v3" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/codersdk" +) + +// FakeIDP is a functional OIDC provider. +// It only supports 1 OIDC client. +type FakeIDP struct { + issuer string + key *rsa.PrivateKey + provider providerJSON + handler http.Handler + cfg *oauth2.Config + + // clientID to be used by coderd + clientID string + clientSecret string + logger slog.Logger + + // These maps are used to control the state of the IDP. + // That is the various access tokens, refresh tokens, states, etc. + codeToStateMap *syncmap.Map[string, string] + // Token -> Email + accessTokens *syncmap.Map[string, string] + // Refresh Token -> Email + refreshTokensUsed *syncmap.Map[string, bool] + refreshTokens *syncmap.Map[string, string] + stateToIDTokenClaims *syncmap.Map[string, jwt.MapClaims] + refreshIDTokenClaims *syncmap.Map[string, jwt.MapClaims] + + // hooks + // hookValidRedirectURL can be used to reject a redirect url from the + // IDP -> Application. Almost all IDPs have the concept of + // "Authorized Redirect URLs". This can be used to emulate that. + hookValidRedirectURL func(redirectURL string) error + hookUserInfo func(email string) jwt.MapClaims + fakeCoderd func(req *http.Request) (*http.Response, error) + hookOnRefresh func(email string) error + // Custom authentication for the client. This is useful if you want + // to test something like PKI auth vs a client_secret. + hookAuthenticateClient func(t testing.TB, req *http.Request) (url.Values, error) + serve bool +} + +type FakeIDPOpt func(idp *FakeIDP) + +func WithAuthorizedRedirectURL(hook func(redirectURL string) error) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookValidRedirectURL = hook + } +} + +// WithRefreshHook is called when a refresh token is used. The email is +// the email of the user that is being refreshed assuming the claims are correct. +func WithRefreshHook(hook func(email string) error) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookOnRefresh = hook + } +} + +func WithCustomClientAuth(hook func(t testing.TB, req *http.Request) (url.Values, error)) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookAuthenticateClient = hook + } +} + +// WithLogging is optional, but will log some HTTP calls made to the IDP. +func WithLogging(t testing.TB, options *slogtest.Options) func(*FakeIDP) { + return func(f *FakeIDP) { + f.logger = slogtest.Make(t, options) + } +} + +// WithStaticUserInfo is optional, but will return the same user info for +// every user on the /userinfo endpoint. +func WithStaticUserInfo(info jwt.MapClaims) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookUserInfo = func(_ string) jwt.MapClaims { + return info + } + } +} + +func WithDynamicUserInfo(userInfoFunc func(email string) jwt.MapClaims) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookUserInfo = userInfoFunc + } +} + +// WithServing makes the IDP run an actual http server. +func WithServing() func(*FakeIDP) { + return func(f *FakeIDP) { + f.serve = true + } +} + +func WithIssuer(issuer string) func(*FakeIDP) { + return func(f *FakeIDP) { + f.issuer = issuer + } +} + +const ( + // nolint:gosec // It thinks this is a secret lol + tokenPath = "/oauth2/token" + authorizePath = "/oauth2/authorize" + keysPath = "/oauth2/keys" + userInfoPath = "/oauth2/userinfo" +) + +func NewFakeIDP(t testing.TB, opts ...FakeIDPOpt) *FakeIDP { + t.Helper() + + block, _ := pem.Decode([]byte(testRSAPrivateKey)) + pkey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + require.NoError(t, err) + + idp := &FakeIDP{ + key: pkey, + clientID: uuid.NewString(), + clientSecret: uuid.NewString(), + logger: slog.Make(), + codeToStateMap: syncmap.New[string, string](), + accessTokens: syncmap.New[string, string](), + refreshTokens: syncmap.New[string, string](), + refreshTokensUsed: syncmap.New[string, bool](), + stateToIDTokenClaims: syncmap.New[string, jwt.MapClaims](), + refreshIDTokenClaims: syncmap.New[string, jwt.MapClaims](), + hookOnRefresh: func(_ string) error { return nil }, + hookUserInfo: func(email string) jwt.MapClaims { return jwt.MapClaims{} }, + hookValidRedirectURL: func(redirectURL string) error { return nil }, + } + + for _, opt := range opts { + opt(idp) + } + + if idp.issuer == "" { + idp.issuer = "https://coder.com" + } + + idp.handler = idp.httpHandler(t) + idp.updateIssuerURL(t, idp.issuer) + if idp.serve { + idp.realServer(t) + } + + return idp +} + +func (f *FakeIDP) updateIssuerURL(t testing.TB, issuer string) { + t.Helper() + + u, err := url.Parse(issuer) + require.NoError(t, err, "invalid issuer URL") + + f.issuer = issuer + // providerJSON is the JSON representation of the OpenID Connect provider + // These are all the urls that the IDP will respond to. + f.provider = providerJSON{ + Issuer: issuer, + AuthURL: u.ResolveReference(&url.URL{Path: authorizePath}).String(), + TokenURL: u.ResolveReference(&url.URL{Path: tokenPath}).String(), + JWKSURL: u.ResolveReference(&url.URL{Path: keysPath}).String(), + UserInfoURL: u.ResolveReference(&url.URL{Path: userInfoPath}).String(), + Algorithms: []string{ + "RS256", + }, + } +} + +// realServer turns the FakeIDP into a real http server. +func (f *FakeIDP) realServer(t testing.TB) *httptest.Server { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + srv := httptest.NewUnstartedServer(f.handler) + srv.Config.BaseContext = func(_ net.Listener) context.Context { + return ctx + } + srv.Start() + t.Cleanup(srv.CloseClientConnections) + t.Cleanup(srv.Close) + t.Cleanup(cancel) + + f.updateIssuerURL(t, srv.URL) + return srv +} + +// Login does the full OIDC flow starting at the "LoginButton". +// The client argument is just to get the URL of the Coder instance. +// +// The client passed in is just to get the url of the Coder instance. +// The actual client that is used is 100% unauthenticated and fresh. +func (f *FakeIDP) Login(t testing.TB, client *codersdk.Client, idTokenClaims jwt.MapClaims, opts ...func(r *http.Request)) (*codersdk.Client, *http.Response) { + t.Helper() + + client, resp := f.AttemptLogin(t, client, idTokenClaims, opts...) + require.Equal(t, http.StatusOK, resp.StatusCode, "client failed to login") + return client, resp +} + +func (f *FakeIDP) AttemptLogin(t testing.TB, client *codersdk.Client, idTokenClaims jwt.MapClaims, opts ...func(r *http.Request)) (*codersdk.Client, *http.Response) { + t.Helper() + var err error + + cli := f.HTTPClient(client.HTTPClient) + shallowCpyCli := *cli + + if shallowCpyCli.Jar == nil { + shallowCpyCli.Jar, err = cookiejar.New(nil) + require.NoError(t, err, "failed to create cookie jar") + } + + unauthenticated := codersdk.New(client.URL) + unauthenticated.HTTPClient = &shallowCpyCli + + return f.LoginWithClient(t, unauthenticated, idTokenClaims, opts...) +} + +// LoginWithClient reuses the context of the passed in client. This means the same +// cookies will be used. This should be an unauthenticated client in most cases. +// +// This is a niche case, but it is needed for testing ConvertLoginType. +func (f *FakeIDP) LoginWithClient(t testing.TB, client *codersdk.Client, idTokenClaims jwt.MapClaims, opts ...func(r *http.Request)) (*codersdk.Client, *http.Response) { + t.Helper() + + coderOauthURL, err := client.URL.Parse("/api/v2/users/oidc/callback") + require.NoError(t, err) + f.SetRedirect(t, coderOauthURL.String()) + + cli := f.HTTPClient(client.HTTPClient) + cli.CheckRedirect = func(req *http.Request, via []*http.Request) error { + // Store the idTokenClaims to the specific state request. This ties + // the claims 1:1 with a given authentication flow. + state := req.URL.Query().Get("state") + f.stateToIDTokenClaims.Store(state, idTokenClaims) + return nil + } + + req, err := http.NewRequestWithContext(context.Background(), "GET", coderOauthURL.String(), nil) + require.NoError(t, err) + if cli.Jar == nil { + cli.Jar, err = cookiejar.New(nil) + require.NoError(t, err, "failed to create cookie jar") + } + + for _, opt := range opts { + opt(req) + } + + res, err := cli.Do(req) + require.NoError(t, err) + + // If the coder session token exists, return the new authed client! + var user *codersdk.Client + cookies := cli.Jar.Cookies(client.URL) + for _, cookie := range cookies { + if cookie.Name == codersdk.SessionTokenCookie { + user = codersdk.New(client.URL) + user.SetSessionToken(cookie.Value) + } + } + + t.Cleanup(func() { + if res.Body != nil { + _ = res.Body.Close() + } + }) + + return user, res +} + +// OIDCCallback will emulate the IDP redirecting back to the Coder callback. +// This is helpful if no Coderd exists because the IDP needs to redirect to +// something. +// Essentially this is used to fake the Coderd side of the exchange. +// The flow starts at the user hitting the OIDC login page. +func (f *FakeIDP) OIDCCallback(t testing.TB, state string, idTokenClaims jwt.MapClaims) (*http.Response, error) { + t.Helper() + if f.serve { + panic("cannot use OIDCCallback with WithServing. This is only for the in memory usage") + } + + f.stateToIDTokenClaims.Store(state, idTokenClaims) + + cli := f.HTTPClient(nil) + u := f.cfg.AuthCodeURL(state) + req, err := http.NewRequest("GET", u, nil) + require.NoError(t, err) + + resp, err := cli.Do(req.WithContext(context.Background())) + require.NoError(t, err) + + t.Cleanup(func() { + if resp.Body != nil { + _ = resp.Body.Close() + } + }) + return resp, nil +} + +type providerJSON struct { + Issuer string `json:"issuer"` + AuthURL string `json:"authorization_endpoint"` + TokenURL string `json:"token_endpoint"` + JWKSURL string `json:"jwks_uri"` + UserInfoURL string `json:"userinfo_endpoint"` + Algorithms []string `json:"id_token_signing_alg_values_supported"` +} + +// newCode enforces the code exchanged is actually a valid code +// created by the IDP. +func (f *FakeIDP) newCode(state string) string { + code := uuid.NewString() + f.codeToStateMap.Store(code, state) + return code +} + +// newToken enforces the access token exchanged is actually a valid access token +// created by the IDP. +func (f *FakeIDP) newToken(email string) string { + accessToken := uuid.NewString() + f.accessTokens.Store(accessToken, email) + return accessToken +} + +func (f *FakeIDP) newRefreshTokens(email string) string { + refreshToken := uuid.NewString() + f.refreshTokens.Store(refreshToken, email) + return refreshToken +} + +// authenticateBearerTokenRequest enforces the access token is valid. +func (f *FakeIDP) authenticateBearerTokenRequest(t testing.TB, req *http.Request) (string, error) { + t.Helper() + + auth := req.Header.Get("Authorization") + token := strings.TrimPrefix(auth, "Bearer ") + _, ok := f.accessTokens.Load(token) + if !ok { + return "", xerrors.New("invalid access token") + } + return token, nil +} + +// authenticateOIDCClientRequest enforces the client_id and client_secret are valid. +func (f *FakeIDP) authenticateOIDCClientRequest(t testing.TB, req *http.Request) (url.Values, error) { + t.Helper() + + if f.hookAuthenticateClient != nil { + return f.hookAuthenticateClient(t, req) + } + + data, err := io.ReadAll(req.Body) + if !assert.NoError(t, err, "read token request body") { + return nil, xerrors.Errorf("authenticate request, read body: %w", err) + } + values, err := url.ParseQuery(string(data)) + if !assert.NoError(t, err, "parse token request values") { + return nil, xerrors.New("invalid token request") + } + + if !assert.Equal(t, f.clientID, values.Get("client_id"), "client_id mismatch") { + return nil, xerrors.New("client_id mismatch") + } + + if !assert.Equal(t, f.clientSecret, values.Get("client_secret"), "client_secret mismatch") { + return nil, xerrors.New("client_secret mismatch") + } + + return values, nil +} + +// encodeClaims is a helper func to convert claims to a valid JWT. +func (f *FakeIDP) encodeClaims(t testing.TB, claims jwt.MapClaims) string { + t.Helper() + + if _, ok := claims["exp"]; !ok { + claims["exp"] = time.Now().Add(time.Hour).UnixMilli() + } + + if _, ok := claims["aud"]; !ok { + claims["aud"] = f.clientID + } + + if _, ok := claims["iss"]; !ok { + claims["iss"] = f.issuer + } + + signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(f.key) + require.NoError(t, err) + + return signed +} + +// httpHandler is the IDP http server. +func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { + t.Helper() + + mux := chi.NewMux() + // This endpoint is required to initialize the OIDC provider. + // It is used to get the OIDC configuration. + mux.Get("/.well-known/openid-configuration", func(rw http.ResponseWriter, r *http.Request) { + f.logger.Info(r.Context(), "http OIDC config", slog.F("url", r.URL.String())) + + _ = json.NewEncoder(rw).Encode(f.provider) + }) + + // Authorize is called when the user is redirected to the IDP to login. + // This is the browser hitting the IDP and the user logging into Google or + // w/e and clicking "Allow". They will be redirected back to the redirect + // when this is done. + mux.Handle(authorizePath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + f.logger.Info(r.Context(), "http call authorize", slog.F("url", r.URL.String())) + + clientID := r.URL.Query().Get("client_id") + if !assert.Equal(t, f.clientID, clientID, "unexpected client_id") { + http.Error(rw, "invalid client_id", http.StatusBadRequest) + return + } + + redirectURI := r.URL.Query().Get("redirect_uri") + state := r.URL.Query().Get("state") + + scope := r.URL.Query().Get("scope") + assert.NotEmpty(t, scope, "scope is empty") + + responseType := r.URL.Query().Get("response_type") + switch responseType { + case "code": + case "token": + t.Errorf("response_type %q not supported", responseType) + http.Error(rw, "invalid response_type", http.StatusBadRequest) + return + default: + t.Errorf("unexpected response_type %q", responseType) + http.Error(rw, "invalid response_type", http.StatusBadRequest) + return + } + + err := f.hookValidRedirectURL(redirectURI) + if err != nil { + t.Errorf("not authorized redirect_uri by custom hook %q: %s", redirectURI, err.Error()) + http.Error(rw, fmt.Sprintf("invalid redirect_uri: %s", err.Error()), http.StatusBadRequest) + return + } + + ru, err := url.Parse(redirectURI) + if err != nil { + t.Errorf("invalid redirect_uri %q: %s", redirectURI, err.Error()) + http.Error(rw, fmt.Sprintf("invalid redirect_uri: %s", err.Error()), http.StatusBadRequest) + return + } + + q := ru.Query() + q.Set("state", state) + q.Set("code", f.newCode(state)) + ru.RawQuery = q.Encode() + + http.Redirect(rw, r, ru.String(), http.StatusTemporaryRedirect) + })) + + mux.Handle(tokenPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + values, err := f.authenticateOIDCClientRequest(t, r) + f.logger.Info(r.Context(), "http idp call token", + slog.Error(err), + slog.F("values", values.Encode()), + ) + if err != nil { + http.Error(rw, fmt.Sprintf("invalid token request: %s", err.Error()), http.StatusBadRequest) + return + } + getEmail := func(claims jwt.MapClaims) string { + email, ok := claims["email"] + if !ok { + return "unknown" + } + emailStr, ok := email.(string) + if !ok { + return "wrong-type" + } + return emailStr + } + + var claims jwt.MapClaims + switch values.Get("grant_type") { + case "authorization_code": + code := values.Get("code") + if !assert.NotEmpty(t, code, "code is empty") { + http.Error(rw, "invalid code", http.StatusBadRequest) + return + } + stateStr, ok := f.codeToStateMap.Load(code) + if !assert.True(t, ok, "invalid code") { + http.Error(rw, "invalid code", http.StatusBadRequest) + return + } + // Always invalidate the code after it is used. + f.codeToStateMap.Delete(code) + + idTokenClaims, ok := f.stateToIDTokenClaims.Load(stateStr) + if !ok { + t.Errorf("missing id token claims") + http.Error(rw, "missing id token claims", http.StatusBadRequest) + return + } + claims = idTokenClaims + case "refresh_token": + refreshToken := values.Get("refresh_token") + if !assert.NotEmpty(t, refreshToken, "refresh_token is empty") { + http.Error(rw, "invalid refresh_token", http.StatusBadRequest) + return + } + + _, ok := f.refreshTokens.Load(refreshToken) + if !assert.True(t, ok, "invalid refresh_token") { + http.Error(rw, "invalid refresh_token", http.StatusBadRequest) + return + } + + idTokenClaims, ok := f.refreshIDTokenClaims.Load(refreshToken) + if !ok { + t.Errorf("missing id token claims in refresh") + http.Error(rw, "missing id token claims in refresh", http.StatusBadRequest) + return + } + + claims = idTokenClaims + err := f.hookOnRefresh(getEmail(claims)) + if err != nil { + http.Error(rw, fmt.Sprintf("refresh hook blocked refresh: %s", err.Error()), http.StatusBadRequest) + return + } + + f.refreshTokensUsed.Store(refreshToken, true) + // Always invalidate the refresh token after it is used. + f.refreshTokens.Delete(refreshToken) + default: + t.Errorf("unexpected grant_type %q", values.Get("grant_type")) + http.Error(rw, "invalid grant_type", http.StatusBadRequest) + return + } + + exp := time.Now().Add(time.Minute * 5) + claims["exp"] = exp.UnixMilli() + email := getEmail(claims) + refreshToken := f.newRefreshTokens(email) + token := map[string]interface{}{ + "access_token": f.newToken(email), + "refresh_token": refreshToken, + "token_type": "Bearer", + "expires_in": int64((time.Minute * 5).Seconds()), + "id_token": f.encodeClaims(t, claims), + } + // Store the claims for the next refresh + f.refreshIDTokenClaims.Store(refreshToken, claims) + + rw.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(rw).Encode(token) + })) + + mux.Handle(userInfoPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + token, err := f.authenticateBearerTokenRequest(t, r) + f.logger.Info(r.Context(), "http call idp user info", + slog.Error(err), + slog.F("url", r.URL.String()), + ) + if err != nil { + http.Error(rw, fmt.Sprintf("invalid user info request: %s", err.Error()), http.StatusBadRequest) + return + } + + email, ok := f.accessTokens.Load(token) + if !ok { + t.Errorf("access token user for user_info has no email to indicate which user") + http.Error(rw, "invalid access token, missing user info", http.StatusBadRequest) + return + } + _ = json.NewEncoder(rw).Encode(f.hookUserInfo(email)) + })) + + mux.Handle(keysPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + f.logger.Info(r.Context(), "http call idp /keys") + set := jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{ + { + Key: f.key.Public(), + KeyID: "test-key", + Algorithm: "RSA", + }, + }, + } + _ = json.NewEncoder(rw).Encode(set) + })) + + mux.NotFound(func(rw http.ResponseWriter, r *http.Request) { + f.logger.Error(r.Context(), "http call not found", slog.F("path", r.URL.Path)) + t.Errorf("unexpected request to IDP at path %q. Not supported", r.URL.Path) + }) + + return mux +} + +// HTTPClient does nothing if IsServing is used. +// +// If IsServing is not used, then it will return a client that will make requests +// to the IDP all in memory. If a request is not to the IDP, then the passed in +// client will be used. If no client is passed in, then any regular network +// requests will fail. +func (f *FakeIDP) HTTPClient(rest *http.Client) *http.Client { + if f.serve { + if rest == nil || rest.Transport == nil { + return &http.Client{} + } + return rest + } + + var jar http.CookieJar + if rest != nil { + jar = rest.Jar + } + return &http.Client{ + Jar: jar, + Transport: fakeRoundTripper{ + roundTrip: func(req *http.Request) (*http.Response, error) { + u, _ := url.Parse(f.issuer) + if req.URL.Host != u.Host { + if f.fakeCoderd != nil { + return f.fakeCoderd(req) + } + if rest == nil || rest.Transport == nil { + return nil, xerrors.Errorf("unexpected network request to %q", req.URL.Host) + } + return rest.Transport.RoundTrip(req) + } + resp := httptest.NewRecorder() + f.handler.ServeHTTP(resp, req) + return resp.Result(), nil + }, + }, + } +} + +// RefreshUsed returns if the refresh token has been used. All refresh tokens +// can only be used once, then they are deleted. +func (f *FakeIDP) RefreshUsed(refreshToken string) bool { + used, _ := f.refreshTokensUsed.Load(refreshToken) + return used +} + +// UpdateRefreshClaims allows the caller to change what claims are returned +// for a given refresh token. By default, all refreshes use the same claims as +// the original IDToken issuance. +func (f *FakeIDP) UpdateRefreshClaims(refreshToken string, claims jwt.MapClaims) { + f.refreshIDTokenClaims.Store(refreshToken, claims) +} + +// SetRedirect is required for the IDP to know where to redirect and call +// Coderd. +func (f *FakeIDP) SetRedirect(t testing.TB, u string) { + t.Helper() + + f.cfg.RedirectURL = u +} + +// SetCoderdCallback is optional and only works if not using the IsServing. +// It will setup a fake "Coderd" for the IDP to call when the IDP redirects +// back after authenticating. +func (f *FakeIDP) SetCoderdCallback(callback func(req *http.Request) (*http.Response, error)) { + if f.serve { + panic("cannot set callback handler when using 'WithServing'. Must implement an actual 'Coderd'") + } + f.fakeCoderd = callback +} + +func (f *FakeIDP) SetCoderdCallbackHandler(handler http.HandlerFunc) { + f.SetCoderdCallback(func(req *http.Request) (*http.Response, error) { + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + return resp.Result(), nil + }) +} + +// OIDCConfig returns the OIDC config to use for Coderd. +func (f *FakeIDP) OIDCConfig(t testing.TB, scopes []string, opts ...func(cfg *coderd.OIDCConfig)) *coderd.OIDCConfig { + t.Helper() + if len(scopes) == 0 { + scopes = []string{"openid", "email", "profile"} + } + + oauthCfg := &oauth2.Config{ + ClientID: f.clientID, + ClientSecret: f.clientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: f.provider.AuthURL, + TokenURL: f.provider.TokenURL, + AuthStyle: oauth2.AuthStyleInParams, + }, + // If the user is using a real network request, they will need to do + // 'fake.SetRedirect()' + RedirectURL: "https://redirect.com", + Scopes: scopes, + } + + ctx := oidc.ClientContext(context.Background(), f.HTTPClient(nil)) + p, err := oidc.NewProvider(ctx, f.provider.Issuer) + require.NoError(t, err, "failed to create OIDC provider") + cfg := &coderd.OIDCConfig{ + OAuth2Config: oauthCfg, + Provider: p, + Verifier: oidc.NewVerifier(f.provider.Issuer, &oidc.StaticKeySet{ + PublicKeys: []crypto.PublicKey{f.key.Public()}, + }, &oidc.Config{ + ClientID: oauthCfg.ClientID, + SupportedSigningAlgs: []string{ + "RS256", + }, + // Todo: add support for Now() + }), + UsernameField: "preferred_username", + EmailField: "email", + AuthURLParams: map[string]string{"access_type": "offline"}, + } + + for _, opt := range opts { + if opt == nil { + continue + } + opt(cfg) + } + + f.cfg = oauthCfg + + return cfg +} + +type fakeRoundTripper struct { + roundTrip func(req *http.Request) (*http.Response, error) +} + +func (f fakeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return f.roundTrip(req) +} + +const testRSAPrivateKey = `-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDLets8+7M+iAQAqN/5BVyCIjhTQ4cmXulL+gm3v0oGMWzLupUS +v8KPA+Tp7dgC/DZPfMLaNH1obBBhJ9DhS6RdS3AS3kzeFrdu8zFHLWF53DUBhS92 +5dCAEuJpDnNizdEhxTfoHrhuCmz8l2nt1pe5eUK2XWgd08Uc93h5ij098wIDAQAB +AoGAHLaZeWGLSaen6O/rqxg2laZ+jEFbMO7zvOTruiIkL/uJfrY1kw+8RLIn+1q0 +wLcWcuEIHgKKL9IP/aXAtAoYh1FBvRPLkovF1NZB0Je/+CSGka6wvc3TGdvppZJe +rKNcUvuOYLxkmLy4g9zuY5qrxFyhtIn2qZzXEtLaVOHzPQECQQDvN0mSajpU7dTB +w4jwx7IRXGSSx65c+AsHSc1Rj++9qtPC6WsFgAfFN2CEmqhMbEUVGPv/aPjdyWk9 +pyLE9xR/AkEA2cGwyIunijE5v2rlZAD7C4vRgdcMyCf3uuPcgzFtsR6ZhyQSgLZ8 +YRPuvwm4cdPJMmO3YwBfxT6XGuSc2k8MjQJBAI0+b8prvpV2+DCQa8L/pjxp+VhR +Xrq2GozrHrgR7NRokTB88hwFRJFF6U9iogy9wOx8HA7qxEbwLZuhm/4AhbECQC2a +d8h4Ht09E+f3nhTEc87mODkl7WJZpHL6V2sORfeq/eIkds+H6CJ4hy5w/bSw8tjf +sz9Di8sGIaUbLZI2rd0CQQCzlVwEtRtoNCyMJTTrkgUuNufLP19RZ5FpyXxBO5/u +QastnN77KfUwdj3SJt44U/uh1jAIv4oSLBr8HYUkbnI8 +-----END RSA PRIVATE KEY-----` diff --git a/coderd/coderdtest/oidctest/idp_test.go b/coderd/coderdtest/oidctest/idp_test.go new file mode 100644 index 0000000000000..519635b067916 --- /dev/null +++ b/coderd/coderdtest/oidctest/idp_test.go @@ -0,0 +1,73 @@ +package oidctest_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" +) + +// TestFakeIDPBasicFlow tests the basic flow of the fake IDP. +// It is done all in memory with no actual network requests. +// nolint:bodyclose +func TestFakeIDPBasicFlow(t *testing.T) { + t.Parallel() + + fake := oidctest.NewFakeIDP(t, + oidctest.WithLogging(t, nil), + ) + + var handler http.Handler + srv := httptest.NewServer(http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler.ServeHTTP(w, r) + }))) + defer srv.Close() + + cfg := fake.OIDCConfig(t, nil) + cli := fake.HTTPClient(nil) + ctx := oidc.ClientContext(context.Background(), cli) + + const expectedState = "random-state" + var token *oauth2.Token + // This is the Coder callback using an actual network request. + fake.SetCoderdCallbackHandler(func(w http.ResponseWriter, r *http.Request) { + // Emulate OIDC flow + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + assert.Equal(t, expectedState, state, "state mismatch") + + oauthToken, err := cfg.Exchange(ctx, code) + if assert.NoError(t, err, "failed to exchange code") { + assert.NotEmpty(t, oauthToken.AccessToken, "access token is empty") + assert.NotEmpty(t, oauthToken.RefreshToken, "refresh token is empty") + } + token = oauthToken + }) + + resp, err := fake.OIDCCallback(t, expectedState, jwt.MapClaims{}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Test the user info + _, err = cfg.Provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) + require.NoError(t, err) + + // Now test it can refresh + refreshed, err := cfg.TokenSource(ctx, &oauth2.Token{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + Expiry: time.Now().Add(time.Minute * -1), + }).Token() + require.NoError(t, err, "failed to refresh token") + require.NotEmpty(t, refreshed.AccessToken, "access token is empty on refresh") +} diff --git a/coderd/coderdtest/swagger_test.go b/coderd/coderdtest/swagger_test.go index 07fb19ad64b85..7b50a27964631 100644 --- a/coderd/coderdtest/swagger_test.go +++ b/coderd/coderdtest/swagger_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest" ) func TestEndpointsDocumented(t *testing.T) { diff --git a/coderd/csp.go b/coderd/csp.go index ba87adfc635e9..84e22daf9a127 100644 --- a/coderd/csp.go +++ b/coderd/csp.go @@ -4,8 +4,8 @@ import ( "encoding/json" "net/http" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" "cdr.dev/slog" ) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 263611d5b168b..d6a5bf4b69ef0 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -3,14 +3,16 @@ package db2sdk import ( "encoding/json" + "strings" "github.com/google/uuid" + "golang.org/x/exp/slices" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/parameter" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/parameter" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionersdk/proto" ) func WorkspaceBuildParameters(params []database.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter { @@ -29,20 +31,10 @@ func WorkspaceBuildParameter(p database.WorkspaceBuildParameter) codersdk.Worksp } func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk.TemplateVersionParameter, error) { - var protoOptions []*proto.RichParameterOption - err := json.Unmarshal(param.Options, &protoOptions) + options, err := templateVersionParameterOptions(param.Options) if err != nil { return codersdk.TemplateVersionParameter{}, err } - options := make([]codersdk.TemplateVersionParameterOption, 0) - for _, option := range protoOptions { - options = append(options, codersdk.TemplateVersionParameterOption{ - Name: option.Name, - Description: option.Description, - Value: option.Value, - Icon: option.Icon, - }) - } descriptionPlaintext, err := parameter.Plaintext(param.Description) if err != nil { @@ -132,3 +124,82 @@ func Role(role rbac.Role) codersdk.Role { Name: role.Name, } } + +func TemplateInsightsParameters(parameterRows []database.GetTemplateParameterInsightsRow) ([]codersdk.TemplateParameterUsage, error) { + // Use a stable sort, similarly to how we would sort in the query, note that + // we don't sort in the query because order varies depending on the table + // collation. + // + // ORDER BY utp.name, utp.type, utp.display_name, utp.description, utp.options, wbp.value + slices.SortFunc(parameterRows, func(a, b database.GetTemplateParameterInsightsRow) int { + if a.Name != b.Name { + return strings.Compare(a.Name, b.Name) + } + if a.Type != b.Type { + return strings.Compare(a.Type, b.Type) + } + if a.DisplayName != b.DisplayName { + return strings.Compare(a.DisplayName, b.DisplayName) + } + if a.Description != b.Description { + return strings.Compare(a.Description, b.Description) + } + if string(a.Options) != string(b.Options) { + return strings.Compare(string(a.Options), string(b.Options)) + } + return strings.Compare(a.Value, b.Value) + }) + + parametersUsage := []codersdk.TemplateParameterUsage{} + indexByNum := make(map[int64]int) + for _, param := range parameterRows { + if _, ok := indexByNum[param.Num]; !ok { + var opts []codersdk.TemplateVersionParameterOption + err := json.Unmarshal(param.Options, &opts) + if err != nil { + return nil, err + } + + plaintextDescription, err := parameter.Plaintext(param.Description) + if err != nil { + return nil, err + } + + parametersUsage = append(parametersUsage, codersdk.TemplateParameterUsage{ + TemplateIDs: param.TemplateIDs, + Name: param.Name, + Type: param.Type, + DisplayName: param.DisplayName, + Description: plaintextDescription, + Options: opts, + }) + indexByNum[param.Num] = len(parametersUsage) - 1 + } + + i := indexByNum[param.Num] + parametersUsage[i].Values = append(parametersUsage[i].Values, codersdk.TemplateParameterValue{ + Value: param.Value, + Count: param.Count, + }) + } + + return parametersUsage, nil +} + +func templateVersionParameterOptions(rawOptions json.RawMessage) ([]codersdk.TemplateVersionParameterOption, error) { + var protoOptions []*proto.RichParameterOption + err := json.Unmarshal(rawOptions, &protoOptions) + if err != nil { + return nil, err + } + var options []codersdk.TemplateVersionParameterOption + for _, option := range protoOptions { + options = append(options, codersdk.TemplateVersionParameterOption{ + Name: option.Name, + Description: option.Description, + Value: option.Value, + Icon: option.Icon, + }) + } + return options, nil +} diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 39020e64e9828..6cbbff7aa5b9c 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -9,10 +9,10 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/db2sdk" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionersdk/proto" ) func TestProvisionerJobStatus(t *testing.T) { diff --git a/coderd/database/db_test.go b/coderd/database/db_test.go index c922da979f506..66ce7eb82115c 100644 --- a/coderd/database/db_test.go +++ b/coderd/database/db_test.go @@ -11,9 +11,9 @@ import ( "github.com/lib/pq" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/migrations" - "github.com/coder/coder/coderd/database/postgres" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/migrations" + "github.com/coder/coder/v2/coderd/database/postgres" ) func TestSerializedRetry(t *testing.T) { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ab865e2cc0f70..9115e9b5ac184 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -15,9 +15,9 @@ import ( "github.com/open-policy-agent/opa/topdown" "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/util/slice" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/slice" ) var _ database.Store = (*querier)(nil) @@ -784,6 +784,14 @@ func (q *querier) GetActiveUserCount(ctx context.Context) (int64, error) { return q.db.GetActiveUserCount(ctx) } +func (q *querier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]database.WorkspaceBuild, error) { + // This is a system-only function. + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { + return []database.WorkspaceBuild{}, err + } + return q.db.GetActiveWorkspaceBuildsByTemplateID(ctx, templateID) +} + func (q *querier) GetAllTailnetAgents(ctx context.Context) ([]database.TailnetAgent, error) { if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTailnetCoordinator); err != nil { return []database.TailnetAgent{}, err @@ -908,11 +916,11 @@ func (q *querier) GetGroupByOrgAndName(ctx context.Context, arg database.GetGrou return fetch(q.log, q.auth, q.db.GetGroupByOrgAndName)(ctx, arg) } -func (q *querier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]database.User, error) { - if _, err := q.GetGroupByID(ctx, groupID); err != nil { // AuthZ check +func (q *querier) GetGroupMembers(ctx context.Context, id uuid.UUID) ([]database.User, error) { + if _, err := q.GetGroupByID(ctx, id); err != nil { // AuthZ check return nil, err } - return q.db.GetGroupMembers(ctx, groupID) + return q.db.GetGroupMembers(ctx, id) } func (q *querier) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]database.Group, error) { @@ -1165,6 +1173,25 @@ func (q *querier) GetTailnetClientsForAgent(ctx context.Context, agentID uuid.UU return q.db.GetTailnetClientsForAgent(ctx, agentID) } +func (q *querier) GetTemplateAppInsights(ctx context.Context, arg database.GetTemplateAppInsightsParams) ([]database.GetTemplateAppInsightsRow, error) { + for _, templateID := range arg.TemplateIDs { + template, err := q.db.GetTemplateByID(ctx, templateID) + if err != nil { + return nil, err + } + + if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil { + return nil, err + } + } + if len(arg.TemplateIDs) == 0 { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + return nil, err + } + } + return q.db.GetTemplateAppInsights(ctx, arg) +} + // Only used by metrics cache. func (q *querier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) { if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { @@ -1466,13 +1493,12 @@ func (q *querier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]databas return q.db.GetUsersByIDs(ctx, ids) } -// GetWorkspaceAgentByAuthToken is used in http middleware to get the workspace agent. -// This should only be used by a system user in that middleware. -func (q *querier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (database.WorkspaceAgent, error) { +func (q *querier) GetWorkspaceAgentAndOwnerByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndOwnerByAuthTokenRow, error) { + // This is a system function if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { - return database.WorkspaceAgent{}, err + return database.GetWorkspaceAgentAndOwnerByAuthTokenRow{}, err } - return q.db.GetWorkspaceAgentByAuthToken(ctx, authToken) + return q.db.GetWorkspaceAgentAndOwnerByAuthToken(ctx, authToken) } func (q *querier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (database.WorkspaceAgent, error) { @@ -1853,6 +1879,13 @@ func (q *querier) InsertLicense(ctx context.Context, arg database.InsertLicenseP return q.db.InsertLicense(ctx, arg) } +func (q *querier) InsertMissingGroups(ctx context.Context, arg database.InsertMissingGroupsParams) ([]database.Group, error) { + if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.InsertMissingGroups(ctx, arg) +} + func (q *querier) InsertOrganization(ctx context.Context, arg database.InsertOrganizationParams) (database.Organization, error) { return insert(q.log, q.auth, rbac.ResourceOrganization, q.db.InsertOrganization)(ctx, arg) } @@ -2016,6 +2049,14 @@ func (q *querier) InsertWorkspaceAgentStat(ctx context.Context, arg database.Ins return q.db.InsertWorkspaceAgentStat(ctx, arg) } +func (q *querier) InsertWorkspaceAgentStats(ctx context.Context, arg database.InsertWorkspaceAgentStatsParams) error { + if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil { + return err + } + + return q.db.InsertWorkspaceAgentStats(ctx, arg) +} + func (q *querier) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil { return database.WorkspaceApp{}, err @@ -2023,6 +2064,13 @@ func (q *querier) InsertWorkspaceApp(ctx context.Context, arg database.InsertWor return q.db.InsertWorkspaceApp(ctx, arg) } +func (q *querier) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { + if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil { + return err + } + return q.db.InsertWorkspaceAppStats(ctx, arg) +} + func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) if err != nil { @@ -2337,6 +2385,14 @@ func (q *querier) UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.Conte return q.db.UpdateTemplateVersionGitAuthProvidersByJobID(ctx, arg) } +func (q *querier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { + fetch := func(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) (database.Template, error) { + return q.db.GetTemplateByID(ctx, arg.TemplateID) + } + + return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateTemplateWorkspacesLastUsedAt)(ctx, arg) +} + // UpdateUserDeletedByID // Deprecated: Delete this function in favor of 'SoftDeleteUserByID'. Deletes are // irreversible. @@ -2580,18 +2636,18 @@ func (q *querier) UpdateWorkspaceDeletedByID(ctx context.Context, arg database.U return deleteQ(q.log, q.auth, fetch, q.db.UpdateWorkspaceDeletedByID)(ctx, arg) } -func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.UpdateWorkspaceLastUsedAtParams) error { - fetch := func(ctx context.Context, arg database.UpdateWorkspaceLastUsedAtParams) (database.Workspace, error) { +func (q *querier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg database.UpdateWorkspaceDormantDeletingAtParams) (database.Workspace, error) { + fetch := func(ctx context.Context, arg database.UpdateWorkspaceDormantDeletingAtParams) (database.Workspace, error) { return q.db.GetWorkspaceByID(ctx, arg.ID) } - return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg) + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceDormantDeletingAt)(ctx, arg) } -func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { - fetch := func(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { +func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.UpdateWorkspaceLastUsedAtParams) error { + fetch := func(ctx context.Context, arg database.UpdateWorkspaceLastUsedAtParams) (database.Workspace, error) { return q.db.GetWorkspaceByID(ctx, arg.ID) } - return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg) + return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg) } func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { @@ -2615,12 +2671,12 @@ func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWor return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceTTL)(ctx, arg) } -func (q *querier) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { - fetch := func(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) (database.Template, error) { +func (q *querier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error { + fetch := func(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) (database.Template, error) { return q.db.GetTemplateByID(ctx, arg.TemplateID) } - return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspacesDeletingAtByTemplateID)(ctx, arg) + return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspacesDormantDeletingAtByTemplateID)(ctx, arg) } func (q *querier) UpsertAppSecurityKey(ctx context.Context, data string) error { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index f3313c768053f..3b41e67a0c0df 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -13,13 +13,13 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/util/slice" + "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/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/slice" ) func TestAsNoActor(t *testing.T) { @@ -1064,8 +1064,10 @@ func (s *MethodTestSuite) TestWorkspace() { res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) check.Args(database.UpdateWorkspaceAgentStartupByIDParams{ - ID: agt.ID, - Subsystem: database.WorkspaceAgentSubsystemNone, + ID: agt.ID, + Subsystems: []database.WorkspaceAgentSubsystem{ + database.WorkspaceAgentSubsystemEnvbox, + }, }).Asserts(ws, rbac.ActionUpdate).Returns() })) s.Run("GetWorkspaceAgentLogsAfter", s.Subtest(func(db database.Store, check *expects) { @@ -1317,10 +1319,6 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{}) check.Args().Asserts(rbac.ResourceSystem, rbac.ActionRead) })) - s.Run("GetWorkspaceAgentByAuthToken", s.Subtest(func(db database.Store, check *expects) { - agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{}) - check.Args(agt.AuthToken).Asserts(rbac.ResourceSystem, rbac.ActionRead).Returns(agt) - })) s.Run("GetActiveUserCount", s.Subtest(func(db database.Store, check *expects) { check.Args().Asserts(rbac.ResourceSystem, rbac.ActionRead).Returns(int64(0)) })) diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index d333c87362cd0..9efcf5ef9418e 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -16,14 +16,14 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbmock" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/rbac/regosql" - "github.com/coder/coder/coderd/util/slice" + "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/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/regosql" + "github.com/coder/coder/v2/coderd/util/slice" ) var skipMethods = map[string]string{ diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 0d5c19fa7656d..d26be831db122 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -19,13 +19,13 @@ import ( "golang.org/x/exp/slices" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/db2sdk" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/rbac/regosql" - "github.com/coder/coder/coderd/util/slice" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/regosql" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" ) var validProxyByHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) @@ -114,33 +114,35 @@ type data struct { userLinks []database.UserLink // New tables - workspaceAgentStats []database.WorkspaceAgentStat - auditLogs []database.AuditLog - files []database.File - gitAuthLinks []database.GitAuthLink - gitSSHKey []database.GitSSHKey - groupMembers []database.GroupMember - groups []database.Group - licenses []database.License - parameterSchemas []database.ParameterSchema - provisionerDaemons []database.ProvisionerDaemon - provisionerJobLogs []database.ProvisionerJobLog - provisionerJobs []database.ProvisionerJob - replicas []database.Replica - templateVersions []database.TemplateVersionTable - templateVersionParameters []database.TemplateVersionParameter - templateVersionVariables []database.TemplateVersionVariable - templates []database.TemplateTable - workspaceAgents []database.WorkspaceAgent - workspaceAgentMetadata []database.WorkspaceAgentMetadatum - workspaceAgentLogs []database.WorkspaceAgentLog - workspaceApps []database.WorkspaceApp - workspaceBuilds []database.WorkspaceBuildTable - workspaceBuildParameters []database.WorkspaceBuildParameter - workspaceResourceMetadata []database.WorkspaceResourceMetadatum - workspaceResources []database.WorkspaceResource - workspaces []database.Workspace - workspaceProxies []database.WorkspaceProxy + workspaceAgentStats []database.WorkspaceAgentStat + auditLogs []database.AuditLog + files []database.File + gitAuthLinks []database.GitAuthLink + gitSSHKey []database.GitSSHKey + groupMembers []database.GroupMember + groups []database.Group + licenses []database.License + parameterSchemas []database.ParameterSchema + provisionerDaemons []database.ProvisionerDaemon + provisionerJobLogs []database.ProvisionerJobLog + provisionerJobs []database.ProvisionerJob + replicas []database.Replica + templateVersions []database.TemplateVersionTable + templateVersionParameters []database.TemplateVersionParameter + templateVersionVariables []database.TemplateVersionVariable + templates []database.TemplateTable + workspaceAgents []database.WorkspaceAgent + workspaceAgentMetadata []database.WorkspaceAgentMetadatum + workspaceAgentLogs []database.WorkspaceAgentLog + workspaceApps []database.WorkspaceApp + workspaceAppStatsLastInsertID int64 + workspaceAppStats []database.WorkspaceAppStat + workspaceBuilds []database.WorkspaceBuildTable + workspaceBuildParameters []database.WorkspaceBuildParameter + workspaceResourceMetadata []database.WorkspaceResourceMetadatum + workspaceResources []database.WorkspaceResource + workspaces []database.Workspace + workspaceProxies []database.WorkspaceProxy // Locks is a map of lock names. Any keys within the map are currently // locked. locks map[int64]struct{} @@ -339,7 +341,7 @@ func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspac AutostartSchedule: w.AutostartSchedule, Ttl: w.Ttl, LastUsedAt: w.LastUsedAt, - LockedAt: w.LockedAt, + DormantAt: w.DormantAt, DeletingAt: w.DeletingAt, Count: count, } @@ -547,6 +549,19 @@ func (q *FakeQuerier) getWorkspaceAgentsByResourceIDsNoLock(_ context.Context, r return workspaceAgents, nil } +func (q *FakeQuerier) getWorkspaceAppByAgentIDAndSlugNoLock(_ context.Context, arg database.GetWorkspaceAppByAgentIDAndSlugParams) (database.WorkspaceApp, error) { + for _, app := range q.workspaceApps { + if app.AgentID != arg.AgentID { + continue + } + if app.Slug != arg.Slug { + continue + } + return app, nil + } + return database.WorkspaceApp{}, sql.ErrNoRows +} + func (q *FakeQuerier) getProvisionerJobByIDNoLock(_ context.Context, id uuid.UUID) (database.ProvisionerJob, error) { for _, provisionerJob := range q.provisionerJobs { if provisionerJob.ID != id { @@ -605,12 +620,50 @@ func uniqueSortedUUIDs(uuids []uuid.UUID) []uuid.UUID { for id := range set { unique = append(unique, id) } - slices.SortFunc(unique, func(a, b uuid.UUID) bool { - return a.String() < b.String() + slices.SortFunc(unique, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) }) return unique } +func (q *FakeQuerier) getOrganizationMemberNoLock(orgID uuid.UUID) []database.OrganizationMember { + var members []database.OrganizationMember + for _, member := range q.organizationMembers { + if member.OrganizationID == orgID { + members = append(members, member) + } + } + + return members +} + +// getEveryoneGroupMembersNoLock fetches all the users in an organization. +func (q *FakeQuerier) getEveryoneGroupMembersNoLock(orgID uuid.UUID) []database.User { + var ( + everyone []database.User + orgMembers = q.getOrganizationMemberNoLock(orgID) + ) + for _, member := range orgMembers { + user, err := q.getUserByIDNoLock(member.UserID) + if err != nil { + return nil + } + everyone = append(everyone, user) + } + return everyone +} + +// isEveryoneGroup returns true if the provided ID matches +// an organization ID. +func (q *FakeQuerier) isEveryoneGroup(id uuid.UUID) bool { + for _, org := range q.organizations { + if org.ID == id { + return true + } + } + return false +} + func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } @@ -918,6 +971,34 @@ func (q *FakeQuerier) GetActiveUserCount(_ context.Context) (int64, error) { return active, nil } +func (q *FakeQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]database.WorkspaceBuild, error) { + workspaceIDs := func() []uuid.UUID { + q.mutex.RLock() + defer q.mutex.RUnlock() + + ids := []uuid.UUID{} + for _, workspace := range q.workspaces { + if workspace.TemplateID == templateID { + ids = append(ids, workspace.ID) + } + } + return ids + }() + + builds, err := q.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, workspaceIDs) + if err != nil { + return nil, err + } + + filteredBuilds := []database.WorkspaceBuild{} + for _, build := range builds { + if build.Transition == database.WorkspaceTransitionStart { + filteredBuilds = append(filteredBuilds, build) + } + } + return filteredBuilds, nil +} + func (*FakeQuerier) GetAllTailnetAgents(_ context.Context) ([]database.TailnetAgent, error) { return nil, ErrUnimplemented } @@ -1348,13 +1429,17 @@ func (q *FakeQuerier) GetGroupByOrgAndName(_ context.Context, arg database.GetGr return database.Group{}, sql.ErrNoRows } -func (q *FakeQuerier) GetGroupMembers(_ context.Context, groupID uuid.UUID) ([]database.User, error) { +func (q *FakeQuerier) GetGroupMembers(_ context.Context, id uuid.UUID) ([]database.User, error) { q.mutex.RLock() defer q.mutex.RUnlock() + if q.isEveryoneGroup(id) { + return q.getEveryoneGroupMembersNoLock(id), nil + } + var members []database.GroupMember for _, member := range q.groupMembers { - if member.GroupID == groupID { + if member.GroupID == id { members = append(members, member) } } @@ -1373,14 +1458,13 @@ func (q *FakeQuerier) GetGroupMembers(_ context.Context, groupID uuid.UUID) ([]d return users, nil } -func (q *FakeQuerier) GetGroupsByOrganizationID(_ context.Context, organizationID uuid.UUID) ([]database.Group, error) { +func (q *FakeQuerier) GetGroupsByOrganizationID(_ context.Context, id uuid.UUID) ([]database.Group, error) { q.mutex.RLock() defer q.mutex.RUnlock() - var groups []database.Group + groups := make([]database.Group, 0, len(q.groups)) for _, group := range q.groups { - // Omit the allUsers group. - if group.OrganizationID == organizationID && group.ID != organizationID { + if group.OrganizationID == id { groups = append(groups, group) } } @@ -1810,9 +1894,17 @@ func (q *FakeQuerier) GetQuotaAllowanceForUser(_ context.Context, userID uuid.UU for _, group := range q.groups { if group.ID == member.GroupID { sum += int64(group.QuotaAllowance) + continue } } } + // Grab the quota for the Everyone group. + for _, group := range q.groups { + if group.ID == group.OrganizationID { + sum += int64(group.QuotaAllowance) + break + } + } return sum, nil } @@ -1887,6 +1979,131 @@ func (*FakeQuerier) GetTailnetClientsForAgent(context.Context, uuid.UUID) ([]dat return nil, ErrUnimplemented } +func (q *FakeQuerier) GetTemplateAppInsights(ctx context.Context, arg database.GetTemplateAppInsightsParams) ([]database.GetTemplateAppInsightsRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + type appKey struct { + AccessMethod string + SlugOrPort string + Slug string + DisplayName string + Icon string + } + type uniqueKey struct { + TemplateID uuid.UUID + UserID uuid.UUID + AgentID uuid.UUID + AppKey appKey + } + + appUsageIntervalsByUserAgentApp := make(map[uniqueKey]map[time.Time]int64) + for _, s := range q.workspaceAppStats { + // (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_) + // OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_) + // OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_) + if !(((s.SessionStartedAt.After(arg.StartTime) || s.SessionStartedAt.Equal(arg.StartTime)) && s.SessionStartedAt.Before(arg.EndTime)) || + (s.SessionEndedAt.After(arg.StartTime) && s.SessionEndedAt.Before(arg.EndTime)) || + (s.SessionStartedAt.Before(arg.StartTime) && (s.SessionEndedAt.After(arg.EndTime) || s.SessionEndedAt.Equal(arg.EndTime)))) { + continue + } + + w, err := q.getWorkspaceByIDNoLock(ctx, s.WorkspaceID) + if err != nil { + return nil, err + } + + if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, w.TemplateID) { + continue + } + + app, _ := q.getWorkspaceAppByAgentIDAndSlugNoLock(ctx, database.GetWorkspaceAppByAgentIDAndSlugParams{ + AgentID: s.AgentID, + Slug: s.SlugOrPort, + }) + + key := uniqueKey{ + TemplateID: w.TemplateID, + UserID: s.UserID, + AgentID: s.AgentID, + AppKey: appKey{ + AccessMethod: s.AccessMethod, + SlugOrPort: s.SlugOrPort, + Slug: app.Slug, + DisplayName: app.DisplayName, + Icon: app.Icon, + }, + } + if appUsageIntervalsByUserAgentApp[key] == nil { + appUsageIntervalsByUserAgentApp[key] = make(map[time.Time]int64) + } + + t := s.SessionStartedAt.Truncate(5 * time.Minute) + if t.Before(arg.StartTime) { + t = arg.StartTime + } + for t.Before(s.SessionEndedAt) && t.Before(arg.EndTime) { + appUsageIntervalsByUserAgentApp[key][t] = 60 // 1 minute. + t = t.Add(1 * time.Minute) + } + } + + appUsageTemplateIDs := make(map[appKey]map[uuid.UUID]struct{}) + appUsageUserIDs := make(map[appKey]map[uuid.UUID]struct{}) + appUsage := make(map[appKey]int64) + for uniqueKey, usage := range appUsageIntervalsByUserAgentApp { + for _, seconds := range usage { + if appUsageTemplateIDs[uniqueKey.AppKey] == nil { + appUsageTemplateIDs[uniqueKey.AppKey] = make(map[uuid.UUID]struct{}) + } + appUsageTemplateIDs[uniqueKey.AppKey][uniqueKey.TemplateID] = struct{}{} + if appUsageUserIDs[uniqueKey.AppKey] == nil { + appUsageUserIDs[uniqueKey.AppKey] = make(map[uuid.UUID]struct{}) + } + appUsageUserIDs[uniqueKey.AppKey][uniqueKey.UserID] = struct{}{} + appUsage[uniqueKey.AppKey] += seconds + } + } + + var rows []database.GetTemplateAppInsightsRow + for appKey, usage := range appUsage { + templateIDs := make([]uuid.UUID, 0, len(appUsageTemplateIDs[appKey])) + for templateID := range appUsageTemplateIDs[appKey] { + templateIDs = append(templateIDs, templateID) + } + slices.SortFunc(templateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) + }) + activeUserIDs := make([]uuid.UUID, 0, len(appUsageUserIDs[appKey])) + for userID := range appUsageUserIDs[appKey] { + activeUserIDs = append(activeUserIDs, userID) + } + slices.SortFunc(activeUserIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) + }) + + rows = append(rows, database.GetTemplateAppInsightsRow{ + TemplateIDs: templateIDs, + ActiveUserIDs: activeUserIDs, + AccessMethod: appKey.AccessMethod, + SlugOrPort: appKey.SlugOrPort, + DisplayName: sql.NullString{String: appKey.DisplayName, Valid: appKey.DisplayName != ""}, + Icon: sql.NullString{String: appKey.Icon, Valid: appKey.Icon != ""}, + IsApp: appKey.Slug != "", + UsageSeconds: usage, + }) + } + + // NOTE(mafredri): Add sorting if we decide on how to handle PostgreSQL collations. + // ORDER BY access_method, slug_or_port, display_name, icon, is_app + return rows, nil +} + func (q *FakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) { if err := validateDatabaseType(arg); err != nil { return database.GetTemplateAverageBuildTimeRow{}, err @@ -2014,12 +2231,15 @@ func (q *FakeQuerier) GetTemplateDAUs(_ context.Context, arg database.GetTemplat return rs, nil } -func (q *FakeQuerier) GetTemplateDailyInsights(_ context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { +func (q *FakeQuerier) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { err := validateDatabaseType(arg) if err != nil { return nil, err } + q.mutex.RLock() + defer q.mutex.RUnlock() + type dailyStat struct { startTime, endTime time.Time userSet map[uuid.UUID]struct{} @@ -2050,7 +2270,31 @@ func (q *FakeQuerier) GetTemplateDailyInsights(_ context.Context, arg database.G } ds.userSet[s.UserID] = struct{}{} ds.templateIDSet[s.TemplateID] = struct{}{} - break + } + } + + for _, s := range q.workspaceAppStats { + w, err := q.getWorkspaceByIDNoLock(ctx, s.WorkspaceID) + if err != nil { + return nil, err + } + + if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, w.TemplateID) { + continue + } + + for _, ds := range dailyStats { + // (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_) + // OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_) + // OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_) + if !(((s.SessionStartedAt.After(ds.startTime) || s.SessionStartedAt.Equal(ds.startTime)) && s.SessionStartedAt.Before(ds.endTime)) || + (s.SessionEndedAt.After(ds.startTime) && s.SessionEndedAt.Before(ds.endTime)) || + (s.SessionStartedAt.Before(ds.startTime) && (s.SessionEndedAt.After(ds.endTime) || s.SessionEndedAt.Equal(ds.endTime)))) { + continue + } + + ds.userSet[s.UserID] = struct{}{} + ds.templateIDSet[w.TemplateID] = struct{}{} } } @@ -2060,8 +2304,8 @@ func (q *FakeQuerier) GetTemplateDailyInsights(_ context.Context, arg database.G for templateID := range ds.templateIDSet { templateIDs = append(templateIDs, templateID) } - slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool { - return a.String() < b.String() + slices.SortFunc(templateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) }) result = append(result, database.GetTemplateDailyInsightsRow{ StartTime: ds.startTime, @@ -2096,22 +2340,22 @@ func (q *FakeQuerier) GetTemplateInsights(_ context.Context, arg database.GetTem if appUsageIntervalsByUser[s.UserID] == nil { appUsageIntervalsByUser[s.UserID] = make(map[time.Time]*database.GetTemplateInsightsRow) } - t := s.CreatedAt.Truncate(5 * time.Minute) + t := s.CreatedAt.Truncate(time.Minute) if _, ok := appUsageIntervalsByUser[s.UserID][t]; !ok { appUsageIntervalsByUser[s.UserID][t] = &database.GetTemplateInsightsRow{} } if s.SessionCountJetBrains > 0 { - appUsageIntervalsByUser[s.UserID][t].UsageJetbrainsSeconds = 300 + appUsageIntervalsByUser[s.UserID][t].UsageJetbrainsSeconds = 60 } if s.SessionCountVSCode > 0 { - appUsageIntervalsByUser[s.UserID][t].UsageVscodeSeconds = 300 + appUsageIntervalsByUser[s.UserID][t].UsageVscodeSeconds = 60 } if s.SessionCountReconnectingPTY > 0 { - appUsageIntervalsByUser[s.UserID][t].UsageReconnectingPtySeconds = 300 + appUsageIntervalsByUser[s.UserID][t].UsageReconnectingPtySeconds = 60 } if s.SessionCountSSH > 0 { - appUsageIntervalsByUser[s.UserID][t].UsageSshSeconds = 300 + appUsageIntervalsByUser[s.UserID][t].UsageSshSeconds = 60 } } @@ -2119,12 +2363,17 @@ func (q *FakeQuerier) GetTemplateInsights(_ context.Context, arg database.GetTem for templateID := range templateIDSet { templateIDs = append(templateIDs, templateID) } - slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool { - return a.String() < b.String() + slices.SortFunc(templateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) }) + activeUserIDs := make([]uuid.UUID, 0, len(appUsageIntervalsByUser)) + for userID := range appUsageIntervalsByUser { + activeUserIDs = append(activeUserIDs, userID) + } + result := database.GetTemplateInsightsRow{ - TemplateIDs: templateIDs, - ActiveUsers: int64(len(appUsageIntervalsByUser)), + TemplateIDs: templateIDs, + ActiveUserIDs: activeUserIDs, } for _, intervals := range appUsageIntervalsByUser { for _, interval := range intervals { @@ -2180,12 +2429,14 @@ func (q *FakeQuerier) GetTemplateParameterInsights(ctx context.Context, arg data if tvp.TemplateVersionID != tv.ID { continue } - key := fmt.Sprintf("%s:%s:%s:%s", tvp.Name, tvp.DisplayName, tvp.Description, tvp.Options) + // GROUP BY tvp.name, tvp.type, tvp.display_name, tvp.description, tvp.options + key := fmt.Sprintf("%s:%s:%s:%s:%s", tvp.Name, tvp.Type, tvp.DisplayName, tvp.Description, tvp.Options) if _, ok := uniqueTemplateParams[key]; !ok { num++ uniqueTemplateParams[key] = &database.GetTemplateParameterInsightsRow{ Num: num, Name: tvp.Name, + Type: tvp.Type, DisplayName: tvp.DisplayName, Description: tvp.Description, Options: tvp.Options, @@ -2220,6 +2471,7 @@ func (q *FakeQuerier) GetTemplateParameterInsights(ctx context.Context, arg data TemplateIDs: uniqueSortedUUIDs(utp.TemplateIDs), Name: utp.Name, DisplayName: utp.DisplayName, + Type: utp.Type, Description: utp.Description, Options: utp.Options, Value: value, @@ -2228,6 +2480,8 @@ func (q *FakeQuerier) GetTemplateParameterInsights(ctx context.Context, arg data } } + // NOTE(mafredri): Add sorting if we decide on how to handle PostgreSQL collations. + // ORDER BY utp.name, utp.type, utp.display_name, utp.description, utp.options, wbp.value return rows, nil } @@ -2341,13 +2595,16 @@ func (q *FakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg dat } // Database orders by created_at - slices.SortFunc(version, func(a, b database.TemplateVersion) bool { + slices.SortFunc(version, func(a, b database.TemplateVersion) int { if a.CreatedAt.Equal(b.CreatedAt) { // Technically the postgres database also orders by uuid. So match // that behavior - return a.ID.String() < b.ID.String() + return slice.Ascending(a.ID.String(), b.ID.String()) + } + if a.CreatedAt.Before(b.CreatedAt) { + return -1 } - return a.CreatedAt.Before(b.CreatedAt) + return 1 }) if arg.AfterID != uuid.Nil { @@ -2406,11 +2663,11 @@ func (q *FakeQuerier) GetTemplates(_ context.Context) ([]database.Template, erro defer q.mutex.RUnlock() templates := slices.Clone(q.templates) - slices.SortFunc(templates, func(i, j database.TemplateTable) bool { - if i.Name != j.Name { - return i.Name < j.Name + slices.SortFunc(templates, func(a, b database.TemplateTable) int { + if a.Name != b.Name { + return slice.Ascending(a.Name, b.Name) } - return i.ID.String() < j.ID.String() + return slice.Ascending(a.ID.String(), b.ID.String()) }) return q.templatesWithUserNoLock(templates), nil @@ -2523,8 +2780,8 @@ func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.Get for templateID := range templateIDSet { templateIDs = append(templateIDs, templateID) } - slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool { - return a.String() < b.String() + slices.SortFunc(templateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) }) user, err := q.getUserByIDNoLock(userID) if err != nil { @@ -2540,8 +2797,8 @@ func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.Get } rows = append(rows, row) } - slices.SortFunc(rows, func(a, b database.GetUserLatencyInsightsRow) bool { - return a.UserID.String() < b.UserID.String() + slices.SortFunc(rows, func(a, b database.GetUserLatencyInsightsRow) int { + return slice.Ascending(a.UserID.String(), b.UserID.String()) }) return rows, nil @@ -2588,8 +2845,8 @@ func (q *FakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams copy(users, q.users) // Database orders by username - slices.SortFunc(users, func(a, b database.User) bool { - return strings.ToLower(a.Username) < strings.ToLower(b.Username) + slices.SortFunc(users, func(a, b database.User) int { + return slice.Ascending(strings.ToLower(a.Username), strings.ToLower(b.Username)) }) // Filter out deleted since they should never be returned.. @@ -2707,18 +2964,72 @@ func (q *FakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]datab return users, nil } -func (q *FakeQuerier) GetWorkspaceAgentByAuthToken(_ context.Context, authToken uuid.UUID) (database.WorkspaceAgent, error) { +func (q *FakeQuerier) GetWorkspaceAgentAndOwnerByAuthToken(_ context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndOwnerByAuthTokenRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() - // The schema sorts this by created at, so we iterate the array backwards. - for i := len(q.workspaceAgents) - 1; i >= 0; i-- { - agent := q.workspaceAgents[i] - if agent.AuthToken == authToken { - return agent, nil + // map of build number -> row + rows := make(map[int32]database.GetWorkspaceAgentAndOwnerByAuthTokenRow) + + // We want to return the latest build number + var latestBuildNumber int32 + + for _, agt := range q.workspaceAgents { + if agt.AuthToken != authToken { + continue + } + // get the related workspace and user + for _, res := range q.workspaceResources { + if agt.ResourceID != res.ID { + continue + } + for _, build := range q.workspaceBuilds { + if build.JobID != res.JobID { + continue + } + for _, ws := range q.workspaces { + if build.WorkspaceID != ws.ID { + continue + } + var row database.GetWorkspaceAgentAndOwnerByAuthTokenRow + row.WorkspaceID = ws.ID + usr, err := q.getUserByIDNoLock(ws.OwnerID) + if err != nil { + return database.GetWorkspaceAgentAndOwnerByAuthTokenRow{}, sql.ErrNoRows + } + row.OwnerID = usr.ID + row.OwnerRoles = append(usr.RBACRoles, "member") + // We also need to get org roles for the user + row.OwnerName = usr.Username + row.WorkspaceAgent = agt + for _, mem := range q.organizationMembers { + if mem.UserID == usr.ID { + row.OwnerRoles = append(row.OwnerRoles, fmt.Sprintf("organization-member:%s", mem.OrganizationID.String())) + } + } + // And group memberships + for _, groupMem := range q.groupMembers { + if groupMem.UserID == usr.ID { + row.OwnerGroups = append(row.OwnerGroups, groupMem.GroupID.String()) + } + } + + // Keep track of the latest build number + rows[build.BuildNumber] = row + if build.BuildNumber > latestBuildNumber { + latestBuildNumber = build.BuildNumber + } + } + } } } - return database.WorkspaceAgent{}, sql.ErrNoRows + + if len(rows) == 0 { + return database.GetWorkspaceAgentAndOwnerByAuthTokenRow{}, sql.ErrNoRows + } + + // Return the row related to the latest build + return rows[latestBuildNumber], nil } func (q *FakeQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (database.WorkspaceAgent, error) { @@ -2797,21 +3108,25 @@ func (q *FakeQuerier) GetWorkspaceAgentStats(_ context.Context, createdAfter tim agentStatsCreatedAfter := make([]database.WorkspaceAgentStat, 0) for _, agentStat := range q.workspaceAgentStats { - if agentStat.CreatedAt.After(createdAfter) { + if agentStat.CreatedAt.After(createdAfter) || agentStat.CreatedAt.Equal(createdAfter) { agentStatsCreatedAfter = append(agentStatsCreatedAfter, agentStat) } } latestAgentStats := map[uuid.UUID]database.WorkspaceAgentStat{} for _, agentStat := range q.workspaceAgentStats { - if agentStat.CreatedAt.After(createdAfter) { + if agentStat.CreatedAt.After(createdAfter) || agentStat.CreatedAt.Equal(createdAfter) { latestAgentStats[agentStat.AgentID] = agentStat } } statByAgent := map[uuid.UUID]database.GetWorkspaceAgentStatsRow{} - for _, agentStat := range latestAgentStats { - stat := statByAgent[agentStat.AgentID] + for agentID, agentStat := range latestAgentStats { + stat := statByAgent[agentID] + stat.AgentID = agentStat.AgentID + stat.TemplateID = agentStat.TemplateID + stat.UserID = agentStat.UserID + stat.WorkspaceID = agentStat.WorkspaceID stat.SessionCountVSCode += agentStat.SessionCountVSCode stat.SessionCountJetBrains += agentStat.SessionCountJetBrains stat.SessionCountReconnectingPTY += agentStat.SessionCountReconnectingPTY @@ -2987,7 +3302,7 @@ func (q *FakeQuerier) GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx context.C return agents, nil } -func (q *FakeQuerier) GetWorkspaceAppByAgentIDAndSlug(_ context.Context, arg database.GetWorkspaceAppByAgentIDAndSlugParams) (database.WorkspaceApp, error) { +func (q *FakeQuerier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg database.GetWorkspaceAppByAgentIDAndSlugParams) (database.WorkspaceApp, error) { if err := validateDatabaseType(arg); err != nil { return database.WorkspaceApp{}, err } @@ -2995,16 +3310,7 @@ func (q *FakeQuerier) GetWorkspaceAppByAgentIDAndSlug(_ context.Context, arg dat q.mutex.RLock() defer q.mutex.RUnlock() - for _, app := range q.workspaceApps { - if app.AgentID != arg.AgentID { - continue - } - if app.Slug != arg.Slug { - continue - } - return app, nil - } - return database.WorkspaceApp{}, sql.ErrNoRows + return q.getWorkspaceAppByAgentIDAndSlugNoLock(ctx, arg) } func (q *FakeQuerier) GetWorkspaceAppsByAgentID(_ context.Context, id uuid.UUID) ([]database.WorkspaceApp, error) { @@ -3126,9 +3432,8 @@ func (q *FakeQuerier) GetWorkspaceBuildsByWorkspaceID(_ context.Context, } // Order by build_number - slices.SortFunc(history, func(a, b database.WorkspaceBuild) bool { - // use greater than since we want descending order - return a.BuildNumber > b.BuildNumber + slices.SortFunc(history, func(a, b database.WorkspaceBuild) int { + return slice.Descending(a.BuildNumber, b.BuildNumber) }) if params.AfterID != uuid.Nil { @@ -3432,14 +3737,14 @@ func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, no if build.Transition == database.WorkspaceTransitionStart && !build.Deadline.IsZero() && build.Deadline.Before(now) && - !workspace.LockedAt.Valid { + !workspace.DormantAt.Valid { workspaces = append(workspaces, workspace) continue } if build.Transition == database.WorkspaceTransitionStop && workspace.AutostartSchedule.Valid && - !workspace.LockedAt.Valid { + !workspace.DormantAt.Valid { workspaces = append(workspaces, workspace) continue } @@ -3457,11 +3762,11 @@ func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, no if err != nil { return nil, xerrors.Errorf("get template by ID: %w", err) } - if !workspace.LockedAt.Valid && template.InactivityTTL > 0 { + if !workspace.DormantAt.Valid && template.TimeTilDormant > 0 { workspaces = append(workspaces, workspace) continue } - if workspace.LockedAt.Valid && template.LockedTTL > 0 { + if workspace.DormantAt.Valid && template.TimeTilDormantAutoDelete > 0 { workspaces = append(workspaces, workspace) continue } @@ -3510,7 +3815,7 @@ func (q *FakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP func (q *FakeQuerier) InsertAllUsersGroup(ctx context.Context, orgID uuid.UUID) (database.Group, error) { return q.InsertGroup(ctx, database.InsertGroupParams{ ID: orgID, - Name: database.AllUsersGroup, + Name: database.EveryoneGroup, DisplayName: "", OrganizationID: orgID, }) @@ -3527,8 +3832,14 @@ func (q *FakeQuerier) InsertAuditLog(_ context.Context, arg database.InsertAudit alog := database.AuditLog(arg) q.auditLogs = append(q.auditLogs, alog) - slices.SortFunc(q.auditLogs, func(a, b database.AuditLog) bool { - return a.Time.Before(b.Time) + slices.SortFunc(q.auditLogs, func(a, b database.AuditLog) int { + if a.Time.Before(b.Time) { + return -1 + } else if a.Time.Equal(b.Time) { + return 0 + } else { + return 1 + } }) return alog, nil @@ -3635,6 +3946,7 @@ func (q *FakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupPar OrganizationID: arg.OrganizationID, AvatarURL: arg.AvatarURL, QuotaAllowance: arg.QuotaAllowance, + Source: database.GroupSourceUser, } q.groups = append(q.groups, group) @@ -3687,6 +3999,45 @@ func (q *FakeQuerier) InsertLicense( return l, nil } +func (q *FakeQuerier) InsertMissingGroups(_ context.Context, arg database.InsertMissingGroupsParams) ([]database.Group, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + groupNameMap := make(map[string]struct{}) + for _, g := range arg.GroupNames { + groupNameMap[g] = struct{}{} + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, g := range q.groups { + if g.OrganizationID != arg.OrganizationID { + continue + } + delete(groupNameMap, g.Name) + } + + newGroups := make([]database.Group, 0, len(groupNameMap)) + for k := range groupNameMap { + g := database.Group{ + ID: uuid.New(), + Name: k, + OrganizationID: arg.OrganizationID, + AvatarURL: "", + QuotaAllowance: 0, + DisplayName: "", + Source: arg.Source, + } + q.groups = append(q.groups, g) + newGroups = append(newGroups, g) + } + + return newGroups, nil +} + func (q *FakeQuerier) InsertOrganization(_ context.Context, arg database.InsertOrganizationParams) (database.Organization, error) { if err := validateDatabaseType(arg); err != nil { return database.Organization{}, err @@ -4177,6 +4528,49 @@ func (q *FakeQuerier) InsertWorkspaceAgentStat(_ context.Context, p database.Ins return stat, nil } +func (q *FakeQuerier) InsertWorkspaceAgentStats(_ context.Context, arg database.InsertWorkspaceAgentStatsParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + var connectionsByProto []map[string]int64 + if err := json.Unmarshal(arg.ConnectionsByProto, &connectionsByProto); err != nil { + return err + } + for i := 0; i < len(arg.ID); i++ { + cbp, err := json.Marshal(connectionsByProto[i]) + if err != nil { + return xerrors.Errorf("failed to marshal connections_by_proto: %w", err) + } + stat := database.WorkspaceAgentStat{ + ID: arg.ID[i], + CreatedAt: arg.CreatedAt[i], + WorkspaceID: arg.WorkspaceID[i], + AgentID: arg.AgentID[i], + UserID: arg.UserID[i], + ConnectionsByProto: cbp, + ConnectionCount: arg.ConnectionCount[i], + RxPackets: arg.RxPackets[i], + RxBytes: arg.RxBytes[i], + TxPackets: arg.TxPackets[i], + TxBytes: arg.TxBytes[i], + TemplateID: arg.TemplateID[i], + SessionCountVSCode: arg.SessionCountVSCode[i], + SessionCountJetBrains: arg.SessionCountJetBrains[i], + SessionCountReconnectingPTY: arg.SessionCountReconnectingPTY[i], + SessionCountSSH: arg.SessionCountSSH[i], + ConnectionMedianLatencyMS: arg.ConnectionMedianLatencyMS[i], + } + q.workspaceAgentStats = append(q.workspaceAgentStats, stat) + } + + return nil +} + func (q *FakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { if err := validateDatabaseType(arg); err != nil { return database.WorkspaceApp{}, err @@ -4211,6 +4605,44 @@ func (q *FakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW return workspaceApp, nil } +func (q *FakeQuerier) InsertWorkspaceAppStats(_ context.Context, arg database.InsertWorkspaceAppStatsParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + +InsertWorkspaceAppStatsLoop: + for i := 0; i < len(arg.UserID); i++ { + stat := database.WorkspaceAppStat{ + ID: q.workspaceAppStatsLastInsertID + 1, + UserID: arg.UserID[i], + WorkspaceID: arg.WorkspaceID[i], + AgentID: arg.AgentID[i], + AccessMethod: arg.AccessMethod[i], + SlugOrPort: arg.SlugOrPort[i], + SessionID: arg.SessionID[i], + SessionStartedAt: arg.SessionStartedAt[i], + SessionEndedAt: arg.SessionEndedAt[i], + Requests: arg.Requests[i], + } + for j, s := range q.workspaceAppStats { + // Check unique constraint for upsert. + if s.UserID == stat.UserID && s.AgentID == stat.AgentID && s.SessionID == stat.SessionID { + q.workspaceAppStats[j].SessionEndedAt = stat.SessionEndedAt + q.workspaceAppStats[j].Requests = stat.Requests + continue InsertWorkspaceAppStatsLoop + } + } + q.workspaceAppStats = append(q.workspaceAppStats, stat) + q.workspaceAppStatsLastInsertID++ + } + + return nil +} + func (q *FakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.InsertWorkspaceBuildParams) error { if err := validateDatabaseType(arg); err != nil { return err @@ -4698,8 +5130,8 @@ func (q *FakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database tpl.RestartRequirementDaysOfWeek = arg.RestartRequirementDaysOfWeek tpl.RestartRequirementWeeks = arg.RestartRequirementWeeks tpl.FailureTTL = arg.FailureTTL - tpl.InactivityTTL = arg.InactivityTTL - tpl.LockedTTL = arg.LockedTTL + tpl.TimeTilDormant = arg.TimeTilDormant + tpl.TimeTilDormantAutoDelete = arg.TimeTilDormantAutoDelete q.templates[idx] = tpl return nil } @@ -4769,6 +5201,26 @@ func (q *FakeQuerier) UpdateTemplateVersionGitAuthProvidersByJobID(_ context.Con return sql.ErrNoRows } +func (q *FakeQuerier) UpdateTemplateWorkspacesLastUsedAt(_ context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, ws := range q.workspaces { + if ws.TemplateID != arg.TemplateID { + continue + } + ws.LastUsedAt = arg.LastUsedAt + q.workspaces[i] = ws + } + + return nil +} + func (q *FakeQuerier) UpdateUserDeletedByID(_ context.Context, params database.UpdateUserDeletedByIDParams) error { if err := validateDatabaseType(params); err != nil { return err @@ -5114,6 +5566,23 @@ func (q *FakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat return err } + if len(arg.Subsystems) > 0 { + seen := map[database.WorkspaceAgentSubsystem]struct{}{ + arg.Subsystems[0]: {}, + } + for i := 1; i < len(arg.Subsystems); i++ { + s := arg.Subsystems[i] + if _, ok := seen[s]; ok { + return xerrors.Errorf("duplicate subsystem %q", s) + } + seen[s] = struct{}{} + + if arg.Subsystems[i-1] > arg.Subsystems[i] { + return xerrors.Errorf("subsystems not sorted: %q > %q", arg.Subsystems[i-1], arg.Subsystems[i]) + } + } + } + q.mutex.Lock() defer q.mutex.Unlock() @@ -5124,7 +5593,7 @@ func (q *FakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat agent.Version = arg.Version agent.ExpandedDirectory = arg.ExpandedDirectory - agent.Subsystem = arg.Subsystem + agent.Subsystems = arg.Subsystems q.workspaceAgents[index] = agent return nil } @@ -5230,29 +5699,9 @@ func (q *FakeQuerier) UpdateWorkspaceDeletedByID(_ context.Context, arg database return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database.UpdateWorkspaceLastUsedAtParams) error { +func (q *FakeQuerier) UpdateWorkspaceDormantDeletingAt(_ context.Context, arg database.UpdateWorkspaceDormantDeletingAtParams) (database.Workspace, error) { if err := validateDatabaseType(arg); err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - for index, workspace := range q.workspaces { - if workspace.ID != arg.ID { - continue - } - workspace.LastUsedAt = arg.LastUsedAt - q.workspaces[index] = workspace - return nil - } - - return sql.ErrNoRows -} - -func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { - if err := validateDatabaseType(arg); err != nil { - return err + return database.Workspace{}, err } q.mutex.Lock() defer q.mutex.Unlock() @@ -5260,12 +5709,12 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat if workspace.ID != arg.ID { continue } - workspace.LockedAt = arg.LockedAt - if workspace.LockedAt.Time.IsZero() { + workspace.DormantAt = arg.DormantAt + if workspace.DormantAt.Time.IsZero() { workspace.LastUsedAt = database.Now() workspace.DeletingAt = sql.NullTime{} } - if !workspace.LockedAt.Time.IsZero() { + if !workspace.DormantAt.Time.IsZero() { var template database.TemplateTable for _, t := range q.templates { if t.ID == workspace.TemplateID { @@ -5274,18 +5723,38 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat } } if template.ID == uuid.Nil { - return xerrors.Errorf("unable to find workspace template") + return database.Workspace{}, xerrors.Errorf("unable to find workspace template") } - if template.LockedTTL > 0 { + if template.TimeTilDormantAutoDelete > 0 { workspace.DeletingAt = sql.NullTime{ Valid: true, - Time: workspace.LockedAt.Time.Add(time.Duration(template.LockedTTL)), + Time: workspace.DormantAt.Time.Add(time.Duration(template.TimeTilDormantAutoDelete)), } } } q.workspaces[index] = workspace + return workspace, nil + } + return database.Workspace{}, sql.ErrNoRows +} + +func (q *FakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database.UpdateWorkspaceLastUsedAtParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, workspace := range q.workspaces { + if workspace.ID != arg.ID { + continue + } + workspace.LastUsedAt = arg.LastUsedAt + q.workspaces[index] = workspace return nil } + return sql.ErrNoRows } @@ -5349,7 +5818,7 @@ func (q *FakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspacesDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { +func (q *FakeQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -5359,14 +5828,26 @@ func (q *FakeQuerier) UpdateWorkspacesDeletingAtByTemplateID(_ context.Context, } for i, ws := range q.workspaces { - if ws.LockedAt.Time.IsZero() { + if ws.TemplateID != arg.TemplateID { + continue + } + + if ws.DormantAt.Time.IsZero() { continue } + + if !arg.DormantAt.IsZero() { + ws.DormantAt = sql.NullTime{ + Valid: true, + Time: arg.DormantAt, + } + } + deletingAt := sql.NullTime{ - Valid: arg.LockedTtlMs > 0, + Valid: arg.TimeTilDormantAutodeleteMs > 0, } - if arg.LockedTtlMs > 0 { - deletingAt.Time = ws.LockedAt.Time.Add(time.Duration(arg.LockedTtlMs) * time.Millisecond) + if arg.TimeTilDormantAutodeleteMs > 0 { + deletingAt.Time = ws.DormantAt.Time.Add(time.Duration(arg.TimeTilDormantAutodeleteMs) * time.Millisecond) } ws.DeletingAt = deletingAt q.workspaces[i] = ws @@ -5482,11 +5963,11 @@ func (q *FakeQuerier) GetAuthorizedTemplates(ctx context.Context, arg database.G templates = append(templates, template) } if len(templates) > 0 { - slices.SortFunc(templates, func(i, j database.Template) bool { - if i.Name != j.Name { - return i.Name < j.Name + slices.SortFunc(templates, func(a, b database.Template) int { + if a.Name != b.Name { + return slice.Ascending(a.Name, b.Name) } - return i.ID.String() < j.ID.String() + return slice.Ascending(a.ID.String(), b.ID.String()) }) return templates, nil } @@ -5572,7 +6053,6 @@ func (q *FakeQuerier) GetTemplateUserRoles(_ context.Context, id uuid.UUID) ([]d return users, nil } -//nolint:gocyclo func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) { if err := validateDatabaseType(arg); err != nil { return nil, err @@ -5617,6 +6097,18 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. continue } + if !arg.LastUsedBefore.IsZero() { + if workspace.LastUsedAt.After(arg.LastUsedBefore) { + continue + } + } + + if !arg.LastUsedAfter.IsZero() { + if workspace.LastUsedAt.Before(arg.LastUsedAfter) { + continue + } + } + if arg.Status != "" { build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) if err != nil { @@ -5730,6 +6222,16 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } } + // We omit locked workspaces by default. + if arg.DormantAt.IsZero() && workspace.DormantAt.Valid { + continue + } + + // Filter out workspaces that are locked after the timestamp. + if !arg.DormantAt.IsZero() && workspace.DormantAt.Time.Before(arg.DormantAt) { + continue + } + if len(arg.TemplateIDs) > 0 { match := false for _, id := range arg.TemplateIDs { diff --git a/coderd/database/dbfake/dbfake_test.go b/coderd/database/dbfake/dbfake_test.go index 445ba6be8ec49..84d3ad39e1200 100644 --- a/coderd/database/dbfake/dbfake_test.go +++ b/coderd/database/dbfake/dbfake_test.go @@ -10,9 +10,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" ) // test that transactions don't deadlock, and that we don't see intermediate state. diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 3e38f5c4561c9..2c3088b9be3b0 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -16,10 +16,10 @@ import ( "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/cryptorand" ) // All methods take in a 'seed' object. Any provided fields in the seed will be @@ -317,8 +317,9 @@ func ProvisionerJob(t testing.TB, db database.Store, orig database.ProvisionerJo // Make sure when we acquire the job, we only get this one. orig.Tags[id.String()] = "true" } + jobID := takeFirst(orig.ID, uuid.New()) job, err := db.InsertProvisionerJob(genCtx, database.InsertProvisionerJobParams{ - ID: takeFirst(orig.ID, uuid.New()), + ID: jobID, CreatedAt: takeFirst(orig.CreatedAt, database.Now()), UpdatedAt: takeFirst(orig.UpdatedAt, database.Now()), OrganizationID: takeFirst(orig.OrganizationID, uuid.New()), @@ -343,7 +344,7 @@ func ProvisionerJob(t testing.TB, db database.Store, orig database.ProvisionerJo if !orig.CompletedAt.Time.IsZero() || orig.Error.String != "" { err := db.UpdateProvisionerJobWithCompleteByID(genCtx, database.UpdateProvisionerJobWithCompleteByIDParams{ - ID: job.ID, + ID: jobID, UpdatedAt: job.UpdatedAt, CompletedAt: orig.CompletedAt, Error: orig.Error, @@ -353,14 +354,14 @@ func ProvisionerJob(t testing.TB, db database.Store, orig database.ProvisionerJo } if !orig.CanceledAt.Time.IsZero() { err := db.UpdateProvisionerJobWithCancelByID(genCtx, database.UpdateProvisionerJobWithCancelByIDParams{ - ID: job.ID, + ID: jobID, CanceledAt: orig.CanceledAt, CompletedAt: orig.CompletedAt, }) require.NoError(t, err) } - job, err = db.GetProvisionerJobByID(genCtx, job.ID) + job, err = db.GetProvisionerJobByID(genCtx, jobID) require.NoError(t, err) return job diff --git a/coderd/database/dbgen/dbgen_test.go b/coderd/database/dbgen/dbgen_test.go index 5509455f7a586..de403d23f49c0 100644 --- a/coderd/database/dbgen/dbgen_test.go +++ b/coderd/database/dbgen/dbgen_test.go @@ -7,9 +7,9 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" ) func TestGenerator(t *testing.T) { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index f78d9b44a46c0..8526eb4da1078 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -12,8 +12,8 @@ import ( "github.com/prometheus/client_golang/prometheus" "golang.org/x/exp/slices" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" ) var ( @@ -237,6 +237,13 @@ func (m metricsStore) GetActiveUserCount(ctx context.Context) (int64, error) { return count, err } +func (m metricsStore) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]database.WorkspaceBuild, error) { + start := time.Now() + r0, r1 := m.s.GetActiveWorkspaceBuildsByTemplateID(ctx, templateID) + m.queryLatencies.WithLabelValues("GetActiveWorkspaceBuildsByTemplateID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetAllTailnetAgents(ctx context.Context) ([]database.TailnetAgent, error) { start := time.Now() r0, r1 := m.s.GetAllTailnetAgents(ctx) @@ -592,6 +599,13 @@ func (m metricsStore) GetTailnetClientsForAgent(ctx context.Context, agentID uui return m.s.GetTailnetClientsForAgent(ctx, agentID) } +func (m metricsStore) GetTemplateAppInsights(ctx context.Context, arg database.GetTemplateAppInsightsParams) ([]database.GetTemplateAppInsightsRow, error) { + start := time.Now() + r0, r1 := m.s.GetTemplateAppInsights(ctx, arg) + m.queryLatencies.WithLabelValues("GetTemplateAppInsights").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) { start := time.Now() buildTime, err := m.s.GetTemplateAverageBuildTime(ctx, arg) @@ -774,11 +788,11 @@ func (m metricsStore) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]dat return users, err } -func (m metricsStore) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (database.WorkspaceAgent, error) { +func (m metricsStore) GetWorkspaceAgentAndOwnerByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndOwnerByAuthTokenRow, error) { start := time.Now() - agent, err := m.s.GetWorkspaceAgentByAuthToken(ctx, authToken) - m.queryLatencies.WithLabelValues("GetWorkspaceAgentByAuthToken").Observe(time.Since(start).Seconds()) - return agent, err + r0, r1 := m.s.GetWorkspaceAgentAndOwnerByAuthToken(ctx, authToken) + m.queryLatencies.WithLabelValues("GetWorkspaceAgentAndOwnerByAuthToken").Observe(time.Since(start).Seconds()) + return r0, r1 } func (m metricsStore) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (database.WorkspaceAgent, error) { @@ -1110,6 +1124,13 @@ func (m metricsStore) InsertLicense(ctx context.Context, arg database.InsertLice return license, err } +func (m metricsStore) InsertMissingGroups(ctx context.Context, arg database.InsertMissingGroupsParams) ([]database.Group, error) { + start := time.Now() + r0, r1 := m.s.InsertMissingGroups(ctx, arg) + m.queryLatencies.WithLabelValues("InsertMissingGroups").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) InsertOrganization(ctx context.Context, arg database.InsertOrganizationParams) (database.Organization, error) { start := time.Now() organization, err := m.s.InsertOrganization(ctx, arg) @@ -1236,6 +1257,13 @@ func (m metricsStore) InsertWorkspaceAgentStat(ctx context.Context, arg database return stat, err } +func (m metricsStore) InsertWorkspaceAgentStats(ctx context.Context, arg database.InsertWorkspaceAgentStatsParams) error { + start := time.Now() + r0 := m.s.InsertWorkspaceAgentStats(ctx, arg) + m.queryLatencies.WithLabelValues("InsertWorkspaceAgentStats").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { start := time.Now() app, err := m.s.InsertWorkspaceApp(ctx, arg) @@ -1243,6 +1271,13 @@ func (m metricsStore) InsertWorkspaceApp(ctx context.Context, arg database.Inser return app, err } +func (m metricsStore) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { + start := time.Now() + r0 := m.s.InsertWorkspaceAppStats(ctx, arg) + m.queryLatencies.WithLabelValues("InsertWorkspaceAppStats").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { start := time.Now() err := m.s.InsertWorkspaceBuild(ctx, arg) @@ -1418,6 +1453,13 @@ func (m metricsStore) UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.C return err } +func (m metricsStore) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { + start := time.Now() + r0 := m.s.UpdateTemplateWorkspacesLastUsedAt(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateWorkspacesLastUsedAt").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpdateUserDeletedByID(ctx context.Context, arg database.UpdateUserDeletedByIDParams) error { start := time.Now() err := m.s.UpdateUserDeletedByID(ctx, arg) @@ -1565,6 +1607,13 @@ func (m metricsStore) UpdateWorkspaceDeletedByID(ctx context.Context, arg databa return err } +func (m metricsStore) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg database.UpdateWorkspaceDormantDeletingAtParams) (database.Workspace, error) { + start := time.Now() + ws, r0 := m.s.UpdateWorkspaceDormantDeletingAt(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceDormantDeletingAt").Observe(time.Since(start).Seconds()) + return ws, r0 +} + func (m metricsStore) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.UpdateWorkspaceLastUsedAtParams) error { start := time.Now() err := m.s.UpdateWorkspaceLastUsedAt(ctx, arg) @@ -1572,13 +1621,6 @@ func (m metricsStore) UpdateWorkspaceLastUsedAt(ctx context.Context, arg databas return err } -func (m metricsStore) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { - start := time.Now() - r0 := m.s.UpdateWorkspaceLockedDeletingAt(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspaceLockedDeletingAt").Observe(time.Since(start).Seconds()) - return r0 -} - func (m metricsStore) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { start := time.Now() proxy, err := m.s.UpdateWorkspaceProxy(ctx, arg) @@ -1600,10 +1642,10 @@ func (m metricsStore) UpdateWorkspaceTTL(ctx context.Context, arg database.Updat return r0 } -func (m metricsStore) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { +func (m metricsStore) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error { start := time.Now() - r0 := m.s.UpdateWorkspacesDeletingAtByTemplateID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspacesDeletingAtByTemplateID").Observe(time.Since(start).Seconds()) + r0 := m.s.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspacesDormantDeletingAtByTemplateID").Observe(time.Since(start).Seconds()) return r0 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index adf573cb99e82..b0ae7955a458d 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/coder/coder/coderd/database (interfaces: Store) +// Source: github.com/coder/coder/v2/coderd/database (interfaces: Store) // Package dbmock is a generated GoMock package. package dbmock @@ -10,8 +10,8 @@ import ( reflect "reflect" time "time" - database "github.com/coder/coder/coderd/database" - rbac "github.com/coder/coder/coderd/rbac" + database "github.com/coder/coder/v2/coderd/database" + rbac "github.com/coder/coder/v2/coderd/rbac" gomock "github.com/golang/mock/gomock" uuid "github.com/google/uuid" ) @@ -371,6 +371,21 @@ func (mr *MockStoreMockRecorder) GetActiveUserCount(arg0 interface{}) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveUserCount", reflect.TypeOf((*MockStore)(nil).GetActiveUserCount), arg0) } +// GetActiveWorkspaceBuildsByTemplateID mocks base method. +func (m *MockStore) GetActiveWorkspaceBuildsByTemplateID(arg0 context.Context, arg1 uuid.UUID) ([]database.WorkspaceBuild, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveWorkspaceBuildsByTemplateID", arg0, arg1) + ret0, _ := ret[0].([]database.WorkspaceBuild) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveWorkspaceBuildsByTemplateID indicates an expected call of GetActiveWorkspaceBuildsByTemplateID. +func (mr *MockStoreMockRecorder) GetActiveWorkspaceBuildsByTemplateID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveWorkspaceBuildsByTemplateID", reflect.TypeOf((*MockStore)(nil).GetActiveWorkspaceBuildsByTemplateID), arg0, arg1) +} + // GetAllTailnetAgents mocks base method. func (m *MockStore) GetAllTailnetAgents(arg0 context.Context) ([]database.TailnetAgent, error) { m.ctrl.T.Helper() @@ -1181,6 +1196,21 @@ func (mr *MockStoreMockRecorder) GetTailnetClientsForAgent(arg0, arg1 interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTailnetClientsForAgent", reflect.TypeOf((*MockStore)(nil).GetTailnetClientsForAgent), arg0, arg1) } +// GetTemplateAppInsights mocks base method. +func (m *MockStore) GetTemplateAppInsights(arg0 context.Context, arg1 database.GetTemplateAppInsightsParams) ([]database.GetTemplateAppInsightsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTemplateAppInsights", arg0, arg1) + ret0, _ := ret[0].([]database.GetTemplateAppInsightsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTemplateAppInsights indicates an expected call of GetTemplateAppInsights. +func (mr *MockStoreMockRecorder) GetTemplateAppInsights(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateAppInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateAppInsights), arg0, arg1) +} + // GetTemplateAverageBuildTime mocks base method. func (m *MockStore) GetTemplateAverageBuildTime(arg0 context.Context, arg1 database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) { m.ctrl.T.Helper() @@ -1601,19 +1631,19 @@ func (mr *MockStoreMockRecorder) GetUsersByIDs(arg0, arg1 interface{}) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersByIDs", reflect.TypeOf((*MockStore)(nil).GetUsersByIDs), arg0, arg1) } -// GetWorkspaceAgentByAuthToken mocks base method. -func (m *MockStore) GetWorkspaceAgentByAuthToken(arg0 context.Context, arg1 uuid.UUID) (database.WorkspaceAgent, error) { +// GetWorkspaceAgentAndOwnerByAuthToken mocks base method. +func (m *MockStore) GetWorkspaceAgentAndOwnerByAuthToken(arg0 context.Context, arg1 uuid.UUID) (database.GetWorkspaceAgentAndOwnerByAuthTokenRow, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetWorkspaceAgentByAuthToken", arg0, arg1) - ret0, _ := ret[0].(database.WorkspaceAgent) + ret := m.ctrl.Call(m, "GetWorkspaceAgentAndOwnerByAuthToken", arg0, arg1) + ret0, _ := ret[0].(database.GetWorkspaceAgentAndOwnerByAuthTokenRow) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetWorkspaceAgentByAuthToken indicates an expected call of GetWorkspaceAgentByAuthToken. -func (mr *MockStoreMockRecorder) GetWorkspaceAgentByAuthToken(arg0, arg1 interface{}) *gomock.Call { +// GetWorkspaceAgentAndOwnerByAuthToken indicates an expected call of GetWorkspaceAgentAndOwnerByAuthToken. +func (mr *MockStoreMockRecorder) GetWorkspaceAgentAndOwnerByAuthToken(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAgentByAuthToken", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAgentByAuthToken), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAgentAndOwnerByAuthToken", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAgentAndOwnerByAuthToken), arg0, arg1) } // GetWorkspaceAgentByID mocks base method. @@ -2332,6 +2362,21 @@ func (mr *MockStoreMockRecorder) InsertLicense(arg0, arg1 interface{}) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertLicense", reflect.TypeOf((*MockStore)(nil).InsertLicense), arg0, arg1) } +// InsertMissingGroups mocks base method. +func (m *MockStore) InsertMissingGroups(arg0 context.Context, arg1 database.InsertMissingGroupsParams) ([]database.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertMissingGroups", arg0, arg1) + ret0, _ := ret[0].([]database.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertMissingGroups indicates an expected call of InsertMissingGroups. +func (mr *MockStoreMockRecorder) InsertMissingGroups(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertMissingGroups", reflect.TypeOf((*MockStore)(nil).InsertMissingGroups), arg0, arg1) +} + // InsertOrganization mocks base method. func (m *MockStore) InsertOrganization(arg0 context.Context, arg1 database.InsertOrganizationParams) (database.Organization, error) { m.ctrl.T.Helper() @@ -2598,6 +2643,20 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceAgentStat(arg0, arg1 interface{} return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAgentStat", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAgentStat), arg0, arg1) } +// InsertWorkspaceAgentStats mocks base method. +func (m *MockStore) InsertWorkspaceAgentStats(arg0 context.Context, arg1 database.InsertWorkspaceAgentStatsParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertWorkspaceAgentStats", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// InsertWorkspaceAgentStats indicates an expected call of InsertWorkspaceAgentStats. +func (mr *MockStoreMockRecorder) InsertWorkspaceAgentStats(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAgentStats", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAgentStats), arg0, arg1) +} + // InsertWorkspaceApp mocks base method. func (m *MockStore) InsertWorkspaceApp(arg0 context.Context, arg1 database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { m.ctrl.T.Helper() @@ -2613,6 +2672,20 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceApp(arg0, arg1 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceApp", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceApp), arg0, arg1) } +// InsertWorkspaceAppStats mocks base method. +func (m *MockStore) InsertWorkspaceAppStats(arg0 context.Context, arg1 database.InsertWorkspaceAppStatsParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertWorkspaceAppStats", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// InsertWorkspaceAppStats indicates an expected call of InsertWorkspaceAppStats. +func (mr *MockStoreMockRecorder) InsertWorkspaceAppStats(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAppStats", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAppStats), arg0, arg1) +} + // InsertWorkspaceBuild mocks base method. func (m *MockStore) InsertWorkspaceBuild(arg0 context.Context, arg1 database.InsertWorkspaceBuildParams) error { m.ctrl.T.Helper() @@ -2989,6 +3062,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateVersionGitAuthProvidersByJobID(ar return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionGitAuthProvidersByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionGitAuthProvidersByJobID), arg0, arg1) } +// UpdateTemplateWorkspacesLastUsedAt mocks base method. +func (m *MockStore) UpdateTemplateWorkspacesLastUsedAt(arg0 context.Context, arg1 database.UpdateTemplateWorkspacesLastUsedAtParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTemplateWorkspacesLastUsedAt", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTemplateWorkspacesLastUsedAt indicates an expected call of UpdateTemplateWorkspacesLastUsedAt. +func (mr *MockStoreMockRecorder) UpdateTemplateWorkspacesLastUsedAt(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateWorkspacesLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateTemplateWorkspacesLastUsedAt), arg0, arg1) +} + // UpdateUserDeletedByID mocks base method. func (m *MockStore) UpdateUserDeletedByID(arg0 context.Context, arg1 database.UpdateUserDeletedByIDParams) error { m.ctrl.T.Helper() @@ -3292,32 +3379,33 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceDeletedByID(arg0, arg1 interface return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceDeletedByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceDeletedByID), arg0, arg1) } -// UpdateWorkspaceLastUsedAt mocks base method. -func (m *MockStore) UpdateWorkspaceLastUsedAt(arg0 context.Context, arg1 database.UpdateWorkspaceLastUsedAtParams) error { +// UpdateWorkspaceDormantDeletingAt mocks base method. +func (m *MockStore) UpdateWorkspaceDormantDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceDormantDeletingAtParams) (database.Workspace, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspaceLastUsedAt", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 + ret := m.ctrl.Call(m, "UpdateWorkspaceDormantDeletingAt", arg0, arg1) + ret0, _ := ret[0].(database.Workspace) + ret1, _ := ret[1].(error) + return ret0, ret1 } -// UpdateWorkspaceLastUsedAt indicates an expected call of UpdateWorkspaceLastUsedAt. -func (mr *MockStoreMockRecorder) UpdateWorkspaceLastUsedAt(arg0, arg1 interface{}) *gomock.Call { +// UpdateWorkspaceDormantDeletingAt indicates an expected call of UpdateWorkspaceDormantDeletingAt. +func (mr *MockStoreMockRecorder) UpdateWorkspaceDormantDeletingAt(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceLastUsedAt), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceDormantDeletingAt", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceDormantDeletingAt), arg0, arg1) } -// UpdateWorkspaceLockedDeletingAt mocks base method. -func (m *MockStore) UpdateWorkspaceLockedDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceLockedDeletingAtParams) error { +// UpdateWorkspaceLastUsedAt mocks base method. +func (m *MockStore) UpdateWorkspaceLastUsedAt(arg0 context.Context, arg1 database.UpdateWorkspaceLastUsedAtParams) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspaceLockedDeletingAt", arg0, arg1) + ret := m.ctrl.Call(m, "UpdateWorkspaceLastUsedAt", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } -// UpdateWorkspaceLockedDeletingAt indicates an expected call of UpdateWorkspaceLockedDeletingAt. -func (mr *MockStoreMockRecorder) UpdateWorkspaceLockedDeletingAt(arg0, arg1 interface{}) *gomock.Call { +// UpdateWorkspaceLastUsedAt indicates an expected call of UpdateWorkspaceLastUsedAt. +func (mr *MockStoreMockRecorder) UpdateWorkspaceLastUsedAt(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceLockedDeletingAt", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceLockedDeletingAt), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceLastUsedAt), arg0, arg1) } // UpdateWorkspaceProxy mocks base method. @@ -3363,18 +3451,18 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceTTL(arg0, arg1 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceTTL), arg0, arg1) } -// UpdateWorkspacesDeletingAtByTemplateID mocks base method. -func (m *MockStore) UpdateWorkspacesDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { +// UpdateWorkspacesDormantDeletingAtByTemplateID mocks base method. +func (m *MockStore) UpdateWorkspacesDormantDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspacesDeletingAtByTemplateID", arg0, arg1) + ret := m.ctrl.Call(m, "UpdateWorkspacesDormantDeletingAtByTemplateID", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } -// UpdateWorkspacesDeletingAtByTemplateID indicates an expected call of UpdateWorkspacesDeletingAtByTemplateID. -func (mr *MockStoreMockRecorder) UpdateWorkspacesDeletingAtByTemplateID(arg0, arg1 interface{}) *gomock.Call { +// UpdateWorkspacesDormantDeletingAtByTemplateID indicates an expected call of UpdateWorkspacesDormantDeletingAtByTemplateID. +func (mr *MockStoreMockRecorder) UpdateWorkspacesDormantDeletingAtByTemplateID(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesDeletingAtByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesDeletingAtByTemplateID), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesDormantDeletingAtByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesDormantDeletingAtByTemplateID), arg0, arg1) } // UpsertAppSecurityKey mocks base method. diff --git a/coderd/database/dbmock/doc.go b/coderd/database/dbmock/doc.go index 2199de635b86b..9d06ed8a0dbf1 100644 --- a/coderd/database/dbmock/doc.go +++ b/coderd/database/dbmock/doc.go @@ -1,4 +1,4 @@ // package dbmock contains a mocked implementation of the database.Store interface for use in tests package dbmock -//go:generate mockgen -destination ./dbmock.go -package dbmock github.com/coder/coder/coderd/database Store +//go:generate mockgen -destination ./dbmock.go -package dbmock github.com/coder/coder/v2/coderd/database Store diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index de7c2db67ef4b..b1062eee312ed 100644 --- a/coderd/database/dbpurge/dbpurge.go +++ b/coderd/database/dbpurge/dbpurge.go @@ -9,8 +9,8 @@ import ( "golang.org/x/sync/errgroup" "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" ) const ( diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index bc51f15b451da..f83d1b81a1d2a 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -9,8 +9,8 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbpurge" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbpurge" ) func TestMain(m *testing.M) { diff --git a/coderd/database/dbtestutil/db.go b/coderd/database/dbtestutil/db.go index ad8cecf143240..00eae9dd11218 100644 --- a/coderd/database/dbtestutil/db.go +++ b/coderd/database/dbtestutil/db.go @@ -8,10 +8,10 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/postgres" - "github.com/coder/coder/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/postgres" + "github.com/coder/coder/v2/coderd/database/pubsub" ) // WillUsePostgres returns true if a call to NewDB() will return a real, postgres-backed Store and Pubsub. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f121fccf8cebb..a2767c9cfd5e1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -31,6 +31,11 @@ CREATE TYPE build_reason AS ENUM ( 'autodelete' ); +CREATE TYPE group_source AS ENUM ( + 'user', + 'oidc' +); + CREATE TYPE log_level AS ENUM ( 'trace', 'debug', @@ -143,7 +148,8 @@ CREATE TYPE workspace_agent_log_source AS ENUM ( CREATE TYPE workspace_agent_subsystem AS ENUM ( 'envbuilder', 'envbox', - 'none' + 'none', + 'exectrace' ); CREATE TYPE workspace_app_health AS ENUM ( @@ -299,11 +305,14 @@ CREATE TABLE groups ( organization_id uuid NOT NULL, avatar_url text DEFAULT ''::text NOT NULL, quota_allowance integer DEFAULT 0 NOT NULL, - display_name text DEFAULT ''::text NOT NULL + display_name text DEFAULT ''::text NOT NULL, + source group_source DEFAULT 'user'::group_source NOT NULL ); COMMENT ON COLUMN groups.display_name IS 'Display name is a custom, human-friendly group name that user can set. This is not required to be unique and can be the empty string.'; +COMMENT ON COLUMN groups.source IS 'Source indicates how the group was created. It can be created by a user manually, or through some system process like OIDC group sync.'; + CREATE TABLE licenses ( id integer NOT NULL, uploaded_at timestamp with time zone NOT NULL, @@ -626,8 +635,8 @@ CREATE TABLE templates ( allow_user_autostart boolean DEFAULT true NOT NULL, allow_user_autostop boolean DEFAULT true NOT NULL, failure_ttl bigint DEFAULT 0 NOT NULL, - inactivity_ttl bigint DEFAULT 0 NOT NULL, - locked_ttl bigint DEFAULT 0 NOT NULL, + time_til_dormant bigint DEFAULT 0 NOT NULL, + time_til_dormant_autodelete bigint DEFAULT 0 NOT NULL, restart_requirement_days_of_week smallint DEFAULT 0 NOT NULL, restart_requirement_weeks bigint DEFAULT 0 NOT NULL ); @@ -667,8 +676,8 @@ CREATE VIEW template_with_users AS templates.allow_user_autostart, templates.allow_user_autostop, templates.failure_ttl, - templates.inactivity_ttl, - templates.locked_ttl, + templates.time_til_dormant, + templates.time_til_dormant_autodelete, templates.restart_requirement_days_of_week, templates.restart_requirement_weeks, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, @@ -767,11 +776,12 @@ CREATE TABLE workspace_agents ( shutdown_script_timeout_seconds integer DEFAULT 0 NOT NULL, logs_length integer DEFAULT 0 NOT NULL, logs_overflowed boolean DEFAULT false NOT NULL, - subsystem workspace_agent_subsystem DEFAULT 'none'::workspace_agent_subsystem NOT NULL, startup_script_behavior startup_script_behavior DEFAULT 'non-blocking'::startup_script_behavior NOT NULL, started_at timestamp with time zone, ready_at timestamp with time zone, - CONSTRAINT max_logs_length CHECK ((logs_length <= 1048576)) + subsystems workspace_agent_subsystem[] DEFAULT '{}'::workspace_agent_subsystem[], + CONSTRAINT max_logs_length CHECK ((logs_length <= 1048576)), + CONSTRAINT subsystems_not_none CHECK ((NOT ('none'::workspace_agent_subsystem = ANY (subsystems)))) ); COMMENT ON COLUMN workspace_agents.version IS 'Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.'; @@ -802,6 +812,50 @@ COMMENT ON COLUMN workspace_agents.started_at IS 'The time the agent entered the COMMENT ON COLUMN workspace_agents.ready_at IS 'The time the agent entered the ready or start_error lifecycle state'; +CREATE TABLE workspace_app_stats ( + id bigint NOT NULL, + user_id uuid NOT NULL, + workspace_id uuid NOT NULL, + agent_id uuid NOT NULL, + access_method text NOT NULL, + slug_or_port text NOT NULL, + session_id uuid NOT NULL, + session_started_at timestamp with time zone NOT NULL, + session_ended_at timestamp with time zone NOT NULL, + requests integer NOT NULL +); + +COMMENT ON TABLE workspace_app_stats IS 'A record of workspace app usage statistics'; + +COMMENT ON COLUMN workspace_app_stats.id IS 'The ID of the record'; + +COMMENT ON COLUMN workspace_app_stats.user_id IS 'The user who used the workspace app'; + +COMMENT ON COLUMN workspace_app_stats.workspace_id IS 'The workspace that the workspace app was used in'; + +COMMENT ON COLUMN workspace_app_stats.agent_id IS 'The workspace agent that was used'; + +COMMENT ON COLUMN workspace_app_stats.access_method IS 'The method used to access the workspace app'; + +COMMENT ON COLUMN workspace_app_stats.slug_or_port IS 'The slug or port used to to identify the app'; + +COMMENT ON COLUMN workspace_app_stats.session_id IS 'The unique identifier for the session'; + +COMMENT ON COLUMN workspace_app_stats.session_started_at IS 'The time the session started'; + +COMMENT ON COLUMN workspace_app_stats.session_ended_at IS 'The time the session ended'; + +COMMENT ON COLUMN workspace_app_stats.requests IS 'The number of requests made during the session, a number larger than 1 indicates that multiple sessions were rolled up into one'; + +CREATE SEQUENCE workspace_app_stats_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE workspace_app_stats_id_seq OWNED BY workspace_app_stats.id; + CREATE TABLE workspace_apps ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -949,7 +1003,7 @@ CREATE TABLE workspaces ( autostart_schedule text, ttl bigint, last_used_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL, - locked_at timestamp with time zone, + dormant_at timestamp with time zone, deleting_at timestamp with time zone ); @@ -959,6 +1013,8 @@ ALTER TABLE ONLY provisioner_job_logs ALTER COLUMN id SET DEFAULT nextval('provi ALTER TABLE ONLY workspace_agent_logs ALTER COLUMN id SET DEFAULT nextval('workspace_agent_startup_logs_id_seq'::regclass); +ALTER TABLE ONLY workspace_app_stats ALTER COLUMN id SET DEFAULT nextval('workspace_app_stats_id_seq'::regclass); + ALTER TABLE ONLY workspace_proxies ALTER COLUMN region_id SET DEFAULT nextval('workspace_proxies_region_id_seq'::regclass); ALTER TABLE ONLY workspace_resource_metadata ALTER COLUMN id SET DEFAULT nextval('workspace_resource_metadata_id_seq'::regclass); @@ -1071,6 +1127,12 @@ ALTER TABLE ONLY workspace_agent_logs ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); +ALTER TABLE ONLY workspace_app_stats + ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY workspace_app_stats + ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id); + ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); @@ -1157,6 +1219,8 @@ CREATE INDEX workspace_agents_auth_token_idx ON workspace_agents USING btree (au CREATE INDEX workspace_agents_resource_id_idx ON workspace_agents USING btree (resource_id); +CREATE INDEX workspace_app_stats_workspace_id_idx ON workspace_app_stats USING btree (workspace_id); + CREATE UNIQUE INDEX workspace_proxies_lower_name_idx ON workspace_proxies USING btree (lower(name)) WHERE (deleted = false); CREATE INDEX workspace_resources_job_id_idx ON workspace_resources USING btree (job_id); @@ -1242,6 +1306,15 @@ ALTER TABLE ONLY workspace_agent_logs ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_resource_id_fkey FOREIGN KEY (resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE; +ALTER TABLE ONLY workspace_app_stats + ADD CONSTRAINT workspace_app_stats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); + +ALTER TABLE ONLY workspace_app_stats + ADD CONSTRAINT workspace_app_stats_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); + +ALTER TABLE ONLY workspace_app_stats + ADD CONSTRAINT workspace_app_stats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); + ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/gen/dump/main.go b/coderd/database/gen/dump/main.go index 2c70c4294c35d..daa26923f9411 100644 --- a/coderd/database/gen/dump/main.go +++ b/coderd/database/gen/dump/main.go @@ -11,8 +11,8 @@ import ( "strconv" "strings" - "github.com/coder/coder/coderd/database/migrations" - "github.com/coder/coder/coderd/database/postgres" + "github.com/coder/coder/v2/coderd/database/migrations" + "github.com/coder/coder/v2/coderd/database/postgres" ) const minimumPostgreSQLVersion = 13 diff --git a/coderd/database/migrations/000148_group_source.down.sql b/coderd/database/migrations/000148_group_source.down.sql new file mode 100644 index 0000000000000..504c227d186bb --- /dev/null +++ b/coderd/database/migrations/000148_group_source.down.sql @@ -0,0 +1,8 @@ +BEGIN; + +ALTER TABLE groups + DROP COLUMN source; + +DROP TYPE group_source; + +COMMIT; diff --git a/coderd/database/migrations/000148_group_source.up.sql b/coderd/database/migrations/000148_group_source.up.sql new file mode 100644 index 0000000000000..d06e89ca2b1d6 --- /dev/null +++ b/coderd/database/migrations/000148_group_source.up.sql @@ -0,0 +1,15 @@ +BEGIN; + +CREATE TYPE group_source AS ENUM ( + -- User created groups + 'user', + -- Groups created by the system through oidc sync + 'oidc' +); + +ALTER TABLE groups + ADD COLUMN source group_source NOT NULL DEFAULT 'user'; + +COMMENT ON COLUMN groups.source IS 'Source indicates how the group was created. It can be created by a user manually, or through some system process like OIDC group sync.'; + +COMMIT; diff --git a/coderd/database/migrations/000149_agent_multiple_subsystems.down.sql b/coderd/database/migrations/000149_agent_multiple_subsystems.down.sql new file mode 100644 index 0000000000000..05bea6c620502 --- /dev/null +++ b/coderd/database/migrations/000149_agent_multiple_subsystems.down.sql @@ -0,0 +1,17 @@ +BEGIN; + +-- Bring back the subsystem column. +ALTER TABLE workspace_agents ADD COLUMN subsystem workspace_agent_subsystem NOT NULL DEFAULT 'none'; + +-- Update all existing workspace_agents to have subsystem = subsystems[0] unless +-- subsystems is empty. +UPDATE workspace_agents SET subsystem = subsystems[1] WHERE cardinality(subsystems) > 0; + +-- Drop the subsystems column from workspace_agents. +ALTER TABLE workspace_agents DROP COLUMN subsystems; + +-- We cannot drop the "exectrace" value from the workspace_agent_subsystem type +-- because you cannot drop values from an enum type. +UPDATE workspace_agents SET subsystem = 'none' WHERE subsystem = 'exectrace'; + +COMMIT; diff --git a/coderd/database/migrations/000149_agent_multiple_subsystems.up.sql b/coderd/database/migrations/000149_agent_multiple_subsystems.up.sql new file mode 100644 index 0000000000000..9ebb71d5bdf5e --- /dev/null +++ b/coderd/database/migrations/000149_agent_multiple_subsystems.up.sql @@ -0,0 +1,21 @@ +BEGIN; + +-- Add "exectrace" to workspace_agent_subsystem type. +ALTER TYPE workspace_agent_subsystem ADD VALUE 'exectrace'; + +-- Create column subsystems in workspace_agents table, with default value being +-- an empty array. +ALTER TABLE workspace_agents ADD COLUMN subsystems workspace_agent_subsystem[] DEFAULT '{}'; + +-- Add a constraint that the subsystems cannot contain the deprecated value +-- 'none'. +ALTER TABLE workspace_agents ADD CONSTRAINT subsystems_not_none CHECK (NOT ('none' = ANY (subsystems))); + +-- Update all existing workspace_agents to have subsystems = [subsystem] unless +-- the subsystem is 'none'. +UPDATE workspace_agents SET subsystems = ARRAY[subsystem] WHERE subsystem != 'none'; + +-- Drop the subsystem column from workspace_agents. +ALTER TABLE workspace_agents DROP COLUMN subsystem; + +COMMIT; diff --git a/coderd/database/migrations/000150_workspace_app_stats.down.sql b/coderd/database/migrations/000150_workspace_app_stats.down.sql new file mode 100644 index 0000000000000..983a13c180edc --- /dev/null +++ b/coderd/database/migrations/000150_workspace_app_stats.down.sql @@ -0,0 +1 @@ +DROP TABLE workspace_app_stats; diff --git a/coderd/database/migrations/000150_workspace_app_stats.up.sql b/coderd/database/migrations/000150_workspace_app_stats.up.sql new file mode 100644 index 0000000000000..ace09e52760f6 --- /dev/null +++ b/coderd/database/migrations/000150_workspace_app_stats.up.sql @@ -0,0 +1,32 @@ +CREATE TABLE workspace_app_stats ( + id BIGSERIAL PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users (id), + workspace_id uuid NOT NULL REFERENCES workspaces (id), + agent_id uuid NOT NULL REFERENCES workspace_agents (id), + access_method text NOT NULL, + slug_or_port text NOT NULL, + session_id uuid NOT NULL, + session_started_at timestamptz NOT NULL, + session_ended_at timestamptz NOT NULL, + requests integer NOT NULL, + + -- Set a unique constraint to allow upserting the session_ended_at + -- and requests fields without risk of collisions. + UNIQUE(user_id, agent_id, session_id) +); + +COMMENT ON TABLE workspace_app_stats IS 'A record of workspace app usage statistics'; + +COMMENT ON COLUMN workspace_app_stats.id IS 'The ID of the record'; +COMMENT ON COLUMN workspace_app_stats.user_id IS 'The user who used the workspace app'; +COMMENT ON COLUMN workspace_app_stats.workspace_id IS 'The workspace that the workspace app was used in'; +COMMENT ON COLUMN workspace_app_stats.agent_id IS 'The workspace agent that was used'; +COMMENT ON COLUMN workspace_app_stats.access_method IS 'The method used to access the workspace app'; +COMMENT ON COLUMN workspace_app_stats.slug_or_port IS 'The slug or port used to to identify the app'; +COMMENT ON COLUMN workspace_app_stats.session_id IS 'The unique identifier for the session'; +COMMENT ON COLUMN workspace_app_stats.session_started_at IS 'The time the session started'; +COMMENT ON COLUMN workspace_app_stats.session_ended_at IS 'The time the session ended'; +COMMENT ON COLUMN workspace_app_stats.requests IS 'The number of requests made during the session, a number larger than 1 indicates that multiple sessions were rolled up into one'; + +-- Create index on workspace_id for joining/filtering by templates. +CREATE INDEX workspace_app_stats_workspace_id_idx ON workspace_app_stats (workspace_id); diff --git a/coderd/database/migrations/000151_rename_locked.down.sql b/coderd/database/migrations/000151_rename_locked.down.sql new file mode 100644 index 0000000000000..4dfb254268fa2 --- /dev/null +++ b/coderd/database/migrations/000151_rename_locked.down.sql @@ -0,0 +1,26 @@ +BEGIN; + +ALTER TABLE templates RENAME COLUMN time_til_dormant TO inactivity_ttl; +ALTER TABLE templates RENAME COLUMN time_til_dormant_autodelete TO locked_ttl; +ALTER TABLE workspaces RENAME COLUMN dormant_at TO locked_at; + +-- Update the template_with_users view; +DROP VIEW template_with_users; +-- If you need to update this view, put 'DROP VIEW template_with_users;' before this. +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; + +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/migrations/000151_rename_locked.up.sql b/coderd/database/migrations/000151_rename_locked.up.sql new file mode 100644 index 0000000000000..ae72c7efa98cb --- /dev/null +++ b/coderd/database/migrations/000151_rename_locked.up.sql @@ -0,0 +1,25 @@ +BEGIN; +ALTER TABLE templates RENAME COLUMN inactivity_ttl TO time_til_dormant; +ALTER TABLE templates RENAME COLUMN locked_ttl TO time_til_dormant_autodelete; +ALTER TABLE workspaces RENAME COLUMN locked_at TO dormant_at; + +-- Update the template_with_users view;a +DROP VIEW template_with_users; +-- If you need to update this view, put 'DROP VIEW template_with_users;' before this. +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; + +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 5726b65609ac2..a138e58bac05f 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -22,9 +22,9 @@ import ( "golang.org/x/exp/slices" "golang.org/x/sync/errgroup" - "github.com/coder/coder/coderd/database/migrations" - "github.com/coder/coder/coderd/database/postgres" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database/migrations" + "github.com/coder/coder/v2/coderd/database/postgres" + "github.com/coder/coder/v2/testutil" ) func TestMain(m *testing.M) { diff --git a/coderd/database/migrations/testdata/fixtures/000150_workspace_app_usage_stats.up.sql b/coderd/database/migrations/testdata/fixtures/000150_workspace_app_usage_stats.up.sql new file mode 100644 index 0000000000000..9a9a8f0fa72dc --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000150_workspace_app_usage_stats.up.sql @@ -0,0 +1,133 @@ +INSERT INTO public.workspace_app_stats ( + id, + user_id, + workspace_id, + agent_id, + access_method, + slug_or_port, + session_id, + session_started_at, + session_ended_at, + requests +) +VALUES + ( + 1498, + '30095c71-380b-457a-8995-97b8ee6e5307', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + 'path', + 'code-server', + '562cbfb8-3d9a-4018-9c04-e8159d5aa43e', + '2023-08-14 20:15:00+00', + '2023-08-14 20:16:00+00', + 1 + ), + ( + 59, + '30095c71-380b-457a-8995-97b8ee6e5307', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + 'terminal', + '', + '281919d0-5d99-48fb-8a93-2c3019010387', + '2023-08-14 14:15:40.085827+00', + '2023-08-14 14:17:41.295989+00', + 1 + ), + ( + 58, + '30095c71-380b-457a-8995-97b8ee6e5307', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + 'path', + 'code-server', + '5b7c9d43-19e6-4401-997b-c26de2c86c55', + '2023-08-14 14:15:34.620496+00', + '2023-08-14 23:58:37.158964+00', + 1 + ), + ( + 57, + '30095c71-380b-457a-8995-97b8ee6e5307', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + 'path', + 'code-server', + 'fe546a68-0921-4a2b-bced-5dc5c5635576', + '2023-08-14 14:15:34.129002+00', + '2023-08-14 23:58:37.158901+00', + 1 + ), + ( + 56, + '30095c71-380b-457a-8995-97b8ee6e5307', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + 'path', + 'code-server', + '96e4e857-598c-4881-bc40-e13008b48bb0', + '2023-08-14 14:15:00+00', + '2023-08-14 14:16:00+00', + 36 + ), + ( + 7, + '30095c71-380b-457a-8995-97b8ee6e5307', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + 'terminal', + '', + '95d22d41-0fde-447b-9743-0b8583edb60a', + '2023-08-14 13:00:28.732837+00', + '2023-08-14 13:09:23.990797+00', + 1 + ), + ( + 4, + '30095c71-380b-457a-8995-97b8ee6e5307', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + 'path', + 'code-server', + '442688ce-f9e7-46df-ba3d-623ef9a1d30d', + '2023-08-14 13:00:12.843977+00', + '2023-08-14 13:09:26.276696+00', + 1 + ), + ( + 3, + '30095c71-380b-457a-8995-97b8ee6e5307', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + 'path', + 'code-server', + 'f963c4f0-55b7-4813-8b61-ea58536754db', + '2023-08-14 13:00:12.323196+00', + '2023-08-14 13:09:26.277073+00', + 1 + ), + ( + 2, + '30095c71-380b-457a-8995-97b8ee6e5307', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + 'terminal', + '', + '5a034459-73e4-4642-91b8-80b0f718f29e', + '2023-08-14 13:00:00+00', + '2023-08-14 13:01:00+00', + 4 + ), + ( + 1, + '30095c71-380b-457a-8995-97b8ee6e5307', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + 'path', + 'code-server', + 'd7a0d8e1-069e-421d-b876-b5d0ddbcaf6d', + '2023-08-14 13:00:00+00', + '2023-08-14 13:01:00+00', + 36 + ); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 3daaec35da595..1cccdd949ecc8 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -7,7 +7,7 @@ import ( "golang.org/x/exp/maps" - "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac" ) type WorkspaceStatus string @@ -84,7 +84,7 @@ func (g Group) Auditable(users []User) AuditableGroup { } } -const AllUsersGroup = "Everyone" +const EveryoneGroup = "Everyone" func (s APIKeyScope) ToRBAC() rbac.ScopeName { switch s { @@ -146,8 +146,8 @@ func (w Workspace) RBACObject() rbac.Object { func (w Workspace) ExecutionRBAC() rbac.Object { // If a workspace is locked it cannot be accessed. - if w.LockedAt.Valid { - return w.LockedRBAC() + if w.DormantAt.Valid { + return w.DormantRBAC() } return rbac.ResourceWorkspaceExecution. @@ -158,8 +158,8 @@ func (w Workspace) ExecutionRBAC() rbac.Object { func (w Workspace) ApplicationConnectRBAC() rbac.Object { // If a workspace is locked it cannot be accessed. - if w.LockedAt.Valid { - return w.LockedRBAC() + if w.DormantAt.Valid { + return w.DormantRBAC() } return rbac.ResourceWorkspaceApplicationConnect. @@ -173,9 +173,9 @@ func (w Workspace) WorkspaceBuildRBAC(transition WorkspaceTransition) rbac.Objec // However we need to allow stopping a workspace by a caller once a workspace // is locked (e.g. for autobuild). Additionally, if a user wants to delete // a locked workspace, they shouldn't have to have it unlocked first. - if w.LockedAt.Valid && transition != WorkspaceTransitionStop && + if w.DormantAt.Valid && transition != WorkspaceTransitionStop && transition != WorkspaceTransitionDelete { - return w.LockedRBAC() + return w.DormantRBAC() } return rbac.ResourceWorkspaceBuild. @@ -184,8 +184,8 @@ func (w Workspace) WorkspaceBuildRBAC(transition WorkspaceTransition) rbac.Objec WithOwner(w.OwnerID.String()) } -func (w Workspace) LockedRBAC() rbac.Object { - return rbac.ResourceWorkspaceLocked. +func (w Workspace) DormantRBAC() rbac.Object { + return rbac.ResourceWorkspaceDormant. WithID(w.ID). InOrg(w.OrganizationID). WithOwner(w.OwnerID.String()) @@ -355,10 +355,14 @@ func ConvertWorkspaceRows(rows []GetWorkspacesRow) []Workspace { AutostartSchedule: r.AutostartSchedule, Ttl: r.Ttl, LastUsedAt: r.LastUsedAt, - LockedAt: r.LockedAt, + DormantAt: r.DormantAt, DeletingAt: r.DeletingAt, } } return workspaces } + +func (g Group) IsEveryone() bool { + return g.ID == g.OrganizationID +} diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index ffa346d04998c..5ccf3282e677c 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -9,8 +9,8 @@ import ( "github.com/lib/pq" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/rbac/regosql" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/regosql" ) const ( @@ -81,8 +81,8 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, - &i.InactivityTTL, - &i.LockedTTL, + &i.TimeTilDormant, + &i.TimeTilDormantAutoDelete, &i.RestartRequirementDaysOfWeek, &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, @@ -217,11 +217,14 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, + arg.DormantAt, + arg.LastUsedBefore, + arg.LastUsedAfter, arg.Offset, arg.Limit, ) if err != nil { - return nil, xerrors.Errorf("get authorized workspaces: %w", err) + return nil, err } defer rows.Close() var items []GetWorkspacesRow @@ -239,7 +242,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, &i.TemplateName, &i.TemplateVersionID, diff --git a/coderd/database/models.go b/coderd/database/models.go index 4e34989b09ae9..85f90020d9fc1 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.19.1 +// sqlc v1.20.0 package database @@ -281,6 +281,64 @@ func AllBuildReasonValues() []BuildReason { } } +type GroupSource string + +const ( + GroupSourceUser GroupSource = "user" + GroupSourceOidc GroupSource = "oidc" +) + +func (e *GroupSource) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = GroupSource(s) + case string: + *e = GroupSource(s) + default: + return fmt.Errorf("unsupported scan type for GroupSource: %T", src) + } + return nil +} + +type NullGroupSource struct { + GroupSource GroupSource `json:"group_source"` + Valid bool `json:"valid"` // Valid is true if GroupSource is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullGroupSource) Scan(value interface{}) error { + if value == nil { + ns.GroupSource, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.GroupSource.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullGroupSource) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.GroupSource), nil +} + +func (e GroupSource) Valid() bool { + switch e { + case GroupSourceUser, + GroupSourceOidc: + return true + } + return false +} + +func AllGroupSourceValues() []GroupSource { + return []GroupSource{ + GroupSourceUser, + GroupSourceOidc, + } +} + type LogLevel string const ( @@ -1249,6 +1307,7 @@ const ( WorkspaceAgentSubsystemEnvbuilder WorkspaceAgentSubsystem = "envbuilder" WorkspaceAgentSubsystemEnvbox WorkspaceAgentSubsystem = "envbox" WorkspaceAgentSubsystemNone WorkspaceAgentSubsystem = "none" + WorkspaceAgentSubsystemExectrace WorkspaceAgentSubsystem = "exectrace" ) func (e *WorkspaceAgentSubsystem) Scan(src interface{}) error { @@ -1290,7 +1349,8 @@ func (e WorkspaceAgentSubsystem) Valid() bool { switch e { case WorkspaceAgentSubsystemEnvbuilder, WorkspaceAgentSubsystemEnvbox, - WorkspaceAgentSubsystemNone: + WorkspaceAgentSubsystemNone, + WorkspaceAgentSubsystemExectrace: return true } return false @@ -1301,6 +1361,7 @@ func AllWorkspaceAgentSubsystemValues() []WorkspaceAgentSubsystem { WorkspaceAgentSubsystemEnvbuilder, WorkspaceAgentSubsystemEnvbox, WorkspaceAgentSubsystemNone, + WorkspaceAgentSubsystemExectrace, } } @@ -1498,6 +1559,8 @@ type Group struct { QuotaAllowance int32 `db:"quota_allowance" json:"quota_allowance"` // Display name is a custom, human-friendly group name that user can set. This is not required to be unique and can be the empty string. DisplayName string `db:"display_name" json:"display_name"` + // Source indicates how the group was created. It can be created by a user manually, or through some system process like OIDC group sync. + Source GroupSource `db:"source" json:"source"` } type GroupMember struct { @@ -1666,8 +1729,8 @@ type Template struct { AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` - InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` - LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` + TimeTilDormant int64 `db:"time_til_dormant" json:"time_til_dormant"` + TimeTilDormantAutoDelete int64 `db:"time_til_dormant_autodelete" json:"time_til_dormant_autodelete"` RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"` CreatedByAvatarURL sql.NullString `db:"created_by_avatar_url" json:"created_by_avatar_url"` @@ -1698,10 +1761,10 @@ type TemplateTable struct { // Allow users to specify an autostart schedule for workspaces (enterprise). AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` // Allow users to specify custom autostop values for workspaces (enterprise). - AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` - FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` - InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` - LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` + AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` + FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` + TimeTilDormant int64 `db:"time_til_dormant" json:"time_til_dormant"` + TimeTilDormantAutoDelete int64 `db:"time_til_dormant_autodelete" json:"time_til_dormant_autodelete"` // A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused. RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` // The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles. @@ -1840,7 +1903,7 @@ type Workspace struct { AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` Ttl sql.NullInt64 `db:"ttl" json:"ttl"` LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - LockedAt sql.NullTime `db:"locked_at" json:"locked_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` } @@ -1884,14 +1947,14 @@ type WorkspaceAgent struct { // Total length of startup logs LogsLength int32 `db:"logs_length" json:"logs_length"` // Whether the startup logs overflowed in length - LogsOverflowed bool `db:"logs_overflowed" json:"logs_overflowed"` - Subsystem WorkspaceAgentSubsystem `db:"subsystem" json:"subsystem"` + LogsOverflowed bool `db:"logs_overflowed" json:"logs_overflowed"` // When startup script behavior is non-blocking, the workspace will be ready and accessible upon agent connection, when it is blocking, workspace will wait for the startup script to complete before becoming ready and accessible. StartupScriptBehavior StartupScriptBehavior `db:"startup_script_behavior" json:"startup_script_behavior"` // The time the agent entered the starting lifecycle state StartedAt sql.NullTime `db:"started_at" json:"started_at"` // The time the agent entered the ready or start_error lifecycle state - ReadyAt sql.NullTime `db:"ready_at" json:"ready_at"` + ReadyAt sql.NullTime `db:"ready_at" json:"ready_at"` + Subsystems []WorkspaceAgentSubsystem `db:"subsystems" json:"subsystems"` } type WorkspaceAgentLog struct { @@ -1953,6 +2016,30 @@ type WorkspaceApp struct { External bool `db:"external" json:"external"` } +// A record of workspace app usage statistics +type WorkspaceAppStat struct { + // The ID of the record + ID int64 `db:"id" json:"id"` + // The user who used the workspace app + UserID uuid.UUID `db:"user_id" json:"user_id"` + // The workspace that the workspace app was used in + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + // The workspace agent that was used + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + // The method used to access the workspace app + AccessMethod string `db:"access_method" json:"access_method"` + // The slug or port used to to identify the app + SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` + // The unique identifier for the session + SessionID uuid.UUID `db:"session_id" json:"session_id"` + // The time the session started + SessionStartedAt time.Time `db:"session_started_at" json:"session_started_at"` + // The time the session ended + SessionEndedAt time.Time `db:"session_ended_at" json:"session_ended_at"` + // The number of requests made during the session, a number larger than 1 indicates that multiple sessions were rolled up into one + Requests int32 `db:"requests" json:"requests"` +} + // Joins in the username + avatar url of the initiated by user. type WorkspaceBuild struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/models_test.go b/coderd/database/models_test.go index 1ebb40ae2ff26..a3c37683ac2c8 100644 --- a/coderd/database/models_test.go +++ b/coderd/database/models_test.go @@ -5,10 +5,24 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" ) +// TestAuditDBEnumsCovered ensures that all enums in the database are covered by the codersdk enums +// for audit log strings. +func TestAuditDBEnumsCovered(t *testing.T) { + t.Parallel() + + dbTypes := database.AllResourceTypeValues() + for _, ty := range dbTypes { + str := codersdk.ResourceType(ty).FriendlyString() + require.NotEqualf(t, "unknown", str, "ResourceType %q not covered by codersdk.ResourceType", ty) + } +} + // TestViewSubsetTemplate ensures TemplateTable is a subset of Template func TestViewSubsetTemplate(t *testing.T) { t.Parallel() diff --git a/coderd/database/postgres/postgres.go b/coderd/database/postgres/postgres.go index 85710d8bb0f7b..8a7d0209ba4e0 100644 --- a/coderd/database/postgres/postgres.go +++ b/coderd/database/postgres/postgres.go @@ -12,8 +12,8 @@ import ( "github.com/ory/dockertest/v3/docker" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database/migrations" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/coderd/database/migrations" + "github.com/coder/coder/v2/cryptorand" ) // Open creates a new PostgreSQL database instance. With DB_FROM environment variable set, it clones a database diff --git a/coderd/database/postgres/postgres_test.go b/coderd/database/postgres/postgres_test.go index 88d9800a57644..4a217d072f4af 100644 --- a/coderd/database/postgres/postgres_test.go +++ b/coderd/database/postgres/postgres_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/goleak" - "github.com/coder/coder/coderd/database/postgres" + "github.com/coder/coder/v2/coderd/database/postgres" ) func TestMain(m *testing.M) { diff --git a/coderd/database/pubsub/pubsub_internal_test.go b/coderd/database/pubsub/pubsub_internal_test.go index adfa70286dbe0..47dd324fc09df 100644 --- a/coderd/database/pubsub/pubsub_internal_test.go +++ b/coderd/database/pubsub/pubsub_internal_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/testutil" ) func Test_msgQueue_ListenerWithError(t *testing.T) { diff --git a/coderd/database/pubsub/pubsub_memory_test.go b/coderd/database/pubsub/pubsub_memory_test.go index 80553c8fa73da..0f392ade742c4 100644 --- a/coderd/database/pubsub/pubsub_memory_test.go +++ b/coderd/database/pubsub/pubsub_memory_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/database/pubsub" ) func TestPubsubMemory(t *testing.T) { diff --git a/coderd/database/pubsub/pubsub_test.go b/coderd/database/pubsub/pubsub_test.go index d1f80fa5a1aed..1d414d9edcd2c 100644 --- a/coderd/database/pubsub/pubsub_test.go +++ b/coderd/database/pubsub/pubsub_test.go @@ -15,9 +15,9 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database/postgres" - "github.com/coder/coder/coderd/database/pubsub" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database/postgres" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/testutil" ) // nolint:tparallel,paralleltest diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5e9da77b66c0a..520266bd1d25c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.19.1 +// sqlc v1.20.0 package database @@ -48,6 +48,7 @@ type sqlcQuerier interface { GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) GetActiveUserCount(ctx context.Context) (int64, error) + GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) GetAllTailnetAgents(ctx context.Context) ([]TailnetAgent, error) GetAllTailnetClients(ctx context.Context) ([]TailnetClient, error) GetAppSecurityKey(ctx context.Context) (string, error) @@ -71,6 +72,8 @@ type sqlcQuerier interface { GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) + // If the group is a user made group, then we need to check the group_members table. + // If it is the "Everyone" group, then we need to check the organization_members table. GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error) GetHungProvisionerJobs(ctx context.Context, updatedAt time.Time) ([]ProvisionerJob, error) @@ -104,6 +107,10 @@ type sqlcQuerier interface { GetServiceBanner(ctx context.Context) (string, error) GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]TailnetAgent, error) GetTailnetClientsForAgent(ctx context.Context, agentID uuid.UUID) ([]TailnetClient, error) + // GetTemplateAppInsights returns the aggregate usage of each app in a given + // timeframe. The result can be filtered on template_ids, meaning only user data + // from workspaces based on those templates will be included. + GetTemplateAppInsights(ctx context.Context, arg GetTemplateAppInsightsParams) ([]GetTemplateAppInsightsRow, error) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) @@ -114,7 +121,8 @@ type sqlcQuerier interface { // interval/template, it will be included in the results with 0 active users. GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error) // GetTemplateInsights has a granularity of 5 minutes where if a session/app was - // in use, we will add 5 minutes to the total usage for that session (per user). + // in use during a minute, we will add 5 minutes to the total usage for that + // session/app (per user). GetTemplateInsights(ctx context.Context, arg GetTemplateInsightsParams) (GetTemplateInsightsRow, error) // GetTemplateParameterInsights does for each template in a given timeframe, // look for the latest workspace build (for every workspace) that has been @@ -148,7 +156,7 @@ type sqlcQuerier interface { // to look up references to actions. eg. a user could build a workspace // for another user, then be deleted... we still want them to appear! GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) - GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (WorkspaceAgent, error) + GetWorkspaceAgentAndOwnerByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndOwnerByAuthTokenRow, error) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) GetWorkspaceAgentLifecycleStateByID(ctx context.Context, id uuid.UUID) (GetWorkspaceAgentLifecycleStateByIDRow, error) @@ -206,6 +214,11 @@ type sqlcQuerier interface { InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) + // Inserts any group by name that does not exist. All new groups are given + // a random uuid, are inserted into the same organization. They have the default + // values for avatar, display name, and quota allowance (all zero values). + // If the name conflicts, do nothing. + InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error) InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) InsertProvisionerDaemon(ctx context.Context, arg InsertProvisionerDaemonParams) (ProvisionerDaemon, error) @@ -225,7 +238,9 @@ type sqlcQuerier interface { InsertWorkspaceAgentLogs(ctx context.Context, arg InsertWorkspaceAgentLogsParams) ([]WorkspaceAgentLog, error) InsertWorkspaceAgentMetadata(ctx context.Context, arg InsertWorkspaceAgentMetadataParams) error InsertWorkspaceAgentStat(ctx context.Context, arg InsertWorkspaceAgentStatParams) (WorkspaceAgentStat, error) + InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) + InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error InsertWorkspaceBuildParameters(ctx context.Context, arg InsertWorkspaceBuildParametersParams) error InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error) @@ -255,6 +270,7 @@ type sqlcQuerier interface { UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionGitAuthProvidersByJobIDParams) error + UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error UpdateUserDeletedByID(ctx context.Context, arg UpdateUserDeletedByIDParams) error UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error) @@ -276,13 +292,13 @@ type sqlcQuerier interface { UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error + UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (Workspace, error) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error - UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) error // This allows editing the properties of a workspace proxy. UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error - UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDeletingAtByTemplateIDParams) error + UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error UpsertAppSecurityKey(ctx context.Context, value string) error // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 07a81111d64c9..2a6328cb96e22 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -13,10 +13,10 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/database/migrations" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/migrations" + "github.com/coder/coder/v2/testutil" ) func TestGetDeploymentWorkspaceAgentStats(t *testing.T) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ff22cc3120193..364ae4c546267 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.19.1 +// sqlc v1.20.0 package database @@ -1069,18 +1069,29 @@ SELECT users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at, users.quiet_hours_schedule FROM users -JOIN +LEFT JOIN group_members ON - users.id = group_members.user_id -WHERE + group_members.user_id = users.id AND group_members.group_id = $1 +LEFT JOIN + organization_members +ON + organization_members.user_id = users.id AND + organization_members.organization_id = $1 +WHERE + -- In either case, the group_id will only match an org or a group. + (group_members.group_id = $1 + OR + organization_members.organization_id = $1) AND users.status = 'active' AND users.deleted = 'false' ` +// If the group is a user made group, then we need to check the group_members table. +// If it is the "Everyone" group, then we need to check the organization_members table. func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) { rows, err := q.db.QueryContext(ctx, getGroupMembers, groupID) if err != nil { @@ -1180,7 +1191,7 @@ func (q *sqlQuerier) DeleteGroupByID(ctx context.Context, id uuid.UUID) error { const getGroupByID = `-- name: GetGroupByID :one SELECT - id, name, organization_id, avatar_url, quota_allowance, display_name + id, name, organization_id, avatar_url, quota_allowance, display_name, source FROM groups WHERE @@ -1199,13 +1210,14 @@ func (q *sqlQuerier) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, err &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ) return i, err } const getGroupByOrgAndName = `-- name: GetGroupByOrgAndName :one SELECT - id, name, organization_id, avatar_url, quota_allowance, display_name + id, name, organization_id, avatar_url, quota_allowance, display_name, source FROM groups WHERE @@ -1231,19 +1243,18 @@ func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrg &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ) return i, err } const getGroupsByOrganizationID = `-- name: GetGroupsByOrganizationID :many SELECT - id, name, organization_id, avatar_url, quota_allowance, display_name + id, name, organization_id, avatar_url, quota_allowance, display_name, source FROM groups WHERE organization_id = $1 -AND - id != $1 ` func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error) { @@ -1262,6 +1273,7 @@ func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organization &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ); err != nil { return nil, err } @@ -1283,7 +1295,7 @@ INSERT INTO groups ( organization_id ) VALUES - ($1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name + ($1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source ` // We use the organization_id as the id @@ -1299,6 +1311,7 @@ func (q *sqlQuerier) InsertAllUsersGroup(ctx context.Context, organizationID uui &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ) return i, err } @@ -1313,7 +1326,7 @@ INSERT INTO groups ( quota_allowance ) VALUES - ($1, $2, $3, $4, $5, $6) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name + ($1, $2, $3, $4, $5, $6) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source ` type InsertGroupParams struct { @@ -1342,10 +1355,70 @@ func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Gr &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ) return i, err } +const insertMissingGroups = `-- name: InsertMissingGroups :many +INSERT INTO groups ( + id, + name, + organization_id, + source +) +SELECT + gen_random_uuid(), + group_name, + $1, + $2 +FROM + UNNEST($3 :: text[]) AS group_name +ON CONFLICT DO NOTHING +RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source +` + +type InsertMissingGroupsParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Source GroupSource `db:"source" json:"source"` + GroupNames []string `db:"group_names" json:"group_names"` +} + +// Inserts any group by name that does not exist. All new groups are given +// a random uuid, are inserted into the same organization. They have the default +// values for avatar, display name, and quota allowance (all zero values). +// If the name conflicts, do nothing. +func (q *sqlQuerier) InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error) { + rows, err := q.db.QueryContext(ctx, insertMissingGroups, arg.OrganizationID, arg.Source, pq.Array(arg.GroupNames)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Group + for rows.Next() { + var i Group + if err := rows.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + &i.QuotaAllowance, + &i.DisplayName, + &i.Source, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateGroupByID = `-- name: UpdateGroupByID :one UPDATE groups @@ -1356,7 +1429,7 @@ SET quota_allowance = $4 WHERE id = $5 -RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name +RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source ` type UpdateGroupByIDParams struct { @@ -1383,25 +1456,141 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ) return i, err } +const getTemplateAppInsights = `-- name: GetTemplateAppInsights :many +WITH app_stats_by_user_and_agent AS ( + SELECT + s.start_time, + 60 as seconds, + w.template_id, + was.user_id, + was.agent_id, + was.access_method, + was.slug_or_port, + wa.display_name, + wa.icon, + (wa.slug IS NOT NULL)::boolean AS is_app + FROM workspace_app_stats was + JOIN workspaces w ON ( + w.id = was.workspace_id + AND CASE WHEN COALESCE(array_length($1::uuid[], 1), 0) > 0 THEN w.template_id = ANY($1::uuid[]) ELSE TRUE END + ) + -- We do a left join here because we want to include user IDs that have used + -- e.g. ports when counting active users. + LEFT JOIN workspace_apps wa ON ( + wa.agent_id = was.agent_id + AND wa.slug = was.slug_or_port + ) + -- This table contains both 1 minute entries and >1 minute entries, + -- to calculate this with our uniqueness constraints, we generate series + -- for the longer intervals. + CROSS JOIN LATERAL generate_series( + date_trunc('minute', was.session_started_at), + -- Subtract 1 microsecond to avoid creating an extra series. + date_trunc('minute', was.session_ended_at - '1 microsecond'::interval), + '1 minute'::interval + ) s(start_time) + WHERE + s.start_time >= $2::timestamptz + -- Subtract one minute because the series only contains the start time. + AND s.start_time < ($3::timestamptz) - '1 minute'::interval + GROUP BY s.start_time, w.template_id, was.user_id, was.agent_id, was.access_method, was.slug_or_port, wa.display_name, wa.icon, wa.slug +) + +SELECT + array_agg(DISTINCT template_id)::uuid[] AS template_ids, + -- Return IDs so we can combine this with GetTemplateInsights. + array_agg(DISTINCT user_id)::uuid[] AS active_user_ids, + access_method, + slug_or_port, + display_name, + icon, + is_app, + SUM(seconds) AS usage_seconds +FROM app_stats_by_user_and_agent +GROUP BY access_method, slug_or_port, display_name, icon, is_app +` + +type GetTemplateAppInsightsParams struct { + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime time.Time `db:"end_time" json:"end_time"` +} + +type GetTemplateAppInsightsRow struct { + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` + ActiveUserIDs []uuid.UUID `db:"active_user_ids" json:"active_user_ids"` + AccessMethod string `db:"access_method" json:"access_method"` + SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` + DisplayName sql.NullString `db:"display_name" json:"display_name"` + Icon sql.NullString `db:"icon" json:"icon"` + IsApp bool `db:"is_app" json:"is_app"` + UsageSeconds int64 `db:"usage_seconds" json:"usage_seconds"` +} + +// GetTemplateAppInsights returns the aggregate usage of each app in a given +// timeframe. The result can be filtered on template_ids, meaning only user data +// from workspaces based on those templates will be included. +func (q *sqlQuerier) GetTemplateAppInsights(ctx context.Context, arg GetTemplateAppInsightsParams) ([]GetTemplateAppInsightsRow, error) { + rows, err := q.db.QueryContext(ctx, getTemplateAppInsights, pq.Array(arg.TemplateIDs), arg.StartTime, arg.EndTime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTemplateAppInsightsRow + for rows.Next() { + var i GetTemplateAppInsightsRow + if err := rows.Scan( + pq.Array(&i.TemplateIDs), + pq.Array(&i.ActiveUserIDs), + &i.AccessMethod, + &i.SlugOrPort, + &i.DisplayName, + &i.Icon, + &i.IsApp, + &i.UsageSeconds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTemplateDailyInsights = `-- name: GetTemplateDailyInsights :many -WITH d AS ( - -- sqlc workaround, use SELECT generate_series instead of SELECT * FROM generate_series. - -- Subtract 1 second from end_time to avoid including the next interval in the results. - SELECT generate_series($1::timestamptz, ($2::timestamptz) - '1 second'::interval, '1 day'::interval) AS d -), ts AS ( +WITH ts AS ( SELECT d::timestamptz AS from_, - CASE WHEN (d + '1 day'::interval)::timestamptz <= $2::timestamptz THEN (d + '1 day'::interval)::timestamptz ELSE $2::timestamptz END AS to_ - FROM d -), usage_by_day AS ( + CASE + WHEN (d::timestamptz + '1 day'::interval) <= $1::timestamptz + THEN (d::timestamptz + '1 day'::interval) + ELSE $1::timestamptz + END AS to_ + FROM + -- Subtract 1 second from end_time to avoid including the next interval in the results. + generate_series($2::timestamptz, ($1::timestamptz) - '1 second'::interval, '1 day'::interval) AS d +), unflattened_usage_by_day AS ( + -- We select data from both workspace agent stats and workspace app stats to + -- get a complete picture of usage. This matches how usage is calculated by + -- the combination of GetTemplateInsights and GetTemplateAppInsights. We use + -- a union all to avoid a costly distinct operation. + -- + -- Note that one query must perform a left join so that all intervals are + -- present at least once. SELECT ts.from_, ts.to_, - was.user_id, - array_agg(was.template_id) AS template_ids + was.template_id, + was.user_id FROM ts LEFT JOIN workspace_agent_stats was ON ( was.created_at >= ts.from_ @@ -1409,33 +1598,39 @@ WITH d AS ( AND was.connection_count > 0 AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN was.template_id = ANY($3::uuid[]) ELSE TRUE END ) - GROUP BY ts.from_, ts.to_, was.user_id -), template_ids AS ( + GROUP BY ts.from_, ts.to_, was.template_id, was.user_id + + UNION ALL + SELECT - template_usage_by_day.from_, - array_agg(template_id) AS ids - FROM ( - SELECT DISTINCT - from_, - unnest(template_ids) AS template_id - FROM usage_by_day - ) AS template_usage_by_day - WHERE template_id IS NOT NULL - GROUP BY template_usage_by_day.from_ + ts.from_, ts.to_, + w.template_id, + was.user_id + FROM ts + JOIN workspace_app_stats was ON ( + (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_) + OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_) + OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_) + ) + JOIN workspaces w ON ( + w.id = was.workspace_id + AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN w.template_id = ANY($3::uuid[]) ELSE TRUE END + ) + GROUP BY ts.from_, ts.to_, w.template_id, was.user_id ) SELECT from_ AS start_time, to_ AS end_time, - COALESCE((SELECT template_ids.ids FROM template_ids WHERE template_ids.from_ = usage_by_day.from_), '{}')::uuid[] AS template_ids, + array_remove(array_agg(DISTINCT template_id), NULL)::uuid[] AS template_ids, COUNT(DISTINCT user_id) AS active_users -FROM usage_by_day +FROM unflattened_usage_by_day GROUP BY from_, to_ ` type GetTemplateDailyInsightsParams struct { - StartTime time.Time `db:"start_time" json:"start_time"` EndTime time.Time `db:"end_time" json:"end_time"` + StartTime time.Time `db:"start_time" json:"start_time"` TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` } @@ -1451,7 +1646,7 @@ type GetTemplateDailyInsightsRow struct { // that interval will be less than 24 hours. If there is no data for a selected // interval/template, it will be included in the results with 0 active users. func (q *sqlQuerier) GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error) { - rows, err := q.db.QueryContext(ctx, getTemplateDailyInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs)) + rows, err := q.db.QueryContext(ctx, getTemplateDailyInsights, arg.EndTime, arg.StartTime, pq.Array(arg.TemplateIDs)) if err != nil { return nil, err } @@ -1479,47 +1674,37 @@ func (q *sqlQuerier) GetTemplateDailyInsights(ctx context.Context, arg GetTempla } const getTemplateInsights = `-- name: GetTemplateInsights :one -WITH d AS ( - -- Subtract 1 second from end_time to avoid including the next interval in the results. - SELECT generate_series($1::timestamptz, ($2::timestamptz) - '1 second'::interval, '5 minute'::interval) AS d -), ts AS ( +WITH agent_stats_by_interval_and_user AS ( SELECT - d::timestamptz AS from_, - (d + '5 minute'::interval)::timestamptz AS to_, - EXTRACT(epoch FROM '5 minute'::interval) AS seconds - FROM d -), usage_by_user AS ( - SELECT - ts.from_, - ts.to_, + date_trunc('minute', was.created_at), was.user_id, array_agg(was.template_id) AS template_ids, - CASE WHEN SUM(was.session_count_vscode) > 0 THEN ts.seconds ELSE 0 END AS usage_vscode_seconds, - CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN ts.seconds ELSE 0 END AS usage_jetbrains_seconds, - CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN ts.seconds ELSE 0 END AS usage_reconnecting_pty_seconds, - CASE WHEN SUM(was.session_count_ssh) > 0 THEN ts.seconds ELSE 0 END AS usage_ssh_seconds - FROM ts - JOIN workspace_agent_stats was ON ( - was.created_at >= ts.from_ - AND was.created_at < ts.to_ + CASE WHEN SUM(was.session_count_vscode) > 0 THEN 60 ELSE 0 END AS usage_vscode_seconds, + CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN 60 ELSE 0 END AS usage_jetbrains_seconds, + CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN 60 ELSE 0 END AS usage_reconnecting_pty_seconds, + CASE WHEN SUM(was.session_count_ssh) > 0 THEN 60 ELSE 0 END AS usage_ssh_seconds + FROM workspace_agent_stats was + WHERE + was.created_at >= $1::timestamptz + AND was.created_at < $2::timestamptz AND was.connection_count > 0 AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN was.template_id = ANY($3::uuid[]) ELSE TRUE END - ) - GROUP BY ts.from_, ts.to_, ts.seconds, was.user_id + GROUP BY date_trunc('minute', was.created_at), was.user_id ), template_ids AS ( SELECT array_agg(DISTINCT template_id) AS ids - FROM usage_by_user, unnest(template_ids) template_id + FROM agent_stats_by_interval_and_user, unnest(template_ids) template_id WHERE template_id IS NOT NULL ) SELECT COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids, - COUNT(DISTINCT user_id) AS active_users, + -- Return IDs so we can combine this with GetTemplateAppInsights. + COALESCE(array_agg(DISTINCT user_id), '{}')::uuid[] AS active_user_ids, COALESCE(SUM(usage_vscode_seconds), 0)::bigint AS usage_vscode_seconds, COALESCE(SUM(usage_jetbrains_seconds), 0)::bigint AS usage_jetbrains_seconds, COALESCE(SUM(usage_reconnecting_pty_seconds), 0)::bigint AS usage_reconnecting_pty_seconds, COALESCE(SUM(usage_ssh_seconds), 0)::bigint AS usage_ssh_seconds -FROM usage_by_user +FROM agent_stats_by_interval_and_user ` type GetTemplateInsightsParams struct { @@ -1530,7 +1715,7 @@ type GetTemplateInsightsParams struct { type GetTemplateInsightsRow struct { TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` - ActiveUsers int64 `db:"active_users" json:"active_users"` + ActiveUserIDs []uuid.UUID `db:"active_user_ids" json:"active_user_ids"` UsageVscodeSeconds int64 `db:"usage_vscode_seconds" json:"usage_vscode_seconds"` UsageJetbrainsSeconds int64 `db:"usage_jetbrains_seconds" json:"usage_jetbrains_seconds"` UsageReconnectingPtySeconds int64 `db:"usage_reconnecting_pty_seconds" json:"usage_reconnecting_pty_seconds"` @@ -1538,13 +1723,14 @@ type GetTemplateInsightsRow struct { } // GetTemplateInsights has a granularity of 5 minutes where if a session/app was -// in use, we will add 5 minutes to the total usage for that session (per user). +// in use during a minute, we will add 5 minutes to the total usage for that +// session/app (per user). func (q *sqlQuerier) GetTemplateInsights(ctx context.Context, arg GetTemplateInsightsParams) (GetTemplateInsightsRow, error) { row := q.db.QueryRowContext(ctx, getTemplateInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs)) var i GetTemplateInsightsRow err := row.Scan( pq.Array(&i.TemplateIDs), - &i.ActiveUsers, + pq.Array(&i.ActiveUserIDs), &i.UsageVscodeSeconds, &i.UsageJetbrainsSeconds, &i.UsageReconnectingPtySeconds, @@ -1560,15 +1746,15 @@ WITH latest_workspace_builds AS ( wbmax.template_id, wb.template_version_id FROM ( - SELECT - tv.template_id, wbmax.workspace_id, MAX(wbmax.build_number) as max_build_number + SELECT + tv.template_id, wbmax.workspace_id, MAX(wbmax.build_number) as max_build_number FROM workspace_builds wbmax JOIN template_versions tv ON (tv.id = wbmax.template_version_id) WHERE wbmax.created_at >= $1::timestamptz AND wbmax.created_at < $2::timestamptz AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN tv.template_id = ANY($3::uuid[]) ELSE TRUE END - GROUP BY tv.template_id, wbmax.workspace_id + GROUP BY tv.template_id, wbmax.workspace_id ) wbmax JOIN workspace_builds wb ON ( wb.workspace_id = wbmax.workspace_id @@ -1580,18 +1766,20 @@ WITH latest_workspace_builds AS ( array_agg(DISTINCT wb.template_id)::uuid[] AS template_ids, array_agg(wb.id)::uuid[] AS workspace_build_ids, tvp.name, + tvp.type, tvp.display_name, tvp.description, tvp.options FROM latest_workspace_builds wb JOIN template_version_parameters tvp ON (tvp.template_version_id = wb.template_version_id) - GROUP BY tvp.name, tvp.display_name, tvp.description, tvp.options + GROUP BY tvp.name, tvp.type, tvp.display_name, tvp.description, tvp.options ) SELECT utp.num, utp.template_ids, utp.name, + utp.type, utp.display_name, utp.description, utp.options, @@ -1599,7 +1787,7 @@ SELECT COUNT(wbp.value) AS count FROM unique_template_params utp JOIN workspace_build_parameters wbp ON (utp.workspace_build_ids @> ARRAY[wbp.workspace_build_id] AND utp.name = wbp.name) -GROUP BY utp.num, utp.name, utp.display_name, utp.description, utp.options, utp.template_ids, wbp.value +GROUP BY utp.num, utp.template_ids, utp.name, utp.type, utp.display_name, utp.description, utp.options, wbp.value ` type GetTemplateParameterInsightsParams struct { @@ -1612,6 +1800,7 @@ type GetTemplateParameterInsightsRow struct { Num int64 `db:"num" json:"num"` TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` Name string `db:"name" json:"name"` + Type string `db:"type" json:"type"` DisplayName string `db:"display_name" json:"display_name"` Description string `db:"description" json:"description"` Options json.RawMessage `db:"options" json:"options"` @@ -1636,6 +1825,7 @@ func (q *sqlQuerier) GetTemplateParameterInsights(ctx context.Context, arg GetTe &i.Num, pq.Array(&i.TemplateIDs), &i.Name, + &i.Type, &i.DisplayName, &i.Description, &i.Options, @@ -3329,11 +3519,13 @@ const getQuotaAllowanceForUser = `-- name: GetQuotaAllowanceForUser :one SELECT coalesce(SUM(quota_allowance), 0)::BIGINT FROM - group_members gm -JOIN groups g ON + groups g +LEFT JOIN group_members gm ON g.id = gm.group_id WHERE user_id = $1 +OR + g.id = g.organization_id ` func (q *sqlQuerier) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) { @@ -4125,7 +4317,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users WHERE @@ -4158,8 +4350,8 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, - &i.InactivityTTL, - &i.LockedTTL, + &i.TimeTilDormant, + &i.TimeTilDormantAutoDelete, &i.RestartRequirementDaysOfWeek, &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, @@ -4170,7 +4362,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -4211,8 +4403,8 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, - &i.InactivityTTL, - &i.LockedTTL, + &i.TimeTilDormant, + &i.TimeTilDormantAutoDelete, &i.RestartRequirementDaysOfWeek, &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, @@ -4222,7 +4414,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates ORDER BY (name, id) ASC ` @@ -4256,8 +4448,8 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, - &i.InactivityTTL, - &i.LockedTTL, + &i.TimeTilDormant, + &i.TimeTilDormantAutoDelete, &i.RestartRequirementDaysOfWeek, &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, @@ -4278,7 +4470,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -4349,8 +4541,8 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, - &i.InactivityTTL, - &i.LockedTTL, + &i.TimeTilDormant, + &i.TimeTilDormantAutoDelete, &i.RestartRequirementDaysOfWeek, &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, @@ -4540,8 +4732,8 @@ SET restart_requirement_days_of_week = $7, restart_requirement_weeks = $8, failure_ttl = $9, - inactivity_ttl = $10, - locked_ttl = $11 + time_til_dormant = $10, + time_til_dormant_autodelete = $11 WHERE id = $1 ` @@ -4556,8 +4748,8 @@ type UpdateTemplateScheduleByIDParams struct { RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"` FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` - InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` - LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` + TimeTilDormant int64 `db:"time_til_dormant" json:"time_til_dormant"` + TimeTilDormantAutoDelete int64 `db:"time_til_dormant_autodelete" json:"time_til_dormant_autodelete"` } func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error { @@ -4571,8 +4763,8 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT arg.RestartRequirementDaysOfWeek, arg.RestartRequirementWeeks, arg.FailureTTL, - arg.InactivityTTL, - arg.LockedTTL, + arg.TimeTilDormant, + arg.TimeTilDormantAutoDelete, ) return err } @@ -6168,61 +6360,120 @@ func (q *sqlQuerier) DeleteOldWorkspaceAgentLogs(ctx context.Context) error { return err } -const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one +const getWorkspaceAgentAndOwnerByAuthToken = `-- name: GetWorkspaceAgentAndOwnerByAuthToken :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at -FROM - workspace_agents + workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.startup_script, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.startup_script_timeout_seconds, workspace_agents.expanded_directory, workspace_agents.shutdown_script, workspace_agents.shutdown_script_timeout_seconds, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.startup_script_behavior, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, + workspaces.id AS workspace_id, + users.id AS owner_id, + users.username AS owner_name, + users.status AS owner_status, + array_cat( + array_append(users.rbac_roles, 'member'), + array_append(ARRAY[]::text[], 'organization-member:' || organization_members.organization_id::text) + )::text[] as owner_roles, + array_agg(COALESCE(group_members.group_id::text, ''))::text[] AS owner_groups +FROM users + INNER JOIN + workspaces + ON + workspaces.owner_id = users.id + INNER JOIN + workspace_builds + ON + workspace_builds.workspace_id = workspaces.id + INNER JOIN + workspace_resources + ON + workspace_resources.job_id = workspace_builds.job_id + INNER JOIN + workspace_agents + ON + workspace_agents.resource_id = workspace_resources.id + INNER JOIN -- every user is a member of some org + organization_members + ON + organization_members.user_id = users.id + LEFT JOIN -- as they may not be a member of any groups + group_members + ON + group_members.user_id = users.id WHERE - auth_token = $1 + -- TODO: we can add more conditions here, such as: + -- 1) The user must be active + -- 2) The user must not be deleted + -- 3) The workspace must be running + workspace_agents.auth_token = $1 +GROUP BY + workspace_agents.id, + workspaces.id, + users.id, + organization_members.organization_id, + workspace_builds.build_number ORDER BY - created_at DESC + workspace_builds.build_number DESC +LIMIT 1 ` -func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (WorkspaceAgent, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceAgentByAuthToken, authToken) - var i WorkspaceAgent +type GetWorkspaceAgentAndOwnerByAuthTokenRow struct { + WorkspaceAgent WorkspaceAgent `db:"workspace_agent" json:"workspace_agent"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OwnerName string `db:"owner_name" json:"owner_name"` + OwnerStatus UserStatus `db:"owner_status" json:"owner_status"` + OwnerRoles []string `db:"owner_roles" json:"owner_roles"` + OwnerGroups []string `db:"owner_groups" json:"owner_groups"` +} + +func (q *sqlQuerier) GetWorkspaceAgentAndOwnerByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndOwnerByAuthTokenRow, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceAgentAndOwnerByAuthToken, authToken) + var i GetWorkspaceAgentAndOwnerByAuthTokenRow err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.Name, - &i.FirstConnectedAt, - &i.LastConnectedAt, - &i.DisconnectedAt, - &i.ResourceID, - &i.AuthToken, - &i.AuthInstanceID, - &i.Architecture, - &i.EnvironmentVariables, - &i.OperatingSystem, - &i.StartupScript, - &i.InstanceMetadata, - &i.ResourceMetadata, - &i.Directory, - &i.Version, - &i.LastConnectedReplicaID, - &i.ConnectionTimeoutSeconds, - &i.TroubleshootingURL, - &i.MOTDFile, - &i.LifecycleState, - &i.StartupScriptTimeoutSeconds, - &i.ExpandedDirectory, - &i.ShutdownScript, - &i.ShutdownScriptTimeoutSeconds, - &i.LogsLength, - &i.LogsOverflowed, - &i.Subsystem, - &i.StartupScriptBehavior, - &i.StartedAt, - &i.ReadyAt, + &i.WorkspaceAgent.ID, + &i.WorkspaceAgent.CreatedAt, + &i.WorkspaceAgent.UpdatedAt, + &i.WorkspaceAgent.Name, + &i.WorkspaceAgent.FirstConnectedAt, + &i.WorkspaceAgent.LastConnectedAt, + &i.WorkspaceAgent.DisconnectedAt, + &i.WorkspaceAgent.ResourceID, + &i.WorkspaceAgent.AuthToken, + &i.WorkspaceAgent.AuthInstanceID, + &i.WorkspaceAgent.Architecture, + &i.WorkspaceAgent.EnvironmentVariables, + &i.WorkspaceAgent.OperatingSystem, + &i.WorkspaceAgent.StartupScript, + &i.WorkspaceAgent.InstanceMetadata, + &i.WorkspaceAgent.ResourceMetadata, + &i.WorkspaceAgent.Directory, + &i.WorkspaceAgent.Version, + &i.WorkspaceAgent.LastConnectedReplicaID, + &i.WorkspaceAgent.ConnectionTimeoutSeconds, + &i.WorkspaceAgent.TroubleshootingURL, + &i.WorkspaceAgent.MOTDFile, + &i.WorkspaceAgent.LifecycleState, + &i.WorkspaceAgent.StartupScriptTimeoutSeconds, + &i.WorkspaceAgent.ExpandedDirectory, + &i.WorkspaceAgent.ShutdownScript, + &i.WorkspaceAgent.ShutdownScriptTimeoutSeconds, + &i.WorkspaceAgent.LogsLength, + &i.WorkspaceAgent.LogsOverflowed, + &i.WorkspaceAgent.StartupScriptBehavior, + &i.WorkspaceAgent.StartedAt, + &i.WorkspaceAgent.ReadyAt, + pq.Array(&i.WorkspaceAgent.Subsystems), + &i.WorkspaceID, + &i.OwnerID, + &i.OwnerName, + &i.OwnerStatus, + pq.Array(&i.OwnerRoles), + pq.Array(&i.OwnerGroups), ) return i, err } const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, startup_script_behavior, started_at, ready_at, subsystems FROM workspace_agents WHERE @@ -6262,17 +6513,17 @@ func (q *sqlQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (W &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ) return i, err } const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, startup_script_behavior, started_at, ready_at, subsystems FROM workspace_agents WHERE @@ -6314,10 +6565,10 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ) return i, err } @@ -6437,7 +6688,7 @@ func (q *sqlQuerier) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAge const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, startup_script_behavior, started_at, ready_at, subsystems FROM workspace_agents WHERE @@ -6483,10 +6734,10 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ); err != nil { return nil, err } @@ -6502,7 +6753,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] } const getWorkspaceAgentsCreatedAfter = `-- name: GetWorkspaceAgentsCreatedAfter :many -SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at FROM workspace_agents WHERE created_at > $1 +SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, startup_script_behavior, started_at, ready_at, subsystems FROM workspace_agents WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) { @@ -6544,10 +6795,10 @@ func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, created &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ); err != nil { return nil, err } @@ -6564,7 +6815,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, created const getWorkspaceAgentsInLatestBuildByWorkspaceID = `-- name: GetWorkspaceAgentsInLatestBuildByWorkspaceID :many SELECT - workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.startup_script, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.startup_script_timeout_seconds, workspace_agents.expanded_directory, workspace_agents.shutdown_script, workspace_agents.shutdown_script_timeout_seconds, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.subsystem, workspace_agents.startup_script_behavior, workspace_agents.started_at, workspace_agents.ready_at + workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.startup_script, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.startup_script_timeout_seconds, workspace_agents.expanded_directory, workspace_agents.shutdown_script, workspace_agents.shutdown_script_timeout_seconds, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.startup_script_behavior, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems FROM workspace_agents JOIN @@ -6622,10 +6873,10 @@ func (q *sqlQuerier) GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx context.Co &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ); err != nil { return nil, err } @@ -6666,7 +6917,7 @@ INSERT INTO shutdown_script_timeout_seconds ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, startup_script_behavior, started_at, ready_at, subsystems ` type InsertWorkspaceAgentParams struct { @@ -6748,10 +6999,10 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ) return i, err } @@ -6971,16 +7222,16 @@ UPDATE SET version = $2, expanded_directory = $3, - subsystem = $4 + subsystems = $4 WHERE id = $1 ` type UpdateWorkspaceAgentStartupByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - Version string `db:"version" json:"version"` - ExpandedDirectory string `db:"expanded_directory" json:"expanded_directory"` - Subsystem WorkspaceAgentSubsystem `db:"subsystem" json:"subsystem"` + ID uuid.UUID `db:"id" json:"id"` + Version string `db:"version" json:"version"` + ExpandedDirectory string `db:"expanded_directory" json:"expanded_directory"` + Subsystems []WorkspaceAgentSubsystem `db:"subsystems" json:"subsystems"` } func (q *sqlQuerier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error { @@ -6988,7 +7239,7 @@ func (q *sqlQuerier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg Up arg.ID, arg.Version, arg.ExpandedDirectory, - arg.Subsystem, + pq.Array(arg.Subsystems), ) return err } @@ -7418,6 +7669,90 @@ func (q *sqlQuerier) InsertWorkspaceAgentStat(ctx context.Context, arg InsertWor return i, err } +const insertWorkspaceAgentStats = `-- name: InsertWorkspaceAgentStats :exec +INSERT INTO + workspace_agent_stats ( + id, + created_at, + user_id, + workspace_id, + template_id, + agent_id, + connections_by_proto, + connection_count, + rx_packets, + rx_bytes, + tx_packets, + tx_bytes, + session_count_vscode, + session_count_jetbrains, + session_count_reconnecting_pty, + session_count_ssh, + connection_median_latency_ms + ) +SELECT + unnest($1 :: uuid[]) AS id, + unnest($2 :: timestamptz[]) AS created_at, + unnest($3 :: uuid[]) AS user_id, + unnest($4 :: uuid[]) AS workspace_id, + unnest($5 :: uuid[]) AS template_id, + unnest($6 :: uuid[]) AS agent_id, + jsonb_array_elements($7 :: jsonb) AS connections_by_proto, + unnest($8 :: bigint[]) AS connection_count, + unnest($9 :: bigint[]) AS rx_packets, + unnest($10 :: bigint[]) AS rx_bytes, + unnest($11 :: bigint[]) AS tx_packets, + unnest($12 :: bigint[]) AS tx_bytes, + unnest($13 :: bigint[]) AS session_count_vscode, + unnest($14 :: bigint[]) AS session_count_jetbrains, + unnest($15 :: bigint[]) AS session_count_reconnecting_pty, + unnest($16 :: bigint[]) AS session_count_ssh, + unnest($17 :: double precision[]) AS connection_median_latency_ms +` + +type InsertWorkspaceAgentStatsParams struct { + ID []uuid.UUID `db:"id" json:"id"` + CreatedAt []time.Time `db:"created_at" json:"created_at"` + UserID []uuid.UUID `db:"user_id" json:"user_id"` + WorkspaceID []uuid.UUID `db:"workspace_id" json:"workspace_id"` + TemplateID []uuid.UUID `db:"template_id" json:"template_id"` + AgentID []uuid.UUID `db:"agent_id" json:"agent_id"` + ConnectionsByProto json.RawMessage `db:"connections_by_proto" json:"connections_by_proto"` + ConnectionCount []int64 `db:"connection_count" json:"connection_count"` + RxPackets []int64 `db:"rx_packets" json:"rx_packets"` + RxBytes []int64 `db:"rx_bytes" json:"rx_bytes"` + TxPackets []int64 `db:"tx_packets" json:"tx_packets"` + TxBytes []int64 `db:"tx_bytes" json:"tx_bytes"` + SessionCountVSCode []int64 `db:"session_count_vscode" json:"session_count_vscode"` + SessionCountJetBrains []int64 `db:"session_count_jetbrains" json:"session_count_jetbrains"` + SessionCountReconnectingPTY []int64 `db:"session_count_reconnecting_pty" json:"session_count_reconnecting_pty"` + SessionCountSSH []int64 `db:"session_count_ssh" json:"session_count_ssh"` + ConnectionMedianLatencyMS []float64 `db:"connection_median_latency_ms" json:"connection_median_latency_ms"` +} + +func (q *sqlQuerier) InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error { + _, err := q.db.ExecContext(ctx, insertWorkspaceAgentStats, + pq.Array(arg.ID), + pq.Array(arg.CreatedAt), + pq.Array(arg.UserID), + pq.Array(arg.WorkspaceID), + pq.Array(arg.TemplateID), + pq.Array(arg.AgentID), + arg.ConnectionsByProto, + pq.Array(arg.ConnectionCount), + pq.Array(arg.RxPackets), + pq.Array(arg.RxBytes), + pq.Array(arg.TxPackets), + pq.Array(arg.TxBytes), + pq.Array(arg.SessionCountVSCode), + pq.Array(arg.SessionCountJetBrains), + pq.Array(arg.SessionCountReconnectingPTY), + pq.Array(arg.SessionCountSSH), + pq.Array(arg.ConnectionMedianLatencyMS), + ) + return err +} + const getWorkspaceAppByAgentIDAndSlug = `-- name: GetWorkspaceAppByAgentIDAndSlug :one SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external FROM workspace_apps WHERE agent_id = $1 AND slug = $2 ` @@ -7678,6 +8013,72 @@ func (q *sqlQuerier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg Updat return err } +const insertWorkspaceAppStats = `-- name: InsertWorkspaceAppStats :exec +INSERT INTO + workspace_app_stats ( + user_id, + workspace_id, + agent_id, + access_method, + slug_or_port, + session_id, + session_started_at, + session_ended_at, + requests + ) +SELECT + unnest($1::uuid[]) AS user_id, + unnest($2::uuid[]) AS workspace_id, + unnest($3::uuid[]) AS agent_id, + unnest($4::text[]) AS access_method, + unnest($5::text[]) AS slug_or_port, + unnest($6::uuid[]) AS session_id, + unnest($7::timestamptz[]) AS session_started_at, + unnest($8::timestamptz[]) AS session_ended_at, + unnest($9::int[]) AS requests +ON CONFLICT + (user_id, agent_id, session_id) +DO + UPDATE SET + session_ended_at = EXCLUDED.session_ended_at, + requests = EXCLUDED.requests + WHERE + workspace_app_stats.user_id = EXCLUDED.user_id + AND workspace_app_stats.agent_id = EXCLUDED.agent_id + AND workspace_app_stats.session_id = EXCLUDED.session_id + -- Since stats are updated in place as time progresses, we only + -- want to update this row if it's fresh. + AND workspace_app_stats.session_ended_at <= EXCLUDED.session_ended_at + AND workspace_app_stats.requests <= EXCLUDED.requests +` + +type InsertWorkspaceAppStatsParams struct { + UserID []uuid.UUID `db:"user_id" json:"user_id"` + WorkspaceID []uuid.UUID `db:"workspace_id" json:"workspace_id"` + AgentID []uuid.UUID `db:"agent_id" json:"agent_id"` + AccessMethod []string `db:"access_method" json:"access_method"` + SlugOrPort []string `db:"slug_or_port" json:"slug_or_port"` + SessionID []uuid.UUID `db:"session_id" json:"session_id"` + SessionStartedAt []time.Time `db:"session_started_at" json:"session_started_at"` + SessionEndedAt []time.Time `db:"session_ended_at" json:"session_ended_at"` + Requests []int32 `db:"requests" json:"requests"` +} + +func (q *sqlQuerier) InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error { + _, err := q.db.ExecContext(ctx, insertWorkspaceAppStats, + pq.Array(arg.UserID), + pq.Array(arg.WorkspaceID), + pq.Array(arg.AgentID), + pq.Array(arg.AccessMethod), + pq.Array(arg.SlugOrPort), + pq.Array(arg.SessionID), + pq.Array(arg.SessionStartedAt), + pq.Array(arg.SessionEndedAt), + pq.Array(arg.Requests), + ) + return err +} + const getWorkspaceBuildParameters = `-- name: GetWorkspaceBuildParameters :many SELECT workspace_build_id, name, value @@ -7731,6 +8132,72 @@ func (q *sqlQuerier) InsertWorkspaceBuildParameters(ctx context.Context, arg Ins return err } +const getActiveWorkspaceBuildsByTemplateID = `-- name: GetActiveWorkspaceBuildsByTemplateID :many +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.initiator_by_avatar_url, wb.initiator_by_username +FROM ( + SELECT + workspace_id, MAX(build_number) as max_build_number + FROM + workspace_build_with_user AS workspace_builds + WHERE + workspace_id IN ( + SELECT + id + FROM + workspaces + WHERE + template_id = $1 + ) + GROUP BY + workspace_id +) m +JOIN + workspace_build_with_user AS wb + ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number +WHERE + wb.transition = 'start'::workspace_transition +` + +func (q *sqlQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) { + rows, err := q.db.QueryContext(ctx, getActiveWorkspaceBuildsByTemplateID, templateID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceBuild + for rows.Next() { + var i WorkspaceBuild + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.BuildNumber, + &i.Transition, + &i.InitiatorID, + &i.ProvisionerState, + &i.JobID, + &i.Deadline, + &i.Reason, + &i.DailyCost, + &i.MaxDeadline, + &i.InitiatorByAvatarUrl, + &i.InitiatorByUsername, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, initiator_by_avatar_url, initiator_by_username @@ -8637,7 +9104,7 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at FROM workspaces WHERE @@ -8680,7 +9147,7 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err @@ -8688,7 +9155,7 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI const getWorkspaceByID = `-- name: GetWorkspaceByID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at FROM workspaces WHERE @@ -8712,7 +9179,7 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err @@ -8720,7 +9187,7 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at FROM workspaces WHERE @@ -8751,7 +9218,7 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err @@ -8759,7 +9226,7 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo const getWorkspaceByWorkspaceAppID = `-- name: GetWorkspaceByWorkspaceAppID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at FROM workspaces WHERE @@ -8809,7 +9276,7 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err @@ -8817,7 +9284,7 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace const getWorkspaces = `-- name: GetWorkspaces :many SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.locked_at, workspaces.deleting_at, + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, @@ -9001,6 +9468,25 @@ WHERE ) > 0 ELSE true END + -- Filter by dormant workspaces. By default we do not return dormant + -- workspaces since they are considered soft-deleted. + AND CASE + WHEN $10 :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN + dormant_at IS NOT NULL AND dormant_at >= $10 + ELSE + dormant_at IS NULL + END + -- Filter by last_used + AND CASE + WHEN $11 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN + workspaces.last_used_at <= $11 + ELSE true + END + AND CASE + WHEN $12 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN + workspaces.last_used_at >= $12 + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY @@ -9012,11 +9498,11 @@ ORDER BY LOWER(workspaces.name) ASC LIMIT CASE - WHEN $11 :: integer > 0 THEN - $11 + WHEN $14 :: integer > 0 THEN + $14 END OFFSET - $10 + $13 ` type GetWorkspacesParams struct { @@ -9029,6 +9515,9 @@ type GetWorkspacesParams struct { Name string `db:"name" json:"name"` HasAgent string `db:"has_agent" json:"has_agent"` AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` + DormantAt time.Time `db:"dormant_at" json:"dormant_at"` + LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"` + LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` } @@ -9045,7 +9534,7 @@ type GetWorkspacesRow struct { AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` Ttl sql.NullInt64 `db:"ttl" json:"ttl"` LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - LockedAt sql.NullTime `db:"locked_at" json:"locked_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` TemplateName string `db:"template_name" json:"template_name"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` @@ -9064,6 +9553,9 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, + arg.DormantAt, + arg.LastUsedBefore, + arg.LastUsedAfter, arg.Offset, arg.Limit, ) @@ -9086,7 +9578,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, &i.TemplateName, &i.TemplateVersionID, @@ -9108,7 +9600,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.locked_at, workspaces.deleting_at + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at FROM workspaces LEFT JOIN @@ -9157,17 +9649,17 @@ WHERE ) OR -- If the workspace's template has an inactivity_ttl set - -- it may be eligible for locking. + -- it may be eligible for dormancy. ( - templates.inactivity_ttl > 0 AND - workspaces.locked_at IS NULL + templates.time_til_dormant > 0 AND + workspaces.dormant_at IS NULL ) OR - -- If the workspace's template has a locked_ttl set - -- and the workspace is already locked + -- If the workspace's template has a time_til_dormant_autodelete set + -- and the workspace is already dormant. ( - templates.locked_ttl > 0 AND - workspaces.locked_at IS NOT NULL + templates.time_til_dormant_autodelete > 0 AND + workspaces.dormant_at IS NOT NULL ) ) AND workspaces.deleted = 'false' ` @@ -9193,7 +9685,7 @@ func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ); err != nil { return nil, err @@ -9224,7 +9716,7 @@ INSERT INTO last_used_at ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at ` type InsertWorkspaceParams struct { @@ -9266,12 +9758,30 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err } +const updateTemplateWorkspacesLastUsedAt = `-- name: UpdateTemplateWorkspacesLastUsedAt :exec +UPDATE workspaces +SET + last_used_at = $1::timestamptz +WHERE + template_id = $2 +` + +type UpdateTemplateWorkspacesLastUsedAtParams struct { + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` +} + +func (q *sqlQuerier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateWorkspacesLastUsedAt, arg.LastUsedAt, arg.TemplateID) + return err +} + const updateWorkspace = `-- name: UpdateWorkspace :one UPDATE workspaces @@ -9280,7 +9790,7 @@ SET WHERE id = $1 AND deleted = false -RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at +RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at ` type UpdateWorkspaceParams struct { @@ -9303,7 +9813,7 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err @@ -9347,51 +9857,68 @@ func (q *sqlQuerier) UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateW return err } -const updateWorkspaceLastUsedAt = `-- name: UpdateWorkspaceLastUsedAt :exec +const updateWorkspaceDormantDeletingAt = `-- name: UpdateWorkspaceDormantDeletingAt :one UPDATE workspaces SET - last_used_at = $2 + dormant_at = $2, + -- When a workspace is active we want to update the last_used_at to avoid the workspace going + -- immediately dormant. If we're transition the workspace to dormant then we leave it alone. + last_used_at = CASE WHEN $2::timestamptz IS NULL THEN now() at time zone 'utc' ELSE last_used_at END, + -- If dormant_at is null (meaning active) or the template-defined time_til_dormant_autodelete is 0 we should set + -- deleting_at to NULL else set it to the dormant_at + time_til_dormant_autodelete duration. + deleting_at = CASE WHEN $2::timestamptz IS NULL OR templates.time_til_dormant_autodelete = 0 THEN NULL ELSE $2::timestamptz + INTERVAL '1 milliseconds' * templates.time_til_dormant_autodelete / 1000000 END +FROM + templates WHERE - id = $1 + workspaces.template_id = templates.id +AND + workspaces.id = $1 +RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at ` -type UpdateWorkspaceLastUsedAtParams struct { - ID uuid.UUID `db:"id" json:"id"` - LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` +type UpdateWorkspaceDormantDeletingAtParams struct { + ID uuid.UUID `db:"id" json:"id"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` } -func (q *sqlQuerier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceLastUsedAt, arg.ID, arg.LastUsedAt) - return err +func (q *sqlQuerier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (Workspace, error) { + row := q.db.QueryRowContext(ctx, updateWorkspaceDormantDeletingAt, arg.ID, arg.DormantAt) + var i Workspace + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.Ttl, + &i.LastUsedAt, + &i.DormantAt, + &i.DeletingAt, + ) + return i, err } -const updateWorkspaceLockedDeletingAt = `-- name: UpdateWorkspaceLockedDeletingAt :exec +const updateWorkspaceLastUsedAt = `-- name: UpdateWorkspaceLastUsedAt :exec UPDATE workspaces SET - locked_at = $2, - -- When a workspace is unlocked we want to update the last_used_at to avoid the workspace getting re-locked. - -- if we're locking the workspace then we leave it alone. - last_used_at = CASE WHEN $2::timestamptz IS NULL THEN now() at time zone 'utc' ELSE last_used_at END, - -- If locked_at is null (meaning unlocked) or the template-defined locked_ttl is 0 we should set - -- deleting_at to NULL else set it to the locked_at + locked_ttl duration. - deleting_at = CASE WHEN $2::timestamptz IS NULL OR templates.locked_ttl = 0 THEN NULL ELSE $2::timestamptz + INTERVAL '1 milliseconds' * templates.locked_ttl / 1000000 END -FROM - templates + last_used_at = $2 WHERE - workspaces.template_id = templates.id -AND - workspaces.id = $1 + id = $1 ` -type UpdateWorkspaceLockedDeletingAtParams struct { - ID uuid.UUID `db:"id" json:"id"` - LockedAt sql.NullTime `db:"locked_at" json:"locked_at"` +type UpdateWorkspaceLastUsedAtParams struct { + ID uuid.UUID `db:"id" json:"id"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` } -func (q *sqlQuerier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceLockedDeletingAt, arg.ID, arg.LockedAt) +func (q *sqlQuerier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceLastUsedAt, arg.ID, arg.LastUsedAt) return err } @@ -9414,23 +9941,28 @@ func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspace return err } -const updateWorkspacesDeletingAtByTemplateID = `-- name: UpdateWorkspacesDeletingAtByTemplateID :exec -UPDATE - workspaces +const updateWorkspacesDormantDeletingAtByTemplateID = `-- name: UpdateWorkspacesDormantDeletingAtByTemplateID :exec +UPDATE workspaces SET - deleting_at = CASE WHEN $1::bigint = 0 THEN NULL ELSE locked_at + interval '1 milliseconds' * $1::bigint END + deleting_at = CASE + WHEN $1::bigint = 0 THEN NULL + WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN ($2::timestamptz) + interval '1 milliseconds' * $1::bigint + ELSE dormant_at + interval '1 milliseconds' * $1::bigint + END, + dormant_at = CASE WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN $2::timestamptz ELSE dormant_at END WHERE - template_id = $2 + template_id = $3 AND - locked_at IS NOT NULL + dormant_at IS NOT NULL ` -type UpdateWorkspacesDeletingAtByTemplateIDParams struct { - LockedTtlMs int64 `db:"locked_ttl_ms" json:"locked_ttl_ms"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` +type UpdateWorkspacesDormantDeletingAtByTemplateIDParams struct { + TimeTilDormantAutodeleteMs int64 `db:"time_til_dormant_autodelete_ms" json:"time_til_dormant_autodelete_ms"` + DormantAt time.Time `db:"dormant_at" json:"dormant_at"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` } -func (q *sqlQuerier) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDeletingAtByTemplateIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspacesDeletingAtByTemplateID, arg.LockedTtlMs, arg.TemplateID) +func (q *sqlQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspacesDormantDeletingAtByTemplateID, arg.TimeTilDormantAutodeleteMs, arg.DormantAt, arg.TemplateID) return err } diff --git a/coderd/database/queries/groupmembers.sql b/coderd/database/queries/groupmembers.sql index a7c04d85f20a8..0b3d0a33f4d54 100644 --- a/coderd/database/queries/groupmembers.sql +++ b/coderd/database/queries/groupmembers.sql @@ -3,12 +3,23 @@ SELECT users.* FROM users -JOIN +-- If the group is a user made group, then we need to check the group_members table. +LEFT JOIN group_members ON - users.id = group_members.user_id + group_members.user_id = users.id AND + group_members.group_id = @group_id +-- If it is the "Everyone" group, then we need to check the organization_members table. +LEFT JOIN + organization_members +ON + organization_members.user_id = users.id AND + organization_members.organization_id = @group_id WHERE - group_members.group_id = $1 + -- In either case, the group_id will only match an org or a group. + (group_members.group_id = @group_id + OR + organization_members.organization_id = @group_id) AND users.status = 'active' AND diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index e1ee6635a5fe0..e772d21a5840f 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -26,9 +26,7 @@ SELECT FROM groups WHERE - organization_id = $1 -AND - id != $1; + organization_id = $1; -- name: InsertGroup :one INSERT INTO groups ( @@ -42,6 +40,28 @@ INSERT INTO groups ( VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; +-- name: InsertMissingGroups :many +-- Inserts any group by name that does not exist. All new groups are given +-- a random uuid, are inserted into the same organization. They have the default +-- values for avatar, display name, and quota allowance (all zero values). +INSERT INTO groups ( + id, + name, + organization_id, + source +) +SELECT + gen_random_uuid(), + group_name, + @organization_id, + @source +FROM + UNNEST(@group_names :: text[]) AS group_name +-- If the name conflicts, do nothing. +ON CONFLICT DO NOTHING +RETURNING *; + + -- We use the organization_id as the id -- for simplicity since all users is -- every member of the org. diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 94a80117dcc1d..d76d106edd5d1 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -23,68 +23,124 @@ ORDER BY user_id ASC; -- name: GetTemplateInsights :one -- GetTemplateInsights has a granularity of 5 minutes where if a session/app was --- in use, we will add 5 minutes to the total usage for that session (per user). -WITH d AS ( - -- Subtract 1 second from end_time to avoid including the next interval in the results. - SELECT generate_series(@start_time::timestamptz, (@end_time::timestamptz) - '1 second'::interval, '5 minute'::interval) AS d -), ts AS ( +-- in use during a minute, we will add 5 minutes to the total usage for that +-- session/app (per user). +WITH agent_stats_by_interval_and_user AS ( SELECT - d::timestamptz AS from_, - (d + '5 minute'::interval)::timestamptz AS to_, - EXTRACT(epoch FROM '5 minute'::interval) AS seconds - FROM d -), usage_by_user AS ( - SELECT - ts.from_, - ts.to_, + date_trunc('minute', was.created_at), was.user_id, array_agg(was.template_id) AS template_ids, - CASE WHEN SUM(was.session_count_vscode) > 0 THEN ts.seconds ELSE 0 END AS usage_vscode_seconds, - CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN ts.seconds ELSE 0 END AS usage_jetbrains_seconds, - CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN ts.seconds ELSE 0 END AS usage_reconnecting_pty_seconds, - CASE WHEN SUM(was.session_count_ssh) > 0 THEN ts.seconds ELSE 0 END AS usage_ssh_seconds - FROM ts - JOIN workspace_agent_stats was ON ( - was.created_at >= ts.from_ - AND was.created_at < ts.to_ + CASE WHEN SUM(was.session_count_vscode) > 0 THEN 60 ELSE 0 END AS usage_vscode_seconds, + CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN 60 ELSE 0 END AS usage_jetbrains_seconds, + CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN 60 ELSE 0 END AS usage_reconnecting_pty_seconds, + CASE WHEN SUM(was.session_count_ssh) > 0 THEN 60 ELSE 0 END AS usage_ssh_seconds + FROM workspace_agent_stats was + WHERE + was.created_at >= @start_time::timestamptz + AND was.created_at < @end_time::timestamptz AND was.connection_count > 0 AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN was.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END - ) - GROUP BY ts.from_, ts.to_, ts.seconds, was.user_id + GROUP BY date_trunc('minute', was.created_at), was.user_id ), template_ids AS ( SELECT array_agg(DISTINCT template_id) AS ids - FROM usage_by_user, unnest(template_ids) template_id + FROM agent_stats_by_interval_and_user, unnest(template_ids) template_id WHERE template_id IS NOT NULL ) SELECT COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids, - COUNT(DISTINCT user_id) AS active_users, + -- Return IDs so we can combine this with GetTemplateAppInsights. + COALESCE(array_agg(DISTINCT user_id), '{}')::uuid[] AS active_user_ids, COALESCE(SUM(usage_vscode_seconds), 0)::bigint AS usage_vscode_seconds, COALESCE(SUM(usage_jetbrains_seconds), 0)::bigint AS usage_jetbrains_seconds, COALESCE(SUM(usage_reconnecting_pty_seconds), 0)::bigint AS usage_reconnecting_pty_seconds, COALESCE(SUM(usage_ssh_seconds), 0)::bigint AS usage_ssh_seconds -FROM usage_by_user; +FROM agent_stats_by_interval_and_user; + +-- name: GetTemplateAppInsights :many +-- GetTemplateAppInsights returns the aggregate usage of each app in a given +-- timeframe. The result can be filtered on template_ids, meaning only user data +-- from workspaces based on those templates will be included. +WITH app_stats_by_user_and_agent AS ( + SELECT + s.start_time, + 60 as seconds, + w.template_id, + was.user_id, + was.agent_id, + was.access_method, + was.slug_or_port, + wa.display_name, + wa.icon, + (wa.slug IS NOT NULL)::boolean AS is_app + FROM workspace_app_stats was + JOIN workspaces w ON ( + w.id = was.workspace_id + AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN w.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END + ) + -- We do a left join here because we want to include user IDs that have used + -- e.g. ports when counting active users. + LEFT JOIN workspace_apps wa ON ( + wa.agent_id = was.agent_id + AND wa.slug = was.slug_or_port + ) + -- This table contains both 1 minute entries and >1 minute entries, + -- to calculate this with our uniqueness constraints, we generate series + -- for the longer intervals. + CROSS JOIN LATERAL generate_series( + date_trunc('minute', was.session_started_at), + -- Subtract 1 microsecond to avoid creating an extra series. + date_trunc('minute', was.session_ended_at - '1 microsecond'::interval), + '1 minute'::interval + ) s(start_time) + WHERE + s.start_time >= @start_time::timestamptz + -- Subtract one minute because the series only contains the start time. + AND s.start_time < (@end_time::timestamptz) - '1 minute'::interval + GROUP BY s.start_time, w.template_id, was.user_id, was.agent_id, was.access_method, was.slug_or_port, wa.display_name, wa.icon, wa.slug +) + +SELECT + array_agg(DISTINCT template_id)::uuid[] AS template_ids, + -- Return IDs so we can combine this with GetTemplateInsights. + array_agg(DISTINCT user_id)::uuid[] AS active_user_ids, + access_method, + slug_or_port, + display_name, + icon, + is_app, + SUM(seconds) AS usage_seconds +FROM app_stats_by_user_and_agent +GROUP BY access_method, slug_or_port, display_name, icon, is_app; -- name: GetTemplateDailyInsights :many -- GetTemplateDailyInsights returns all daily intervals between start and end -- time, if end time is a partial day, it will be included in the results and -- that interval will be less than 24 hours. If there is no data for a selected -- interval/template, it will be included in the results with 0 active users. -WITH d AS ( - -- sqlc workaround, use SELECT generate_series instead of SELECT * FROM generate_series. - -- Subtract 1 second from end_time to avoid including the next interval in the results. - SELECT generate_series(@start_time::timestamptz, (@end_time::timestamptz) - '1 second'::interval, '1 day'::interval) AS d -), ts AS ( +WITH ts AS ( SELECT d::timestamptz AS from_, - CASE WHEN (d + '1 day'::interval)::timestamptz <= @end_time::timestamptz THEN (d + '1 day'::interval)::timestamptz ELSE @end_time::timestamptz END AS to_ - FROM d -), usage_by_day AS ( + CASE + WHEN (d::timestamptz + '1 day'::interval) <= @end_time::timestamptz + THEN (d::timestamptz + '1 day'::interval) + ELSE @end_time::timestamptz + END AS to_ + FROM + -- Subtract 1 second from end_time to avoid including the next interval in the results. + generate_series(@start_time::timestamptz, (@end_time::timestamptz) - '1 second'::interval, '1 day'::interval) AS d +), unflattened_usage_by_day AS ( + -- We select data from both workspace agent stats and workspace app stats to + -- get a complete picture of usage. This matches how usage is calculated by + -- the combination of GetTemplateInsights and GetTemplateAppInsights. We use + -- a union all to avoid a costly distinct operation. + -- + -- Note that one query must perform a left join so that all intervals are + -- present at least once. SELECT ts.*, - was.user_id, - array_agg(was.template_id) AS template_ids + was.template_id, + was.user_id FROM ts LEFT JOIN workspace_agent_stats was ON ( was.created_at >= ts.from_ @@ -92,30 +148,35 @@ WITH d AS ( AND was.connection_count > 0 AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN was.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END ) - GROUP BY ts.from_, ts.to_, was.user_id -), template_ids AS ( + GROUP BY ts.from_, ts.to_, was.template_id, was.user_id + + UNION ALL + SELECT - template_usage_by_day.from_, - array_agg(template_id) AS ids - FROM ( - SELECT DISTINCT - from_, - unnest(template_ids) AS template_id - FROM usage_by_day - ) AS template_usage_by_day - WHERE template_id IS NOT NULL - GROUP BY template_usage_by_day.from_ + ts.*, + w.template_id, + was.user_id + FROM ts + JOIN workspace_app_stats was ON ( + (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_) + OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_) + OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_) + ) + JOIN workspaces w ON ( + w.id = was.workspace_id + AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN w.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END + ) + GROUP BY ts.from_, ts.to_, w.template_id, was.user_id ) SELECT from_ AS start_time, to_ AS end_time, - COALESCE((SELECT template_ids.ids FROM template_ids WHERE template_ids.from_ = usage_by_day.from_), '{}')::uuid[] AS template_ids, + array_remove(array_agg(DISTINCT template_id), NULL)::uuid[] AS template_ids, COUNT(DISTINCT user_id) AS active_users -FROM usage_by_day +FROM unflattened_usage_by_day GROUP BY from_, to_; - -- name: GetTemplateParameterInsights :many -- GetTemplateParameterInsights does for each template in a given timeframe, -- look for the latest workspace build (for every workspace) that has been @@ -127,15 +188,15 @@ WITH latest_workspace_builds AS ( wbmax.template_id, wb.template_version_id FROM ( - SELECT - tv.template_id, wbmax.workspace_id, MAX(wbmax.build_number) as max_build_number + SELECT + tv.template_id, wbmax.workspace_id, MAX(wbmax.build_number) as max_build_number FROM workspace_builds wbmax JOIN template_versions tv ON (tv.id = wbmax.template_version_id) WHERE wbmax.created_at >= @start_time::timestamptz AND wbmax.created_at < @end_time::timestamptz AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN tv.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END - GROUP BY tv.template_id, wbmax.workspace_id + GROUP BY tv.template_id, wbmax.workspace_id ) wbmax JOIN workspace_builds wb ON ( wb.workspace_id = wbmax.workspace_id @@ -147,18 +208,20 @@ WITH latest_workspace_builds AS ( array_agg(DISTINCT wb.template_id)::uuid[] AS template_ids, array_agg(wb.id)::uuid[] AS workspace_build_ids, tvp.name, + tvp.type, tvp.display_name, tvp.description, tvp.options FROM latest_workspace_builds wb JOIN template_version_parameters tvp ON (tvp.template_version_id = wb.template_version_id) - GROUP BY tvp.name, tvp.display_name, tvp.description, tvp.options + GROUP BY tvp.name, tvp.type, tvp.display_name, tvp.description, tvp.options ) SELECT utp.num, utp.template_ids, utp.name, + utp.type, utp.display_name, utp.description, utp.options, @@ -166,4 +229,4 @@ SELECT COUNT(wbp.value) AS count FROM unique_template_params utp JOIN workspace_build_parameters wbp ON (utp.workspace_build_ids @> ARRAY[wbp.workspace_build_id] AND utp.name = wbp.name) -GROUP BY utp.num, utp.name, utp.display_name, utp.description, utp.options, utp.template_ids, wbp.value; +GROUP BY utp.num, utp.template_ids, utp.name, utp.type, utp.display_name, utp.description, utp.options, wbp.value; diff --git a/coderd/database/queries/quotas.sql b/coderd/database/queries/quotas.sql index c640ba02ce982..48b9a673c7f03 100644 --- a/coderd/database/queries/quotas.sql +++ b/coderd/database/queries/quotas.sql @@ -2,11 +2,13 @@ SELECT coalesce(SUM(quota_allowance), 0)::BIGINT FROM - group_members gm -JOIN groups g ON + groups g +LEFT JOIN group_members gm ON g.id = gm.group_id WHERE - user_id = $1; + user_id = $1 +OR + g.id = g.organization_id; -- name: GetQuotaConsumedForUser :one WITH latest_builds AS ( diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 7f4c9ce5de4ab..5387bea009c2d 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -121,8 +121,8 @@ SET restart_requirement_days_of_week = $7, restart_requirement_weeks = $8, failure_ttl = $9, - inactivity_ttl = $10, - locked_ttl = $11 + time_til_dormant = $10, + time_til_dormant_autodelete = $11 WHERE id = $1 ; diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index 4025ac7e59a1b..9906d367e7bcf 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -1,13 +1,3 @@ --- name: GetWorkspaceAgentByAuthToken :one -SELECT - * -FROM - workspace_agents -WHERE - auth_token = $1 -ORDER BY - created_at DESC; - -- name: GetWorkspaceAgentByID :one SELECT * @@ -83,7 +73,7 @@ UPDATE SET version = $2, expanded_directory = $3, - subsystem = $4 + subsystems = $4 WHERE id = $1; @@ -200,3 +190,56 @@ WHERE WHERE wb.workspace_id = @workspace_id :: uuid ); + +-- name: GetWorkspaceAgentAndOwnerByAuthToken :one +SELECT + sqlc.embed(workspace_agents), + workspaces.id AS workspace_id, + users.id AS owner_id, + users.username AS owner_name, + users.status AS owner_status, + array_cat( + array_append(users.rbac_roles, 'member'), + array_append(ARRAY[]::text[], 'organization-member:' || organization_members.organization_id::text) + )::text[] as owner_roles, + array_agg(COALESCE(group_members.group_id::text, ''))::text[] AS owner_groups +FROM users + INNER JOIN + workspaces + ON + workspaces.owner_id = users.id + INNER JOIN + workspace_builds + ON + workspace_builds.workspace_id = workspaces.id + INNER JOIN + workspace_resources + ON + workspace_resources.job_id = workspace_builds.job_id + INNER JOIN + workspace_agents + ON + workspace_agents.resource_id = workspace_resources.id + INNER JOIN -- every user is a member of some org + organization_members + ON + organization_members.user_id = users.id + LEFT JOIN -- as they may not be a member of any groups + group_members + ON + group_members.user_id = users.id +WHERE + -- TODO: we can add more conditions here, such as: + -- 1) The user must be active + -- 2) The user must not be deleted + -- 3) The workspace must be running + workspace_agents.auth_token = @auth_token +GROUP BY + workspace_agents.id, + workspaces.id, + users.id, + organization_members.organization_id, + workspace_builds.build_number +ORDER BY + workspace_builds.build_number DESC +LIMIT 1; diff --git a/coderd/database/queries/workspaceagentstats.sql b/coderd/database/queries/workspaceagentstats.sql index 1a598bd6a6263..daba093a3d9e1 100644 --- a/coderd/database/queries/workspaceagentstats.sql +++ b/coderd/database/queries/workspaceagentstats.sql @@ -22,6 +22,46 @@ INSERT INTO VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING *; +-- name: InsertWorkspaceAgentStats :exec +INSERT INTO + workspace_agent_stats ( + id, + created_at, + user_id, + workspace_id, + template_id, + agent_id, + connections_by_proto, + connection_count, + rx_packets, + rx_bytes, + tx_packets, + tx_bytes, + session_count_vscode, + session_count_jetbrains, + session_count_reconnecting_pty, + session_count_ssh, + connection_median_latency_ms + ) +SELECT + unnest(@id :: uuid[]) AS id, + unnest(@created_at :: timestamptz[]) AS created_at, + unnest(@user_id :: uuid[]) AS user_id, + unnest(@workspace_id :: uuid[]) AS workspace_id, + unnest(@template_id :: uuid[]) AS template_id, + unnest(@agent_id :: uuid[]) AS agent_id, + jsonb_array_elements(@connections_by_proto :: jsonb) AS connections_by_proto, + unnest(@connection_count :: bigint[]) AS connection_count, + unnest(@rx_packets :: bigint[]) AS rx_packets, + unnest(@rx_bytes :: bigint[]) AS rx_bytes, + unnest(@tx_packets :: bigint[]) AS tx_packets, + unnest(@tx_bytes :: bigint[]) AS tx_bytes, + unnest(@session_count_vscode :: bigint[]) AS session_count_vscode, + unnest(@session_count_jetbrains :: bigint[]) AS session_count_jetbrains, + unnest(@session_count_reconnecting_pty :: bigint[]) AS session_count_reconnecting_pty, + unnest(@session_count_ssh :: bigint[]) AS session_count_ssh, + unnest(@connection_median_latency_ms :: double precision[]) AS connection_median_latency_ms; + -- name: GetTemplateDAUs :many SELECT (created_at at TIME ZONE cast(@tz_offset::integer as text))::date as date, diff --git a/coderd/database/queries/workspaceappstats.sql b/coderd/database/queries/workspaceappstats.sql new file mode 100644 index 0000000000000..98da75e6972c7 --- /dev/null +++ b/coderd/database/queries/workspaceappstats.sql @@ -0,0 +1,37 @@ +-- name: InsertWorkspaceAppStats :exec +INSERT INTO + workspace_app_stats ( + user_id, + workspace_id, + agent_id, + access_method, + slug_or_port, + session_id, + session_started_at, + session_ended_at, + requests + ) +SELECT + unnest(@user_id::uuid[]) AS user_id, + unnest(@workspace_id::uuid[]) AS workspace_id, + unnest(@agent_id::uuid[]) AS agent_id, + unnest(@access_method::text[]) AS access_method, + unnest(@slug_or_port::text[]) AS slug_or_port, + unnest(@session_id::uuid[]) AS session_id, + unnest(@session_started_at::timestamptz[]) AS session_started_at, + unnest(@session_ended_at::timestamptz[]) AS session_ended_at, + unnest(@requests::int[]) AS requests +ON CONFLICT + (user_id, agent_id, session_id) +DO + UPDATE SET + session_ended_at = EXCLUDED.session_ended_at, + requests = EXCLUDED.requests + WHERE + workspace_app_stats.user_id = EXCLUDED.user_id + AND workspace_app_stats.agent_id = EXCLUDED.agent_id + AND workspace_app_stats.session_id = EXCLUDED.session_id + -- Since stats are updated in place as time progresses, we only + -- want to update this row if it's fresh. + AND workspace_app_stats.session_ended_at <= EXCLUDED.session_ended_at + AND workspace_app_stats.requests <= EXCLUDED.requests; diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index ea2ccdb8d08ce..1020b729c4f27 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -144,3 +144,27 @@ SET WHERE id = $1; +-- name: GetActiveWorkspaceBuildsByTemplateID :many +SELECT wb.* +FROM ( + SELECT + workspace_id, MAX(build_number) as max_build_number + FROM + workspace_build_with_user AS workspace_builds + WHERE + workspace_id IN ( + SELECT + id + FROM + workspaces + WHERE + template_id = $1 + ) + GROUP BY + workspace_id +) m +JOIN + workspace_build_with_user AS wb + ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number +WHERE + wb.transition = 'start'::workspace_transition; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 5e540a0e5c90a..0aa073301eb8f 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -259,6 +259,25 @@ WHERE ) > 0 ELSE true END + -- Filter by dormant workspaces. By default we do not return dormant + -- workspaces since they are considered soft-deleted. + AND CASE + WHEN @dormant_at :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN + dormant_at IS NOT NULL AND dormant_at >= @dormant_at + ELSE + dormant_at IS NULL + END + -- Filter by last_used + AND CASE + WHEN @last_used_before :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN + workspaces.last_used_at <= @last_used_before + ELSE true + END + AND CASE + WHEN @last_used_after :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN + workspaces.last_used_at >= @last_used_after + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY @@ -460,44 +479,56 @@ WHERE ) OR -- If the workspace's template has an inactivity_ttl set - -- it may be eligible for locking. + -- it may be eligible for dormancy. ( - templates.inactivity_ttl > 0 AND - workspaces.locked_at IS NULL + templates.time_til_dormant > 0 AND + workspaces.dormant_at IS NULL ) OR - -- If the workspace's template has a locked_ttl set - -- and the workspace is already locked + -- If the workspace's template has a time_til_dormant_autodelete set + -- and the workspace is already dormant. ( - templates.locked_ttl > 0 AND - workspaces.locked_at IS NOT NULL + templates.time_til_dormant_autodelete > 0 AND + workspaces.dormant_at IS NOT NULL ) ) AND workspaces.deleted = 'false'; --- name: UpdateWorkspaceLockedDeletingAt :exec +-- name: UpdateWorkspaceDormantDeletingAt :one UPDATE workspaces SET - locked_at = $2, - -- When a workspace is unlocked we want to update the last_used_at to avoid the workspace getting re-locked. - -- if we're locking the workspace then we leave it alone. + dormant_at = $2, + -- When a workspace is active we want to update the last_used_at to avoid the workspace going + -- immediately dormant. If we're transition the workspace to dormant then we leave it alone. last_used_at = CASE WHEN $2::timestamptz IS NULL THEN now() at time zone 'utc' ELSE last_used_at END, - -- If locked_at is null (meaning unlocked) or the template-defined locked_ttl is 0 we should set - -- deleting_at to NULL else set it to the locked_at + locked_ttl duration. - deleting_at = CASE WHEN $2::timestamptz IS NULL OR templates.locked_ttl = 0 THEN NULL ELSE $2::timestamptz + INTERVAL '1 milliseconds' * templates.locked_ttl / 1000000 END + -- If dormant_at is null (meaning active) or the template-defined time_til_dormant_autodelete is 0 we should set + -- deleting_at to NULL else set it to the dormant_at + time_til_dormant_autodelete duration. + deleting_at = CASE WHEN $2::timestamptz IS NULL OR templates.time_til_dormant_autodelete = 0 THEN NULL ELSE $2::timestamptz + INTERVAL '1 milliseconds' * templates.time_til_dormant_autodelete / 1000000 END FROM templates WHERE workspaces.template_id = templates.id AND - workspaces.id = $1; + workspaces.id = $1 +RETURNING workspaces.*; --- name: UpdateWorkspacesDeletingAtByTemplateID :exec -UPDATE - workspaces +-- name: UpdateWorkspacesDormantDeletingAtByTemplateID :exec +UPDATE workspaces SET - deleting_at = CASE WHEN @locked_ttl_ms::bigint = 0 THEN NULL ELSE locked_at + interval '1 milliseconds' * @locked_ttl_ms::bigint END + deleting_at = CASE + WHEN @time_til_dormant_autodelete_ms::bigint = 0 THEN NULL + WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@dormant_at::timestamptz) + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint + ELSE dormant_at + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint + END, + dormant_at = CASE WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN @dormant_at::timestamptz ELSE dormant_at END WHERE - template_id = @template_id + template_id = @template_id AND - locked_at IS NOT NULL; + dormant_at IS NOT NULL; + +-- name: UpdateTemplateWorkspacesLastUsedAt :exec +UPDATE workspaces +SET + last_used_at = @last_used_at::timestamptz +WHERE + template_id = @template_id; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 526f62d3a5ca5..7718b01e0335e 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -67,10 +67,10 @@ overrides: motd_file: MOTDFile uuid: UUID failure_ttl: FailureTTL - inactivity_ttl: InactivityTTL + time_til_dormant_autodelete: TimeTilDormantAutoDelete eof: EOF - locked_ttl: LockedTTL template_ids: TemplateIDs + active_user_ids: ActiveUserIDs sql: - schema: "./dump.sql" diff --git a/coderd/database/tx.go b/coderd/database/tx.go new file mode 100644 index 0000000000000..43da15f3f058c --- /dev/null +++ b/coderd/database/tx.go @@ -0,0 +1,49 @@ +package database + +import ( + "database/sql" + + "github.com/lib/pq" + "golang.org/x/xerrors" +) + +const maxRetries = 5 + +// ReadModifyUpdate is a helper function to run a db transaction that reads some +// object(s), modifies some of the data, and writes the modified object(s) back +// to the database. It is run in a transaction at RepeatableRead isolation so +// that if another database client also modifies the data we are writing and +// commits, then the transaction is rolled back and restarted. +// +// This is needed because we typically read all object columns, modify some +// subset, and then write all columns. Consider an object with columns A, B and +// initial values A=1, B=1. Two database clients work simultaneously, with one +// client attempting to set A=2, and another attempting to set B=2. They both +// initially read A=1, B=1, and then one writes A=2, B=1, and the other writes +// A=1, B=2. With default PostgreSQL isolation of ReadCommitted, both of these +// transactions would succeed and we end up with either A=2, B=1 or A=1, B=2. +// One or other client gets their transaction wiped out even though the data +// they wanted to change didn't conflict. +// +// If we run at RepeatableRead isolation, then one or other transaction will +// fail. Let's say the transaction that sets A=2 succeeds. Then the first B=2 +// transaction fails, but here we retry. The second attempt we read A=2, B=1, +// then write A=2, B=2 as desired, and this succeeds. +func ReadModifyUpdate(db Store, f func(tx Store) error, +) error { + var err error + for retries := 0; retries < maxRetries; retries++ { + err = db.InTx(f, &sql.TxOptions{ + Isolation: sql.LevelRepeatableRead, + }) + var pqe *pq.Error + if xerrors.As(err, &pqe) { + if pqe.Code == "40001" { + // serialization error, retry + continue + } + } + return err + } + return xerrors.Errorf("too many errors; last error: %w", err) +} diff --git a/coderd/database/tx_test.go b/coderd/database/tx_test.go new file mode 100644 index 0000000000000..ff7569ef562df --- /dev/null +++ b/coderd/database/tx_test.go @@ -0,0 +1,81 @@ +package database_test + +import ( + "database/sql" + "testing" + + "github.com/golang/mock/gomock" + "github.com/lib/pq" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmock" +) + +func TestReadModifyUpdate_OK(t *testing.T) { + t.Parallel() + + mDB := dbmock.NewMockStore(gomock.NewController(t)) + + mDB.EXPECT(). + InTx(gomock.Any(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead}). + Times(1). + Return(nil) + err := database.ReadModifyUpdate(mDB, func(tx database.Store) error { + return nil + }) + require.NoError(t, err) +} + +func TestReadModifyUpdate_RetryOK(t *testing.T) { + t.Parallel() + + mDB := dbmock.NewMockStore(gomock.NewController(t)) + + firstUpdate := mDB.EXPECT(). + InTx(gomock.Any(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead}). + Times(1). + Return(&pq.Error{Code: pq.ErrorCode("40001")}) + mDB.EXPECT(). + InTx(gomock.Any(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead}). + After(firstUpdate). + Times(1). + Return(nil) + + err := database.ReadModifyUpdate(mDB, func(tx database.Store) error { + return nil + }) + require.NoError(t, err) +} + +func TestReadModifyUpdate_HardError(t *testing.T) { + t.Parallel() + + mDB := dbmock.NewMockStore(gomock.NewController(t)) + + mDB.EXPECT(). + InTx(gomock.Any(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead}). + Times(1). + Return(xerrors.New("a bad thing happened")) + + err := database.ReadModifyUpdate(mDB, func(tx database.Store) error { + return nil + }) + require.ErrorContains(t, err, "a bad thing happened") +} + +func TestReadModifyUpdate_TooManyRetries(t *testing.T) { + t.Parallel() + + mDB := dbmock.NewMockStore(gomock.NewController(t)) + + mDB.EXPECT(). + InTx(gomock.Any(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead}). + Times(5). + Return(&pq.Error{Code: pq.ErrorCode("40001")}) + err := database.ReadModifyUpdate(mDB, func(tx database.Store) error { + return nil + }) + require.ErrorContains(t, err, "too many errors") +} diff --git a/coderd/database/types.go b/coderd/database/types.go index 629138de2cfda..c3a58329ac204 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac" ) // AuditOAuthConvertState is never stored in the database. It is stored in a cookie diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index c8dbc831e8651..294b4b12d51af 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -18,6 +18,7 @@ const ( UniqueTemplateVersionParametersTemplateVersionIDNameKey UniqueConstraint = "template_version_parameters_template_version_id_name_key" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name); UniqueTemplateVersionVariablesTemplateVersionIDNameKey UniqueConstraint = "template_version_variables_template_version_id_name_key" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_name_key UNIQUE (template_version_id, name); UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); + UniqueWorkspaceAppStatsUserIDAgentIDSessionIDKey UniqueConstraint = "workspace_app_stats_user_id_agent_id_session_id_key" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id); UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); UniqueWorkspaceBuildParametersWorkspaceBuildIDNameKey UniqueConstraint = "workspace_build_parameters_workspace_build_id_name_key" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_name_key UNIQUE (workspace_build_id, name); UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id); diff --git a/coderd/debug.go b/coderd/debug.go index 045cb0076c953..eb48fd66b0308 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -5,10 +5,10 @@ import ( "net/http" "time" - "github.com/coder/coder/coderd/healthcheck" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/healthcheck" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) // @Summary Debug Info Wireguard Coordinator diff --git a/coderd/debug_test.go b/coderd/debug_test.go index 2cff8f176c4c3..242d271b297b7 100644 --- a/coderd/debug_test.go +++ b/coderd/debug_test.go @@ -10,9 +10,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/healthcheck" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/healthcheck" + "github.com/coder/coder/v2/testutil" ) func TestDebugHealth(t *testing.T) { diff --git a/coderd/deployment.go b/coderd/deployment.go index 5f12f39cc3461..255f9c7ac2a8d 100644 --- a/coderd/deployment.go +++ b/coderd/deployment.go @@ -4,10 +4,10 @@ import ( "net/http" "net/url" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" ) // @Summary Get deployment config diff --git a/coderd/deployment_test.go b/coderd/deployment_test.go index d525166993aac..617947e6eb607 100644 --- a/coderd/deployment_test.go +++ b/coderd/deployment_test.go @@ -7,8 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/testutil" ) func TestDeploymentValues(t *testing.T) { diff --git a/coderd/deprecated.go b/coderd/deprecated.go index 59e23b8d48136..f656451a83edd 100644 --- a/coderd/deprecated.go +++ b/coderd/deprecated.go @@ -3,7 +3,7 @@ package coderd import ( "net/http" - "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi" ) // @Summary Removed: Get parameters by template version diff --git a/coderd/devtunnel/servers.go b/coderd/devtunnel/servers.go index 1ac1b6ce26a7c..db909d2e1db0e 100644 --- a/coderd/devtunnel/servers.go +++ b/coderd/devtunnel/servers.go @@ -10,7 +10,8 @@ import ( "golang.org/x/sync/errgroup" "golang.org/x/xerrors" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/cryptorand" ) type Region struct { @@ -115,8 +116,8 @@ func FindClosestNode(nodes []Node) (Node, error) { return Node{}, err } - slices.SortFunc(nodes, func(i, j Node) bool { - return i.AvgLatency < j.AvgLatency + slices.SortFunc(nodes, func(a, b Node) int { + return slice.Ascending(a.AvgLatency, b.AvgLatency) }) return nodes[0], nil } diff --git a/coderd/devtunnel/tunnel.go b/coderd/devtunnel/tunnel.go index 11bbc7dad6bee..d61976cef4f32 100644 --- a/coderd/devtunnel/tunnel.go +++ b/coderd/devtunnel/tunnel.go @@ -15,8 +15,8 @@ import ( "golang.zx2c4.com/wireguard/device" "cdr.dev/slog" - "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cryptorand" "github.com/coder/wgtunnel/tunnelsdk" ) diff --git a/coderd/devtunnel/tunnel_test.go b/coderd/devtunnel/tunnel_test.go index a389001523375..1dc804c8ee660 100644 --- a/coderd/devtunnel/tunnel_test.go +++ b/coderd/devtunnel/tunnel_test.go @@ -22,8 +22,8 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/devtunnel" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/devtunnel" + "github.com/coder/coder/v2/testutil" "github.com/coder/wgtunnel/tunneld" "github.com/coder/wgtunnel/tunnelsdk" ) diff --git a/coderd/dormancy/dormantusersjob.go b/coderd/dormancy/dormantusersjob.go index be1e0cf0fe61b..8f69ad6260e5e 100644 --- a/coderd/dormancy/dormantusersjob.go +++ b/coderd/dormancy/dormantusersjob.go @@ -9,7 +9,7 @@ import ( "cdr.dev/slog" - "github.com/coder/coder/coderd/database" + "github.com/coder/coder/v2/coderd/database" ) const ( diff --git a/coderd/dormancy/dormantusersjob_test.go b/coderd/dormancy/dormantusersjob_test.go index 73224da872c6e..f937589faac76 100644 --- a/coderd/dormancy/dormantusersjob_test.go +++ b/coderd/dormancy/dormantusersjob_test.go @@ -11,10 +11,10 @@ import ( "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/dormancy" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/dormancy" + "github.com/coder/coder/v2/testutil" ) func TestCheckInactiveUsers(t *testing.T) { diff --git a/coderd/experiments.go b/coderd/experiments.go index 651ae3155c369..1a8bb5ce1812a 100644 --- a/coderd/experiments.go +++ b/coderd/experiments.go @@ -3,7 +3,7 @@ package coderd import ( "net/http" - "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi" ) // @Summary Get experiments diff --git a/coderd/experiments_test.go b/coderd/experiments_test.go index 5526d8324d7e3..0f498e7e7cf2b 100644 --- a/coderd/experiments_test.go +++ b/coderd/experiments_test.go @@ -6,10 +6,10 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func Test_Experiments(t *testing.T) { diff --git a/coderd/files.go b/coderd/files.go index 486ef26b90c90..842761236cb6b 100644 --- a/coderd/files.go +++ b/coderd/files.go @@ -12,10 +12,10 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) const ( diff --git a/coderd/files_test.go b/coderd/files_test.go index 0841785c8c660..1a3f407a6e1f6 100644 --- a/coderd/files_test.go +++ b/coderd/files_test.go @@ -9,9 +9,9 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func TestPostFiles(t *testing.T) { diff --git a/coderd/gitauth.go b/coderd/gitauth.go index 537f0b8794e32..5bab419662935 100644 --- a/coderd/gitauth.go +++ b/coderd/gitauth.go @@ -8,11 +8,11 @@ import ( "golang.org/x/sync/errgroup" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) // @Summary Get git auth by ID diff --git a/coderd/gitauth/askpass_test.go b/coderd/gitauth/askpass_test.go index ce7cc75989603..72fd6319a2303 100644 --- a/coderd/gitauth/askpass_test.go +++ b/coderd/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/coderd/gitauth" ) func TestCheckCommand(t *testing.T) { diff --git a/coderd/gitauth/config.go b/coderd/gitauth/config.go index 29d4804dcd538..31b0f052fcd9e 100644 --- a/coderd/gitauth/config.go +++ b/coderd/gitauth/config.go @@ -8,15 +8,17 @@ import ( "net/http" "net/url" "regexp" + "time" "golang.org/x/oauth2" "golang.org/x/xerrors" "github.com/google/go-github/v43/github" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/retry" ) type OAuth2Config interface { @@ -75,12 +77,26 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, gitAuthLin // we aren't trying to surface an error, we're just trying to obtain a valid token. return gitAuthLink, false, nil } - + r := retry.New(50*time.Millisecond, 200*time.Millisecond) + // See the comment below why the retry and cancel is required. + retryCtx, retryCtxCancel := context.WithTimeout(ctx, time.Second) + defer retryCtxCancel() +validate: valid, _, err := c.ValidateToken(ctx, token.AccessToken) if err != nil { return gitAuthLink, false, xerrors.Errorf("validate git auth token: %w", err) } if !valid { + // A customer using GitHub in Australia reported that validating immediately + // after refreshing the token would intermittently fail with a 401. Waiting + // a few milliseconds with the exact same token on the exact same request + // would resolve the issue. It seems likely that the write is not propagating + // to the read replica in time. + // + // We do an exponential backoff here to give the write time to propagate. + if c.Type == codersdk.GitProviderGitHub && r.Wait(retryCtx) { + goto validate + } // The token is no longer valid! return gitAuthLink, false, nil } diff --git a/coderd/gitauth/config_test.go b/coderd/gitauth/config_test.go index 31d6392341426..bcd650e82ad3a 100644 --- a/coderd/gitauth/config_test.go +++ b/coderd/gitauth/config_test.go @@ -12,12 +12,12 @@ import ( "golang.org/x/oauth2" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func TestRefreshToken(t *testing.T) { @@ -73,6 +73,39 @@ func TestRefreshToken(t *testing.T) { require.NoError(t, err) require.False(t, refreshed) }) + t.Run("ValidateRetryGitHub", func(t *testing.T) { + t.Parallel() + hit := false + // We need to ensure that the exponential backoff kicks in properly. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !hit { + hit = true + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Not permitted")) + return + } + w.WriteHeader(http.StatusOK) + })) + config := &gitauth.Config{ + ID: "test", + OAuth2Config: &testutil.OAuth2Config{ + Token: &oauth2.Token{ + AccessToken: "updated", + }, + }, + ValidateURL: srv.URL, + Type: codersdk.GitProviderGitHub, + } + db := dbfake.New() + link := dbgen.GitAuthLink(t, db, database.GitAuthLink{ + ProviderID: config.ID, + OAuthAccessToken: "initial", + }) + _, refreshed, err := config.RefreshToken(context.Background(), db, link) + require.NoError(t, err) + require.True(t, refreshed) + require.True(t, hit) + }) t.Run("ValidateNoUpdate", func(t *testing.T) { t.Parallel() validated := make(chan struct{}) diff --git a/coderd/gitauth/oauth.go b/coderd/gitauth/oauth.go index daba6a8faf075..63e938fb756e7 100644 --- a/coderd/gitauth/oauth.go +++ b/coderd/gitauth/oauth.go @@ -12,8 +12,8 @@ import ( "golang.org/x/oauth2/github" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" ) // endpoint contains default SaaS URLs for each Git provider. diff --git a/coderd/gitauth/vscode_test.go b/coderd/gitauth/vscode_test.go index f61fb97ea681a..f940f151aadc3 100644 --- a/coderd/gitauth/vscode_test.go +++ b/coderd/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/coderd/gitauth" ) func TestOverrideVSCodeConfigs(t *testing.T) { diff --git a/coderd/gitauth_test.go b/coderd/gitauth_test.go index 578f7bea145c5..c0ad89a1b53cc 100644 --- a/coderd/gitauth_test.go +++ b/coderd/gitauth_test.go @@ -16,15 +16,14 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/oauth2" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/httpapi" - "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/testutil" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/gitauth" + "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/provisioner/echo" + "github.com/coder/coder/v2/testutil" ) func TestGitAuthByID(t *testing.T) { @@ -227,7 +226,7 @@ func TestGitAuthCallback(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -256,24 +255,9 @@ func TestGitAuthCallback(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) authToken := 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{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - Agents: []*proto.Agent{{ - Id: uuid.NewString(), - Auth: &proto.Agent_Token{ - Token: authToken, - }, - }}, - }}, - }, - }, - }}, + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) @@ -342,7 +326,7 @@ func TestGitAuthCallback(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -400,7 +384,7 @@ func TestGitAuthCallback(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -443,7 +427,7 @@ func TestGitAuthCallback(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/coderd/gitsshkey.go b/coderd/gitsshkey.go index 096844caa0a28..d4d3ed5f14775 100644 --- a/coderd/gitsshkey.go +++ b/coderd/gitsshkey.go @@ -3,13 +3,13 @@ package coderd import ( "net/http" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/gitsshkey" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "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/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" ) // @Summary Regenerate user SSH key diff --git a/coderd/gitsshkey/gitsshkey_test.go b/coderd/gitsshkey/gitsshkey_test.go index e5dc9b9c6faf8..88ddbfc598930 100644 --- a/coderd/gitsshkey/gitsshkey_test.go +++ b/coderd/gitsshkey/gitsshkey_test.go @@ -6,8 +6,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" - "github.com/coder/coder/coderd/gitsshkey" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/coderd/gitsshkey" + "github.com/coder/coder/v2/cryptorand" ) func TestGitSSHKeys(t *testing.T) { diff --git a/coderd/gitsshkey_test.go b/coderd/gitsshkey_test.go index 42b227869c7a7..be1f43c52eb2f 100644 --- a/coderd/gitsshkey_test.go +++ b/coderd/gitsshkey_test.go @@ -8,13 +8,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/gitsshkey" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/gitsshkey" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/testutil" ) func TestGitSSHKey(t *testing.T) { @@ -108,7 +108,7 @@ func TestAgentGitSSHKey(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) project := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/coderd/healthcheck/accessurl.go b/coderd/healthcheck/accessurl.go index b91889b2842a2..6f86944b7ca4e 100644 --- a/coderd/healthcheck/accessurl.go +++ b/coderd/healthcheck/accessurl.go @@ -9,7 +9,7 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/util/ptr" ) // @typescript-generate AccessURLReport diff --git a/coderd/healthcheck/accessurl_test.go b/coderd/healthcheck/accessurl_test.go index 6097d6cb50810..3464030b61eb1 100644 --- a/coderd/healthcheck/accessurl_test.go +++ b/coderd/healthcheck/accessurl_test.go @@ -11,8 +11,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/healthcheck" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/healthcheck" ) func TestAccessURL(t *testing.T) { diff --git a/coderd/healthcheck/database.go b/coderd/healthcheck/database.go index c92ef3c447d56..70005dc5b3d9f 100644 --- a/coderd/healthcheck/database.go +++ b/coderd/healthcheck/database.go @@ -7,7 +7,7 @@ import ( "golang.org/x/exp/slices" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" + "github.com/coder/coder/v2/coderd/database" ) // @typescript-generate DatabaseReport diff --git a/coderd/healthcheck/database_test.go b/coderd/healthcheck/database_test.go index 615728a8b573b..f6c2782aacacd 100644 --- a/coderd/healthcheck/database_test.go +++ b/coderd/healthcheck/database_test.go @@ -10,9 +10,9 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database/dbmock" - "github.com/coder/coder/coderd/healthcheck" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/healthcheck" + "github.com/coder/coder/v2/testutil" ) func TestDatabase(t *testing.T) { diff --git a/coderd/healthcheck/derp.go b/coderd/healthcheck/derp.go index 9fc88a37e40db..d3b627f29a539 100644 --- a/coderd/healthcheck/derp.go +++ b/coderd/healthcheck/derp.go @@ -21,7 +21,7 @@ import ( "tailscale.com/types/key" tslogger "tailscale.com/types/logger" - "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/util/ptr" ) // @typescript-generate DERPReport @@ -118,7 +118,7 @@ func (r *DERPReport) Run(ctx context.Context, opts *DERPReportOptions) { mu.Unlock() } nc := &netcheck.Client{ - PortMapper: portmapper.NewClient(tslogger.WithPrefix(ncLogf, "portmap: "), nil), + PortMapper: portmapper.NewClient(tslogger.WithPrefix(ncLogf, "portmap: "), nil, nil, nil), Logf: tslogger.WithPrefix(ncLogf, "netcheck: "), } ncReport, netcheckErr := nc.GetReport(ctx, opts.DERPMap) diff --git a/coderd/healthcheck/derp_test.go b/coderd/healthcheck/derp_test.go index d27cdc182ec31..f291170531d2f 100644 --- a/coderd/healthcheck/derp_test.go +++ b/coderd/healthcheck/derp_test.go @@ -17,9 +17,9 @@ import ( "tailscale.com/tailcfg" "tailscale.com/types/key" - "github.com/coder/coder/coderd/healthcheck" - "github.com/coder/coder/tailnet" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/healthcheck" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/testutil" ) //nolint:tparallel diff --git a/coderd/healthcheck/healthcheck.go b/coderd/healthcheck/healthcheck.go index 29a4398b8391c..0a29165db9839 100644 --- a/coderd/healthcheck/healthcheck.go +++ b/coderd/healthcheck/healthcheck.go @@ -10,9 +10,9 @@ import ( "tailscale.com/tailcfg" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/util/ptr" ) const ( diff --git a/coderd/healthcheck/healthcheck_test.go b/coderd/healthcheck/healthcheck_test.go index 26be9021eaf46..40f5efd586fc7 100644 --- a/coderd/healthcheck/healthcheck_test.go +++ b/coderd/healthcheck/healthcheck_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/coder/coder/coderd/healthcheck" + "github.com/coder/coder/v2/coderd/healthcheck" ) type testChecker struct { diff --git a/coderd/healthcheck/websocket_test.go b/coderd/healthcheck/websocket_test.go index cb56081197577..44df237a49cbb 100644 --- a/coderd/healthcheck/websocket_test.go +++ b/coderd/healthcheck/websocket_test.go @@ -11,8 +11,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/healthcheck" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/healthcheck" + "github.com/coder/coder/v2/testutil" ) func TestWebsocket(t *testing.T) { diff --git a/coderd/httpapi/cookie.go b/coderd/httpapi/cookie.go index 289ee2188cf06..4879478cb73b9 100644 --- a/coderd/httpapi/cookie.go +++ b/coderd/httpapi/cookie.go @@ -4,7 +4,7 @@ import ( "net/textproto" "strings" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" ) // StripCoderCookies removes the session token from the cookie header provided. diff --git a/coderd/httpapi/cookie_test.go b/coderd/httpapi/cookie_test.go index 48c66abc439f0..4d44cd8f7d130 100644 --- a/coderd/httpapi/cookie_test.go +++ b/coderd/httpapi/cookie_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi" ) func TestStripCoderCookies(t *testing.T) { diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index b7559d5feeabe..0691edd8e9d0f 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -16,10 +16,10 @@ import ( "github.com/go-playground/validator/v10" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/codersdk" ) var Validate *validator.Validate diff --git a/coderd/httpapi/httpapi_test.go b/coderd/httpapi/httpapi_test.go index ea6d5a92ea5be..635ed2bdc1e29 100644 --- a/coderd/httpapi/httpapi_test.go +++ b/coderd/httpapi/httpapi_test.go @@ -14,8 +14,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "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 TestInternalServerError(t *testing.T) { diff --git a/coderd/httpapi/json_test.go b/coderd/httpapi/json_test.go index 62e5c546a0b4b..a0a93e884d44f 100644 --- a/coderd/httpapi/json_test.go +++ b/coderd/httpapi/json_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi" ) func TestDuration(t *testing.T) { diff --git a/coderd/httpapi/name_test.go b/coderd/httpapi/name_test.go index b78a15867ca53..e28115eecbbd7 100644 --- a/coderd/httpapi/name_test.go +++ b/coderd/httpapi/name_test.go @@ -6,7 +6,7 @@ import ( "github.com/moby/moby/pkg/namesgenerator" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi" ) func TestUsernameValid(t *testing.T) { diff --git a/coderd/httpapi/queryparams.go b/coderd/httpapi/queryparams.go index 3f16565e1dd20..1ff9abc7961ea 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -10,8 +10,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" ) // QueryParamParser is a helper for parsing all query params and gathering all @@ -45,7 +45,7 @@ func (p *QueryParamParser) ErrorExcessParams(values url.Values) { if _, ok := p.Parsed[k]; !ok { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: k, - Detail: fmt.Sprintf("Query param %q is not a valid query param", k), + Detail: fmt.Sprintf("%q is not a valid query param", k), }) } } diff --git a/coderd/httpapi/queryparams_test.go b/coderd/httpapi/queryparams_test.go index ecf7b3a99cf40..da0dac4ad0aa0 100644 --- a/coderd/httpapi/queryparams_test.go +++ b/coderd/httpapi/queryparams_test.go @@ -10,8 +10,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" ) type queryParamTestCase[T any] struct { diff --git a/coderd/httpapi/url_test.go b/coderd/httpapi/url_test.go index 3beee451f7391..8c1dfd8995b94 100644 --- a/coderd/httpapi/url_test.go +++ b/coderd/httpapi/url_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi" ) func TestApplicationURLString(t *testing.T) { diff --git a/coderd/httpmw/actor.go b/coderd/httpmw/actor.go index 7df5294b17c49..af3142aed2de8 100644 --- a/coderd/httpmw/actor.go +++ b/coderd/httpmw/actor.go @@ -3,8 +3,8 @@ package httpmw import ( "net/http" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) // RequireAPIKeyOrWorkspaceProxyAuth is middleware that should be inserted after diff --git a/coderd/httpmw/actor_test.go b/coderd/httpmw/actor_test.go index 5d30f5c072eda..deb529b0fd983 100644 --- a/coderd/httpmw/actor_test.go +++ b/coderd/httpmw/actor_test.go @@ -10,11 +10,11 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) func TestRequireAPIKeyOrWorkspaceProxyAuth(t *testing.T) { diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 5f0ec0dc263c7..5335c29676376 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -18,11 +18,11 @@ import ( "golang.org/x/oauth2" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" ) type apiKeyContextKey struct{} @@ -142,6 +142,56 @@ func ExtractAPIKeyMW(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { } } +func APIKeyFromRequest(ctx context.Context, db database.Store, sessionTokenFunc func(r *http.Request) string, r *http.Request) (*database.APIKey, codersdk.Response, bool) { + tokenFunc := APITokenFromRequest + if sessionTokenFunc != nil { + tokenFunc = sessionTokenFunc + } + + token := tokenFunc(r) + if token == "" { + return nil, codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenCookie), + }, false + } + + keyID, keySecret, err := SplitAPIToken(token) + if err != nil { + return nil, codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: "Invalid API key format: " + err.Error(), + }, false + } + + //nolint:gocritic // System needs to fetch API key to check if it's valid. + key, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), keyID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: "API key is invalid.", + }, false + } + + return nil, codersdk.Response{ + Message: internalErrorMessage, + Detail: fmt.Sprintf("Internal error fetching API key by id. %s", err.Error()), + }, false + } + + // Checking to see if the secret is valid. + hashedSecret := sha256.Sum256([]byte(keySecret)) + if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 { + return nil, codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: "API key secret is invalid.", + }, false + } + + return &key, codersdk.Response{}, true +} + // ExtractAPIKey requires authentication using a valid API key. It handles // extending an API key if it comes close to expiry, updating the last used time // in the database. @@ -179,49 +229,9 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon return nil, nil, false } - tokenFunc := APITokenFromRequest - if cfg.SessionTokenFunc != nil { - tokenFunc = cfg.SessionTokenFunc - } - token := tokenFunc(r) - if token == "" { - return optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: SignedOutErrorMessage, - Detail: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenCookie), - }) - } - - keyID, keySecret, err := SplitAPIToken(token) - if err != nil { - return optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: SignedOutErrorMessage, - Detail: "Invalid API key format: " + err.Error(), - }) - } - - //nolint:gocritic // System needs to fetch API key to check if it's valid. - key, err := cfg.DB.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), keyID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: SignedOutErrorMessage, - Detail: "API key is invalid.", - }) - } - - return write(http.StatusInternalServerError, codersdk.Response{ - Message: internalErrorMessage, - Detail: fmt.Sprintf("Internal error fetching API key by id. %s", err.Error()), - }) - } - - // Checking to see if the secret is valid. - hashedSecret := sha256.Sum256([]byte(keySecret)) - if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 { - return optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: SignedOutErrorMessage, - Detail: "API key secret is invalid.", - }) + key, resp, ok := APIKeyFromRequest(ctx, cfg.DB, cfg.SessionTokenFunc, r) + if !ok { + return optionalWrite(http.StatusUnauthorized, resp) } var ( @@ -231,6 +241,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon changed = false ) if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC { + var err error //nolint:gocritic // System needs to fetch UserLink to check if it's valid. link, err = cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{ UserID: key.UserID, @@ -298,6 +309,9 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon } // Checking if the key is expired. + // NOTE: The `RequireAuth` React component depends on this `Detail` to detect when + // the users token has expired. If you change the text here, make sure to update it + // in site/src/components/RequireAuth/RequireAuth.tsx as well. if key.ExpiresAt.Before(now) { return optionalWrite(http.StatusUnauthorized, codersdk.Response{ Message: SignedOutErrorMessage, @@ -427,7 +441,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }.WithCachedASTValue(), } - return &key, &authz, true + return key, &authz, true } // APITokenFromRequest returns the api token from the request. diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index b4b7bb01f8eeb..d7426e4087b91 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -3,11 +3,13 @@ package httpmw_test import ( "context" "crypto/sha256" + "encoding/json" "fmt" "io" "net" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "time" @@ -16,14 +18,14 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/oauth2" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/testutil" ) func randomAPIKeyParts() (id string, secret string) { @@ -197,6 +199,11 @@ func TestAPIKey(t *testing.T) { res := rw.Result() defer res.Body.Close() require.Equal(t, http.StatusUnauthorized, res.StatusCode) + + var apiRes codersdk.Response + dec := json.NewDecoder(res.Body) + _ = dec.Decode(&apiRes) + require.True(t, strings.HasPrefix(apiRes.Detail, "API key expired")) }) t.Run("Valid", func(t *testing.T) { diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index b2491d3de4707..6557b307c8a2b 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -15,11 +15,11 @@ import ( "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbtestutil" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" ) func TestExtractUserRoles(t *testing.T) { diff --git a/coderd/httpmw/authz.go b/coderd/httpmw/authz.go index 6fc5c396a101f..4c94ce362be2a 100644 --- a/coderd/httpmw/authz.go +++ b/coderd/httpmw/authz.go @@ -5,7 +5,7 @@ import ( "github.com/go-chi/chi/v5" - "github.com/coder/coder/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbauthz" ) // AsAuthzSystem is a chained handler that temporarily sets the dbauthz context diff --git a/coderd/httpmw/authz_test.go b/coderd/httpmw/authz_test.go index 29474aa264bd9..b469a8f23a5ed 100644 --- a/coderd/httpmw/authz_test.go +++ b/coderd/httpmw/authz_test.go @@ -8,9 +8,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpmw" ) func TestAsAuthzSystem(t *testing.T) { diff --git a/coderd/httpmw/clitelemetry.go b/coderd/httpmw/clitelemetry.go index 2262862beba49..7d6b67bb004b1 100644 --- a/coderd/httpmw/clitelemetry.go +++ b/coderd/httpmw/clitelemetry.go @@ -11,8 +11,8 @@ import ( "tailscale.com/tstime/rate" "cdr.dev/slog" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/codersdk" ) func ReportCLITelemetry(log slog.Logger, rep telemetry.Reporter) func(http.Handler) http.Handler { diff --git a/coderd/httpmw/cors.go b/coderd/httpmw/cors.go index 7206881d24f85..b00810fbf9322 100644 --- a/coderd/httpmw/cors.go +++ b/coderd/httpmw/cors.go @@ -7,7 +7,7 @@ import ( "github.com/go-chi/cors" - "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi" ) const ( diff --git a/coderd/httpmw/cors_test.go b/coderd/httpmw/cors_test.go index 7668771b1e6db..ae63073b237ed 100644 --- a/coderd/httpmw/cors_test.go +++ b/coderd/httpmw/cors_test.go @@ -7,8 +7,8 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" ) func TestWorkspaceAppCors(t *testing.T) { diff --git a/coderd/httpmw/csp_test.go b/coderd/httpmw/csp_test.go index bb352537b10cd..2dca209faa5c3 100644 --- a/coderd/httpmw/csp_test.go +++ b/coderd/httpmw/csp_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/v2/coderd/httpmw" ) func TestCSPConnect(t *testing.T) { diff --git a/coderd/httpmw/csrf.go b/coderd/httpmw/csrf.go index ce25c600940b5..2a1f383a7490a 100644 --- a/coderd/httpmw/csrf.go +++ b/coderd/httpmw/csrf.go @@ -7,7 +7,7 @@ import ( "github.com/justinas/nosurf" "golang.org/x/xerrors" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" ) // CSRF is a middleware that verifies that a CSRF token is present in the request diff --git a/coderd/httpmw/gitauthparam.go b/coderd/httpmw/gitauthparam.go index 2ce592d54f98a..240732275bd83 100644 --- a/coderd/httpmw/gitauthparam.go +++ b/coderd/httpmw/gitauthparam.go @@ -6,8 +6,8 @@ import ( "github.com/go-chi/chi/v5" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/coderd/httpapi" ) type gitAuthParamContextKey struct{} diff --git a/coderd/httpmw/gitauthparam_test.go b/coderd/httpmw/gitauthparam_test.go index 01ea35470f025..665e438a23c0c 100644 --- a/coderd/httpmw/gitauthparam_test.go +++ b/coderd/httpmw/gitauthparam_test.go @@ -9,8 +9,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/coderd/httpmw" ) //nolint:bodyclose diff --git a/coderd/httpmw/groupparam.go b/coderd/httpmw/groupparam.go index 5b6d3bfe2dd15..9cf2a113020dd 100644 --- a/coderd/httpmw/groupparam.go +++ b/coderd/httpmw/groupparam.go @@ -6,9 +6,9 @@ import ( "github.com/go-chi/chi/v5" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) type groupParamContextKey struct{} diff --git a/coderd/httpmw/groupparam_test.go b/coderd/httpmw/groupparam_test.go index b7f59528e7b34..a0c50ee0857b5 100644 --- a/coderd/httpmw/groupparam_test.go +++ b/coderd/httpmw/groupparam_test.go @@ -10,10 +10,10 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpmw" ) func TestGroupParam(t *testing.T) { diff --git a/coderd/httpmw/hsts_test.go b/coderd/httpmw/hsts_test.go index 16dcee78cbf61..3bc3463e69e65 100644 --- a/coderd/httpmw/hsts_test.go +++ b/coderd/httpmw/hsts_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/v2/coderd/httpmw" ) func TestHSTS(t *testing.T) { diff --git a/coderd/httpmw/httpmw.go b/coderd/httpmw/httpmw.go index f6a0dac8b0b65..552469bf044b0 100644 --- a/coderd/httpmw/httpmw.go +++ b/coderd/httpmw/httpmw.go @@ -7,8 +7,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) // ParseUUIDParam consumes a url parameter and parses it as a UUID. diff --git a/coderd/httpmw/httpmw_internal_test.go b/coderd/httpmw/httpmw_internal_test.go index 87aa3a6960822..5a6578cf3799f 100644 --- a/coderd/httpmw/httpmw_internal_test.go +++ b/coderd/httpmw/httpmw_internal_test.go @@ -11,7 +11,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/coderd/httpmw/logger.go b/coderd/httpmw/logger.go index e9ee400c5c581..ef0a7560bf4db 100644 --- a/coderd/httpmw/logger.go +++ b/coderd/httpmw/logger.go @@ -7,8 +7,8 @@ import ( "time" "cdr.dev/slog" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/tracing" ) func Logger(log slog.Logger) func(next http.Handler) http.Handler { diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index ceb5c25250ed6..e51a17a5a8394 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -8,9 +8,9 @@ import ( "golang.org/x/oauth2" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" ) type oauth2StateKey struct{} diff --git a/coderd/httpmw/oauth2_test.go b/coderd/httpmw/oauth2_test.go index 3ed3f7f354113..b0bc3f75e4f27 100644 --- a/coderd/httpmw/oauth2_test.go +++ b/coderd/httpmw/oauth2_test.go @@ -12,8 +12,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/oauth2" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) type testOAuth2Provider struct { diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go index 55ceec57387ff..85e94ef4a0d96 100644 --- a/coderd/httpmw/organizationparam.go +++ b/coderd/httpmw/organizationparam.go @@ -4,9 +4,9 @@ import ( "context" "net/http" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) type ( diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index b566b0675822e..176cb44ed3ce8 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -10,11 +10,11 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) func TestOrganizationParam(t *testing.T) { diff --git a/coderd/httpmw/patternmatcher/routepatterns_test.go b/coderd/httpmw/patternmatcher/routepatterns_test.go index 972e22727ad32..dc7f779136360 100644 --- a/coderd/httpmw/patternmatcher/routepatterns_test.go +++ b/coderd/httpmw/patternmatcher/routepatterns_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/httpmw/patternmatcher" + "github.com/coder/coder/v2/coderd/httpmw/patternmatcher" ) func Test_RoutePatterns(t *testing.T) { diff --git a/coderd/httpmw/prometheus.go b/coderd/httpmw/prometheus.go index 5012b0023c8dd..b96be84e879e3 100644 --- a/coderd/httpmw/prometheus.go +++ b/coderd/httpmw/prometheus.go @@ -7,8 +7,8 @@ import ( "github.com/go-chi/chi/v5" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/tracing" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" diff --git a/coderd/httpmw/prometheus_test.go b/coderd/httpmw/prometheus_test.go index dce55c26bc134..a51eea5d00312 100644 --- a/coderd/httpmw/prometheus_test.go +++ b/coderd/httpmw/prometheus_test.go @@ -10,8 +10,8 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/tracing" ) func TestPrometheus(t *testing.T) { diff --git a/coderd/httpmw/ratelimit.go b/coderd/httpmw/ratelimit.go index ff4e888232411..bd1d1d6423fbf 100644 --- a/coderd/httpmw/ratelimit.go +++ b/coderd/httpmw/ratelimit.go @@ -9,11 +9,11 @@ import ( "github.com/go-chi/httprate" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/coderd/database" + "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/cryptorand" ) // RateLimit returns a handler that limits requests per-minute based diff --git a/coderd/httpmw/ratelimit_test.go b/coderd/httpmw/ratelimit_test.go index a201634edbcc5..edb368829cf37 100644 --- a/coderd/httpmw/ratelimit_test.go +++ b/coderd/httpmw/ratelimit_test.go @@ -12,12 +12,12 @@ import ( "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" ) func randRemoteAddr() string { diff --git a/coderd/httpmw/realip.go b/coderd/httpmw/realip.go index e7cc679ba95c0..6f0f318b83224 100644 --- a/coderd/httpmw/realip.go +++ b/coderd/httpmw/realip.go @@ -8,7 +8,7 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi" ) const ( diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index 85036a3c63197..3070070bd90d8 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/v2/coderd/httpmw" ) // TestExtractAddress checks the ExtractAddress function. diff --git a/coderd/httpmw/recover.go b/coderd/httpmw/recover.go index 3d19918f8d505..a8d6020561e09 100644 --- a/coderd/httpmw/recover.go +++ b/coderd/httpmw/recover.go @@ -6,8 +6,8 @@ import ( "runtime/debug" "cdr.dev/slog" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/tracing" ) func Recover(log slog.Logger) func(h http.Handler) http.Handler { diff --git a/coderd/httpmw/recover_test.go b/coderd/httpmw/recover_test.go index 8e2746166903e..35306e0b50f57 100644 --- a/coderd/httpmw/recover_test.go +++ b/coderd/httpmw/recover_test.go @@ -8,8 +8,8 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/tracing" ) func TestRecover(t *testing.T) { diff --git a/coderd/httpmw/requestid_test.go b/coderd/httpmw/requestid_test.go index c632dbbde8c4e..7dc21a8f23a43 100644 --- a/coderd/httpmw/requestid_test.go +++ b/coderd/httpmw/requestid_test.go @@ -8,7 +8,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/v2/coderd/httpmw" ) func TestRequestID(t *testing.T) { diff --git a/coderd/httpmw/templateparam.go b/coderd/httpmw/templateparam.go index eadb072d50131..8fe6f2a452199 100644 --- a/coderd/httpmw/templateparam.go +++ b/coderd/httpmw/templateparam.go @@ -6,9 +6,9 @@ import ( "github.com/go-chi/chi/v5" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) type templateParamContextKey struct{} diff --git a/coderd/httpmw/templateparam_test.go b/coderd/httpmw/templateparam_test.go index c630d5570b25d..d8608781905d5 100644 --- a/coderd/httpmw/templateparam_test.go +++ b/coderd/httpmw/templateparam_test.go @@ -10,11 +10,11 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) func TestTemplateParam(t *testing.T) { diff --git a/coderd/httpmw/templateversionparam.go b/coderd/httpmw/templateversionparam.go index 9f8f1c58561c6..702357b3d14fa 100644 --- a/coderd/httpmw/templateversionparam.go +++ b/coderd/httpmw/templateversionparam.go @@ -8,9 +8,9 @@ import ( "github.com/go-chi/chi/v5" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) type templateVersionParamContextKey struct{} diff --git a/coderd/httpmw/templateversionparam_test.go b/coderd/httpmw/templateversionparam_test.go index febaa8f50bcdc..1cf4da6e832b0 100644 --- a/coderd/httpmw/templateversionparam_test.go +++ b/coderd/httpmw/templateversionparam_test.go @@ -10,11 +10,11 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) func TestTemplateVersionParam(t *testing.T) { diff --git a/coderd/httpmw/userparam.go b/coderd/httpmw/userparam.go index f565687e00bdd..de3446d69cdaf 100644 --- a/coderd/httpmw/userparam.go +++ b/coderd/httpmw/userparam.go @@ -2,15 +2,16 @@ package httpmw import ( "context" + "fmt" "net/http" "github.com/go-chi/chi/v5" "github.com/google/uuid" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) type userParamContextKey struct{} @@ -85,6 +86,7 @@ func ExtractUserParam(db database.Store, redirectToLoginOnMe bool) func(http.Han if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: userErrorMessage, + Detail: fmt.Sprintf("queried user=%q", userQuery), }) return } @@ -96,6 +98,7 @@ func ExtractUserParam(db database.Store, redirectToLoginOnMe bool) func(http.Han if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: userErrorMessage, + Detail: fmt.Sprintf("queried user=%q", userQuery), }) return } diff --git a/coderd/httpmw/userparam_test.go b/coderd/httpmw/userparam_test.go index 4ce4e317f9f15..bd1b5b2b277c7 100644 --- a/coderd/httpmw/userparam_test.go +++ b/coderd/httpmw/userparam_test.go @@ -9,11 +9,11 @@ import ( "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) func TestUserParam(t *testing.T) { diff --git a/coderd/httpmw/workspaceagent.go b/coderd/httpmw/workspaceagent.go index f039c6bbf7afb..883a54e404c4e 100644 --- a/coderd/httpmw/workspaceagent.go +++ b/coderd/httpmw/workspaceagent.go @@ -9,11 +9,11 @@ import ( "github.com/google/uuid" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" ) type workspaceAgentContextKey struct{} @@ -74,8 +74,9 @@ func ExtractWorkspaceAgent(opts ExtractWorkspaceAgentConfig) func(http.Handler) }) return } + //nolint:gocritic // System needs to be able to get workspace agents. - agent, err := opts.DB.GetWorkspaceAgentByAuthToken(dbauthz.AsSystemRestricted(ctx), token) + row, err := opts.DB.GetWorkspaceAgentAndOwnerByAuthToken(dbauthz.AsSystemRestricted(ctx), token) if err != nil { if errors.Is(err, sql.ErrNoRows) { optionalWrite(http.StatusUnauthorized, codersdk.Response{ @@ -86,56 +87,23 @@ func ExtractWorkspaceAgent(opts ExtractWorkspaceAgentConfig) func(http.Handler) } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace agent.", + Message: "Internal error checking workspace agent authorization.", Detail: err.Error(), }) return } - //nolint:gocritic // System needs to be able to get workspace agents. - subject, err := getAgentSubject(dbauthz.AsSystemRestricted(ctx), opts.DB, agent) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace agent.", - Detail: err.Error(), - }) - return - } + subject := rbac.Subject{ + ID: row.OwnerID.String(), + Roles: rbac.RoleNames(row.OwnerRoles), + Groups: row.OwnerGroups, + Scope: rbac.WorkspaceAgentScope(row.WorkspaceID, row.OwnerID), + }.WithCachedASTValue() - ctx = context.WithValue(ctx, workspaceAgentContextKey{}, agent) + ctx = context.WithValue(ctx, workspaceAgentContextKey{}, row.WorkspaceAgent) // Also set the dbauthz actor for the request. ctx = dbauthz.As(ctx, subject) next.ServeHTTP(rw, r.WithContext(ctx)) }) } } - -func getAgentSubject(ctx context.Context, db database.Store, agent database.WorkspaceAgent) (rbac.Subject, error) { - // TODO: make a different query that gets the workspace owner and roles along with the agent. - workspace, err := db.GetWorkspaceByAgentID(ctx, agent.ID) - if err != nil { - return rbac.Subject{}, err - } - - user, err := db.GetUserByID(ctx, workspace.OwnerID) - if err != nil { - return rbac.Subject{}, err - } - - roles, err := db.GetAuthorizationUserRoles(ctx, user.ID) - if err != nil { - return rbac.Subject{}, err - } - - // A user that creates a workspace can use this agent auth token and - // impersonate the workspace. So to prevent privilege escalation, the - // subject inherits the roles of the user that owns the workspace. - // We then add a workspace-agent scope to limit the permissions - // to only what the workspace agent needs. - return rbac.Subject{ - ID: user.ID.String(), - Roles: rbac.RoleNames(roles.Roles), - Groups: roles.Groups, - Scope: rbac.WorkspaceAgentScope(workspace.ID, user.ID), - }.WithCachedASTValue(), nil -} diff --git a/coderd/httpmw/workspaceagent_test.go b/coderd/httpmw/workspaceagent_test.go index 5b50aa14b4802..126526e963199 100644 --- a/coderd/httpmw/workspaceagent_test.go +++ b/coderd/httpmw/workspaceagent_test.go @@ -9,36 +9,29 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) func TestWorkspaceAgent(t *testing.T) { t.Parallel() - setup := func(db database.Store, token uuid.UUID) *http.Request { - r := httptest.NewRequest("GET", "/", nil) - r.Header.Set(codersdk.SessionTokenHeader, token.String()) - return r - } - t.Run("None", func(t *testing.T) { t.Parallel() - db := dbfake.New() - rtr := chi.NewRouter() - rtr.Use( - httpmw.ExtractWorkspaceAgent(httpmw.ExtractWorkspaceAgentConfig{ + db, _ := dbtestutil.NewDB(t) + + req, rtr := setup(t, db, uuid.New(), httpmw.ExtractWorkspaceAgent( + httpmw.ExtractWorkspaceAgentConfig{ DB: db, Optional: false, - }), - ) - rtr.Get("/", nil) - r := setup(db, uuid.New()) + })) + rw := httptest.NewRecorder() - rtr.ServeHTTP(rw, r) + req.Header.Set(codersdk.SessionTokenHeader, uuid.New().String()) + rtr.ServeHTTP(rw, req) res := rw.Result() defer res.Body.Close() @@ -47,42 +40,72 @@ func TestWorkspaceAgent(t *testing.T) { t.Run("Found", func(t *testing.T) { t.Parallel() - db := dbfake.New() - var ( - user = dbgen.User(t, db, database.User{}) - workspace = dbgen.Workspace(t, db, database.Workspace{ - OwnerID: user.ID, - }) - job = dbgen.ProvisionerJob(t, db, database.ProvisionerJob{}) - resource = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ - JobID: job.ID, - }) - _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: workspace.ID, - JobID: job.ID, - }) - agent = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ - ResourceID: resource.ID, - }) - ) - - rtr := chi.NewRouter() - rtr.Use( - httpmw.ExtractWorkspaceAgent(httpmw.ExtractWorkspaceAgentConfig{ + db, _ := dbtestutil.NewDB(t) + authToken := uuid.New() + req, rtr := setup(t, db, authToken, httpmw.ExtractWorkspaceAgent( + httpmw.ExtractWorkspaceAgentConfig{ DB: db, Optional: false, - }), - ) - rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { - _ = httpmw.WorkspaceAgent(r) - rw.WriteHeader(http.StatusOK) - }) - r := setup(db, agent.AuthToken) + })) + rw := httptest.NewRecorder() - rtr.ServeHTTP(rw, r) + req.Header.Set(codersdk.SessionTokenHeader, authToken.String()) + rtr.ServeHTTP(rw, req) + //nolint:bodyclose // Closed in `t.Cleanup` res := rw.Result() - defer res.Body.Close() + t.Cleanup(func() { _ = res.Body.Close() }) require.Equal(t, http.StatusOK, res.StatusCode) }) } + +func setup(t testing.TB, db database.Store, authToken uuid.UUID, mw func(http.Handler) http.Handler) (*http.Request, http.Handler) { + t.Helper() + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{ + Status: database.UserStatusActive, + }) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + ActiveVersionID: templateVersion.ID, + CreatedBy: user.ID, + }) + workspace := dbgen.Workspace(t, db, database.Workspace{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: template.ID, + }) + job := dbgen.ProvisionerJob(t, db, database.ProvisionerJob{ + OrganizationID: org.ID, + }) + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: job.ID, + }) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + JobID: job.ID, + TemplateVersionID: templateVersion.ID, + }) + _ = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + AuthToken: authToken, + }) + + req := httptest.NewRequest("GET", "/", nil) + rtr := chi.NewRouter() + rtr.Use(mw) + rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { + _ = httpmw.WorkspaceAgent(r) + rw.WriteHeader(http.StatusOK) + }) + + return req, rtr +} diff --git a/coderd/httpmw/workspaceagentparam.go b/coderd/httpmw/workspaceagentparam.go index 7e31c9e15be31..67f6db0a5de4d 100644 --- a/coderd/httpmw/workspaceagentparam.go +++ b/coderd/httpmw/workspaceagentparam.go @@ -8,9 +8,9 @@ import ( "github.com/go-chi/chi/v5" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) type workspaceAgentParamContextKey struct{} diff --git a/coderd/httpmw/workspaceagentparam_test.go b/coderd/httpmw/workspaceagentparam_test.go index f6a4c3fe1eaa7..233b5d0d8b570 100644 --- a/coderd/httpmw/workspaceagentparam_test.go +++ b/coderd/httpmw/workspaceagentparam_test.go @@ -10,11 +10,11 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) func TestWorkspaceAgentParam(t *testing.T) { diff --git a/coderd/httpmw/workspacebuildparam.go b/coderd/httpmw/workspacebuildparam.go index 518029465eb12..895f73ac0a9ff 100644 --- a/coderd/httpmw/workspacebuildparam.go +++ b/coderd/httpmw/workspacebuildparam.go @@ -6,9 +6,9 @@ import ( "github.com/go-chi/chi/v5" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) type workspaceBuildParamContextKey struct{} diff --git a/coderd/httpmw/workspacebuildparam_test.go b/coderd/httpmw/workspacebuildparam_test.go index ac495537a1de3..bade2b19d8dfc 100644 --- a/coderd/httpmw/workspacebuildparam_test.go +++ b/coderd/httpmw/workspacebuildparam_test.go @@ -10,11 +10,11 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) func TestWorkspaceBuildParam(t *testing.T) { diff --git a/coderd/httpmw/workspaceparam.go b/coderd/httpmw/workspaceparam.go index b0f264abe3619..21e8dcfd62863 100644 --- a/coderd/httpmw/workspaceparam.go +++ b/coderd/httpmw/workspaceparam.go @@ -9,9 +9,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) type workspaceParamContextKey struct{} diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 1c7504b793f64..36a2d0e600714 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -15,12 +15,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" ) func TestWorkspaceParam(t *testing.T) { diff --git a/coderd/httpmw/workspaceproxy.go b/coderd/httpmw/workspaceproxy.go index 5df5e64a0cc07..d3a93962aaf6e 100644 --- a/coderd/httpmw/workspaceproxy.go +++ b/coderd/httpmw/workspaceproxy.go @@ -12,10 +12,10 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) const ( diff --git a/coderd/httpmw/workspaceproxy_test.go b/coderd/httpmw/workspaceproxy_test.go index 818acf85c4442..27b85643ce43d 100644 --- a/coderd/httpmw/workspaceproxy_test.go +++ b/coderd/httpmw/workspaceproxy_test.go @@ -11,13 +11,13 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" ) func TestExtractWorkspaceProxy(t *testing.T) { diff --git a/coderd/httpmw/workspaceresourceparam.go b/coderd/httpmw/workspaceresourceparam.go index 41d19a4ea0519..f2a9661b5c6c7 100644 --- a/coderd/httpmw/workspaceresourceparam.go +++ b/coderd/httpmw/workspaceresourceparam.go @@ -8,9 +8,9 @@ import ( "github.com/go-chi/chi/v5" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) type workspaceResourceParamContextKey struct{} diff --git a/coderd/httpmw/workspaceresourceparam_test.go b/coderd/httpmw/workspaceresourceparam_test.go index bee32b0d304c3..61c4d77fbf3da 100644 --- a/coderd/httpmw/workspaceresourceparam_test.go +++ b/coderd/httpmw/workspaceresourceparam_test.go @@ -10,10 +10,10 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/httpmw" ) func TestWorkspaceResourceParam(t *testing.T) { diff --git a/coderd/insights.go b/coderd/insights.go index b643303dd0df2..e19f95d40dc0c 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -2,20 +2,22 @@ package coderd import ( "context" - "encoding/json" "fmt" "net/http" - "sort" + "strings" "time" "github.com/google/uuid" "golang.org/x/exp/slices" + "golang.org/x/sync/errgroup" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" ) // Duplicated in codersdk. @@ -132,8 +134,8 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { for templateID := range templateIDSet { seenTemplateIDs = append(seenTemplateIDs, templateID) } - slices.SortFunc(seenTemplateIDs, func(a, b uuid.UUID) bool { - return a.String() < b.String() + slices.SortFunc(seenTemplateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) }) resp := codersdk.UserLatencyInsightsResponse{ @@ -188,15 +190,20 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { } var usage database.GetTemplateInsightsRow + var appUsage []database.GetTemplateAppInsightsRow var dailyUsage []database.GetTemplateDailyInsightsRow + var parameterRows []database.GetTemplateParameterInsightsRow - // Use a transaction to ensure that we get consistent data between - // the full and interval report. - err := api.Database.InTx(func(tx database.Store) error { - var err error + eg, egCtx := errgroup.WithContext(ctx) + eg.SetLimit(4) + // The following insights data queries have a theoretical chance to be + // inconsistent between eachother when looking at "today", however, the + // overhead from a transaction is not worth it. + eg.Go(func() error { + var err error if interval != "" { - dailyUsage, err = tx.GetTemplateDailyInsights(ctx, database.GetTemplateDailyInsightsParams{ + dailyUsage, err = api.Database.GetTemplateDailyInsights(egCtx, database.GetTemplateDailyInsightsParams{ StartTime: startTime, EndTime: endTime, TemplateIDs: templateIDs, @@ -205,8 +212,11 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("get template daily insights: %w", err) } } - - usage, err = tx.GetTemplateInsights(ctx, database.GetTemplateInsightsParams{ + return nil + }) + eg.Go(func() error { + var err error + usage, err = api.Database.GetTemplateInsights(egCtx, database.GetTemplateInsightsParams{ StartTime: startTime, EndTime: endTime, TemplateIDs: templateIDs, @@ -214,9 +224,37 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("get template insights: %w", err) } + return nil + }) + eg.Go(func() error { + var err error + appUsage, err = api.Database.GetTemplateAppInsights(egCtx, database.GetTemplateAppInsightsParams{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: templateIDs, + }) + if err != nil { + return xerrors.Errorf("get template app insights: %w", err) + } + return nil + }) + // Template parameter insights have no risk of inconsistency with the other + // insights. + eg.Go(func() error { + var err error + parameterRows, err = api.Database.GetTemplateParameterInsights(ctx, database.GetTemplateParameterInsightsParams{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: templateIDs, + }) + if err != nil { + return xerrors.Errorf("get template parameter insights: %w", err) + } return nil - }, nil) + }) + + err := eg.Wait() if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return @@ -229,22 +267,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { return } - // Template parameter insights have no risk of inconsistency with the other - // insights, so we don't need to perform this in a transaction. - parameterRows, err := api.Database.GetTemplateParameterInsights(ctx, database.GetTemplateParameterInsightsParams{ - StartTime: startTime, - EndTime: endTime, - TemplateIDs: templateIDs, - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching template parameter insights.", - Detail: err.Error(), - }) - return - } - - parametersUsage, err := convertTemplateInsightsParameters(parameterRows) + parametersUsage, err := db2sdk.TemplateInsightsParameters(parameterRows) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error converting template parameter insights.", @@ -257,17 +280,19 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { Report: codersdk.TemplateInsightsReport{ StartTime: startTime, EndTime: endTime, - TemplateIDs: usage.TemplateIDs, - ActiveUsers: usage.ActiveUsers, - AppsUsage: convertTemplateInsightsBuiltinApps(usage), + TemplateIDs: convertTemplateInsightsTemplateIDs(usage, appUsage), + ActiveUsers: convertTemplateInsightsActiveUsers(usage, appUsage), + AppsUsage: convertTemplateInsightsApps(usage, appUsage), ParametersUsage: parametersUsage, }, IntervalReports: []codersdk.TemplateInsightsIntervalReport{}, } for _, row := range dailyUsage { resp.IntervalReports = append(resp.IntervalReports, codersdk.TemplateInsightsIntervalReport{ - StartTime: row.StartTime, - EndTime: row.EndTime, + // NOTE(mafredri): This might not be accurate over DST since the + // parsed location only contains the offset. + StartTime: row.StartTime.In(startTime.Location()), + EndTime: row.EndTime.In(startTime.Location()), Interval: interval, TemplateIDs: row.TemplateIDs, ActiveUsers: row.ActiveUsers, @@ -276,10 +301,45 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, resp) } -// convertTemplateInsightsBuiltinApps builds the list of builtin apps from the -// database row, these are apps that are implicitly a part of all templates. -func convertTemplateInsightsBuiltinApps(usage database.GetTemplateInsightsRow) []codersdk.TemplateAppUsage { - return []codersdk.TemplateAppUsage{ +func convertTemplateInsightsTemplateIDs(usage database.GetTemplateInsightsRow, appUsage []database.GetTemplateAppInsightsRow) []uuid.UUID { + templateIDSet := make(map[uuid.UUID]struct{}) + for _, id := range usage.TemplateIDs { + templateIDSet[id] = struct{}{} + } + for _, app := range appUsage { + for _, id := range app.TemplateIDs { + templateIDSet[id] = struct{}{} + } + } + templateIDs := make([]uuid.UUID, 0, len(templateIDSet)) + for id := range templateIDSet { + templateIDs = append(templateIDs, id) + } + slices.SortFunc(templateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) + }) + return templateIDs +} + +func convertTemplateInsightsActiveUsers(usage database.GetTemplateInsightsRow, appUsage []database.GetTemplateAppInsightsRow) int64 { + activeUserIDSet := make(map[uuid.UUID]struct{}) + for _, id := range usage.ActiveUserIDs { + activeUserIDSet[id] = struct{}{} + } + for _, app := range appUsage { + for _, id := range app.ActiveUserIDs { + activeUserIDSet[id] = struct{}{} + } + } + return int64(len(activeUserIDSet)) +} + +// convertTemplateInsightsApps builds the list of builtin apps and template apps +// from the provided database rows, builtin apps are implicitly a part of all +// templates. +func convertTemplateInsightsApps(usage database.GetTemplateInsightsRow, appUsage []database.GetTemplateAppInsightsRow) []codersdk.TemplateAppUsage { + // Builtin apps. + apps := []codersdk.TemplateAppUsage{ { TemplateIDs: usage.TemplateIDs, Type: codersdk.TemplateAppsTypeBuiltin, @@ -296,6 +356,12 @@ func convertTemplateInsightsBuiltinApps(usage database.GetTemplateInsightsRow) [ Icon: "/icon/intellij.svg", Seconds: usage.UsageJetbrainsSeconds, }, + // TODO(mafredri): We could take Web Terminal usage from appUsage since + // that should be more accurate. The difference is that this reflects + // the rpty session as seen by the agent (can live past the connection), + // whereas appUsage reflects the lifetime of the client connection. The + // condition finding the corresponding app entry in appUsage is: + // !app.IsApp && app.AccessMethod == "terminal" && app.SlugOrPort == "" { TemplateIDs: usage.TemplateIDs, Type: codersdk.TemplateAppsTypeBuiltin, @@ -313,39 +379,49 @@ func convertTemplateInsightsBuiltinApps(usage database.GetTemplateInsightsRow) [ Seconds: usage.UsageSshSeconds, }, } -} -func convertTemplateInsightsParameters(parameterRows []database.GetTemplateParameterInsightsRow) ([]codersdk.TemplateParameterUsage, error) { - parametersByNum := make(map[int64]*codersdk.TemplateParameterUsage) - for _, param := range parameterRows { - if _, ok := parametersByNum[param.Num]; !ok { - var opts []codersdk.TemplateVersionParameterOption - err := json.Unmarshal(param.Options, &opts) - if err != nil { - return nil, xerrors.Errorf("unmarshal template parameter options: %w", err) - } - parametersByNum[param.Num] = &codersdk.TemplateParameterUsage{ - TemplateIDs: param.TemplateIDs, - Name: param.Name, - DisplayName: param.DisplayName, - Options: opts, - } + // Use a stable sort, similarly to how we would sort in the query, note that + // we don't sort in the query because order varies depending on the table + // collation. + // + // ORDER BY access_method, slug_or_port, display_name, icon, is_app + slices.SortFunc(appUsage, func(a, b database.GetTemplateAppInsightsRow) int { + if a.AccessMethod != b.AccessMethod { + return strings.Compare(a.AccessMethod, b.AccessMethod) + } + if a.SlugOrPort != b.SlugOrPort { + return strings.Compare(a.SlugOrPort, b.SlugOrPort) + } + if a.DisplayName.String != b.DisplayName.String { + return strings.Compare(a.DisplayName.String, b.DisplayName.String) } - parametersByNum[param.Num].Values = append(parametersByNum[param.Num].Values, codersdk.TemplateParameterValue{ - Value: param.Value, - Count: param.Count, + if a.Icon.String != b.Icon.String { + return strings.Compare(a.Icon.String, b.Icon.String) + } + if !a.IsApp && b.IsApp { + return -1 + } else if a.IsApp && !b.IsApp { + return 1 + } + return 0 + }) + + // Template apps. + for _, app := range appUsage { + if !app.IsApp { + continue + } + apps = append(apps, codersdk.TemplateAppUsage{ + TemplateIDs: app.TemplateIDs, + Type: codersdk.TemplateAppsTypeApp, + DisplayName: app.DisplayName.String, + Slug: app.SlugOrPort, + Icon: app.Icon.String, + Seconds: app.UsageSeconds, }) } - parametersUsage := []codersdk.TemplateParameterUsage{} - for _, param := range parametersByNum { - parametersUsage = append(parametersUsage, *param) - } - - sort.Slice(parametersUsage, func(i, j int) bool { - return parametersUsage[i].Name < parametersUsage[j].Name - }) - return parametersUsage, nil + return apps } // parseInsightsStartAndEndTime parses the start and end time query parameters diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 2469cc0f4b362..83498bbb365fd 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -2,26 +2,37 @@ package coderd_test import ( "context" + "encoding/json" "fmt" "io" "net/http" + "os" + "path/filepath" + "strings" "testing" "time" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/exp/slices" + "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/rbac" - "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/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/coderd/batchstats" + "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/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/workspaceapps" + "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/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" ) func TestDeploymentInsights(t *testing.T) { @@ -37,7 +48,7 @@ func TestDeploymentInsights(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -123,7 +134,7 @@ func TestUserLatencyInsights(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -229,211 +240,849 @@ func TestUserLatencyInsights_BadRequest(t *testing.T) { assert.Error(t, err, "want error for end time partial day when not today") } -func TestTemplateInsights(t *testing.T) { +func TestTemplateInsights_Golden(t *testing.T) { t.Parallel() - const ( - firstParameterName = "first_parameter" - firstParameterDisplayName = "First PARAMETER" - firstParameterType = "string" - firstParameterDescription = "This is first parameter" - firstParameterValue = "abc" - - secondParameterName = "second_parameter" - secondParameterDisplayName = "Second PARAMETER" - secondParameterType = "number" - secondParameterDescription = "This is second parameter" - secondParameterValue = "123" - - thirdParameterName = "third_parameter" - thirdParameterDisplayName = "Third PARAMETER" - thirdParameterType = "string" - thirdParameterDescription = "This is third parameter" - thirdParameterValue = "bbb" - thirdParameterOptionName1 = "This is AAA" - thirdParameterOptionValue1 = "aaa" - thirdParameterOptionName2 = "This is BBB" - thirdParameterOptionValue2 = "bbb" - thirdParameterOptionName3 = "This is CCC" - thirdParameterOptionValue3 = "ccc" - ) + // Prepare test data types. + type templateParameterOption struct { + name string + value string + } + type templateParameter struct { + name string + description string + options []templateParameterOption + } + type templateApp struct { + name string + icon string + } + type testTemplate struct { + name string + parameters []*templateParameter + apps []templateApp - logger := slogtest.Make(t, nil) - opts := &coderdtest.Options{ - IncludeProvisionerDaemon: true, - AgentStatsRefreshInterval: time.Millisecond * 100, + // Filled later. + id uuid.UUID } - client := coderdtest.New(t, opts) + type buildParameter struct { + templateParameter *templateParameter + value string + } + type workspaceApp templateApp + type testWorkspace struct { + name string + template *testTemplate + buildParameters []buildParameter + + // Filled later. + id uuid.UUID + user any // *testUser, but it's not available yet, defined below. + agentID uuid.UUID + apps []*workspaceApp + agentClient *agentsdk.Client + } + type testUser struct { + name string + workspaces []*testWorkspace - user := coderdtest.CreateFirstUser(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: []*proto.RichParameter{ - {Name: firstParameterName, DisplayName: firstParameterDisplayName, Type: firstParameterType, Description: firstParameterDescription, Required: true}, - {Name: secondParameterName, DisplayName: secondParameterDisplayName, Type: secondParameterType, Description: secondParameterDescription, Required: true}, - {Name: thirdParameterName, DisplayName: thirdParameterDisplayName, Type: thirdParameterType, Description: thirdParameterDescription, Required: true, Options: []*proto.RichParameterOption{ - {Name: thirdParameterOptionName1, Value: thirdParameterOptionValue1}, - {Name: thirdParameterOptionName2, Value: thirdParameterOptionValue2}, - {Name: thirdParameterOptionName3, Value: thirdParameterOptionValue3}, - }}, - }, - }, - }, - }, - }, - ProvisionApply: echo.ProvisionApplyWithAgent(authToken), - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart]) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + client *codersdk.Client + sdk codersdk.User + } - buildParameters := []codersdk.WorkspaceBuildParameter{ - {Name: firstParameterName, Value: firstParameterValue}, - {Name: secondParameterName, Value: secondParameterValue}, - {Name: thirdParameterName, Value: thirdParameterValue}, + // Represent agent stats, to be inserted via stats batcher. + type agentStat struct { + // Set a range via start/end, multiple stats will be generated + // within the range. + startedAt time.Time + endedAt time.Time + + sessionCountVSCode int64 + sessionCountJetBrains int64 + sessionCountReconnectingPTY int64 + sessionCountSSH int64 + noConnections bool + } + // Represent app usage stats, to be inserted via stats reporter. + type appUsage struct { + app *workspaceApp + startedAt time.Time + endedAt time.Time + requests int } - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.RichParameterValues = buildParameters - }) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + // Represent actual data being generated on a per-workspace basis. + type testDataGen struct { + agentStats []agentStat + appUsage []appUsage + } - // Start an agent so that we can generate stats. - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(authToken) - agentCloser := agent.New(agent.Options{ - Logger: logger.Named("agent"), - Client: agentClient, - }) - defer func() { - _ = agentCloser.Close() - }() - resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + prepareFixtureAndTestData := func(t *testing.T, makeFixture func() ([]*testTemplate, []*testUser), makeData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen) ([]*testTemplate, []*testUser, map[*testWorkspace]testDataGen) { + var stableIDs []uuid.UUID + newStableUUID := func() uuid.UUID { + stableIDs = append(stableIDs, uuid.MustParse(fmt.Sprintf("00000000-0000-0000-0000-%012d", len(stableIDs)+1))) + stableID := stableIDs[len(stableIDs)-1] + return stableID + } - // Start must be at the beginning of the day, initialize it early in case - // the day changes so that we get the relevant stats faster. - y, m, d := time.Now().UTC().Date() - today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + templates, users := makeFixture() + for _, template := range templates { + template.id = newStableUUID() + } + for _, user := range users { + for _, workspace := range user.workspaces { + workspace.user = user + for _, app := range workspace.template.apps { + app := workspaceApp(app) + workspace.apps = append(workspace.apps, &app) + } + for _, bp := range workspace.buildParameters { + foundBuildParam := false + for _, param := range workspace.template.parameters { + if bp.templateParameter == param { + foundBuildParam = true + break + } + } + require.True(t, foundBuildParam, "test bug: parameter not in workspace %s template %q", workspace.name, workspace.template.name) + } + } + } - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + testData := makeData(templates, users) + // Sanity check. + for ws, data := range testData { + for _, usage := range data.appUsage { + found := false + for _, app := range ws.apps { + if usage.app == app { // Pointer equality + found = true + break + } + } + if !found { + for _, user := range users { + for _, workspace := range user.workspaces { + for _, app := range workspace.apps { + if usage.app == app { // Pointer equality + require.True(t, found, "test bug: app %q not in workspace %q: want user=%s workspace=%s; got user=%s workspace=%s ", usage.app.name, ws.name, ws.user.(*testUser).name, ws.name, user.name, workspace.name) + break + } + } + } + } + require.True(t, found, "test bug: app %q not in workspace %q", usage.app.name, ws.name) + } + } + } - // Connect to the agent to generate usage/latency stats. - conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ - Logger: logger.Named("client"), - }) - require.NoError(t, err) - defer conn.Close() + return templates, users, testData + } - sshConn, err := conn.SSHClient(ctx) - require.NoError(t, err) - defer sshConn.Close() + prepare := func(t *testing.T, templates []*testTemplate, users []*testUser, testData map[*testWorkspace]testDataGen) *codersdk.Client { + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + Logger: &logger, + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Hour, // Not relevant for this test. + }) + firstUser := coderdtest.CreateFirstUser(t, client) - // Start an SSH session to generate SSH usage stats. - sess, err := sshConn.NewSession() - require.NoError(t, err) - defer sess.Close() + // Prepare all test users. + for _, user := range users { + user.client, user.sdk = coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + r.Username = user.name + }) + user.client.SetLogger(logger.Named("user").With(slog.Field{Name: "name", Value: user.name})) + } - r, w := io.Pipe() - defer r.Close() - defer w.Close() - sess.Stdin = r - err = sess.Start("cat") - require.NoError(t, err) + // Prepare all the templates. + for _, template := range templates { + template := template + + var parameters []*proto.RichParameter + for _, parameter := range template.parameters { + var options []*proto.RichParameterOption + var defaultValue string + for _, option := range parameter.options { + if defaultValue == "" { + defaultValue = option.value + } + options = append(options, &proto.RichParameterOption{ + Name: option.name, + Value: option.value, + }) + } + parameters = append(parameters, &proto.RichParameter{ + Name: parameter.name, + DisplayName: parameter.name, + Type: "string", + Description: parameter.description, + Options: options, + DefaultValue: defaultValue, + }) + } - // Start an rpty session to generate rpty usage stats. - rpty, err := client.WorkspaceAgentReconnectingPTY(ctx, codersdk.WorkspaceAgentReconnectingPTYOpts{ - AgentID: resources[0].Agents[0].ID, - Reconnect: uuid.New(), - Width: 80, - Height: 24, - }) - require.NoError(t, err) - defer rpty.Close() - - var resp codersdk.TemplateInsightsResponse - var req codersdk.TemplateInsightsRequest - waitForAppSeconds := func(slug string) func() bool { - return func() bool { - req = codersdk.TemplateInsightsRequest{ - StartTime: today, - EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), - Interval: codersdk.InsightsReportIntervalDay, + // Prepare all workspace resources (agents and apps). + var ( + createWorkspaces []func(uuid.UUID) + waitWorkspaces []func() + ) + var resources []*proto.Resource + for _, user := range users { + user := user + for _, workspace := range user.workspaces { + workspace := workspace + + if workspace.template != template { + continue + } + authToken := uuid.New() + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken.String()) + workspace.agentClient = agentClient + + var apps []*proto.App + for _, app := range workspace.apps { + apps = append(apps, &proto.App{ + Slug: app.name, + DisplayName: app.name, + Icon: app.icon, + SharingLevel: proto.AppSharingLevel_OWNER, + Url: "http://", + }) + } + + resources = append(resources, &proto.Resource{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), // Doesn't matter, not used in DB. + Name: "dev", + Auth: &proto.Agent_Token{ + Token: authToken.String(), + }, + Apps: apps, + }}, + }) + + var buildParameters []codersdk.WorkspaceBuildParameter + for _, buildParameter := range workspace.buildParameters { + buildParameters = append(buildParameters, codersdk.WorkspaceBuildParameter{ + Name: buildParameter.templateParameter.name, + Value: buildParameter.value, + }) + } + + createWorkspaces = append(createWorkspaces, func(templateID uuid.UUID) { + // Create workspace using the users client. + createdWorkspace := coderdtest.CreateWorkspace(t, user.client, firstUser.OrganizationID, templateID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.RichParameterValues = buildParameters + }) + workspace.id = createdWorkspace.ID + waitWorkspaces = append(waitWorkspaces, func() { + coderdtest.AwaitWorkspaceBuildJob(t, user.client, createdWorkspace.LatestBuild.ID) + ctx := testutil.Context(t, testutil.WaitShort) + ws, err := user.client.Workspace(ctx, workspace.id) + require.NoError(t, err, "want no error getting workspace") + + workspace.agentID = ws.LatestBuild.Resources[0].Agents[0].ID + }) + }) + } + } + + // Create the template version and template. + version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: parameters, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: resources, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + // Create template, essentially a modified version of CreateTemplate + // where we can control the template ID. + // createdTemplate := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID) + createdTemplate := dbgen.Template(t, db, database.Template{ + ID: template.id, + ActiveVersionID: version.ID, + OrganizationID: firstUser.OrganizationID, + CreatedBy: firstUser.UserID, + GroupACL: database.TemplateACL{ + firstUser.OrganizationID.String(): []rbac.Action{rbac.ActionRead}, + }, + }) + err := db.UpdateTemplateVersionByID(context.Background(), database.UpdateTemplateVersionByIDParams{ + ID: version.ID, + TemplateID: uuid.NullUUID{ + UUID: createdTemplate.ID, + Valid: true, + }, + }) + require.NoError(t, err, "want no error updating template version") + + // Create all workspaces and wait for them. + for _, createWorkspace := range createWorkspaces { + createWorkspace(template.id) } - resp, err = client.TemplateInsights(ctx, req) - if !assert.NoError(t, err) { - return false + for _, waitWorkspace := range waitWorkspaces { + waitWorkspace() } + } - if slices.IndexFunc(resp.Report.AppsUsage, func(au codersdk.TemplateAppUsage) bool { - return au.Slug == slug && au.Seconds > 0 - }) != -1 { - return true + ctx := testutil.Context(t, testutil.WaitSuperLong) + + // Use agent stats batcher to insert agent stats, similar to live system. + // NOTE(mafredri): Ideally we would pass batcher as a coderd option and + // insert using the agentClient, but we have a circular dependency on + // the database. + batcher, batcherCloser, err := batchstats.New( + ctx, + batchstats.WithStore(db), + batchstats.WithLogger(logger.Named("batchstats")), + batchstats.WithInterval(time.Hour), + ) + require.NoError(t, err) + defer batcherCloser() // Flushes the stats, this is to ensure they're written. + + for workspace, data := range testData { + for _, stat := range data.agentStats { + createdAt := stat.startedAt + connectionCount := int64(1) + if stat.noConnections { + connectionCount = 0 + } + for createdAt.Before(stat.endedAt) { + err = batcher.Add(createdAt, workspace.agentID, workspace.template.id, workspace.user.(*testUser).sdk.ID, workspace.id, agentsdk.Stats{ + ConnectionCount: connectionCount, + SessionCountVSCode: stat.sessionCountVSCode, + SessionCountJetBrains: stat.sessionCountJetBrains, + SessionCountReconnectingPTY: stat.sessionCountReconnectingPTY, + SessionCountSSH: stat.sessionCountSSH, + }) + require.NoError(t, err, "want no error inserting agent stats") + createdAt = createdAt.Add(30 * time.Second) + } } - return false } + + // Insert app usage. + var stats []workspaceapps.StatsReport + for workspace, data := range testData { + for _, usage := range data.appUsage { + appName := usage.app.name + accessMethod := workspaceapps.AccessMethodPath + if usage.app.name == "terminal" { + appName = "" + accessMethod = workspaceapps.AccessMethodTerminal + } + stats = append(stats, workspaceapps.StatsReport{ + UserID: workspace.user.(*testUser).sdk.ID, + WorkspaceID: workspace.id, + AgentID: workspace.agentID, + AccessMethod: accessMethod, + SlugOrPort: appName, + SessionID: uuid.New(), + SessionStartedAt: usage.startedAt, + SessionEndedAt: usage.endedAt, + Requests: usage.requests, + }) + } + } + reporter := workspaceapps.NewStatsDBReporter(db, workspaceapps.DefaultStatsDBReporterBatchSize) + //nolint:gocritic // This is a test. + err = reporter.Report(dbauthz.AsSystemRestricted(ctx), stats) + require.NoError(t, err, "want no error inserting app stats") + + return client } - require.Eventually(t, waitForAppSeconds("reconnecting-pty"), testutil.WaitMedium, testutil.IntervalFast, "reconnecting-pty seconds missing") - require.Eventually(t, waitForAppSeconds("ssh"), testutil.WaitMedium, testutil.IntervalFast, "ssh seconds missing") - // We got our data, close down sessions and connections. - _ = rpty.Close() - _ = sess.Close() - _ = sshConn.Close() + baseTemplateAndUserFixture := func() ([]*testTemplate, []*testUser) { + // Test templates and configuration to generate. + templates := []*testTemplate{ + // Create two templates with near-identical apps and parameters + // to allow testing for grouping similar data. + { + name: "template1", + parameters: []*templateParameter{ + {name: "param1", description: "This is first parameter"}, + {name: "param2", description: "This is second parameter"}, + {name: "param3", description: "This is third parameter"}, + { + name: "param4", + description: "This is fourth parameter", + options: []templateParameterOption{ + {name: "option1", value: "option1"}, + {name: "option2", value: "option2"}, + }, + }, + }, + apps: []templateApp{ + {name: "app1", icon: "/icon1.png"}, + {name: "app2", icon: "/icon2.png"}, + {name: "app3", icon: "/icon2.png"}, + }, + }, + { + name: "template2", + parameters: []*templateParameter{ + {name: "param1", description: "This is first parameter"}, + {name: "param2", description: "This is second parameter"}, + {name: "param3", description: "This is third parameter"}, + }, + apps: []templateApp{ + {name: "app1", icon: "/icon1.png"}, + {name: "app2", icon: "/icon2.png"}, + {name: "app3", icon: "/icon2.png"}, + }, + }, + // Create another template with different parameters and apps. + { + name: "othertemplate", + parameters: []*templateParameter{ + {name: "otherparam1", description: "This is another parameter"}, + }, + apps: []templateApp{ + {name: "otherapp1", icon: "/icon1.png"}, - assert.WithinDuration(t, req.StartTime, resp.Report.StartTime, 0) - assert.WithinDuration(t, req.EndTime, resp.Report.EndTime, 0) - assert.Equal(t, resp.Report.ActiveUsers, int64(1), "want one active user") - for _, app := range resp.Report.AppsUsage { - if slices.Contains([]string{"reconnecting-pty", "ssh"}, app.Slug) { - assert.Equal(t, app.Seconds, int64(300), "want app %q to have 5 minutes of usage", app.Slug) - } else { - assert.Equal(t, app.Seconds, int64(0), "want app %q to have 0 minutes of usage", app.Slug) + // This "special test app" will be converted into web + // terminal usage, this is not included in stats since we + // currently rely on agent stats for this data. + {name: "terminal", icon: "/terminal.png"}, + }, + }, } + + // Users and workspaces to generate. + users := []*testUser{ + { + name: "user1", + workspaces: []*testWorkspace{ + { + name: "workspace1", + template: templates[0], + buildParameters: []buildParameter{ + {templateParameter: templates[0].parameters[0], value: "abc"}, + {templateParameter: templates[0].parameters[1], value: "123"}, + {templateParameter: templates[0].parameters[2], value: "bbb"}, + {templateParameter: templates[0].parameters[3], value: "option1"}, + }, + }, + { + name: "workspace2", + template: templates[1], + buildParameters: []buildParameter{ + {templateParameter: templates[1].parameters[0], value: "ABC"}, + {templateParameter: templates[1].parameters[1], value: "123"}, + {templateParameter: templates[1].parameters[2], value: "BBB"}, + }, + }, + { + name: "otherworkspace3", + template: templates[2], + }, + }, + }, + { + name: "user2", + workspaces: []*testWorkspace{ + { + name: "workspace1", + template: templates[0], + buildParameters: []buildParameter{ + {templateParameter: templates[0].parameters[0], value: "abc"}, + {templateParameter: templates[0].parameters[1], value: "123"}, + {templateParameter: templates[0].parameters[2], value: "BBB"}, + {templateParameter: templates[0].parameters[3], value: "option1"}, + }, + }, + }, + }, + { + name: "user3", + workspaces: []*testWorkspace{ + { + name: "otherworkspace1", + template: templates[2], + buildParameters: []buildParameter{ + {templateParameter: templates[2].parameters[0], value: "xyz"}, + }, + }, + { + name: "workspace2", + template: templates[0], + buildParameters: []buildParameter{ + {templateParameter: templates[0].parameters[3], value: "option2"}, + }, + }, + }, + }, + } + + return templates, users + } + + // Time range for report, test data will be generated within and + // outside this range, but only data within the range should be + // included in the report. + frozenLastNight := time.Date(2023, 8, 22, 0, 0, 0, 0, time.UTC) + frozenWeekAgo := frozenLastNight.AddDate(0, 0, -7) + + saoPaulo, err := time.LoadLocation("America/Sao_Paulo") + require.NoError(t, err) + frozenWeekAgoSaoPaulo, err := time.ParseInLocation(time.DateTime, frozenWeekAgo.Format(time.DateTime), saoPaulo) + require.NoError(t, err) + + makeBaseTestData := func(templates []*testTemplate, users []*testUser) map[*testWorkspace]testDataGen { + return map[*testWorkspace]testDataGen{ + users[0].workspaces[0]: { + agentStats: []agentStat{ + { // One hour of usage. + startedAt: frozenWeekAgo, + endedAt: frozenWeekAgo.Add(time.Hour), + sessionCountVSCode: 1, + sessionCountSSH: 1, + }, + { // 12 minutes of usage. + startedAt: frozenWeekAgo.AddDate(0, 0, 1), + endedAt: frozenWeekAgo.AddDate(0, 0, 1).Add(12 * time.Minute), + sessionCountSSH: 1, + }, + { // 1m30s of usage -> 2m rounded. + startedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(4*time.Minute + 30*time.Second), + endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Minute), + sessionCountJetBrains: 1, + }, + }, + appUsage: []appUsage{ + { // One hour of usage. + app: users[0].workspaces[0].apps[0], + startedAt: frozenWeekAgo, + endedAt: frozenWeekAgo.Add(time.Hour), + requests: 1, + }, + { // 30s of app usage -> 1m rounded. + app: users[0].workspaces[0].apps[0], + startedAt: frozenWeekAgo.Add(2*time.Hour + 10*time.Second), + endedAt: frozenWeekAgo.Add(2*time.Hour + 40*time.Second), + requests: 1, + }, + { // 1m30s of app usage -> 2m rounded (included in São Paulo). + app: users[0].workspaces[0].apps[0], + startedAt: frozenWeekAgo.Add(3*time.Hour + 30*time.Second), + endedAt: frozenWeekAgo.Add(3*time.Hour + 90*time.Second), + requests: 1, + }, + { // used an app on the last day, counts as active user, 12m. + app: users[0].workspaces[0].apps[2], + startedAt: frozenWeekAgo.AddDate(0, 0, 6), + endedAt: frozenWeekAgo.AddDate(0, 0, 6).Add(12 * time.Minute), + requests: 1, + }, + }, + }, + users[0].workspaces[1]: { + agentStats: []agentStat{ + { + // One hour of usage in second template at the same time + // as in first template. When selecting both templates + // this user and their app usage will only be counted + // once but the template ID will show up in the data. + startedAt: frozenWeekAgo, + endedAt: frozenWeekAgo.Add(time.Hour), + sessionCountVSCode: 1, + sessionCountSSH: 1, + }, + }, + appUsage: []appUsage{ + // TODO(mafredri): This doesn't behave correctly right now + // and will add more usage to the app. This could be + // considered both correct and incorrect behavior. + // { // One hour of usage, but same user and same template app, only count once. + // app: users[0].workspaces[1].apps[0], + // startedAt: frozenWeekAgo, + // endedAt: frozenWeekAgo.Add(time.Hour), + // requests: 1, + // }, + { + // Different templates but identical apps, apps will be + // combined and usage will be summed. + app: users[0].workspaces[1].apps[0], + startedAt: frozenWeekAgo.AddDate(0, 0, 2), + endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Hour), + requests: 1, + }, + }, + }, + users[0].workspaces[2]: { + agentStats: []agentStat{}, + appUsage: []appUsage{}, + }, + users[1].workspaces[0]: { + agentStats: []agentStat{ + { // One hour of agent usage before timeframe (exclude). + startedAt: frozenWeekAgo.Add(-time.Hour), + endedAt: frozenWeekAgo, + sessionCountVSCode: 1, + sessionCountSSH: 1, + }, + { // One hour of usage. + startedAt: frozenWeekAgo, + endedAt: frozenWeekAgo.Add(time.Hour), + sessionCountSSH: 1, + }, + { // One hour of agent usage after timeframe (exclude in UTC, include in São Paulo). + startedAt: frozenWeekAgo.AddDate(0, 0, 7), + endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour), + sessionCountVSCode: 1, + sessionCountSSH: 1, + }, + }, + appUsage: []appUsage{ + { // One hour of app usage before timeframe (exclude). + app: users[1].workspaces[0].apps[2], + startedAt: frozenWeekAgo.Add(-time.Hour), + endedAt: frozenWeekAgo, + requests: 1, + }, + { // One hour of app usage after timeframe (exclude in UTC, include in São Paulo). + app: users[1].workspaces[0].apps[2], + startedAt: frozenWeekAgo.AddDate(0, 0, 7), + endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour), + requests: 1, + }, + }, + }, + users[2].workspaces[0]: { + agentStats: []agentStat{ + { // One hour of usage. + startedAt: frozenWeekAgo, + endedAt: frozenWeekAgo.Add(time.Hour), + sessionCountSSH: 1, + sessionCountReconnectingPTY: 1, + }, + }, + appUsage: []appUsage{ + { + app: users[2].workspaces[0].apps[0], + startedAt: frozenWeekAgo.AddDate(0, 0, 2), + endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(5 * time.Minute), + requests: 1, + }, + { // Special app; excluded from apps, but counted as active during the day. + app: users[2].workspaces[0].apps[1], + startedAt: frozenWeekAgo.AddDate(0, 0, 3), + endedAt: frozenWeekAgo.AddDate(0, 0, 3).Add(5 * time.Minute), + requests: 1, + }, + }, + }, + } + } + type testRequest struct { + name string + makeRequest func([]*testTemplate) codersdk.TemplateInsightsRequest + ignoreTimes bool + } + tests := []struct { + name string + makeFixture func() ([]*testTemplate, []*testUser) + makeTestData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen + requests []testRequest + }{ + { + name: "multiple users and workspaces", + makeFixture: baseTemplateAndUserFixture, + makeTestData: makeBaseTestData, + requests: []testRequest{ + { + name: "week deployment wide", + makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest { + return codersdk.TemplateInsightsRequest{ + StartTime: frozenWeekAgo, + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + Interval: codersdk.InsightsReportIntervalDay, + } + }, + }, + { + name: "week all templates", + makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest { + return codersdk.TemplateInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[0].id, templates[1].id, templates[2].id}, + StartTime: frozenWeekAgo, + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + Interval: codersdk.InsightsReportIntervalDay, + } + }, + }, + { + name: "week first template", + makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest { + return codersdk.TemplateInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[0].id}, + StartTime: frozenWeekAgo, + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + Interval: codersdk.InsightsReportIntervalDay, + } + }, + }, + { + name: "week second template", + makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest { + return codersdk.TemplateInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[1].id}, + StartTime: frozenWeekAgo, + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + Interval: codersdk.InsightsReportIntervalDay, + } + }, + }, + { + name: "week third template", + makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest { + return codersdk.TemplateInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[2].id}, + StartTime: frozenWeekAgo, + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + Interval: codersdk.InsightsReportIntervalDay, + } + }, + }, + { + // São Paulo is three hours behind UTC, so we should not see + // any data between weekAgo and weekAgo.Add(3 * time.Hour). + name: "week other timezone (São Paulo)", + makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest { + return codersdk.TemplateInsightsRequest{ + StartTime: frozenWeekAgoSaoPaulo, + EndTime: frozenWeekAgoSaoPaulo.AddDate(0, 0, 7), + Interval: codersdk.InsightsReportIntervalDay, + } + }, + }, + }, + }, + { + name: "parameters", + makeFixture: baseTemplateAndUserFixture, + makeTestData: func(templates []*testTemplate, users []*testUser) map[*testWorkspace]testDataGen { + return map[*testWorkspace]testDataGen{} + }, + requests: []testRequest{ + { + // Since workspaces are created "now", we can only get + // parameters using a time range that includes "now". + // We check yesterday and today for stability just in case + // the test runs at UTC midnight. + name: "yesterday and today deployment wide", + ignoreTimes: true, + makeRequest: func(_ []*testTemplate) codersdk.TemplateInsightsRequest { + now := time.Now().UTC() + return codersdk.TemplateInsightsRequest{ + StartTime: now.Truncate(24*time.Hour).AddDate(0, 0, -1), + EndTime: now.Truncate(time.Hour).Add(time.Hour), + } + }, + }, + { + name: "two days ago, no data", + ignoreTimes: true, + makeRequest: func(_ []*testTemplate) codersdk.TemplateInsightsRequest { + twoDaysAgo := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -2) + return codersdk.TemplateInsightsRequest{ + StartTime: twoDaysAgo, + EndTime: twoDaysAgo.AddDate(0, 0, 1), + } + }, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.NotNil(t, tt.makeFixture, "test bug: makeFixture must be set") + require.NotNil(t, tt.makeTestData, "test bug: makeTestData must be set") + templates, users, testData := prepareFixtureAndTestData(t, tt.makeFixture, tt.makeTestData) + client := prepare(t, templates, users, testData) + + for _, req := range tt.requests { + req := req + t.Run(req.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + + report, err := client.TemplateInsights(ctx, req.makeRequest(templates)) + require.NoError(t, err, "want no error getting template insights") + + if req.ignoreTimes { + // Ignore times, we're only interested in the data. + report.Report.StartTime = time.Time{} + report.Report.EndTime = time.Time{} + for i := range report.IntervalReports { + report.IntervalReports[i].StartTime = time.Time{} + report.IntervalReports[i].EndTime = time.Time{} + } + } + + partialName := strings.Join(strings.Split(t.Name(), "/")[1:], "_") + goldenFile := filepath.Join("testdata", "insights", partialName+".json.golden") + if *updateGoldenFiles { + err = os.MkdirAll(filepath.Dir(goldenFile), 0o755) + require.NoError(t, err, "want no error creating golden file directory") + f, err := os.Create(goldenFile) + require.NoError(t, err, "want no error creating golden file") + defer f.Close() + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + enc.Encode(report) + return + } + + f, err := os.Open(goldenFile) + require.NoError(t, err, "open golden file, run \"make update-golden-files\" and commit the changes") + defer f.Close() + var want codersdk.TemplateInsightsResponse + err = json.NewDecoder(f).Decode(&want) + require.NoError(t, err, "want no error decoding golden file") + + cmpOpts := []cmp.Option{ + // Ensure readable UUIDs in diff. + cmp.Transformer("UUIDs", func(in []uuid.UUID) (s []string) { + for _, id := range in { + s = append(s, id.String()) + } + return s + }), + } + // Use cmp.Diff here because it produces more readable diffs. + assert.Empty(t, cmp.Diff(want, report, cmpOpts...), "golden file mismatch (-want +got): %s, run \"make update-golden-files\", verify and commit the changes", goldenFile) + }) + } + }) } - // The full timeframe is <= 24h, so the interval matches exactly. - require.Len(t, resp.IntervalReports, 1, "want one interval report") - assert.WithinDuration(t, req.StartTime, resp.IntervalReports[0].StartTime, 0) - assert.WithinDuration(t, req.EndTime, resp.IntervalReports[0].EndTime, 0) - assert.Equal(t, resp.IntervalReports[0].ActiveUsers, int64(1), "want one active user in the interval report") - - // The workspace uses 3 parameters - require.Len(t, resp.Report.ParametersUsage, 3) - assert.Equal(t, firstParameterName, resp.Report.ParametersUsage[0].Name) - assert.Equal(t, firstParameterDisplayName, resp.Report.ParametersUsage[0].DisplayName) - assert.Contains(t, resp.Report.ParametersUsage[0].Values, codersdk.TemplateParameterValue{ - Value: firstParameterValue, - Count: 1, - }) - assert.Contains(t, resp.Report.ParametersUsage[0].TemplateIDs, template.ID) - assert.Empty(t, resp.Report.ParametersUsage[0].Options) - - assert.Equal(t, secondParameterName, resp.Report.ParametersUsage[1].Name) - assert.Equal(t, secondParameterDisplayName, resp.Report.ParametersUsage[1].DisplayName) - assert.Contains(t, resp.Report.ParametersUsage[1].Values, codersdk.TemplateParameterValue{ - Value: secondParameterValue, - Count: 1, - }) - assert.Contains(t, resp.Report.ParametersUsage[1].TemplateIDs, template.ID) - assert.Empty(t, resp.Report.ParametersUsage[1].Options) - - assert.Equal(t, thirdParameterName, resp.Report.ParametersUsage[2].Name) - assert.Equal(t, thirdParameterDisplayName, resp.Report.ParametersUsage[2].DisplayName) - assert.Contains(t, resp.Report.ParametersUsage[2].Values, codersdk.TemplateParameterValue{ - Value: thirdParameterValue, - Count: 1, - }) - assert.Contains(t, resp.Report.ParametersUsage[2].TemplateIDs, template.ID) - assert.Equal(t, []codersdk.TemplateVersionParameterOption{ - {Name: thirdParameterOptionName1, Value: thirdParameterOptionValue1}, - {Name: thirdParameterOptionName2, Value: thirdParameterOptionValue2}, - {Name: thirdParameterOptionName3, Value: thirdParameterOptionValue3}, - }, resp.Report.ParametersUsage[2].Options) } func TestTemplateInsights_BadRequest(t *testing.T) { diff --git a/coderd/members.go b/coderd/members.go index 67ab19cf45687..91083cbb89814 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -8,13 +8,13 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database/db2sdk" - "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) // @Summary Assign role to organization member diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index d25b716f2df15..f5aaeb89f7a24 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -15,9 +15,9 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/codersdk" "github.com/coder/retry" ) @@ -146,8 +146,14 @@ func convertDAUResponse[T dauRow](rows []T, tzOffset int) codersdk.DAUsResponse } dates := maps.Keys(respMap) - slices.SortFunc(dates, func(a, b time.Time) bool { - return a.Before(b) + slices.SortFunc(dates, func(a, b time.Time) int { + if a.Before(b) { + return -1 + } else if a.Equal(b) { + return 0 + } else { + return 1 + } }) var resp codersdk.DAUsResponse diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index 52c0df8a3ebd8..fc22cc1139c09 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -10,12 +10,12 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/metricscache" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/metricscache" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func dateH(year, month, day, hour int) time.Time { diff --git a/coderd/oauthpki/oidcpki.go b/coderd/oauthpki/oidcpki.go new file mode 100644 index 0000000000000..c44d130e5be9f --- /dev/null +++ b/coderd/oauthpki/oidcpki.go @@ -0,0 +1,275 @@ +package oauthpki + +import ( + "context" + "crypto/rsa" + "crypto/sha1" //#nosec // Not used for cryptography. + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "golang.org/x/oauth2" + "golang.org/x/oauth2/jws" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/httpmw" +) + +// Config uses jwt assertions over client_secret for oauth2 authentication of +// the application. This implementation was made specifically for Azure AD. +// +// https://learn.microsoft.com/en-us/azure/active-directory/develop/certificate-credentials +// +// However this does mostly follow the standard. We can generalize this as we +// include support for more IDPs. +// +// https://datatracker.ietf.org/doc/html/rfc7523 +type Config struct { + cfg httpmw.OAuth2Config + + // These values should match those provided in the oauth2.Config. + // Because the inner config is an interface, we need to duplicate these + // values here. + scopes []string + clientID string + tokenURL string + + // ClientSecret is the private key of the PKI cert. + // Azure AD only supports RS256 signing algorithm. + clientKey *rsa.PrivateKey + // Base64url-encoded SHA-1 thumbprint of the X.509 certificate's DER encoding. + // This is specific to Azure AD + x5t string +} + +type ConfigParams struct { + ClientID string + TokenURL string + Scopes []string + PemEncodedKey []byte + PemEncodedCert []byte + + Config httpmw.OAuth2Config +} + +// NewOauth2PKIConfig creates the oauth2 config for PKI based auth. It requires the certificate and it's private key. +// The values should be passed in as PEM encoded values, which is the standard encoding for x509 certs saved to disk. +// It should look like: +// +// -----BEGIN RSA PRIVATE KEY---- +// ... +// -----END RSA PRIVATE KEY----- +// +// -----BEGIN CERTIFICATE----- +// ... +// -----END CERTIFICATE----- +func NewOauth2PKIConfig(params ConfigParams) (*Config, error) { + if params.ClientID == "" { + return nil, xerrors.Errorf("") + } + if len(params.Scopes) == 0 { + return nil, xerrors.Errorf("scopes are required") + } + + rsaKey, err := decodeClientKey(params.PemEncodedKey) + if err != nil { + return nil, err + } + + // Azure AD requires a certificate. The sha1 of the cert is used to identify the signer. + // This is not required in the general specification. + if strings.Contains(strings.ToLower(params.TokenURL), "microsoftonline") && len(params.PemEncodedCert) == 0 { + return nil, xerrors.Errorf("oidc client certificate is required and missing") + } + + block, _ := pem.Decode(params.PemEncodedCert) + // Used as an identifier, not an actual cryptographic hash. + //nolint:gosec + hashed := sha1.Sum(block.Bytes) + + return &Config{ + clientID: params.ClientID, + tokenURL: params.TokenURL, + scopes: params.Scopes, + cfg: params.Config, + clientKey: rsaKey, + x5t: base64.StdEncoding.EncodeToString(hashed[:]), + }, nil +} + +// decodeClientKey decodes a PEM encoded rsa secret. +func decodeClientKey(pemEncoded []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemEncoded) + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, xerrors.Errorf("failed to parse private key: %w", err) + } + + return key, nil +} + +func (ja *Config) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + return ja.cfg.AuthCodeURL(state, opts...) +} + +// Exchange includes the client_assertion signed JWT. +func (ja *Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + signed, err := ja.jwtToken() + if err != nil { + return nil, xerrors.Errorf("failed jwt assertion: %w", err) + } + opts = append(opts, + oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), + oauth2.SetAuthURLParam("client_assertion", signed), + ) + return ja.cfg.Exchange(ctx, code, opts...) +} + +func (ja *Config) jwtToken() (string, error) { + now := time.Now() + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": ja.clientID, + "sub": ja.clientID, + "aud": ja.tokenURL, + // 5-10 minutes is recommended in the Azure docs. + // So we'll use 5 minutes. + "exp": now.Add(time.Minute * 5).Unix(), + "jti": uuid.New().String(), + "nbf": now.Unix(), + "iat": now.Unix(), + }) + token.Header["x5t"] = ja.x5t + + signed, err := token.SignedString(ja.clientKey) + if err != nil { + return "", xerrors.Errorf("sign jwt assertion: %w", err) + } + return signed, nil +} + +func (ja *Config) TokenSource(ctx context.Context, token *oauth2.Token) oauth2.TokenSource { + return oauth2.ReuseTokenSource(token, &jwtTokenSource{ + cfg: ja, + ctx: ctx, + refreshToken: token.RefreshToken, + }) +} + +type jwtTokenSource struct { + cfg *Config + ctx context.Context + refreshToken string +} + +// Token must be safe for concurrent use by multiple go routines +// Very similar to the RetrieveToken implementation by the oauth2 package. +// https://github.com/golang/oauth2/blob/master/internal/token.go#L212 +// Oauth2 package keeps this code unexported or in an /internal package, +// so we have to copy the implementation :( +func (src *jwtTokenSource) Token() (*oauth2.Token, error) { + if src.refreshToken == "" { + return nil, xerrors.New("oauth2: token expired and refresh token is not set") + } + cli := http.DefaultClient + if v, ok := src.ctx.Value(oauth2.HTTPClient).(*http.Client); ok { + cli = v + } + + token, err := src.cfg.jwtToken() + if err != nil { + return nil, xerrors.Errorf("failed jwt assertion: %w", err) + } + + v := url.Values{ + "client_assertion": {token}, + "client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}, + "client_id": {src.cfg.clientID}, + "grant_type": {"refresh_token"}, + "scope": {strings.Join(src.cfg.scopes, " ")}, + "refresh_token": {src.refreshToken}, + } + // Using params based auth + req, err := http.NewRequest("POST", src.cfg.tokenURL, strings.NewReader(v.Encode())) + if err != nil { + return nil, xerrors.Errorf("oauth2: make token refresh request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req = req.WithContext(src.ctx) + resp, err := cli.Do(req) + if err != nil { + return nil, xerrors.Errorf("oauth2: cannot get token: %w", err) + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, xerrors.Errorf("oauth2: cannot fetch token reading response body: %w", err) + } + + var tokenRes struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + + // Extra fields returned by the refresh that are needed + IDToken string `json:"id_token"` + ExpiresIn int64 `json:"expires_in"` // relative seconds from now + // error fields + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description"` + ErrorURI string `json:"error_uri"` + } + + unmarshalError := json.Unmarshal(body, &tokenRes) + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + // Return a standard oauth2 error. Attempt to read some error fields. The error fields + // can be encoded in a few places, so this does not catch all of them. + return nil, &oauth2.RetrieveError{ + Response: resp, + Body: body, + // Best effort for error fields + ErrorCode: tokenRes.ErrorCode, + ErrorDescription: tokenRes.ErrorDescription, + ErrorURI: tokenRes.ErrorURI, + } + } + + if unmarshalError != nil { + return nil, xerrors.Errorf("oauth2: cannot unmarshal token: %w", err) + } + + newToken := &oauth2.Token{ + AccessToken: tokenRes.AccessToken, + TokenType: tokenRes.TokenType, + RefreshToken: tokenRes.RefreshToken, + } + + if secs := tokenRes.ExpiresIn; secs > 0 { + newToken.Expiry = time.Now().Add(time.Duration(secs) * time.Second) + } + + // ID token is a JWT token. We can decode it to get the expiry. + // Not really sure what to do if the ExpiresIn and JWT expiry differ, + // but this one is attached in the JWT and guaranteed to be right for local + // validation. So use this one if found. + if v := tokenRes.IDToken; v != "" { + // decode returned id token to get expiry + claimSet, err := jws.Decode(v) + if err != nil { + return nil, xerrors.Errorf("oauth2: error decoding JWT token: %w", err) + } + newToken.Expiry = time.Unix(claimSet.Exp, 0) + } + + return newToken, nil +} diff --git a/coderd/oauthpki/okidcpki_test.go b/coderd/oauthpki/okidcpki_test.go new file mode 100644 index 0000000000000..ab6e3e3a08179 --- /dev/null +++ b/coderd/oauthpki/okidcpki_test.go @@ -0,0 +1,351 @@ +package oauthpki_test + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" + "github.com/coder/coder/v2/coderd/oauthpki" + "github.com/coder/coder/v2/testutil" +) + +const ( + testClientKey = `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAnUryZEfn5kA8wuk9a7ogFuWbk3uPHEhioYuAg9m3/tIdqSqu +ASpRzw8+1nORTf3ykWRRlhxZWnKimmkB0Ux5Yrz9TDVWDQbzEH3B8ibMlmaNcoN8 +wYVzeEpqCe3fJagnV0lh0sHB1Z+vhcJ/M2nEAdyfhIgQEbG6Xtl2+WcGqyMWUJpV +g8+ebK+JkXELAGN1hg3DdV52gjodEjoe1/ibHz8y3NR7j2tOKix7iKOhccyFkD35 +xqSnfyZJK5yxIfmGiWdVOIGqc2rYpgvrXJLTOjLoeyDSNi+Q604T64ZxsqfuM4LX +BakVG3EwHFXPBfsBKjUE9HYvXEXw3fJP9K6mIwIDAQABAoIBAQCb+aH7x0IylSir +r1Z06RDBI9bunOwBA9aqkwdRuCg4zGsVQXljNnABgACz7837JQPRIUW2MU553otX +yyE+RzNnsjkLxSgbqvSFOe+FDOx7iB5jm/euf4NNmZ0lU3iggurgJ6iVsgVgrQUF +AyXX+d2gawLUDYjBwxgozkSodH2sXYSX+SWfSOXHsFzSa3tLtUMbAIflM0rlRXf7 +Z57M8mMomZUvmmojH+TnBQljJlU8lhrvOaDD4DT8qAtVHE3VluDBQ9/3E8OIjz+E +EqUgWLgrdq1rIMhJbHN90NwLwWs+2PcRfdB6hqKPktLne2KZFOgVKlxPKOYByBq1 +PX/vJ/HBAoGBAMFmJ6nYqyUVl26ajlXmnXBjQ+iBLHo9lcUu84+rpqRf90Bsm5bd +jMmYr3Yo3yXNiit3rvZzBfPElo+IVa1HpPtgOaa2AU5B3QzxWCNT0FNRQqMG2LcA +CvB10pOdJEABQxr7d4eFRg2/KbF1fr0r0vqMEelwa5ejTg6ROD3DtadpAoGBANA0 +4EClniCwvd1IECy2oTuTDosXgmRKwRAcwgE34YXy1Y/L4X/ghFeCHi3ybrep0uwL +ptJNK+0sqvPu6UhC356GfMqfuzOKNMkXybnPUbHrz5KTkN+QQMfPc73Veel2gpD3 +xNataEmHtxcOx0X1OnjwyZZpmMbrUY3Cackn+durAoGBAKYR5nU+jJfnloVvSlIR +GZhsZN++LEc7ouQTkSoJp6r2jQZRPLmrvT1PUzwPlK6NdNwmhaMy2iWc5fySgZ+u +KcmBs3+oQi7E9+ApThnn2rfwy1vagTWDX+FkC1KeWYZsjwcYcGd61dDwGgk8b3xZ +qW1j4e2mj31CycBQiw7eg5ohAoGADvkOe3etlHpBXS12hFCp7afYruYE6YN6uNbo +mL/VBxX8h7fIwrJ5sfVYiENb9PdQhMsdtxf3pbnFnX875Ydxn2vag5PTGZTB0QhV +6HfhTyM/LTJRg9JS5kuj7i3w83ojT5uR20JjMo6A+zaD3CMTjmj6hkeXxg5cMg6e +HuoyDLsCgYBcbboYMFT1cUSxBeMtPGt3CxxZUYnUQaRUeOcjqYYlFL+DCWhY7pxH +EnLhwW/KzkDzOmwRmmNOMqD7UhR/ayxR+avRt6v5d5l8fVCuNexgs7kR9L5IQp9l +YV2wsCoXBCcuPmio/te44U//BlzprEu0w1iHpb3ibmQg4y291R0TvQ== +-----END RSA PRIVATE KEY-----` + + testClientCert = ` +-----BEGIN CERTIFICATE----- +MIIEOjCCAiKgAwIBAgIQMO50KnWsRbmrrthPQgyubjANBgkqhkiG9w0BAQsFADAY +MRYwFAYDVQQDEw1Mb2NhbGhvc3RDZXJ0MB4XDTIzMDgxMDE2MjYxOFoXDTI1MDIx +MDE2MjU0M1owFDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAnUryZEfn5kA8wuk9a7ogFuWbk3uPHEhioYuAg9m3/tId +qSquASpRzw8+1nORTf3ykWRRlhxZWnKimmkB0Ux5Yrz9TDVWDQbzEH3B8ibMlmaN +coN8wYVzeEpqCe3fJagnV0lh0sHB1Z+vhcJ/M2nEAdyfhIgQEbG6Xtl2+WcGqyMW +UJpVg8+ebK+JkXELAGN1hg3DdV52gjodEjoe1/ibHz8y3NR7j2tOKix7iKOhccyF +kD35xqSnfyZJK5yxIfmGiWdVOIGqc2rYpgvrXJLTOjLoeyDSNi+Q604T64Zxsqfu +M4LXBakVG3EwHFXPBfsBKjUE9HYvXEXw3fJP9K6mIwIDAQABo4GDMIGAMA4GA1Ud +DwEB/wQEAwIDuDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0O +BBYEFAYCdgydG3h2SNWF+BfAyJtNliJtMB8GA1UdIwQYMBaAFHR/aptP0RUNNFyf +5uky527SECt1MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIBAI6P +ymG7l06JvJ3p6xgaMyOxgkpQl6WkY4LJHVEhfeDSoO3qsJc4PxUdSExJsT84weXb +lF+tK6D/CPlvjmG720IlB5cSKJ71rWjwmaMWKxWKXyoZdDrHAp55+FNdXegUZF2o +EF/ZM5CHaO8iHMkuWEv1OASHBQWC/o4spUN5HGQ9HepwLVvO/aX++LYfvfL9faKA +IT+w9i8pJbfItFmfA8x2OEVZk8aEA0WtKdfsMwzGmZ1GSGa4UYcynxQGCMiB5h4L +C/dpoJRbEzdGLuTZgV2SCaN3k5BrH4aaILI9tqZaq0gamN9Rd2yji3cGiduCeAAo +RmVcl9fBliMLxylWEP5+B2JmCZEc8Lfm0TBNnjaG17KY40gzbfBYixBxBTYgsPua +bfprtfksSG++zcsDbkC8CtPamtlNWtDAiFp4yQRkP79PlJO6qCdTrFWPukTMCMso +25hjLvxj1fLy/jSMDEZu/oQ14TMCZSGHRjz4CPiaCfXqgqOtVOD+5+yWInwUGp/i +Nb1vIq4ruEAbyCbdWKHbE0yT5AP7hm5ZNybpZ4/311AEBD2HKip/OqB05p99XcLw +BIC4ODNvwCn6x00KZoqWz/MX2dEQ/HqWiWaDB/OSemfTVE3I94mzEWnqpF2cQpcT +B1B7CpkMU55hPP+7nsofCszNrMDXT8Z5w2a3zLKM +-----END CERTIFICATE----- +` +) + +// TestAzureADPKIOIDC ensures we do not break Azure AD compatibility. +// It runs an oauth2.Exchange method and hijacks the request to only check the +// request side of the transaction. +func TestAzureADPKIOIDC(t *testing.T) { + t.Parallel() + + oauthCfg := &oauth2.Config{ + ClientID: "random-client-id", + Endpoint: oauth2.Endpoint{ + TokenURL: "https://login.microsoftonline.com/6a1e9139-13f2-4afb-8f46-036feac8bd79/v2.0/token", + }, + } + + pkiConfig, err := oauthpki.NewOauth2PKIConfig(oauthpki.ConfigParams{ + ClientID: oauthCfg.ClientID, + TokenURL: oauthCfg.Endpoint.TokenURL, + PemEncodedKey: []byte(testClientKey), + PemEncodedCert: []byte(testClientCert), + Config: oauthCfg, + Scopes: []string{"openid", "email", "profile"}, + }) + require.NoError(t, err, "failed to create pki config") + + ctx := testutil.Context(t, testutil.WaitMedium) + ctx = oidc.ClientContext(ctx, &http.Client{ + Transport: &fakeRoundTripper{ + roundTrip: func(req *http.Request) (*http.Response, error) { + resp := &http.Response{ + Status: "500 Internal Service Error", + } + // This is the easiest way to hijack the request and check + // the params. The oauth2 package uses unexported types and + // options, so we need to view the actual request created. + assertJWTAuth(t, req) + return resp, nil + }, + }, + }) + _, err = pkiConfig.Exchange(ctx, base64.StdEncoding.EncodeToString([]byte("random-code"))) + // We hijack the request and return an error intentionally + require.Error(t, err, "error expected") +} + +// TestAzureAKPKIWithCoderd uses a fake IDP and a real Coderd to test PKI auth. +// nolint:bodyclose +func TestAzureAKPKIWithCoderd(t *testing.T) { + t.Parallel() + + scopes := []string{"openid", "email", "profile", "offline_access"} + fake := oidctest.NewFakeIDP(t, + oidctest.WithIssuer("https://login.microsoftonline.com/fake_app"), + oidctest.WithCustomClientAuth(func(t testing.TB, req *http.Request) (url.Values, error) { + values := assertJWTAuth(t, req) + if values == nil { + return nil, xerrors.New("authorizatin failed in request") + } + return values, nil + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, scopes, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) + + oauthCfg := cfg.OAuth2Config.(*oauth2.Config) + // Create the oauthpki config + pki, err := oauthpki.NewOauth2PKIConfig(oauthpki.ConfigParams{ + ClientID: oauthCfg.ClientID, + TokenURL: oauthCfg.Endpoint.TokenURL, + Scopes: scopes, + PemEncodedKey: []byte(testClientKey), + PemEncodedCert: []byte(testClientCert), + Config: oauthCfg, + }) + require.NoError(t, err) + cfg.OAuth2Config = pki + + owner, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + OIDCConfig: cfg, + }) + + // Create a user and login + const email = "alice@coder.com" + claims := jwt.MapClaims{ + "email": email, + } + helper := oidctest.NewLoginHelper(owner, fake) + user, _ := helper.Login(t, claims) + + // Try refreshing the token more than once. + for i := 0; i < 2; i++ { + helper.ForceRefresh(t, api.Database, user, claims) + } +} + +// TestSavedAzureADPKIOIDC was created by capturing actual responses from an Azure +// AD instance and saving them to replay, removing some details. +// The reason this is done is that this is the only way to assert values +// passed to the oauth2 provider via http requests. +// It is not feasible to run against an actual Azure AD instance, so this attempts +// to prevent some regressions by running a full "e2e" oauth and asserting some +// of the request values. +func TestSavedAzureADPKIOIDC(t *testing.T) { + t.Parallel() + + var ( + stateString = "random-state" + oauth2Code = base64.StdEncoding.EncodeToString([]byte("random-code")) + ) + + // Real oauth config. We will hijack all http requests so some of these values + // are fake. + cfg := &oauth2.Config{ + ClientID: "fake_app", + ClientSecret: "", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://login.microsoftonline.com/fake_app/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/fake_app/oauth2/v2.0/token", + AuthStyle: 0, + }, + RedirectURL: "http://localhost/api/v2/users/oidc/callback", + Scopes: []string{"openid", "profile", "email", "offline_access"}, + } + + initialExchange := false + tokenRefreshed := false + + // Create the oauthpki config + pki, err := oauthpki.NewOauth2PKIConfig(oauthpki.ConfigParams{ + ClientID: cfg.ClientID, + TokenURL: cfg.Endpoint.TokenURL, + Scopes: []string{"openid", "email", "profile", "offline_access"}, + PemEncodedKey: []byte(testClientKey), + PemEncodedCert: []byte(testClientCert), + Config: cfg, + }) + require.NoError(t, err) + + var fakeCtx context.Context + fakeClient := &http.Client{ + Transport: fakeRoundTripper{ + roundTrip: func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.String(), "authorize") { + // This is the user hitting the browser endpoint to begin the OIDC + // auth flow. + + // Authorize should redirect the user back to the app after authentication on + // the IDP. + resp := httptest.NewRecorder() + v := url.Values{ + "code": {oauth2Code}, + "state": {stateString}, + "session_state": {"a18cf797-1e2b-4bc3-baf9-66b41a4997cf"}, + } + + // This url doesn't really matter since the fake client will hiject this actual request. + http.Redirect(resp, req, "http://localhost:3000/api/v2/users/oidc/callback?"+v.Encode(), http.StatusTemporaryRedirect) + return resp.Result(), nil + } + if strings.Contains(req.URL.String(), "v2.0/token") { + vals := assertJWTAuth(t, req) + switch vals.Get("grant_type") { + case "authorization_code": + // Initial token + initialExchange = true + assert.Equal(t, oauth2Code, vals.Get("code"), "initial exchange code mismatch") + case "refresh_token": + // refreshed token + tokenRefreshed = true + assert.Equal(t, "", vals.Get("refresh_token"), "refresh token required") + } + + resp := httptest.NewRecorder() + // Taken from an actual response + // Just always return a token no matter what. + resp.Header().Set("Content-Type", "application/json") + _, _ = resp.Write([]byte(`{ + "token_type":"Bearer", + "scope":"email openid profile AccessReview.ReadWrite.Membership Group.Read.All Group.ReadWrite.All User.Read", + "expires_in":4009, + "ext_expires_in":4009, + "access_token":"", + "refresh_token":"", + "id_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ii1LSTNROW5OUjdiUm9meG1lWm9YcWJIWkdldyJ9.eyJhdWQiOiIxZjAxODMyYS1mZWViLTQyZGMtODFkOS01ZjBhYjZhMDQxZTAiLCJpc3MiOiJodHRwczovL2xvZ2luLm1pY3Jvc29mdG9ubGluZS5jb20vMTEwZjBjMGYtY2Q3Ni00NzE3LWE2ZjgtNGVlYTNkMGY4MTA5L3YyLjAiLCJpYXQiOjE2OTE3OTI2MzQsIm5iZiI6MTY5MTc5MjYzNCwiZXhwIjoxNjkxNzk2NTM0LCJhaW8iOiJBWVFBZS84VUFBQUE1eEtqMmVTdWFXVmZsRlhCeGJJTnMvSkVyVHFvUGlaQW5ENmJIZWF3a2RRcisyRVRwM3RGNGY3akxicnh3ODhhVm9QOThrY0xMNjhON1hVV3FCN1I1N2JQRU9EclRlSUI1S0lyUHBjbCtIeXR0a1ljOVdWQklVVEErSllQbzl1a0ZjbGNWZ1krWUc3eHlmdi90K3Q1ZEczblNuZEdEQ1FYRVIxbDlTNko1T2c9IiwiZW1haWwiOiJzdGV2ZW5AY29kZXIuY29tIiwiZ3JvdXBzIjpbImM4MDQ4ZTkxLWY1YzMtNDdlNS05NjkzLTgzNGRlODQwMzRhZCIsIjcwYjQ4MTc1LTEwN2ItNGFkOC1iNDA1LTRkODg4YTFjNDY2ZiJdLCJpZHAiOiJtYWlsIiwibmFtZSI6IlN0ZXZlbiBNIiwib2lkIjoiN2JhNDYzNjAtZTAyNy00OTVhLTlhZTUtM2FlYWZlMzY3MGEyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoic3RldmVuQGNvZGVyLmNvbSIsInByb3ZfZGF0YSI6W3siQXQiOnRydWUsIlByb3YiOiJnaXRodWIuY29tIiwiQWx0c2VjaWQiOiI1NDQ2Mjk4IiwiQWNjZXNzVG9rZW4iOm51bGx9XSwicmgiOiIwLkFUZ0FEd3dQRVhiTkYwZW0tRTdxUFEtQkNTcURBUl9yX3R4Q2dkbGZDcmFnUWVBNEFPRS4iLCJyb2xlcyI6WyJUZW1wbGF0ZUF1dGhvcnMiXSwic3ViIjoib0JTN3FjUERKdWlDMEYyQ19XdDJycVlvanhpT0o3S3JFWjlkQ1RkTGVYNCIsInRpZCI6IjExMGYwYzBmLWNkNzYtNDcxNy1hNmY4LTRlZWEzZDBmODEwOSIsInV0aSI6IktReGlIWGtaZUVxcC1tQWlVdTlyQUEiLCJ2ZXIiOiIyLjAiLCJyb2xlczIiOiJUZW1wbGF0ZUF1dGhvcnMifQ.JevFI4Xm9dW7kQq4xEgZnUaU0SqbeOAFtT0YIKQNefR9Db4sjxCaKRmX0pPt-CM9j45d6fAiAkLFDAqjlSbi4Zi0GbEomT3yegmuxKgEgjPpJlGjF2TBUpsNNyn5gJ9Wkct9BfwALJhX2ePJFzIlkvx9opNNbNK1qHKMMjOSRFG6AGExKRDiQAME0a4hVgCwrAdUs4JrCcj4LqB84dODN-eoh-jx2-1wDvf6fovfwLHDQwjY4lfBxaYdNavKM369hrhU-U067rSnCzvDD26f4VLhPF52hiQIbTVN5t7p_1XmcduUiaNnmr9AZiZxZ-94mctSRRR8xG0pNwO2yv84iA" + }`)) + return resp.Result(), nil + } + // This is the "Coder" half of things. We can keep this in the fake + // client, essentially being the fake client on both sides of the OIDC + // flow. + if strings.Contains(req.URL.String(), "v2/users/oidc/callback") { + // This is the callback from the IDP. + code := req.URL.Query().Get("code") + require.Equal(t, oauth2Code, code, "code mismatch") + state := req.URL.Query().Get("state") + require.Equal(t, stateString, state, "state mismatch") + + // Exchange for token should work + token, err := pki.Exchange(fakeCtx, code) + if !assert.NoError(t, err) { + return httptest.NewRecorder().Result(), nil + } + + // Also try a refresh + cpy := token + cpy.Expiry = time.Now().Add(time.Minute * -1) + src := pki.TokenSource(fakeCtx, cpy) + _, err = src.Token() + tokenRefreshed = true + assert.NoError(t, err, "token refreshed") + return httptest.NewRecorder().Result(), nil + } + + return nil, xerrors.Errorf("not implemented") + }, + }, + } + fakeCtx = oidc.ClientContext(context.Background(), fakeClient) + _ = fakeCtx + + // This simulates a client logging into the browser. The 307 redirect will + // make sure this goes through the full flow. + // nolint: noctx + resp, err := fakeClient.Get(pki.AuthCodeURL("state", oauth2.AccessTypeOffline)) + require.NoError(t, err) + _ = resp.Body.Close() + + require.True(t, initialExchange, "initial token exchange complete") + require.True(t, tokenRefreshed, "token was refreshed") +} + +type fakeRoundTripper struct { + roundTrip func(req *http.Request) (*http.Response, error) +} + +func (f fakeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return f.roundTrip(req) +} + +// assertJWTAuth will assert the basic JWT auth assertions. It will return the +// url.Values from the request body for any additional assertions to be made. +func assertJWTAuth(t testing.TB, r *http.Request) url.Values { + body, err := io.ReadAll(r.Body) + if !assert.NoError(t, err) { + return nil + } + vals, err := url.ParseQuery(string(body)) + if !assert.NoError(t, err) { + return nil + } + + assert.Equal(t, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", vals.Get("client_assertion_type")) + jwtToken := vals.Get("client_assertion") + // No need to actually verify the jwt is signed right. + parsedToken, _, err := (&jwt.Parser{}).ParseUnverified(jwtToken, jwt.MapClaims{}) + if !assert.NoError(t, err, "failed to parse jwt token") { + return nil + } + + // Azure requirements + assert.NotEmpty(t, parsedToken.Header["x5t"], "hashed cert missing") + assert.Equal(t, "RS256", parsedToken.Header["alg"], "azure only accepts RS256") + assert.Equal(t, "JWT", parsedToken.Header["typ"], "azure only accepts JWT") + + return vals +} diff --git a/coderd/organizations.go b/coderd/organizations.go index 13906baef63b3..3353324482fbf 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -9,11 +9,11 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" ) // @Summary Get organization by ID @@ -94,7 +94,7 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { _, err = tx.InsertAllUsersGroup(ctx, organization.ID) if err != nil { - return xerrors.Errorf("create %q group: %w", database.AllUsersGroup, err) + return xerrors.Errorf("create %q group: %w", database.EveryoneGroup, err) } return nil }, nil) diff --git a/coderd/organizations_test.go b/coderd/organizations_test.go index 87d69fc508c61..c8cde696e22a2 100644 --- a/coderd/organizations_test.go +++ b/coderd/organizations_test.go @@ -7,9 +7,9 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func TestOrganizationsByUser(t *testing.T) { diff --git a/coderd/pagination.go b/coderd/pagination.go index 8bc4228b3bcea..b50358e1f57f5 100644 --- a/coderd/pagination.go +++ b/coderd/pagination.go @@ -5,8 +5,8 @@ import ( "github.com/google/uuid" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) // parsePagination extracts pagination query params from the http request. diff --git a/coderd/pagination_internal_test.go b/coderd/pagination_internal_test.go index d1e6bd107cfd8..94077b1083f4f 100644 --- a/coderd/pagination_internal_test.go +++ b/coderd/pagination_internal_test.go @@ -10,7 +10,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" ) func TestPagination(t *testing.T) { diff --git a/coderd/parameter/plaintext_test.go b/coderd/parameter/plaintext_test.go index bb11f376d31b5..78945d9984e10 100644 --- a/coderd/parameter/plaintext_test.go +++ b/coderd/parameter/plaintext_test.go @@ -3,7 +3,7 @@ package parameter_test import ( "testing" - "github.com/coder/coder/coderd/parameter" + "github.com/coder/coder/v2/coderd/parameter" "github.com/stretchr/testify/require" ) diff --git a/coderd/prometheusmetrics/aggregator.go b/coderd/prometheusmetrics/aggregator.go index 7704f5cfae7ec..b1091b2451405 100644 --- a/coderd/prometheusmetrics/aggregator.go +++ b/coderd/prometheusmetrics/aggregator.go @@ -10,7 +10,7 @@ import ( "cdr.dev/slog" - "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/v2/codersdk/agentsdk" ) const ( diff --git a/coderd/prometheusmetrics/aggregator_test.go b/coderd/prometheusmetrics/aggregator_test.go index a5e5b0f11eaef..45f0de14851c3 100644 --- a/coderd/prometheusmetrics/aggregator_test.go +++ b/coderd/prometheusmetrics/aggregator_test.go @@ -12,10 +12,10 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/prometheusmetrics" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/cryptorand" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/prometheusmetrics" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/testutil" ) const ( diff --git a/coderd/prometheusmetrics/collector_test.go b/coderd/prometheusmetrics/collector_test.go index 9d63f6669113d..651be04477c7c 100644 --- a/coderd/prometheusmetrics/collector_test.go +++ b/coderd/prometheusmetrics/collector_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/prometheusmetrics" + "github.com/coder/coder/v2/coderd/prometheusmetrics" ) func TestCollector_Add(t *testing.T) { @@ -115,11 +115,11 @@ func TestCollector_Set_Add(t *testing.T) { assert.Equal(t, 6, int(metrics[1].Gauge.GetValue())) // Metric value } -func collectAndSortMetrics(t *testing.T, collector prometheus.Collector, count int) []dto.Metric { +func collectAndSortMetrics(t *testing.T, collector prometheus.Collector, count int) []*dto.Metric { ch := make(chan prometheus.Metric, count) defer close(ch) - var metrics []dto.Metric + var metrics []*dto.Metric collector.Collect(ch) for i := 0; i < count; i++ { @@ -129,7 +129,7 @@ func collectAndSortMetrics(t *testing.T, collector prometheus.Collector, count i err := m.Write(&metric) require.NoError(t, err) - metrics = append(metrics, metric) + metrics = append(metrics, &metric) } // Ensure always the same order of metrics diff --git a/coderd/prometheusmetrics/prometheusmetrics.go b/coderd/prometheusmetrics/prometheusmetrics.go index c1f749622accc..3db7985c4818e 100644 --- a/coderd/prometheusmetrics/prometheusmetrics.go +++ b/coderd/prometheusmetrics/prometheusmetrics.go @@ -15,10 +15,10 @@ import ( "tailscale.com/tailcfg" "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/db2sdk" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/tailnet" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/tailnet" ) const ( diff --git a/coderd/prometheusmetrics/prometheusmetrics_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index 3ea774df1186d..bf6f475ad1be6 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -11,6 +11,9 @@ import ( "testing" "time" + "github.com/coder/coder/v2/coderd/batchstats" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" @@ -20,18 +23,18 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/prometheusmetrics" - "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/tailnet" - "github.com/coder/coder/tailnet/tailnettest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/prometheusmetrics" + "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/provisionersdk/proto" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/tailnettest" + "github.com/coder/coder/v2/testutil" ) func TestActiveUsers(t *testing.T) { @@ -265,10 +268,10 @@ func TestAgents(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) 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: "example", Type: "aws_instance", @@ -372,9 +375,29 @@ func TestAgents(t *testing.T) { func TestAgentStats(t *testing.T) { t.Parallel() + ctx, cancelFunc := context.WithCancel(context.Background()) + t.Cleanup(cancelFunc) + + db, pubsub := dbtestutil.NewDB(t) + log := slogtest.Make(t, nil) + + batcher, closeBatcher, err := batchstats.New(ctx, + batchstats.WithStore(db), + // We want our stats, and we want them NOW. + batchstats.WithBatchSize(1), + batchstats.WithInterval(time.Hour), + batchstats.WithLogger(log), + ) + require.NoError(t, err, "create stats batcher failed") + t.Cleanup(closeBatcher) + // Build sample workspaces with test agents and fake agent client - client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - db := api.Database + client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: db, + IncludeProvisionerDaemon: true, + Pubsub: pubsub, + StatsBatcher: batcher, + }) user := coderdtest.CreateFirstUser(t, client) @@ -384,11 +407,7 @@ func TestAgentStats(t *testing.T) { registry := prometheus.NewRegistry() - ctx, cancelFunc := context.WithCancel(context.Background()) - defer cancelFunc() - // given - var err error var i int64 for i = 0; i < 3; i++ { _, err = agent1.PostStats(ctx, &agentsdk.Stats{ @@ -475,7 +494,7 @@ func prepareWorkspaceAndAgent(t *testing.T, client *codersdk.Client, user coders version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index ad0d5b6b4c86f..695892c86c9cc 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -26,21 +26,21 @@ import ( protobuf "google.golang.org/protobuf/proto" "cdr.dev/slog" - "github.com/coder/coder/coderd/apikey" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/database/pubsub" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner" - "github.com/coder/coder/provisionerd/proto" - "github.com/coder/coder/provisionersdk" - sdkproto "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/v2/coderd/apikey" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/coderd/httpmw" + "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/codersdk" + "github.com/coder/coder/v2/provisioner" + "github.com/coder/coder/v2/provisionerd/proto" + "github.com/coder/coder/v2/provisionersdk" + sdkproto "github.com/coder/coder/v2/provisionersdk/proto" ) var ( @@ -280,7 +280,7 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac RichParameterValues: convertRichParameterValues(workspaceBuildParameters), VariableValues: asVariableValues(templateVariables), GitAuthProviders: gitAuthProviders, - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: server.AccessURL.String(), WorkspaceTransition: transition, WorkspaceName: workspace.Name, @@ -316,7 +316,7 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac TemplateDryRun: &proto.AcquiredJob_TemplateDryRun{ RichParameterValues: convertRichParameterValues(input.RichParameterValues), VariableValues: asVariableValues(templateVariables), - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: server.AccessURL.String(), WorkspaceName: input.WorkspaceName, }, @@ -337,7 +337,7 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac protoJob.Type = &proto.AcquiredJob_TemplateImport_{ TemplateImport: &proto.AcquiredJob_TemplateImport{ UserVariableValues: convertVariableValues(userVariableValues), - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: server.AccessURL.String(), }, }, @@ -744,8 +744,6 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p } // CompleteJob is triggered by a provision daemon to mark a provisioner job as completed. -// -//nolint:gocyclo func (server *Server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) (*proto.Empty, error) { ctx, span := server.startTrace(ctx, tracing.FuncName()) defer span.End() diff --git a/coderd/provisionerdserver/provisionerdserver_internal_test.go b/coderd/provisionerdserver/provisionerdserver_internal_test.go index 9652f8e7c7f82..bd232d3d16d85 100644 --- a/coderd/provisionerdserver/provisionerdserver_internal_test.go +++ b/coderd/provisionerdserver/provisionerdserver_internal_test.go @@ -9,10 +9,10 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/oauth2" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/testutil" ) func TestObtainOIDCAccessToken(t *testing.T) { diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index ee0faca6d2e84..5a317cd531530 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -17,21 +17,21 @@ import ( "golang.org/x/oauth2" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/database/pubsub" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/provisionerdserver" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisionerd/proto" - "github.com/coder/coder/provisionersdk" - sdkproto "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/coderd/provisionerdserver" + "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/codersdk" + "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/testutil" ) func mockAuditor() *atomic.Pointer[audit.Auditor] { @@ -267,7 +267,7 @@ func TestAcquireJob(t *testing.T) { Id: gitAuthProvider, AccessToken: "access_token", }}, - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: srv.AccessURL.String(), WorkspaceTransition: sdkproto.WorkspaceTransition_START, WorkspaceName: workspace.Name, @@ -359,7 +359,7 @@ func TestAcquireJob(t *testing.T) { want, err := json.Marshal(&proto.AcquiredJob_TemplateDryRun_{ TemplateDryRun: &proto.AcquiredJob_TemplateDryRun{ - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: srv.AccessURL.String(), WorkspaceName: "testing", }, @@ -391,7 +391,7 @@ func TestAcquireJob(t *testing.T) { want, err := json.Marshal(&proto.AcquiredJob_TemplateImport_{ TemplateImport: &proto.AcquiredJob_TemplateImport{ - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: srv.AccessURL.String(), }, }, @@ -434,7 +434,7 @@ func TestAcquireJob(t *testing.T) { UserVariableValues: []*sdkproto.VariableValue{ {Name: "first", Sensitive: true, Value: "first_value"}, }, - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: srv.AccessURL.String(), }, }, diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 1be1a56518d28..4b49c385c80f4 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -16,13 +16,13 @@ import ( "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/db2sdk" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/database/pubsub" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisionersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionersdk" ) // Returns provisioner logs based on query parameters. @@ -402,7 +402,7 @@ func (f *logFollower) follow() { if f.ctx.Err() == nil && !xerrors.Is(err, io.EOF) { // neither context expiry, nor EOF, close and log f.logger.Error(f.ctx, "failed to query logs", slog.Error(err)) - err = f.conn.Close(websocket.StatusInternalError, err.Error()) + err = f.conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("%s", err.Error())) if err != nil { f.logger.Warn(f.ctx, "failed to close webscoket", slog.Error(err)) } diff --git a/coderd/provisionerjobs_internal_test.go b/coderd/provisionerjobs_internal_test.go index fd05eb3e62219..31ae0fb608ac5 100644 --- a/coderd/provisionerjobs_internal_test.go +++ b/coderd/provisionerjobs_internal_test.go @@ -18,12 +18,12 @@ import ( "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbmock" - "github.com/coder/coder/coderd/database/pubsub" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisionersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionersdk" + "github.com/coder/coder/v2/testutil" ) func TestConvertProvisionerJob_Unit(t *testing.T) { diff --git a/coderd/provisionerjobs_test.go b/coderd/provisionerjobs_test.go index 505031d50c949..5d1715a9fe52d 100644 --- a/coderd/provisionerjobs_test.go +++ b/coderd/provisionerjobs_test.go @@ -6,10 +6,10 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" ) func TestProvisionerJobLogs(t *testing.T) { @@ -20,16 +20,16 @@ func TestProvisionerJobLogs(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_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "log-output", }, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, }) @@ -59,16 +59,16 @@ func TestProvisionerJobLogs(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_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "log-output", }, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, }) diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 51b71302525e7..42625b07c3e0a 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -18,10 +18,10 @@ import ( "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/rbac/regosql" - "github.com/coder/coder/coderd/rbac/regosql/sqltypes" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/coderd/util/slice" + "github.com/coder/coder/v2/coderd/rbac/regosql" + "github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/coderd/util/slice" ) // Action represents the allowed actions to be done on an object. diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 6211aafd44677..e264e31c73a8c 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -13,8 +13,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/rbac/regosql" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/rbac/regosql" + "github.com/coder/coder/v2/testutil" ) type fakeObject struct { diff --git a/coderd/rbac/authz_test.go b/coderd/rbac/authz_test.go index e13df4176abcd..05f402abe4507 100644 --- a/coderd/rbac/authz_test.go +++ b/coderd/rbac/authz_test.go @@ -9,8 +9,8 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" ) type benchmarkCase struct { diff --git a/coderd/rbac/error_test.go b/coderd/rbac/error_test.go index 23bbc7b3bc54c..cd9d319dabba8 100644 --- a/coderd/rbac/error_test.go +++ b/coderd/rbac/error_test.go @@ -3,7 +3,7 @@ package rbac_test import ( "testing" - "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac" "github.com/stretchr/testify/require" "golang.org/x/xerrors" diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 39f57c7fcc6da..1e3f1f45e59ea 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -37,10 +37,10 @@ var ( Type: "workspace_build", } - // ResourceWorkspaceLocked is returned if a workspace is locked. + // ResourceWorkspaceDormant is returned if a workspace is dormant. // It grants restricted permissions on workspace builds. - ResourceWorkspaceLocked = Object{ - Type: "workspace_locked", + ResourceWorkspaceDormant = Object{ + Type: "workspace_dormant", } // ResourceWorkspaceProxy CRUD. Org diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 10506b3f719c2..86a03d4552d45 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -26,8 +26,8 @@ func AllResources() []Object { ResourceWorkspace, ResourceWorkspaceApplicationConnect, ResourceWorkspaceBuild, + ResourceWorkspaceDormant, ResourceWorkspaceExecution, - ResourceWorkspaceLocked, ResourceWorkspaceProxy, } } diff --git a/coderd/rbac/object_test.go b/coderd/rbac/object_test.go index cbd043c753983..505f12b8cc7b0 100644 --- a/coderd/rbac/object_test.go +++ b/coderd/rbac/object_test.go @@ -3,8 +3,8 @@ package rbac_test import ( "testing" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/util/slice" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/slice" ) func TestObjectEqual(t *testing.T) { diff --git a/coderd/rbac/regosql/acl_group_var.go b/coderd/rbac/regosql/acl_group_var.go index d695683a72d61..328dfbcd48d0a 100644 --- a/coderd/rbac/regosql/acl_group_var.go +++ b/coderd/rbac/regosql/acl_group_var.go @@ -7,7 +7,7 @@ import ( "github.com/open-policy-agent/opa/ast" - "github.com/coder/coder/coderd/rbac/regosql/sqltypes" + "github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes" ) var ( diff --git a/coderd/rbac/regosql/compile.go b/coderd/rbac/regosql/compile.go index 398cbbf54c08b..69ef2a018f36c 100644 --- a/coderd/rbac/regosql/compile.go +++ b/coderd/rbac/regosql/compile.go @@ -8,7 +8,7 @@ import ( "github.com/open-policy-agent/opa/rego" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/rbac/regosql/sqltypes" + "github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes" ) // ConvertConfig is required to generate SQL from the rego queries. diff --git a/coderd/rbac/regosql/compile_test.go b/coderd/rbac/regosql/compile_test.go index 5673b8621c2c7..be0385bf83699 100644 --- a/coderd/rbac/regosql/compile_test.go +++ b/coderd/rbac/regosql/compile_test.go @@ -7,8 +7,8 @@ import ( "github.com/open-policy-agent/opa/rego" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/rbac/regosql" - "github.com/coder/coder/coderd/rbac/regosql/sqltypes" + "github.com/coder/coder/v2/coderd/rbac/regosql" + "github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes" ) // TestRegoQueriesNoVariables handles cases without variables. These should be diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index b683a11af3123..68d3b6264cb3b 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -1,6 +1,6 @@ package regosql -import "github.com/coder/coder/coderd/rbac/regosql/sqltypes" +import "github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes" func resourceIDMatcher() sqltypes.VariableMatcher { return sqltypes.StringVarMatcher("id :: text", []string{"input", "object", "id"}) diff --git a/coderd/rbac/regosql/sqltypes/equality_test.go b/coderd/rbac/regosql/sqltypes/equality_test.go index 8764508ad858a..17a3d7f45eed1 100644 --- a/coderd/rbac/regosql/sqltypes/equality_test.go +++ b/coderd/rbac/regosql/sqltypes/equality_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/rbac/regosql/sqltypes" + "github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes" ) func TestEquality(t *testing.T) { diff --git a/coderd/rbac/regosql/sqltypes/member_test.go b/coderd/rbac/regosql/sqltypes/member_test.go index 91259e286ee1c..0fedcc176c49f 100644 --- a/coderd/rbac/regosql/sqltypes/member_test.go +++ b/coderd/rbac/regosql/sqltypes/member_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/rbac/regosql/sqltypes" + "github.com/coder/coder/v2/coderd/rbac/regosql/sqltypes" ) func TestMembership(t *testing.T) { diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 93aeaca017592..4f159480a0491 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -121,7 +121,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { opts = &RoleOptions{} } - ownerAndAdminExceptions := []Object{ResourceWorkspaceLocked} + ownerAndAdminExceptions := []Object{ResourceWorkspaceDormant} if opts.NoOwnerWorkspaceExec { ownerAndAdminExceptions = append(ownerAndAdminExceptions, ResourceWorkspaceExecution, @@ -150,7 +150,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceProvisionerDaemon.Type: {ActionRead}, }), Org: map[string][]Permission{}, - User: append(allPermsExcept(ResourceWorkspaceLocked, ResourceUser, ResourceOrganizationMember), + User: append(allPermsExcept(ResourceWorkspaceDormant, ResourceUser, ResourceOrganizationMember), Permissions(map[string][]Action{ // Users cannot do create/update/delete on themselves, but they // can read their own details. @@ -246,7 +246,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: []Permission{}, Org: map[string][]Permission{ // Org admins should not have workspace exec perms. - organizationID: allPermsExcept(ResourceWorkspaceExecution, ResourceWorkspaceLocked), + organizationID: allPermsExcept(ResourceWorkspaceExecution, ResourceWorkspaceDormant), }, User: []Permission{}, } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 4ecb53e1832d2..fc47413fd19f2 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac" ) type authSubject struct { @@ -319,9 +319,9 @@ func TestRolePermissions(t *testing.T) { }, }, { - Name: "WorkspaceLocked", + Name: "WorkspaceDormant", Actions: rbac.AllActions(), - Resource: rbac.ResourceWorkspaceLocked.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID), + Resource: rbac.ResourceWorkspaceDormant.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID), AuthorizeMap: map[bool][]authSubject{ true: {}, false: {memberMe, orgAdmin, userAdmin, otherOrgAdmin, otherOrgMember, orgMemberMe, owner, templateAdmin}, diff --git a/coderd/rbac/subject_test.go b/coderd/rbac/subject_test.go index 294200aff8f8c..330ad7403797b 100644 --- a/coderd/rbac/subject_test.go +++ b/coderd/rbac/subject_test.go @@ -3,7 +3,7 @@ package rbac_test import ( "testing" - "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac" ) func TestSubjectEqual(t *testing.T) { diff --git a/coderd/roles.go b/coderd/roles.go index 177a5301eae00..bbee06d6927dd 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -3,11 +3,11 @@ package coderd import ( "net/http" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" ) // assignableSiteRoles returns all site wide roles that can be assigned. diff --git a/coderd/roles_test.go b/coderd/roles_test.go index 2b9eb35f34e15..275edc25bfcd2 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -7,10 +7,10 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "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/testutil" ) func TestListRoles(t *testing.T) { diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index 6934640045506..6fe3d5848db02 100644 --- a/coderd/schedule/autostop.go +++ b/coderd/schedule/autostop.go @@ -4,9 +4,12 @@ import ( "context" "time" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/tracing" ) const ( @@ -72,6 +75,13 @@ type AutostopTime struct { // Deadline is a cost saving measure, while max deadline is a // compliance/updating measure. func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (AutostopTime, error) { + ctx, span := tracing.StartSpan(ctx, + trace.WithAttributes(attribute.String("coder.workspace_id", params.Workspace.ID.String())), + trace.WithAttributes(attribute.String("coder.template_id", params.Workspace.TemplateID.String())), + ) + defer span.End() + defer span.End() + var ( db = params.Database workspace = params.Workspace diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index 6be5c5eaf81d4..8a93c819698b4 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -11,11 +11,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/database/dbtestutil" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/testutil" ) func TestCalculateAutoStop(t *testing.T) { diff --git a/coderd/schedule/cron_test.go b/coderd/schedule/cron_test.go index d09feb5578b20..b8ab1600ef2c5 100644 --- a/coderd/schedule/cron_test.go +++ b/coderd/schedule/cron_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/schedule" + "github.com/coder/coder/v2/coderd/schedule" ) func Test_Weekly(t *testing.T) { diff --git a/coderd/schedule/mock.go b/coderd/schedule/mock.go index 4a22197b57dc4..1fe33bb549e81 100644 --- a/coderd/schedule/mock.go +++ b/coderd/schedule/mock.go @@ -5,7 +5,7 @@ import ( "github.com/google/uuid" - "github.com/coder/coder/coderd/database" + "github.com/coder/coder/v2/coderd/database" ) type MockTemplateScheduleStore struct { diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index de97dffc9ac2a..9c1b2fa5aa787 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -7,7 +7,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/tracing" ) const MaxTemplateRestartRequirementWeeks = 16 @@ -98,12 +99,24 @@ type TemplateScheduleOptions struct { // FailureTTL dictates the duration after which failed workspaces will be // stopped automatically. FailureTTL time.Duration `json:"failure_ttl"` - // InactivityTTL dictates the duration after which inactive workspaces will - // be locked. - InactivityTTL time.Duration `json:"inactivity_ttl"` - // LockedTTL dictates the duration after which locked workspaces will be + // TimeTilDormant dictates the duration after which inactive workspaces will + // go dormant. + TimeTilDormant time.Duration `json:"time_til_dormant"` + // TimeTilDormantAutoDelete dictates the duration after which dormant workspaces will be // permanently deleted. - LockedTTL time.Duration `json:"locked_ttl"` + TimeTilDormantAutoDelete time.Duration `json:"time_til_dormant_autodelete"` + // UpdateWorkspaceLastUsedAt updates the template's workspaces' + // last_used_at field. This is useful for preventing updates to the + // templates inactivity_ttl immediately triggering a dormant action against + // workspaces whose last_used_at field violates the new template + // inactivity_ttl threshold. + UpdateWorkspaceLastUsedAt bool `json:"update_workspace_last_used_at"` + // UpdateWorkspaceDormantAt updates the template's workspaces' + // dormant_at field. This is useful for preventing updates to the + // templates locked_ttl immediately triggering a delete action against + // workspaces whose dormant_at field violates the new template time_til_dormant_autodelete + // threshold. + UpdateWorkspaceDormantAt bool `json:"update_workspace_dormant_at"` } // TemplateScheduleStore provides an interface for retrieving template @@ -122,6 +135,9 @@ func NewAGPLTemplateScheduleStore() TemplateScheduleStore { } func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + tpl, err := db.GetTemplateByID(ctx, templateID) if err != nil { return TemplateScheduleOptions{}, err @@ -134,20 +150,23 @@ func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, te UserAutostopEnabled: true, DefaultTTL: time.Duration(tpl.DefaultTTL), // Disregard the values in the database, since RestartRequirement, - // FailureTTL, InactivityTTL, and LockedTTL are enterprise features. + // FailureTTL, TimeTilDormant, and TimeTilDormantAutoDelete are enterprise features. UseRestartRequirement: false, MaxTTL: 0, RestartRequirement: TemplateRestartRequirement{ DaysOfWeek: 0, Weeks: 0, }, - FailureTTL: 0, - InactivityTTL: 0, - LockedTTL: 0, + FailureTTL: 0, + TimeTilDormant: 0, + TimeTilDormantAutoDelete: 0, }, nil } func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + if int64(opts.DefaultTTL) == tpl.DefaultTTL { // Avoid updating the UpdatedAt timestamp if nothing will be changed. return tpl, nil @@ -167,8 +186,8 @@ func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tp AllowUserAutostart: tpl.AllowUserAutostart, AllowUserAutostop: tpl.AllowUserAutostop, FailureTTL: tpl.FailureTTL, - InactivityTTL: tpl.InactivityTTL, - LockedTTL: tpl.LockedTTL, + TimeTilDormant: tpl.TimeTilDormant, + TimeTilDormantAutoDelete: tpl.TimeTilDormantAutoDelete, }) if err != nil { return xerrors.Errorf("update template schedule: %w", err) diff --git a/coderd/schedule/user.go b/coderd/schedule/user.go index 967a430fcccd2..18df0b40e7a37 100644 --- a/coderd/schedule/user.go +++ b/coderd/schedule/user.go @@ -5,7 +5,7 @@ import ( "github.com/google/uuid" - "github.com/coder/coder/coderd/database" + "github.com/coder/coder/v2/coderd/database" ) type UserQuietHoursScheduleOptions struct { diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 9b216d0180e15..efdde1bb1d2e7 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -10,10 +10,10 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" ) func AuditLogs(query string) (database.GetAuditLogsOffsetParams, []codersdk.ValidationError) { @@ -114,9 +114,17 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT filter.Name = parser.String(values, "", "name") filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus])) filter.HasAgent = parser.String(values, "", "has-agent") + filter.DormantAt = parser.Time(values, time.Time{}, "dormant_at", "2006-01-02") + filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after") + filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before") if _, ok := values["deleting_by"]; ok { postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02")) + // We want to make sure to grab dormant workspaces since they + // are omitted by default. + if filter.DormantAt.IsZero() { + filter.DormantAt = time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) + } } parser.ErrorExcessParams(values) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 4a7f61331a5f2..929a17b169643 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -9,11 +9,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/searchquery" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/searchquery" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" ) func TestSearchWorkspace(t *testing.T) { @@ -142,7 +142,7 @@ func TestSearchWorkspace(t *testing.T) { { Name: "ExtraKeys", Query: `foo:bar`, - ExpectedErrorContains: `Query param "foo" is not a valid query param`, + ExpectedErrorContains: `"foo" is not a valid query param`, }, } @@ -239,7 +239,7 @@ func TestSearchAudit(t *testing.T) { { Name: "ExtraKeys", Query: `foo:bar`, - ExpectedErrorContains: `Query param "foo" is not a valid query param`, + ExpectedErrorContains: `"foo" is not a valid query param`, }, { Name: "Dates", @@ -370,7 +370,7 @@ func TestSearchUsers(t *testing.T) { { Name: "ExtraKeys", Query: `foo:bar`, - ExpectedErrorContains: `Query param "foo" is not a valid query param`, + ExpectedErrorContains: `"foo" is not a valid query param`, }, } diff --git a/coderd/tailnet.go b/coderd/tailnet.go index c37f583da252e..ca2a86d27f71e 100644 --- a/coderd/tailnet.go +++ b/coderd/tailnet.go @@ -14,15 +14,17 @@ import ( "time" "github.com/google/uuid" + "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" "tailscale.com/derp" "tailscale.com/tailcfg" "cdr.dev/slog" - "github.com/coder/coder/coderd/wsconncache" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/site" - "github.com/coder/coder/tailnet" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/coderd/wsconncache" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/site" + "github.com/coder/coder/v2/tailnet" "github.com/coder/retry" ) @@ -42,31 +44,59 @@ func NewServerTailnet( ctx context.Context, logger slog.Logger, derpServer *derp.Server, - derpMap *tailcfg.DERPMap, + derpMapFn func() *tailcfg.DERPMap, + derpForceWebSockets bool, getMultiAgent func(context.Context) (tailnet.MultiAgentConn, error), cache *wsconncache.Cache, + traceProvider trace.TracerProvider, ) (*ServerTailnet, error) { logger = logger.Named("servertailnet") + originalDerpMap := derpMapFn() conn, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, - DERPMap: derpMap, - Logger: logger, + Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, + DERPMap: originalDerpMap, + DERPForceWebSockets: derpForceWebSockets, + Logger: logger, }) if err != nil { return nil, xerrors.Errorf("create tailnet conn: %w", err) } serverCtx, cancel := context.WithCancel(ctx) + derpMapUpdaterClosed := make(chan struct{}) + go func() { + defer close(derpMapUpdaterClosed) + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-serverCtx.Done(): + return + case <-ticker.C: + } + + newDerpMap := derpMapFn() + if !tailnet.CompareDERPMaps(originalDerpMap, newDerpMap) { + conn.SetDERPMap(newDerpMap) + originalDerpMap = newDerpMap + } + } + }() + tn := &ServerTailnet{ - ctx: serverCtx, - cancel: cancel, - logger: logger, - conn: conn, - getMultiAgent: getMultiAgent, - cache: cache, - agentNodes: map[uuid.UUID]time.Time{}, - agentTickets: map[uuid.UUID]map[uuid.UUID]struct{}{}, - transport: tailnetTransport.Clone(), + ctx: serverCtx, + cancel: cancel, + derpMapUpdaterClosed: derpMapUpdaterClosed, + logger: logger, + tracer: traceProvider.Tracer(tracing.TracerName), + conn: conn, + getMultiAgent: getMultiAgent, + cache: cache, + agentConnectionTimes: map[uuid.UUID]time.Time{}, + agentTickets: map[uuid.UUID]map[uuid.UUID]struct{}{}, + transport: tailnetTransport.Clone(), } tn.transport.DialContext = tn.dialContext tn.transport.MaxIdleConnsPerHost = 10 @@ -139,25 +169,50 @@ func (s *ServerTailnet) expireOldAgents() { case <-ticker.C: } - s.nodesMu.Lock() - agentConn := s.getAgentConn() - for agentID, lastConnection := range s.agentNodes { - // If no one has connected since the cutoff and there are no active - // connections, remove the agent. - if time.Since(lastConnection) > cutoff && len(s.agentTickets[agentID]) == 0 { - _ = agentConn - // err := agentConn.UnsubscribeAgent(agentID) - // if err != nil { - // s.logger.Error(s.ctx, "unsubscribe expired agent", slog.Error(err), slog.F("agent_id", agentID)) - // } - // delete(s.agentNodes, agentID) - - // TODO(coadler): actually remove from the netmap, then reenable - // the above + s.doExpireOldAgents(cutoff) + } +} + +func (s *ServerTailnet) doExpireOldAgents(cutoff time.Duration) { + // TODO: add some attrs to this. + ctx, span := s.tracer.Start(s.ctx, tracing.FuncName()) + defer span.End() + + start := time.Now() + deletedCount := 0 + + s.nodesMu.Lock() + s.logger.Debug(ctx, "pruning inactive agents", slog.F("agent_count", len(s.agentConnectionTimes))) + agentConn := s.getAgentConn() + for agentID, lastConnection := range s.agentConnectionTimes { + // If no one has connected since the cutoff and there are no active + // connections, remove the agent. + if time.Since(lastConnection) > cutoff && len(s.agentTickets[agentID]) == 0 { + deleted, err := s.conn.RemovePeer(tailnet.PeerSelector{ + ID: tailnet.NodeID(agentID), + IP: netip.PrefixFrom(tailnet.IPFromUUID(agentID), 128), + }) + if err != nil { + s.logger.Warn(ctx, "failed to remove peer from server tailnet", slog.Error(err)) + continue + } + if !deleted { + s.logger.Warn(ctx, "peer didn't exist in tailnet", slog.Error(err)) + } + + deletedCount++ + delete(s.agentConnectionTimes, agentID) + err = agentConn.UnsubscribeAgent(agentID) + if err != nil { + s.logger.Error(ctx, "unsubscribe expired agent", slog.Error(err), slog.F("agent_id", agentID)) } } - s.nodesMu.Unlock() } + s.nodesMu.Unlock() + s.logger.Debug(s.ctx, "successfully pruned inactive agents", + slog.F("deleted", deletedCount), + slog.F("took", time.Since(start)), + ) } func (s *ServerTailnet) watchAgentUpdates() { @@ -196,7 +251,7 @@ func (s *ServerTailnet) reinitCoordinator() { s.agentConn.Store(&agentConn) // Resubscribe to all of the agents we're tracking. - for agentID := range s.agentNodes { + for agentID := range s.agentConnectionTimes { err := agentConn.SubscribeAgent(agentID) if err != nil { s.logger.Warn(s.ctx, "resubscribe to agent", slog.Error(err), slog.F("agent_id", agentID)) @@ -208,18 +263,21 @@ func (s *ServerTailnet) reinitCoordinator() { } type ServerTailnet struct { - ctx context.Context - cancel func() + ctx context.Context + cancel func() + derpMapUpdaterClosed chan struct{} logger slog.Logger + tracer trace.Tracer conn *tailnet.Conn getMultiAgent func(context.Context) (tailnet.MultiAgentConn, error) agentConn atomic.Pointer[tailnet.MultiAgentConn] cache *wsconncache.Cache nodesMu sync.Mutex - // agentNodes is a map of agent tailnetNodes the server wants to keep a - // connection to. It contains the last time the agent was connected to. - agentNodes map[uuid.UUID]time.Time + // agentConnectionTimes is a map of agent tailnetNodes the server wants to + // keep a connection to. It contains the last time the agent was connected + // to. + agentConnectionTimes map[uuid.UUID]time.Time // agentTockets holds a map of all open connections to an agent. agentTickets map[uuid.UUID]map[uuid.UUID]struct{} @@ -268,7 +326,7 @@ func (s *ServerTailnet) ensureAgent(agentID uuid.UUID) error { s.nodesMu.Lock() defer s.nodesMu.Unlock() - _, ok := s.agentNodes[agentID] + _, ok := s.agentConnectionTimes[agentID] // If we don't have the node, subscribe. if !ok { s.logger.Debug(s.ctx, "subscribing to agent", slog.F("agent_id", agentID)) @@ -279,14 +337,27 @@ func (s *ServerTailnet) ensureAgent(agentID uuid.UUID) error { s.agentTickets[agentID] = map[uuid.UUID]struct{}{} } - s.agentNodes[agentID] = time.Now() + s.agentConnectionTimes[agentID] = time.Now() return nil } +func (s *ServerTailnet) acquireTicket(agentID uuid.UUID) (release func()) { + id := uuid.New() + s.nodesMu.Lock() + s.agentTickets[agentID][id] = struct{}{} + s.nodesMu.Unlock() + + return func() { + s.nodesMu.Lock() + delete(s.agentTickets[agentID], id) + s.nodesMu.Unlock() + } +} + func (s *ServerTailnet) AgentConn(ctx context.Context, agentID uuid.UUID) (*codersdk.WorkspaceAgentConn, func(), error) { var ( conn *codersdk.WorkspaceAgentConn - ret = func() {} + ret func() ) if s.getAgentConn().AgentIsLegacy(agentID) { @@ -299,12 +370,13 @@ func (s *ServerTailnet) AgentConn(ctx context.Context, agentID uuid.UUID) (*code conn = cconn.WorkspaceAgentConn ret = release } else { + s.logger.Debug(s.ctx, "acquiring agent", slog.F("agent_id", agentID)) err := s.ensureAgent(agentID) if err != nil { return nil, nil, xerrors.Errorf("ensure agent: %w", err) } + ret = s.acquireTicket(agentID) - s.logger.Debug(s.ctx, "acquiring agent", slog.F("agent_id", agentID)) conn = codersdk.NewWorkspaceAgentConn(s.conn, codersdk.WorkspaceAgentConnOptions{ AgentID: agentID, CloseFunc: func() error { return codersdk.ErrSkipClose }, @@ -317,7 +389,6 @@ func (s *ServerTailnet) AgentConn(ctx context.Context, agentID uuid.UUID) (*code reachable := conn.AwaitReachable(ctx) if !reachable { ret() - conn.Close() return nil, nil, xerrors.New("agent is unreachable") } @@ -336,13 +407,11 @@ func (s *ServerTailnet) DialAgentNetConn(ctx context.Context, agentID uuid.UUID, nc, err := conn.DialContext(ctx, network, addr) if err != nil { release() - conn.Close() return nil, xerrors.Errorf("dial context: %w", err) } return &netConnCloser{Conn: nc, close: func() { release() - conn.Close() }}, err } @@ -361,5 +430,6 @@ func (s *ServerTailnet) Close() error { _ = s.cache.Close() _ = s.conn.Close() s.transport.CloseIdleConnections() + <-s.derpMapUpdaterClosed return nil } diff --git a/coderd/tailnet_test.go b/coderd/tailnet_test.go index 05adbcc4fb597..2a0b0dfdbae70 100644 --- a/coderd/tailnet_test.go +++ b/coderd/tailnet_test.go @@ -14,18 +14,20 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" + "tailscale.com/tailcfg" "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "github.com/coder/coder/agent/agenttest" - "github.com/coder/coder/coderd" - "github.com/coder/coder/coderd/wsconncache" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "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/agenttest" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/wsconncache" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/tailnettest" + "github.com/coder/coder/v2/testutil" ) func TestServerTailnet_AgentConn_OK(t *testing.T) { @@ -229,9 +231,11 @@ func setupAgent(t *testing.T, agentAddresses []netip.Prefix) (uuid.UUID, agent.A context.Background(), logger, derpServer, - manifest.DERPMap, + func() *tailcfg.DERPMap { return manifest.DERPMap }, + false, func(context.Context) (tailnet.MultiAgentConn, error) { return coord.ServeMultiAgent(uuid.New()), nil }, cache, + trace.NewNoopTracerProvider(), ) require.NoError(t, err) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index ced52a58fd273..f190599aa3dd6 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -22,9 +22,8 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/coderd/database" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/database" ) const ( @@ -460,6 +459,17 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { } return nil }) + eg.Go(func() error { + proxies, err := r.options.Database.GetWorkspaceProxies(ctx) + if err != nil { + return xerrors.Errorf("get workspace proxies: %w", err) + } + snapshot.WorkspaceProxies = make([]WorkspaceProxy, 0, len(proxies)) + for _, proxy := range proxies { + snapshot.WorkspaceProxies = append(snapshot.WorkspaceProxies, ConvertWorkspaceProxy(proxy)) + } + return nil + }) err := eg.Wait() if err != nil { @@ -534,6 +544,11 @@ func ConvertProvisionerJob(job database.ProvisionerJob) ProvisionerJob { // ConvertWorkspaceAgent anonymizes a workspace agent. func ConvertWorkspaceAgent(agent database.WorkspaceAgent) WorkspaceAgent { + subsystems := []string{} + for _, subsystem := range agent.Subsystems { + subsystems = append(subsystems, string(subsystem)) + } + snapAgent := WorkspaceAgent{ ID: agent.ID, CreatedAt: agent.CreatedAt, @@ -546,7 +561,7 @@ func ConvertWorkspaceAgent(agent database.WorkspaceAgent) WorkspaceAgent { Directory: agent.Directory != "", ConnectionTimeoutSeconds: agent.ConnectionTimeoutSeconds, ShutdownScript: agent.ShutdownScript.Valid, - Subsystem: string(agent.Subsystem), + Subsystems: subsystems, } if agent.FirstConnectedAt.Valid { snapAgent.FirstConnectedAt = &agent.FirstConnectedAt.Time @@ -665,6 +680,19 @@ func ConvertLicense(license database.License) License { } } +// ConvertWorkspaceProxy anonymizes a workspace proxy. +func ConvertWorkspaceProxy(proxy database.WorkspaceProxy) WorkspaceProxy { + return WorkspaceProxy{ + ID: proxy.ID, + Name: proxy.Name, + DisplayName: proxy.DisplayName, + DerpEnabled: proxy.DerpEnabled, + DerpOnly: proxy.DerpOnly, + CreatedAt: proxy.CreatedAt, + UpdatedAt: proxy.UpdatedAt, + } +} + // Snapshot represents a point-in-time anonymized database dump. // Data is aggregated by latest on the server-side, so partial data // can be sent without issue. @@ -684,6 +712,7 @@ type Snapshot struct { WorkspaceBuilds []WorkspaceBuild `json:"workspace_build"` WorkspaceResources []WorkspaceResource `json:"workspace_resources"` WorkspaceResourceMetadata []WorkspaceResourceMetadata `json:"workspace_resource_metadata"` + WorkspaceProxies []WorkspaceProxy `json:"workspace_proxies"` CLIInvocations []CLIInvocation `json:"cli_invocations"` } @@ -768,7 +797,7 @@ type WorkspaceAgent struct { DisconnectedAt *time.Time `json:"disconnected_at"` ConnectionTimeoutSeconds int32 `json:"connection_timeout_seconds"` ShutdownScript bool `json:"shutdown_script"` - Subsystem string `json:"subsystem"` + Subsystems []string `json:"subsystems"` } type WorkspaceAgentStat struct { @@ -872,6 +901,18 @@ type CLIInvocation struct { InvokedAt time.Time `json:"invoked_at"` } +type WorkspaceProxy struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + // No URLs since we don't send deployment URL. + DerpEnabled bool `json:"derp_enabled"` + DerpOnly bool `json:"derp_only"` + // No Status since it may contain sensitive information. + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type noopReporter struct{} func (*noopReporter) Report(_ *Snapshot) {} diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 93e1a5295475b..670df9ab43ab0 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,12 +16,12 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/testutil" ) func TestMain(m *testing.M) { @@ -54,15 +54,16 @@ func TestTelemetry(t *testing.T) { SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, }) - wsagent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ - Subsystem: database.WorkspaceAgentSubsystemEnvbox, - }) + wsagent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{}) // Update the workspace agent to have a valid subsystem. err = db.UpdateWorkspaceAgentStartupByID(ctx, database.UpdateWorkspaceAgentStartupByIDParams{ ID: wsagent.ID, Version: wsagent.Version, ExpandedDirectory: wsagent.ExpandedDirectory, - Subsystem: database.WorkspaceAgentSubsystemEnvbox, + Subsystems: []database.WorkspaceAgentSubsystem{ + database.WorkspaceAgentSubsystemEnvbox, + database.WorkspaceAgentSubsystemExectrace, + }, }) require.NoError(t, err) @@ -81,6 +82,8 @@ func TestTelemetry(t *testing.T) { UUID: uuid.New(), }) assert.NoError(t, err) + _, _ = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{}) + _, snapshot := collectSnapshot(t, db) require.Len(t, snapshot.ProvisionerJobs, 1) require.Len(t, snapshot.Licenses, 1) @@ -93,9 +96,12 @@ func TestTelemetry(t *testing.T) { require.Len(t, snapshot.WorkspaceBuilds, 1) require.Len(t, snapshot.WorkspaceResources, 1) require.Len(t, snapshot.WorkspaceAgentStats, 1) + require.Len(t, snapshot.WorkspaceProxies, 1) wsa := snapshot.WorkspaceAgents[0] - require.Equal(t, string(database.WorkspaceAgentSubsystemEnvbox), wsa.Subsystem) + require.Len(t, wsa.Subsystems, 2) + require.Equal(t, string(database.WorkspaceAgentSubsystemEnvbox), wsa.Subsystems[0]) + require.Equal(t, string(database.WorkspaceAgentSubsystemExectrace), wsa.Subsystems[1]) }) t.Run("HashedEmail", func(t *testing.T) { t.Parallel() diff --git a/coderd/templates.go b/coderd/templates.go index 95fa9032d28ff..7d6dc46d2bf90 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -12,17 +12,17 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/examples" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/examples" ) // Returns a single template. @@ -219,8 +219,8 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque restartRequirementDaysOfWeek []string restartRequirementWeeks int64 failureTTL time.Duration - inactivityTTL time.Duration - lockedTTL time.Duration + dormantTTL time.Duration + dormantAutoDeletionTTL time.Duration ) if createTemplate.DefaultTTLMillis != nil { defaultTTL = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond @@ -232,11 +232,11 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque if createTemplate.FailureTTLMillis != nil { failureTTL = time.Duration(*createTemplate.FailureTTLMillis) * time.Millisecond } - if createTemplate.InactivityTTLMillis != nil { - inactivityTTL = time.Duration(*createTemplate.InactivityTTLMillis) * time.Millisecond + if createTemplate.TimeTilDormantMillis != nil { + dormantTTL = time.Duration(*createTemplate.TimeTilDormantMillis) * time.Millisecond } - if createTemplate.LockedTTLMillis != nil { - lockedTTL = time.Duration(*createTemplate.LockedTTLMillis) * time.Millisecond + if createTemplate.TimeTilDormantAutoDeleteMillis != nil { + dormantAutoDeletionTTL = time.Duration(*createTemplate.TimeTilDormantAutoDeleteMillis) * time.Millisecond } var ( @@ -270,11 +270,11 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque if failureTTL < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."}) } - if inactivityTTL < 0 { - validErrs = append(validErrs, codersdk.ValidationError{Field: "inactivity_ttl_ms", Detail: "Must be a positive integer."}) + if dormantTTL < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_autodeletion_ms", Detail: "Must be a positive integer."}) } - if lockedTTL < 0 { - validErrs = append(validErrs, codersdk.ValidationError{Field: "locked_ttl_ms", Detail: "Must be a positive integer."}) + if dormantAutoDeletionTTL < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_autodeletion_ms", Detail: "Must be a positive integer."}) } if len(validErrs) > 0 { @@ -340,9 +340,9 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque DaysOfWeek: restartRequirementDaysOfWeekParsed, Weeks: restartRequirementWeeks, }, - FailureTTL: failureTTL, - InactivityTTL: inactivityTTL, - LockedTTL: lockedTTL, + FailureTTL: failureTTL, + TimeTilDormant: dormantTTL, + TimeTilDormantAutoDelete: dormantAutoDeletionTTL, }) if err != nil { return xerrors.Errorf("set template schedule options: %s", err) @@ -533,13 +533,13 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if req.FailureTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."}) } - if req.InactivityTTLMillis < 0 { + if req.TimeTilDormantMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "inactivity_ttl_ms", Detail: "Must be a positive integer."}) } - if req.InactivityTTLMillis < 0 { + if req.TimeTilDormantMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "inactivity_ttl_ms", Detail: "Must be a positive integer."}) } - if req.LockedTTLMillis < 0 { + if req.TimeTilDormantAutoDeleteMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "locked_ttl_ms", Detail: "Must be a positive integer."}) } @@ -565,8 +565,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { restartRequirementDaysOfWeekParsed == scheduleOpts.RestartRequirement.DaysOfWeek && req.RestartRequirement.Weeks == scheduleOpts.RestartRequirement.Weeks && req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() && - req.InactivityTTLMillis == time.Duration(template.InactivityTTL).Milliseconds() && - req.LockedTTLMillis == time.Duration(template.LockedTTL).Milliseconds() { + req.TimeTilDormantMillis == time.Duration(template.TimeTilDormant).Milliseconds() && + req.TimeTilDormantAutoDeleteMillis == time.Duration(template.TimeTilDormantAutoDelete).Milliseconds() { return nil } @@ -598,16 +598,16 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond failureTTL := time.Duration(req.FailureTTLMillis) * time.Millisecond - inactivityTTL := time.Duration(req.InactivityTTLMillis) * time.Millisecond - lockedTTL := time.Duration(req.LockedTTLMillis) * time.Millisecond + inactivityTTL := time.Duration(req.TimeTilDormantMillis) * time.Millisecond + timeTilDormantAutoDelete := time.Duration(req.TimeTilDormantAutoDeleteMillis) * time.Millisecond if defaultTTL != time.Duration(template.DefaultTTL) || maxTTL != time.Duration(template.MaxTTL) || restartRequirementDaysOfWeekParsed != scheduleOpts.RestartRequirement.DaysOfWeek || req.RestartRequirement.Weeks != scheduleOpts.RestartRequirement.Weeks || failureTTL != time.Duration(template.FailureTTL) || - inactivityTTL != time.Duration(template.InactivityTTL) || - lockedTTL != time.Duration(template.LockedTTL) || + inactivityTTL != time.Duration(template.TimeTilDormant) || + timeTilDormantAutoDelete != time.Duration(template.TimeTilDormantAutoDelete) || req.AllowUserAutostart != template.AllowUserAutostart || req.AllowUserAutostop != template.AllowUserAutostop { updated, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, updated, schedule.TemplateScheduleOptions{ @@ -622,9 +622,11 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { DaysOfWeek: restartRequirementDaysOfWeekParsed, Weeks: req.RestartRequirement.Weeks, }, - FailureTTL: failureTTL, - InactivityTTL: inactivityTTL, - LockedTTL: lockedTTL, + FailureTTL: failureTTL, + TimeTilDormant: inactivityTTL, + TimeTilDormantAutoDelete: timeTilDormantAutoDelete, + UpdateWorkspaceLastUsedAt: req.UpdateWorkspaceLastUsedAt, + UpdateWorkspaceDormantAt: req.UpdateWorkspaceDormantAt, }) if err != nil { return xerrors.Errorf("set template schedule options: %w", err) @@ -736,28 +738,28 @@ func (api *API) convertTemplate( buildTimeStats := api.metricsCache.TemplateBuildTimeStats(template.ID) return codersdk.Template{ - ID: template.ID, - CreatedAt: template.CreatedAt, - UpdatedAt: template.UpdatedAt, - OrganizationID: template.OrganizationID, - Name: template.Name, - DisplayName: template.DisplayName, - Provisioner: codersdk.ProvisionerType(template.Provisioner), - ActiveVersionID: template.ActiveVersionID, - ActiveUserCount: activeCount, - BuildTimeStats: buildTimeStats, - Description: template.Description, - Icon: template.Icon, - DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(), - MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(), - CreatedByID: template.CreatedBy, - CreatedByName: template.CreatedByUsername, - AllowUserAutostart: template.AllowUserAutostart, - AllowUserAutostop: template.AllowUserAutostop, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - FailureTTLMillis: time.Duration(template.FailureTTL).Milliseconds(), - InactivityTTLMillis: time.Duration(template.InactivityTTL).Milliseconds(), - LockedTTLMillis: time.Duration(template.LockedTTL).Milliseconds(), + ID: template.ID, + CreatedAt: template.CreatedAt, + UpdatedAt: template.UpdatedAt, + OrganizationID: template.OrganizationID, + Name: template.Name, + DisplayName: template.DisplayName, + Provisioner: codersdk.ProvisionerType(template.Provisioner), + ActiveVersionID: template.ActiveVersionID, + ActiveUserCount: activeCount, + BuildTimeStats: buildTimeStats, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(), + MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(), + CreatedByID: template.CreatedBy, + CreatedByName: template.CreatedByUsername, + AllowUserAutostart: template.AllowUserAutostart, + AllowUserAutostop: template.AllowUserAutostop, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + FailureTTLMillis: time.Duration(template.FailureTTL).Milliseconds(), + TimeTilDormantMillis: time.Duration(template.TimeTilDormant).Milliseconds(), + TimeTilDormantAutoDeleteMillis: time.Duration(template.TimeTilDormantAutoDelete).Milliseconds(), RestartRequirement: codersdk.TemplateRestartRequirement{ DaysOfWeek: codersdk.BitmapToWeekdays(uint8(template.RestartRequirementDaysOfWeek)), Weeks: template.RestartRequirementWeeks, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 2b7c41c9ad8f8..403370b5da670 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -12,16 +12,16 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/util/ptr" + "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/testutil" ) func TestTemplate(t *testing.T) { @@ -270,8 +270,8 @@ func TestPostTemplateByOrganization(t *testing.T) { RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), - InactivityTTL: int64(options.InactivityTTL), - LockedTTL: int64(options.LockedTTL), + TimeTilDormant: int64(options.TimeTilDormant), + TimeTilDormantAutoDelete: int64(options.TimeTilDormantAutoDelete), }) if !assert.NoError(t, err) { return database.Template{}, err @@ -320,8 +320,8 @@ func TestPostTemplateByOrganization(t *testing.T) { RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), - InactivityTTL: int64(options.InactivityTTL), - LockedTTL: int64(options.LockedTTL), + TimeTilDormant: int64(options.TimeTilDormant), + TimeTilDormantAutoDelete: int64(options.TimeTilDormantAutoDelete), }) if !assert.NoError(t, err) { return database.Template{}, err @@ -598,8 +598,8 @@ func TestPatchTemplateMeta(t *testing.T) { RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), - InactivityTTL: int64(options.InactivityTTL), - LockedTTL: int64(options.LockedTTL), + TimeTilDormant: int64(options.TimeTilDormant), + TimeTilDormantAutoDelete: int64(options.TimeTilDormantAutoDelete), }) if !assert.NoError(t, err) { return database.Template{}, err @@ -697,9 +697,9 @@ func TestPatchTemplateMeta(t *testing.T) { t.Parallel() const ( - failureTTL = 7 * 24 * time.Hour - inactivityTTL = 180 * 24 * time.Hour - lockedTTL = 360 * 24 * time.Hour + failureTTL = 7 * 24 * time.Hour + inactivityTTL = 180 * 24 * time.Hour + timeTilDormantAutoDelete = 360 * 24 * time.Hour ) t.Run("OK", func(t *testing.T) { @@ -711,12 +711,12 @@ func TestPatchTemplateMeta(t *testing.T) { SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { if atomic.AddInt64(&setCalled, 1) == 2 { require.Equal(t, failureTTL, options.FailureTTL) - require.Equal(t, inactivityTTL, options.InactivityTTL) - require.Equal(t, lockedTTL, options.LockedTTL) + require.Equal(t, inactivityTTL, options.TimeTilDormant) + require.Equal(t, timeTilDormantAutoDelete, options.TimeTilDormantAutoDelete) } template.FailureTTL = int64(options.FailureTTL) - template.InactivityTTL = int64(options.InactivityTTL) - template.LockedTTL = int64(options.LockedTTL) + template.TimeTilDormant = int64(options.TimeTilDormant) + template.TimeTilDormantAutoDelete = int64(options.TimeTilDormantAutoDelete) return template, nil }, }, @@ -725,31 +725,31 @@ func TestPatchTemplateMeta(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.FailureTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) - ctr.InactivityTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) - ctr.LockedTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + ctr.TimeTilDormantMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref(0 * time.Hour.Milliseconds()) }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, - DefaultTTLMillis: 0, - RestartRequirement: &template.RestartRequirement, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - FailureTTLMillis: failureTTL.Milliseconds(), - InactivityTTLMillis: inactivityTTL.Milliseconds(), - LockedTTLMillis: lockedTTL.Milliseconds(), + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: 0, + RestartRequirement: &template.RestartRequirement, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + FailureTTLMillis: failureTTL.Milliseconds(), + TimeTilDormantMillis: inactivityTTL.Milliseconds(), + TimeTilDormantAutoDeleteMillis: timeTilDormantAutoDelete.Milliseconds(), }) require.NoError(t, err) require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) require.Equal(t, failureTTL.Milliseconds(), got.FailureTTLMillis) - require.Equal(t, inactivityTTL.Milliseconds(), got.InactivityTTLMillis) - require.Equal(t, lockedTTL.Milliseconds(), got.LockedTTLMillis) + require.Equal(t, inactivityTTL.Milliseconds(), got.TimeTilDormantMillis) + require.Equal(t, timeTilDormantAutoDelete.Milliseconds(), got.TimeTilDormantAutoDeleteMillis) }) t.Run("IgnoredUnlicensed", func(t *testing.T) { @@ -760,29 +760,29 @@ func TestPatchTemplateMeta(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.FailureTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) - ctr.InactivityTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) - ctr.LockedTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + ctr.TimeTilDormantMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref(0 * time.Hour.Milliseconds()) }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, - DefaultTTLMillis: template.DefaultTTLMillis, - RestartRequirement: &template.RestartRequirement, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - FailureTTLMillis: failureTTL.Milliseconds(), - InactivityTTLMillis: inactivityTTL.Milliseconds(), - LockedTTLMillis: lockedTTL.Milliseconds(), + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: template.DefaultTTLMillis, + RestartRequirement: &template.RestartRequirement, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + FailureTTLMillis: failureTTL.Milliseconds(), + TimeTilDormantMillis: inactivityTTL.Milliseconds(), + TimeTilDormantAutoDeleteMillis: timeTilDormantAutoDelete.Milliseconds(), }) require.NoError(t, err) require.Zero(t, got.FailureTTLMillis) - require.Zero(t, got.InactivityTTLMillis) - require.Zero(t, got.LockedTTLMillis) + require.Zero(t, got.TimeTilDormantMillis) + require.Zero(t, got.TimeTilDormantAutoDeleteMillis) }) }) @@ -989,8 +989,8 @@ func TestPatchTemplateMeta(t *testing.T) { RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), - InactivityTTL: int64(options.InactivityTTL), - LockedTTL: int64(options.LockedTTL), + TimeTilDormant: int64(options.TimeTilDormant), + TimeTilDormantAutoDelete: int64(options.TimeTilDormantAutoDelete), }) if !assert.NoError(t, err) { return database.Template{}, err @@ -1058,8 +1058,8 @@ func TestPatchTemplateMeta(t *testing.T) { RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), - InactivityTTL: int64(options.InactivityTTL), - LockedTTL: int64(options.LockedTTL), + TimeTilDormant: int64(options.TimeTilDormant), + TimeTilDormantAutoDelete: int64(options.TimeTilDormantAutoDelete), }) if !assert.NoError(t, err) { return database.Template{}, err @@ -1203,7 +1203,7 @@ func TestTemplateMetrics(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 80ad85a3ecc12..551a807418c9e 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -18,18 +18,18 @@ import ( "cdr.dev/slog" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/parameter" - "github.com/coder/coder/coderd/provisionerdserver" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/examples" - sdkproto "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/parameter" + "github.com/coder/coder/v2/coderd/provisionerdserver" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/examples" + sdkproto "github.com/coder/coder/v2/provisionersdk/proto" ) // @Summary Get template version by ID diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 5826d31b06286..2ca934e2d4085 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -13,17 +13,17 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/provisionerdserver" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/examples" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/coderd/provisionerdserver" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/examples" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" ) func TestTemplateVersion(t *testing.T) { @@ -136,8 +136,8 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) data, err := echo.Tar(&echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: echo.PlanComplete, }) require.NoError(t, err) @@ -245,8 +245,8 @@ func TestPatchCancelTemplateVersion(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_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }}, @@ -284,8 +284,8 @@ func TestPatchCancelTemplateVersion(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_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }}, @@ -308,11 +308,13 @@ func TestPatchCancelTemplateVersion(t *testing.T) { require.Eventually(t, func() bool { var err error version, err = client.TemplateVersion(ctx, version.ID) + // job gets marked Failed when there is an Error; in practice we never get to Status = Canceled + // because provisioners report an Error when canceled. We check the Error string to ensure we don't mask + // other errors in this test. + t.Logf("got version %s | %s", version.Job.Error, version.Job.Status) return assert.NoError(t, err) && - // The job will never actually cancel successfully because it will never send a - // provision complete response. - assert.Empty(t, version.Job.Error) && - version.Job.Status == codersdk.ProvisionerJobCanceling + strings.HasSuffix(version.Job.Error, "canceled") && + version.Job.Status == codersdk.ProvisionerJobFailed }, testutil.WaitShort, testutil.IntervalFast) }) } @@ -346,9 +348,9 @@ func TestTemplateVersionsGitAuth(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.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{ GitAuthProviders: []string{"github"}, }, }, @@ -400,9 +402,9 @@ func TestTemplateVersionResources(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: "some", Type: "example", @@ -439,17 +441,17 @@ func TestTemplateVersionLogs(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "example", }, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "some", Type: "example", @@ -610,15 +612,15 @@ func TestTemplateVersionDryRun(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{resource}, }, }, @@ -677,8 +679,8 @@ func TestTemplateVersionDryRun(t *testing.T) { // This import job will never finish version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }}, @@ -705,15 +707,15 @@ func TestTemplateVersionDryRun(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }, }, @@ -776,15 +778,15 @@ func TestTemplateVersionDryRun(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }, }, @@ -1040,21 +1042,17 @@ func TestTemplateVersionVariables(t *testing.T) { createEchoResponses := func(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: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, } } @@ -1418,10 +1416,10 @@ func TestTemplateVersionParameters_Order(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &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: firstParameterName, @@ -1453,11 +1451,7 @@ func TestTemplateVersionParameters_Order(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, + ProvisionApply: echo.ApplyComplete, }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_all_templates.json.golden b/coderd/testdata/insights/multiple_users_and_workspaces_week_all_templates.json.golden new file mode 100644 index 0000000000000..664e2fed8f250 --- /dev/null +++ b/coderd/testdata/insights/multiple_users_and_workspaces_week_all_templates.json.golden @@ -0,0 +1,159 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "active_users": 3, + "apps_usage": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "Visual Studio Code", + "slug": "vscode", + "icon": "/icon/code.svg", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "JetBrains", + "slug": "jetbrains", + "icon": "/icon/intellij.svg", + "seconds": 120 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "Web Terminal", + "slug": "reconnecting-pty", + "icon": "/icon/terminal.svg", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "SSH", + "slug": "ssh", + "icon": "/icon/terminal.svg", + "seconds": 11520 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ], + "type": "app", + "display_name": "app1", + "slug": "app1", + "icon": "/icon1.png", + "seconds": 25380 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "app", + "display_name": "app3", + "slug": "app3", + "icon": "/icon2.png", + "seconds": 720 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "type": "app", + "display_name": "otherapp1", + "slug": "otherapp1", + "icon": "/icon1.png", + "seconds": 300 + } + ], + "parameters_usage": [] + }, + "interval_reports": [ + { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-16T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "interval": "day", + "active_users": 3 + }, + { + "start_time": "2023-08-16T00:00:00Z", + "end_time": "2023-08-17T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-17T00:00:00Z", + "end_time": "2023-08-18T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "interval": "day", + "active_users": 2 + }, + { + "start_time": "2023-08-18T00:00:00Z", + "end_time": "2023-08-19T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-19T00:00:00Z", + "end_time": "2023-08-20T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-20T00:00:00Z", + "end_time": "2023-08-21T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-21T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "interval": "day", + "active_users": 1 + } + ] +} diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_deployment_wide.json.golden b/coderd/testdata/insights/multiple_users_and_workspaces_week_deployment_wide.json.golden new file mode 100644 index 0000000000000..664e2fed8f250 --- /dev/null +++ b/coderd/testdata/insights/multiple_users_and_workspaces_week_deployment_wide.json.golden @@ -0,0 +1,159 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "active_users": 3, + "apps_usage": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "Visual Studio Code", + "slug": "vscode", + "icon": "/icon/code.svg", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "JetBrains", + "slug": "jetbrains", + "icon": "/icon/intellij.svg", + "seconds": 120 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "Web Terminal", + "slug": "reconnecting-pty", + "icon": "/icon/terminal.svg", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "SSH", + "slug": "ssh", + "icon": "/icon/terminal.svg", + "seconds": 11520 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ], + "type": "app", + "display_name": "app1", + "slug": "app1", + "icon": "/icon1.png", + "seconds": 25380 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "app", + "display_name": "app3", + "slug": "app3", + "icon": "/icon2.png", + "seconds": 720 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "type": "app", + "display_name": "otherapp1", + "slug": "otherapp1", + "icon": "/icon1.png", + "seconds": 300 + } + ], + "parameters_usage": [] + }, + "interval_reports": [ + { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-16T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "interval": "day", + "active_users": 3 + }, + { + "start_time": "2023-08-16T00:00:00Z", + "end_time": "2023-08-17T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-17T00:00:00Z", + "end_time": "2023-08-18T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "interval": "day", + "active_users": 2 + }, + { + "start_time": "2023-08-18T00:00:00Z", + "end_time": "2023-08-19T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-19T00:00:00Z", + "end_time": "2023-08-20T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-20T00:00:00Z", + "end_time": "2023-08-21T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-21T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "interval": "day", + "active_users": 1 + } + ] +} diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_first_template.json.golden b/coderd/testdata/insights/multiple_users_and_workspaces_week_first_template.json.golden new file mode 100644 index 0000000000000..d96469dc5c724 --- /dev/null +++ b/coderd/testdata/insights/multiple_users_and_workspaces_week_first_template.json.golden @@ -0,0 +1,132 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "active_users": 2, + "apps_usage": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "builtin", + "display_name": "Visual Studio Code", + "slug": "vscode", + "icon": "/icon/code.svg", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "builtin", + "display_name": "JetBrains", + "slug": "jetbrains", + "icon": "/icon/intellij.svg", + "seconds": 120 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "builtin", + "display_name": "Web Terminal", + "slug": "reconnecting-pty", + "icon": "/icon/terminal.svg", + "seconds": 0 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "builtin", + "display_name": "SSH", + "slug": "ssh", + "icon": "/icon/terminal.svg", + "seconds": 7920 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "app", + "display_name": "app1", + "slug": "app1", + "icon": "/icon1.png", + "seconds": 3780 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "app", + "display_name": "app3", + "slug": "app3", + "icon": "/icon2.png", + "seconds": 720 + } + ], + "parameters_usage": [] + }, + "interval_reports": [ + { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-16T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "interval": "day", + "active_users": 2 + }, + { + "start_time": "2023-08-16T00:00:00Z", + "end_time": "2023-08-17T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-17T00:00:00Z", + "end_time": "2023-08-18T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-18T00:00:00Z", + "end_time": "2023-08-19T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-19T00:00:00Z", + "end_time": "2023-08-20T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-20T00:00:00Z", + "end_time": "2023-08-21T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-21T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "interval": "day", + "active_users": 1 + } + ] +} diff --git "a/coderd/testdata/insights/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" "b/coderd/testdata/insights/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" new file mode 100644 index 0000000000000..8f447e4112dd0 --- /dev/null +++ "b/coderd/testdata/insights/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" @@ -0,0 +1,150 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00-03:00", + "end_time": "2023-08-22T00:00:00-03:00", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "active_users": 3, + "apps_usage": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "builtin", + "display_name": "Visual Studio Code", + "slug": "vscode", + "icon": "/icon/code.svg", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "builtin", + "display_name": "JetBrains", + "slug": "jetbrains", + "icon": "/icon/intellij.svg", + "seconds": 120 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "builtin", + "display_name": "Web Terminal", + "slug": "reconnecting-pty", + "icon": "/icon/terminal.svg", + "seconds": 0 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "builtin", + "display_name": "SSH", + "slug": "ssh", + "icon": "/icon/terminal.svg", + "seconds": 4320 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ], + "type": "app", + "display_name": "app1", + "slug": "app1", + "icon": "/icon1.png", + "seconds": 21720 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "type": "app", + "display_name": "app3", + "slug": "app3", + "icon": "/icon2.png", + "seconds": 4320 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "type": "app", + "display_name": "otherapp1", + "slug": "otherapp1", + "icon": "/icon1.png", + "seconds": 300 + } + ], + "parameters_usage": [] + }, + "interval_reports": [ + { + "start_time": "2023-08-15T00:00:00-03:00", + "end_time": "2023-08-16T00:00:00-03:00", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-16T00:00:00-03:00", + "end_time": "2023-08-17T00:00:00-03:00", + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "interval": "day", + "active_users": 2 + }, + { + "start_time": "2023-08-17T00:00:00-03:00", + "end_time": "2023-08-18T00:00:00-03:00", + "template_ids": [ + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ], + "interval": "day", + "active_users": 2 + }, + { + "start_time": "2023-08-18T00:00:00-03:00", + "end_time": "2023-08-19T00:00:00-03:00", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-19T00:00:00-03:00", + "end_time": "2023-08-20T00:00:00-03:00", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-20T00:00:00-03:00", + "end_time": "2023-08-21T00:00:00-03:00", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-21T00:00:00-03:00", + "end_time": "2023-08-22T00:00:00-03:00", + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "interval": "day", + "active_users": 1 + } + ] +} diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_second_template.json.golden b/coderd/testdata/insights/multiple_users_and_workspaces_week_second_template.json.golden new file mode 100644 index 0000000000000..b15cba10a8520 --- /dev/null +++ b/coderd/testdata/insights/multiple_users_and_workspaces_week_second_template.json.golden @@ -0,0 +1,118 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "active_users": 1, + "apps_usage": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "type": "builtin", + "display_name": "Visual Studio Code", + "slug": "vscode", + "icon": "/icon/code.svg", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "type": "builtin", + "display_name": "JetBrains", + "slug": "jetbrains", + "icon": "/icon/intellij.svg", + "seconds": 0 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "type": "builtin", + "display_name": "Web Terminal", + "slug": "reconnecting-pty", + "icon": "/icon/terminal.svg", + "seconds": 0 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "type": "builtin", + "display_name": "SSH", + "slug": "ssh", + "icon": "/icon/terminal.svg", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "type": "app", + "display_name": "app1", + "slug": "app1", + "icon": "/icon1.png", + "seconds": 21600 + } + ], + "parameters_usage": [] + }, + "interval_reports": [ + { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-16T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-16T00:00:00Z", + "end_time": "2023-08-17T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-17T00:00:00Z", + "end_time": "2023-08-18T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-18T00:00:00Z", + "end_time": "2023-08-19T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-19T00:00:00Z", + "end_time": "2023-08-20T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-20T00:00:00Z", + "end_time": "2023-08-21T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-21T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + } + ] +} diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_third_template.json.golden b/coderd/testdata/insights/multiple_users_and_workspaces_week_third_template.json.golden new file mode 100644 index 0000000000000..ea4002e09f152 --- /dev/null +++ b/coderd/testdata/insights/multiple_users_and_workspaces_week_third_template.json.golden @@ -0,0 +1,120 @@ +{ + "report": { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "active_users": 1, + "apps_usage": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "Visual Studio Code", + "slug": "vscode", + "icon": "/icon/code.svg", + "seconds": 0 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "JetBrains", + "slug": "jetbrains", + "icon": "/icon/intellij.svg", + "seconds": 0 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "Web Terminal", + "slug": "reconnecting-pty", + "icon": "/icon/terminal.svg", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "type": "builtin", + "display_name": "SSH", + "slug": "ssh", + "icon": "/icon/terminal.svg", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "type": "app", + "display_name": "otherapp1", + "slug": "otherapp1", + "icon": "/icon1.png", + "seconds": 300 + } + ], + "parameters_usage": [] + }, + "interval_reports": [ + { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-16T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-16T00:00:00Z", + "end_time": "2023-08-17T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-17T00:00:00Z", + "end_time": "2023-08-18T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-18T00:00:00Z", + "end_time": "2023-08-19T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "interval": "day", + "active_users": 1 + }, + { + "start_time": "2023-08-19T00:00:00Z", + "end_time": "2023-08-20T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-20T00:00:00Z", + "end_time": "2023-08-21T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + }, + { + "start_time": "2023-08-21T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [], + "interval": "day", + "active_users": 0 + } + ] +} diff --git a/coderd/testdata/insights/parameters_two_days_ago,_no_data.json.golden b/coderd/testdata/insights/parameters_two_days_ago,_no_data.json.golden new file mode 100644 index 0000000000000..e3875b2a34b38 --- /dev/null +++ b/coderd/testdata/insights/parameters_two_days_ago,_no_data.json.golden @@ -0,0 +1,44 @@ +{ + "report": { + "start_time": "0001-01-01T00:00:00Z", + "end_time": "0001-01-01T00:00:00Z", + "template_ids": [], + "active_users": 0, + "apps_usage": [ + { + "template_ids": [], + "type": "builtin", + "display_name": "Visual Studio Code", + "slug": "vscode", + "icon": "/icon/code.svg", + "seconds": 0 + }, + { + "template_ids": [], + "type": "builtin", + "display_name": "JetBrains", + "slug": "jetbrains", + "icon": "/icon/intellij.svg", + "seconds": 0 + }, + { + "template_ids": [], + "type": "builtin", + "display_name": "Web Terminal", + "slug": "reconnecting-pty", + "icon": "/icon/terminal.svg", + "seconds": 0 + }, + { + "template_ids": [], + "type": "builtin", + "display_name": "SSH", + "slug": "ssh", + "icon": "/icon/terminal.svg", + "seconds": 0 + } + ], + "parameters_usage": [] + }, + "interval_reports": [] +} diff --git a/coderd/testdata/insights/parameters_yesterday_and_today_deployment_wide.json.golden b/coderd/testdata/insights/parameters_yesterday_and_today_deployment_wide.json.golden new file mode 100644 index 0000000000000..fc7ccd8a50ec4 --- /dev/null +++ b/coderd/testdata/insights/parameters_yesterday_and_today_deployment_wide.json.golden @@ -0,0 +1,165 @@ +{ + "report": { + "start_time": "0001-01-01T00:00:00Z", + "end_time": "0001-01-01T00:00:00Z", + "template_ids": [], + "active_users": 0, + "apps_usage": [ + { + "template_ids": [], + "type": "builtin", + "display_name": "Visual Studio Code", + "slug": "vscode", + "icon": "/icon/code.svg", + "seconds": 0 + }, + { + "template_ids": [], + "type": "builtin", + "display_name": "JetBrains", + "slug": "jetbrains", + "icon": "/icon/intellij.svg", + "seconds": 0 + }, + { + "template_ids": [], + "type": "builtin", + "display_name": "Web Terminal", + "slug": "reconnecting-pty", + "icon": "/icon/terminal.svg", + "seconds": 0 + }, + { + "template_ids": [], + "type": "builtin", + "display_name": "SSH", + "slug": "ssh", + "icon": "/icon/terminal.svg", + "seconds": 0 + } + ], + "parameters_usage": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "display_name": "otherparam1", + "name": "otherparam1", + "type": "string", + "description": "This is another parameter", + "values": [ + { + "value": "", + "count": 1 + }, + { + "value": "xyz", + "count": 1 + } + ] + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ], + "display_name": "param1", + "name": "param1", + "type": "string", + "description": "This is first parameter", + "values": [ + { + "value": "", + "count": 1 + }, + { + "value": "ABC", + "count": 1 + }, + { + "value": "abc", + "count": 2 + } + ] + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ], + "display_name": "param2", + "name": "param2", + "type": "string", + "description": "This is second parameter", + "values": [ + { + "value": "", + "count": 1 + }, + { + "value": "123", + "count": 3 + } + ] + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002" + ], + "display_name": "param3", + "name": "param3", + "type": "string", + "description": "This is third parameter", + "values": [ + { + "value": "", + "count": 1 + }, + { + "value": "BBB", + "count": 2 + }, + { + "value": "bbb", + "count": 1 + } + ] + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000001" + ], + "display_name": "param4", + "name": "param4", + "type": "string", + "description": "This is fourth parameter", + "options": [ + { + "name": "option1", + "description": "", + "value": "option1", + "icon": "" + }, + { + "name": "option2", + "description": "", + "value": "option2", + "icon": "" + } + ], + "values": [ + { + "value": "option1", + "count": 2 + }, + { + "value": "option2", + "count": 1 + } + ] + } + ] + }, + "interval_reports": [] +} diff --git a/coderd/tracing/httpmw.go b/coderd/tracing/httpmw.go index 308fbd88a4c26..653a74386245b 100644 --- a/coderd/tracing/httpmw.go +++ b/coderd/tracing/httpmw.go @@ -13,7 +13,7 @@ import ( "go.opentelemetry.io/otel/semconv/v1.14.0/netconv" "go.opentelemetry.io/otel/trace" - "github.com/coder/coder/coderd/httpmw/patternmatcher" + "github.com/coder/coder/v2/coderd/httpmw/patternmatcher" ) // Middleware adds tracing to http routes. diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index 052544fdc7ed9..e866acd513ec3 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -13,8 +13,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/testutil" ) type fakeTracer struct { diff --git a/coderd/tracing/slog_test.go b/coderd/tracing/slog_test.go index 2db0013d20792..5dae380e07c42 100644 --- a/coderd/tracing/slog_test.go +++ b/coderd/tracing/slog_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/tracing" ) type stringer string diff --git a/coderd/tracing/status_writer.go b/coderd/tracing/status_writer.go index 9409c3adf5e69..e9337c20e022f 100644 --- a/coderd/tracing/status_writer.go +++ b/coderd/tracing/status_writer.go @@ -12,7 +12,7 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/buildinfo" + "github.com/coder/coder/v2/buildinfo" ) var ( diff --git a/coderd/tracing/status_writer_test.go b/coderd/tracing/status_writer_test.go index 4cc3e5507d600..ba19cd29a915c 100644 --- a/coderd/tracing/status_writer_test.go +++ b/coderd/tracing/status_writer_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/tracing" ) func TestStatusWriter(t *testing.T) { diff --git a/coderd/tracing/util_test.go b/coderd/tracing/util_test.go index 1835d5325c415..708218328f01f 100644 --- a/coderd/tracing/util_test.go +++ b/coderd/tracing/util_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/tracing" ) // t.Parallel affects the result of these tests. diff --git a/coderd/unhanger/detector.go b/coderd/unhanger/detector.go index a2dcb56689fb3..975b0a5f53d17 100644 --- a/coderd/unhanger/detector.go +++ b/coderd/unhanger/detector.go @@ -13,11 +13,11 @@ import ( "github.com/google/uuid" "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/db2sdk" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/database/pubsub" - "github.com/coder/coder/provisionersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/provisionersdk" ) const ( diff --git a/coderd/unhanger/detector_test.go b/coderd/unhanger/detector_test.go index 41d2dd26f2a90..45e52cafdcb55 100644 --- a/coderd/unhanger/detector_test.go +++ b/coderd/unhanger/detector_test.go @@ -14,12 +14,12 @@ import ( "go.uber.org/goleak" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/database/dbtestutil" - "github.com/coder/coder/coderd/unhanger" - "github.com/coder/coder/provisionersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/unhanger" + "github.com/coder/coder/v2/provisionersdk" + "github.com/coder/coder/v2/testutil" ) func TestMain(m *testing.M) { diff --git a/coderd/updatecheck.go b/coderd/updatecheck.go index 882a5e6f4b5a2..4e4b07683ecf1 100644 --- a/coderd/updatecheck.go +++ b/coderd/updatecheck.go @@ -8,9 +8,9 @@ import ( "golang.org/x/mod/semver" "golang.org/x/xerrors" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) // @Summary Update check diff --git a/coderd/updatecheck/updatecheck.go b/coderd/updatecheck/updatecheck.go index 5f180dd196de9..de14071a903b6 100644 --- a/coderd/updatecheck/updatecheck.go +++ b/coderd/updatecheck/updatecheck.go @@ -19,8 +19,8 @@ import ( "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" ) const ( diff --git a/coderd/updatecheck/updatecheck_test.go b/coderd/updatecheck/updatecheck_test.go index d414d8c72af10..103064eb7e6de 100644 --- a/coderd/updatecheck/updatecheck_test.go +++ b/coderd/updatecheck/updatecheck_test.go @@ -14,9 +14,9 @@ import ( "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/database/dbfake" - "github.com/coder/coder/coderd/updatecheck" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/updatecheck" + "github.com/coder/coder/v2/testutil" ) func TestChecker_Notify(t *testing.T) { diff --git a/coderd/updatecheck_test.go b/coderd/updatecheck_test.go index 24dd8eba59ab3..c81dc0821a152 100644 --- a/coderd/updatecheck_test.go +++ b/coderd/updatecheck_test.go @@ -10,11 +10,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/updatecheck" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/updatecheck" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func TestUpdateCheck_NewVersion(t *testing.T) { diff --git a/coderd/userauth.go b/coderd/userauth.go index 9b6ba7992bad5..80c40b7e4c5d8 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/mail" + "regexp" "sort" "strconv" "strings" @@ -22,17 +23,17 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/apikey" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/userpassword" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" - "github.com/coder/coder/site" + "github.com/coder/coder/v2/coderd/apikey" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/site" ) const ( @@ -183,7 +184,9 @@ func (api *API) postConvertLoginType(rw http.ResponseWriter, r *http.Request) { Expires: claims.ExpiresAt.Time, Secure: api.SecureAuthCookie, HttpOnly: true, - SameSite: http.SameSiteStrictMode, + // Must be SameSite to work on the redirected auth flow from the + // oauth provider. + SameSite: http.SameSiteLaxMode, }) httpapi.Write(ctx, rw, http.StatusCreated, codersdk.OAuthConversionResponse{ StateString: stateString, @@ -688,6 +691,13 @@ type OIDCConfig struct { // groups. If the group field is the empty string, then no group updates // will ever come from the OIDC provider. GroupField string + // CreateMissingGroups controls whether groups returned by the OIDC provider + // are automatically created in Coder if they are missing. + CreateMissingGroups bool + // GroupFilter is a regular expression that filters the groups returned by + // the OIDC provider. Any group not matched by this regex will be ignored. + // If the group filter is nil, then no group filtering will occur. + GroupFilter *regexp.Regexp // GroupMapping controls how groups returned by the OIDC provider get mapped // to groups within Coder. // map[oidcGroupName]coderGroupName @@ -1029,19 +1039,21 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { } params := (&oauthLoginParams{ - User: user, - Link: link, - State: state, - LinkedID: oidcLinkedID(idToken), - LoginType: database.LoginTypeOIDC, - AllowSignups: api.OIDCConfig.AllowSignups, - Email: email, - Username: username, - AvatarURL: picture, - UsingGroups: usingGroups, - UsingRoles: api.OIDCConfig.RoleSyncEnabled(), - Roles: roles, - Groups: groups, + User: user, + Link: link, + State: state, + LinkedID: oidcLinkedID(idToken), + LoginType: database.LoginTypeOIDC, + AllowSignups: api.OIDCConfig.AllowSignups, + Email: email, + Username: username, + AvatarURL: picture, + UsingGroups: usingGroups, + UsingRoles: api.OIDCConfig.RoleSyncEnabled(), + Roles: roles, + Groups: groups, + CreateMissingGroups: api.OIDCConfig.CreateMissingGroups, + GroupFilter: api.OIDCConfig.GroupFilter, }).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) { return audit.InitRequest[database.User](rw, params) }) @@ -1125,8 +1137,10 @@ type oauthLoginParams struct { AvatarURL string // Is UsingGroups is true, then the user will be assigned // to the Groups provided. - UsingGroups bool - Groups []string + UsingGroups bool + CreateMissingGroups bool + Groups []string + GroupFilter *regexp.Regexp // Is UsingRoles is true, then the user will be assigned // the roles provided. UsingRoles bool @@ -1342,8 +1356,18 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C // Ensure groups are correct. if params.UsingGroups { + filtered := params.Groups + if params.GroupFilter != nil { + filtered = make([]string, 0, len(params.Groups)) + for _, group := range params.Groups { + if params.GroupFilter.MatchString(group) { + filtered = append(filtered, group) + } + } + } + //nolint:gocritic - err := api.Options.SetUserGroups(dbauthz.AsSystemRestricted(ctx), tx, user.ID, params.Groups) + err := api.Options.SetUserGroups(dbauthz.AsSystemRestricted(ctx), logger, tx, user.ID, filtered, params.CreateMissingGroups) if err != nil { return xerrors.Errorf("set user groups: %w", err) } @@ -1362,7 +1386,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C } //nolint:gocritic - err := api.Options.SetUserSiteRoles(dbauthz.AsSystemRestricted(ctx), tx, user.ID, filtered) + err := api.Options.SetUserSiteRoles(dbauthz.AsSystemRestricted(ctx), logger, tx, user.ID, filtered) if err != nil { return httpError{ code: http.StatusBadRequest, @@ -1427,7 +1451,8 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C } var key database.APIKey - if oldKey, ok := httpmw.APIKeyOptional(r); ok && isConvertLoginType { + oldKey, _, ok := httpmw.APIKeyFromRequest(ctx, api.Database, nil, r) + if ok && oldKey != nil && isConvertLoginType { // If this is a convert login type, and it succeeds, then delete the old // session. Force the user to log back in. err := api.Database.DeleteAPIKeyByID(r.Context(), oldKey.ID) @@ -1447,7 +1472,9 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C Secure: api.SecureAuthCookie, HttpOnly: true, }) - key = oldKey + // This is intentional setting the key to the deleted old key, + // as the user needs to be forced to log back in. + key = *oldKey } else { //nolint:gocritic cookie, newKey, err := api.createAPIKey(dbauthz.AsSystemRestricted(ctx), apikey.CreateParams{ @@ -1648,7 +1675,7 @@ func clearOAuthConvertCookie() *http.Cookie { func wrongLoginTypeHTTPError(user database.LoginType, params database.LoginType) httpError { addedMsg := "" if user == database.LoginTypePassword { - addedMsg = " Try logging in with your password." + addedMsg = " You can convert your account to use this login type by visiting your account settings." } return httpError{ code: http.StatusForbidden, diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 6f49222ff8764..1f37a0721a1e7 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -4,32 +4,72 @@ import ( "context" "crypto" "fmt" - "io" "net/http" "net/http/cookiejar" + "net/url" "strings" "testing" "github.com/coreos/go-oidc/v3/oidc" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v4" "github.com/google/go-github/v43/github" "github.com/google/uuid" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/oauth2" "golang.org/x/xerrors" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/database/dbtestutil" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) +// This test specifically tests logging in with OIDC when an expired +// OIDC session token exists. +// The token refreshing should not happen since we are reauthenticating. +// nolint:bodyclose +func TestOIDCOauthLoginWithExisting(t *testing.T) { + t.Parallel() + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.IgnoreUserInfo = true + }) + + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + OIDCConfig: cfg, + }) + + const username = "alice" + claims := jwt.MapClaims{ + "email": "alice@coder.com", + "email_verified": true, + "preferred_username": username, + } + + helper := oidctest.NewLoginHelper(client, fake) + // Signup alice + userClient, _ := helper.Login(t, claims) + + // Expire the link. This will force the client to refresh the token. + helper.ExpireOauthToken(t, api.Database, userClient) + + // Instead of refreshing, just log in again. + helper.Login(t, claims) +} + func TestUserLogin(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { @@ -59,7 +99,7 @@ func TestUserLogin(t *testing.T) { require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) }) // Password auth should fail if the user is made without password login. - t.Run("LoginTypeNone", func(t *testing.T) { + t.Run("DisableLoginDeprecatedField", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) @@ -74,6 +114,22 @@ func TestUserLogin(t *testing.T) { }) require.Error(t, err) }) + + t.Run("LoginTypeNone", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, client, user.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + r.Password = "" + r.UserLoginType = codersdk.LoginTypeNone + }) + + _, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ + Email: anotherUser.Email, + Password: "SomeSecurePassword!", + }) + require.Error(t, err) + }) } func TestUserAuthMethods(t *testing.T) { @@ -558,7 +614,7 @@ func TestUserOIDC(t *testing.T) { "email": "kyle@kwc.io", }, AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, Username: "kyle", }, { Name: "EmailNotVerified", @@ -583,7 +639,7 @@ func TestUserOIDC(t *testing.T) { "email_verified": false, }, AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, Username: "kyle", IgnoreEmailVerified: true, }, { @@ -607,7 +663,7 @@ func TestUserOIDC(t *testing.T) { EmailDomain: []string{ "kwc.io", }, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "EmptyClaims", IDTokenClaims: jwt.MapClaims{}, @@ -628,7 +684,7 @@ func TestUserOIDC(t *testing.T) { }, Username: "kyle", AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "UsernameFromClaims", IDTokenClaims: jwt.MapClaims{ @@ -638,7 +694,7 @@ func TestUserOIDC(t *testing.T) { }, Username: "hotdog", AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { // Services like Okta return the email as the username: // https://developer.okta.com/docs/reference/api/oidc/#base-claims-always-present @@ -650,7 +706,7 @@ func TestUserOIDC(t *testing.T) { }, Username: "kyle", AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { // See: https://github.com/coder/coder/issues/4472 Name: "UsernameIsEmail", @@ -659,7 +715,7 @@ func TestUserOIDC(t *testing.T) { }, Username: "kyle", AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "WithPicture", IDTokenClaims: jwt.MapClaims{ @@ -671,7 +727,7 @@ func TestUserOIDC(t *testing.T) { Username: "kyle", AllowSignups: true, AvatarURL: "/example.png", - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "WithUserInfoClaims", IDTokenClaims: jwt.MapClaims{ @@ -685,7 +741,7 @@ func TestUserOIDC(t *testing.T) { Username: "potato", AllowSignups: true, AvatarURL: "/example.png", - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "GroupsDoesNothing", IDTokenClaims: jwt.MapClaims{ @@ -693,7 +749,7 @@ func TestUserOIDC(t *testing.T) { "groups": []string{"pingpong"}, }, AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "UserInfoOverridesIDTokenClaims", IDTokenClaims: jwt.MapClaims{ @@ -708,7 +764,7 @@ func TestUserOIDC(t *testing.T) { Username: "user", AllowSignups: true, IgnoreEmailVerified: false, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "InvalidUserInfo", IDTokenClaims: jwt.MapClaims{ @@ -735,36 +791,41 @@ func TestUserOIDC(t *testing.T) { Username: "user", IgnoreUserInfo: true, AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }} { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() - auditor := audit.NewMock() - conf := coderdtest.NewOIDCConfig(t, "") - - config := conf.OIDCConfig(t, tc.UserInfoClaims) - config.AllowSignups = tc.AllowSignups - config.EmailDomain = tc.EmailDomain - config.IgnoreEmailVerified = tc.IgnoreEmailVerified - config.IgnoreUserInfo = tc.IgnoreUserInfo + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + oidctest.WithStaticUserInfo(tc.UserInfoClaims), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = tc.AllowSignups + cfg.EmailDomain = tc.EmailDomain + cfg.IgnoreEmailVerified = tc.IgnoreEmailVerified + cfg.IgnoreUserInfo = tc.IgnoreUserInfo + }) + auditor := audit.NewMock() logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - client := coderdtest.New(t, &coderdtest.Options{ + owner := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, - OIDCConfig: config, + OIDCConfig: cfg, Logger: &logger, }) numLogs := len(auditor.AuditLogs()) - resp := oidcCallback(t, client, conf.EncodeClaims(t, tc.IDTokenClaims)) + client, resp := fake.AttemptLogin(t, owner, tc.IDTokenClaims) numLogs++ // add an audit log for login - assert.Equal(t, tc.StatusCode, resp.StatusCode) + require.Equal(t, tc.StatusCode, resp.StatusCode) ctx := testutil.Context(t, testutil.WaitLong) if tc.Username != "" { - client.SetSessionToken(authCookieValue(resp.Cookies())) user, err := client.User(ctx, "me") require.NoError(t, err) require.Equal(t, tc.Username, user.Username) @@ -775,7 +836,6 @@ func TestUserOIDC(t *testing.T) { } if tc.AvatarURL != "" { - client.SetSessionToken(authCookieValue(resp.Cookies())) user, err := client.User(ctx, "me") require.NoError(t, err) require.Equal(t, tc.AvatarURL, user.AvatarURL) @@ -788,26 +848,29 @@ func TestUserOIDC(t *testing.T) { t.Run("OIDCConvert", func(t *testing.T) { t.Parallel() - auditor := audit.NewMock() - conf := coderdtest.NewOIDCConfig(t, "") - config := conf.OIDCConfig(t, nil) - config.AllowSignups = true + auditor := audit.NewMock() + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) - cfg := coderdtest.DeploymentValues(t) client := coderdtest.New(t, &coderdtest.Options{ - Auditor: auditor, - OIDCConfig: config, - DeploymentValues: cfg, + Auditor: auditor, + OIDCConfig: cfg, }) - owner := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) user, userData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - code := conf.EncodeClaims(t, jwt.MapClaims{ + claims := jwt.MapClaims{ "email": userData.Email, - }) - + } var err error user.HTTPClient.Jar, err = cookiejar.New(nil) require.NoError(t, err) @@ -819,52 +882,58 @@ func TestUserOIDC(t *testing.T) { }) require.NoError(t, err) - resp := oidcCallbackWithState(t, user, code, convertResponse.StateString) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + fake.LoginWithClient(t, user, claims, func(r *http.Request) { + r.URL.RawQuery = url.Values{ + "oidc_merge_state": {convertResponse.StateString}, + }.Encode() + r.Header.Set(codersdk.SessionTokenHeader, user.SessionToken()) + cookies := user.HTTPClient.Jar.Cookies(r.URL) + for _, cookie := range cookies { + r.AddCookie(cookie) + } + }) }) t.Run("AlternateUsername", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() - conf := coderdtest.NewOIDCConfig(t, "") - - config := conf.OIDCConfig(t, nil) - config.AllowSignups = true + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) client := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, - OIDCConfig: config, + OIDCConfig: cfg, }) - numLogs := len(auditor.AuditLogs()) - code := conf.EncodeClaims(t, jwt.MapClaims{ + numLogs := len(auditor.AuditLogs()) + claims := jwt.MapClaims{ "email": "jon@coder.com", - }) - resp := oidcCallback(t, client, code) - numLogs++ // add an audit log for login + } - assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + userClient, _ := fake.Login(t, client, claims) + numLogs++ // add an audit log for login ctx := testutil.Context(t, testutil.WaitLong) - - client.SetSessionToken(authCookieValue(resp.Cookies())) - user, err := client.User(ctx, "me") + user, err := userClient.User(ctx, "me") require.NoError(t, err) require.Equal(t, "jon", user.Username) // Pass a different subject field so that we prompt creating a - // new user. - code = conf.EncodeClaims(t, jwt.MapClaims{ + // new user + userClient, _ = fake.Login(t, client, jwt.MapClaims{ "email": "jon@example2.com", "sub": "diff", }) - resp = oidcCallback(t, client, code) numLogs++ // add an audit log for login - assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - - client.SetSessionToken(authCookieValue(resp.Cookies())) - user, err = client.User(ctx, "me") + user, err = userClient.User(ctx, "me") require.NoError(t, err) require.True(t, strings.HasPrefix(user.Username, "jon-"), "username %q should have prefix %q", user.Username, "jon-") @@ -875,45 +944,62 @@ func TestUserOIDC(t *testing.T) { t.Run("Disabled", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) - resp := oidcCallback(t, client, "asdf") + oauthURL, err := client.URL.Parse("/api/v2/users/oidc/callback") + require.NoError(t, err) + + req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) + require.NoError(t, err) + resp, err := client.HTTPClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("NoIDToken", func(t *testing.T) { t.Parallel() + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) + client := coderdtest.New(t, &coderdtest.Options{ - OIDCConfig: &coderd.OIDCConfig{ - OAuth2Config: &testutil.OAuth2Config{}, - }, + OIDCConfig: cfg, }) - resp := oidcCallback(t, client, "asdf") + _, resp := fake.AttemptLogin(t, client, jwt.MapClaims{}) require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("BadVerify", func(t *testing.T) { t.Parallel() - verifier := oidc.NewVerifier("", &oidc.StaticKeySet{ + badVerifier := oidc.NewVerifier("", &oidc.StaticKeySet{ PublicKeys: []crypto.PublicKey{}, }, &oidc.Config{}) - provider := &oidc.Provider{} + badProvider := &oidc.Provider{} + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.Provider = badProvider + cfg.Verifier = badVerifier + }) client := coderdtest.New(t, &coderdtest.Options{ - OIDCConfig: &coderd.OIDCConfig{ - OAuth2Config: &testutil.OAuth2Config{ - Token: (&oauth2.Token{ - AccessToken: "token", - }).WithExtra(map[string]interface{}{ - "id_token": "invalid", - }), - }, - Provider: provider, - Verifier: verifier, - }, + OIDCConfig: cfg, }) - resp := oidcCallback(t, client, "asdf") - + _, resp := fake.AttemptLogin(t, client, jwt.MapClaims{}) require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) } @@ -1044,33 +1130,6 @@ func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { return res } -func oidcCallback(t *testing.T, client *codersdk.Client, code string) *http.Response { - return oidcCallbackWithState(t, client, code, "somestate") -} - -func oidcCallbackWithState(t *testing.T, client *codersdk.Client, code, state string) *http.Response { - t.Helper() - - client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - oauthURL, err := client.URL.Parse(fmt.Sprintf("/api/v2/users/oidc/callback?code=%s&state=%s", code, state)) - require.NoError(t, err) - req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) - require.NoError(t, err) - req.AddCookie(&http.Cookie{ - Name: codersdk.OAuth2StateCookie, - Value: state, - }) - res, err := client.HTTPClient.Do(req) - require.NoError(t, err) - defer res.Body.Close() - data, err := io.ReadAll(res.Body) - require.NoError(t, err) - t.Log(string(data)) - return res -} - func i64ptr(i int64) *int64 { return &i } diff --git a/coderd/userpassword/hashing_bench_test.go b/coderd/userpassword/hashing_bench_test.go index 109a1724cbf06..7b1d8cb7e9449 100644 --- a/coderd/userpassword/hashing_bench_test.go +++ b/coderd/userpassword/hashing_bench_test.go @@ -7,7 +7,7 @@ import ( "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/pbkdf2" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/cryptorand" ) var ( diff --git a/coderd/userpassword/userpassword_test.go b/coderd/userpassword/userpassword_test.go index 6976a94e1c0a6..1617748d5ada1 100644 --- a/coderd/userpassword/userpassword_test.go +++ b/coderd/userpassword/userpassword_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/userpassword" + "github.com/coder/coder/v2/coderd/userpassword" ) func TestUserPassword(t *testing.T) { diff --git a/coderd/users.go b/coderd/users.go index 017e20d408586..ef9c9cd5679f4 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -12,19 +12,19 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/db2sdk" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/gitsshkey" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/searchquery" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/coderd/userpassword" - "github.com/coder/coder/coderd/util/slice" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "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/rbac" + "github.com/coder/coder/v2/coderd/searchquery" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" ) // Returns whether the initial user has been created or not. @@ -287,11 +287,27 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } + if req.UserLoginType == "" && req.DisableLogin { + // Handle the deprecated field + req.UserLoginType = codersdk.LoginTypeNone + } + if req.UserLoginType == "" { + // Default to password auth + req.UserLoginType = codersdk.LoginTypePassword + } + + if req.UserLoginType != codersdk.LoginTypePassword && req.Password != "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Password cannot be set for non-password (%q) authentication.", req.UserLoginType), + }) + return + } + // If password auth is disabled, don't allow new users to be // created with a password! - if api.DeploymentValues.DisablePasswordAuth { + if api.DeploymentValues.DisablePasswordAuth && req.UserLoginType == codersdk.LoginTypePassword { httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "You cannot manually provision new users with password authentication disabled!", + Message: "Password based authentication is disabled! Unable to provision new users with password authentication.", }) return } @@ -353,17 +369,11 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { } } - if req.DisableLogin && req.Password != "" { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Cannot set password when disabling login.", - }) - return - } - var loginType database.LoginType - if req.DisableLogin { + switch req.UserLoginType { + case codersdk.LoginTypeNone: loginType = database.LoginTypeNone - } else { + case codersdk.LoginTypePassword: err = userpassword.Validate(req.Password) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -376,6 +386,14 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } loginType = database.LoginTypePassword + case codersdk.LoginTypeOIDC: + loginType = database.LoginTypeOIDC + case codersdk.LoginTypeGithub: + loginType = database.LoginTypeGithub + default: + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Unsupported login type %q for manually creating new users.", req.UserLoginType), + }) } user, _, err := api.CreateUser(ctx, api.Database, CreateUserRequest{ @@ -733,6 +751,13 @@ func (api *API) putUserPassword(rw http.ResponseWriter, r *http.Request) { return } + if user.LoginType != database.LoginTypePassword { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Users without password login type cannot change their password.", + }) + return + } + err := userpassword.Validate(params.Password) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -1070,7 +1095,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create _, err = tx.InsertAllUsersGroup(ctx, organization.ID) if err != nil { - return xerrors.Errorf("create %q group: %w", database.AllUsersGroup, err) + return xerrors.Errorf("create %q group: %w", database.EveryoneGroup, err) } } diff --git a/coderd/users_test.go b/coderd/users_test.go index eff3174ad83a2..60e6ddb82aecf 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -4,24 +4,29 @@ import ( "context" "fmt" "net/http" - "sort" "strings" "testing" "time" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" + + "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" "golang.org/x/sync/errgroup" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/coderd/audit" + "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/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func TestFirstUser(t *testing.T) { @@ -401,6 +406,7 @@ func TestPostLogout(t *testing.T) { }) } +// nolint:bodyclose func TestPostUsers(t *testing.T) { t.Parallel() t.Run("NoAuth", func(t *testing.T) { @@ -565,6 +571,65 @@ func TestPostUsers(t *testing.T) { } } }) + + t.Run("CreateNoneLoginType", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: first.OrganizationID, + Email: "another@user.org", + Username: "someone-else", + Password: "", + UserLoginType: codersdk.LoginTypeNone, + }) + require.NoError(t, err) + + found, err := client.User(ctx, user.ID.String()) + require.NoError(t, err) + require.Equal(t, found.LoginType, codersdk.LoginTypeNone) + }) + + t.Run("CreateOIDCLoginType", func(t *testing.T) { + t.Parallel() + email := "another@user.org" + fake := oidctest.NewFakeIDP(t, + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) + + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: cfg, + }) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: first.OrganizationID, + Email: email, + Username: "someone-else", + Password: "", + UserLoginType: codersdk.LoginTypeOIDC, + }) + require.NoError(t, err) + + // Try to log in with OIDC. + userClient, _ := fake.Login(t, client, jwt.MapClaims{ + "email": email, + }) + + found, err := userClient.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, found.LoginType, codersdk.LoginTypeOIDC) + }) } func TestUpdateUserProfile(t *testing.T) { @@ -1804,8 +1869,8 @@ func assertPagination(ctx context.Context, t *testing.T, client *codersdk.Client // sortUsers sorts by (created_at, id) func sortUsers(users []codersdk.User) { - sort.Slice(users, func(i, j int) bool { - return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username) + slices.SortFunc(users, func(a, b codersdk.User) int { + return slice.Ascending(strings.ToLower(a.Username), strings.ToLower(b.Username)) }) } diff --git a/coderd/util/ptr/ptr_test.go b/coderd/util/ptr/ptr_test.go index 2dee346c8f5e4..355b32fc5cd68 100644 --- a/coderd/util/ptr/ptr_test.go +++ b/coderd/util/ptr/ptr_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/util/ptr" ) func Test_Ref_Deref(t *testing.T) { diff --git a/coderd/util/slice/slice.go b/coderd/util/slice/slice.go index 9909fe2b72c21..c366b04f91d8d 100644 --- a/coderd/util/slice/slice.go +++ b/coderd/util/slice/slice.go @@ -1,5 +1,9 @@ package slice +import ( + "golang.org/x/exp/constraints" +) + // SameElements returns true if the 2 lists have the same elements in any // order. func SameElements[T comparable](a []T, b []T) bool { @@ -38,17 +42,19 @@ func Overlap[T comparable](a []T, b []T) bool { } // Unique returns a new slice with all duplicate elements removed. -// This is a slow function on large lists. -// TODO: Sort elements and implement a faster search algorithm if we -// really start to use this. func Unique[T comparable](a []T) []T { cpy := make([]T, 0, len(a)) + seen := make(map[T]struct{}, len(a)) + for _, v := range a { - v := v - if !Contains(cpy, v) { - cpy = append(cpy, v) + if _, ok := seen[v]; ok { + continue } + + seen[v] = struct{}{} + cpy = append(cpy, v) } + return cpy } @@ -67,3 +73,17 @@ func OverlapCompare[T any](a []T, b []T, equal func(a, b T) bool) bool { func New[T any](items ...T) []T { return items } + +func Ascending[T constraints.Ordered](a, b T) int { + if a < b { + return -1 + } else if a == b { + return 0 + } else { + return 1 + } +} + +func Descending[T constraints.Ordered](a, b T) int { + return -Ascending[T](a, b) +} diff --git a/coderd/util/slice/slice_test.go b/coderd/util/slice/slice_test.go index b21e0cc0b52a5..cf686f3de4a48 100644 --- a/coderd/util/slice/slice_test.go +++ b/coderd/util/slice/slice_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/util/slice" + "github.com/coder/coder/v2/coderd/util/slice" ) func TestSameElements(t *testing.T) { @@ -107,3 +107,19 @@ func assertSetContains[T comparable](t *testing.T, set []T, in []T, out []T) { require.False(t, slice.Contains(set, e), "expect element in set") } } + +func TestAscending(t *testing.T) { + t.Parallel() + + assert.Equal(t, -1, slice.Ascending(1, 2)) + assert.Equal(t, 0, slice.Ascending(1, 1)) + assert.Equal(t, 1, slice.Ascending(2, 1)) +} + +func TestDescending(t *testing.T) { + t.Parallel() + + assert.Equal(t, 1, slice.Descending(1, 2)) + assert.Equal(t, 0, slice.Descending(1, 1)) + assert.Equal(t, -1, slice.Descending(2, 1)) +} diff --git a/coderd/util/strings/strings_test.go b/coderd/util/strings/strings_test.go index a5db8ebbf8734..a107a7754fc7f 100644 --- a/coderd/util/strings/strings_test.go +++ b/coderd/util/strings/strings_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/util/strings" + "github.com/coder/coder/v2/coderd/util/strings" ) func TestJoinWithConjunction(t *testing.T) { diff --git a/coderd/util/syncmap/map.go b/coderd/util/syncmap/map.go new file mode 100644 index 0000000000000..d245973efa844 --- /dev/null +++ b/coderd/util/syncmap/map.go @@ -0,0 +1,77 @@ +package syncmap + +import "sync" + +// Map is a type safe sync.Map +type Map[K, V any] struct { + m sync.Map +} + +func New[K, V any]() *Map[K, V] { + return &Map[K, V]{ + m: sync.Map{}, + } +} + +func (m *Map[K, V]) Store(k K, v V) { + m.m.Store(k, v) +} + +//nolint:forcetypeassert +func (m *Map[K, V]) Load(key K) (value V, ok bool) { + v, ok := m.m.Load(key) + if !ok { + var empty V + return empty, false + } + return v.(V), ok +} + +func (m *Map[K, V]) Delete(key K) { + m.m.Delete(key) +} + +//nolint:forcetypeassert +func (m *Map[K, V]) LoadAndDelete(key K) (actual V, loaded bool) { + act, loaded := m.m.LoadAndDelete(key) + if !loaded { + var empty V + return empty, loaded + } + return act.(V), loaded +} + +//nolint:forcetypeassert +func (m *Map[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) { + act, loaded := m.m.LoadOrStore(key, value) + if !loaded { + var empty V + return empty, loaded + } + return act.(V), loaded +} + +func (m *Map[K, V]) CompareAndSwap(key K, old V, new V) bool { + return m.m.CompareAndSwap(key, old, new) +} + +func (m *Map[K, V]) CompareAndDelete(key K, old V) (deleted bool) { + return m.m.CompareAndDelete(key, old) +} + +//nolint:forcetypeassert +func (m *Map[K, V]) Swap(key K, value V) (previous any, loaded bool) { + previous, loaded = m.m.Swap(key, value) + if !loaded { + var empty V + return empty, loaded + } + return previous.(V), loaded +} + +//nolint:forcetypeassert +func (m *Map[K, V]) Range(f func(key K, value V) bool) { + m.m.Range(func(key, value interface{}) bool { + return f(key.(K), value.(V)) + }) +} diff --git a/coderd/util/tz/tz_test.go b/coderd/util/tz/tz_test.go index f70046837064f..a0e7971bd7492 100644 --- a/coderd/util/tz/tz_test.go +++ b/coderd/util/tz/tz_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/util/tz" + "github.com/coder/coder/v2/coderd/util/tz" ) //nolint:paralleltest // Environment variables diff --git a/coderd/util/xio/limitwriter_test.go b/coderd/util/xio/limitwriter_test.go index 52d6075fbb7f3..f14c873e96422 100644 --- a/coderd/util/xio/limitwriter_test.go +++ b/coderd/util/xio/limitwriter_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/util/xio" + "github.com/coder/coder/v2/coderd/util/xio" ) func TestLimitWriter(t *testing.T) { diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 8567ff1d895b3..7a3b25eee96fc 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -6,7 +6,6 @@ import ( "database/sql" "encoding/json" "errors" - "flag" "fmt" "io" "net" @@ -14,15 +13,16 @@ import ( "net/netip" "net/url" "runtime/pprof" + "sort" "strconv" "strings" "sync" "sync/atomic" "time" - "github.com/bep/debounce" "github.com/go-chi/chi/v5" "github.com/google/uuid" + "golang.org/x/exp/maps" "golang.org/x/exp/slices" "golang.org/x/mod/semver" "golang.org/x/sync/errgroup" @@ -31,16 +31,16 @@ import ( "tailscale.com/tailcfg" "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/gitauth" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/tailnet" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/gitauth" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/tailnet" ) // @Summary Get workspace agent by ID @@ -165,6 +165,7 @@ func (api *API) workspaceAgentManifest(rw http.ResponseWriter, r *http.Request) AgentID: apiAgent.ID, Apps: convertApps(dbApps), DERPMap: api.DERPMap(), + DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), GitAuthConfigs: len(api.GitAuthConfigs), EnvironmentVariables: apiAgent.EnvironmentVariables, StartupScript: apiAgent.StartupScript, @@ -209,7 +210,13 @@ func (api *API) postWorkspaceAgentStartup(rw http.ResponseWriter, r *http.Reques return } - api.Logger.Info(ctx, "post workspace agent version", slog.F("agent_id", apiAgent.ID), slog.F("agent_version", req.Version)) + api.Logger.Debug( + ctx, + "post workspace agent version", + slog.F("agent_id", apiAgent.ID), + slog.F("agent_version", req.Version), + slog.F("remote_addr", r.RemoteAddr), + ) if !semver.IsValid(req.Version) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -219,11 +226,31 @@ func (api *API) postWorkspaceAgentStartup(rw http.ResponseWriter, r *http.Reques return } + // Validate subsystems. + seen := make(map[codersdk.AgentSubsystem]bool) + for _, s := range req.Subsystems { + if !s.Valid() { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid workspace agent subsystem provided.", + Detail: fmt.Sprintf("invalid subsystem: %q", s), + }) + return + } + if seen[s] { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid workspace agent subsystem provided.", + Detail: fmt.Sprintf("duplicate subsystem: %q", s), + }) + return + } + seen[s] = true + } + if err := api.Database.UpdateWorkspaceAgentStartupByID(ctx, database.UpdateWorkspaceAgentStartupByIDParams{ ID: apiAgent.ID, Version: req.Version, ExpandedDirectory: req.ExpandedDirectory, - Subsystem: convertWorkspaceAgentSubsystem(req.Subsystem), + Subsystems: convertWorkspaceAgentSubsystems(req.Subsystems), }); err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Error setting agent version", @@ -455,6 +482,15 @@ func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { return } + workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace by agent id.", + Detail: err.Error(), + }) + return + } + api.WebsocketWaitMutex.Lock() api.WebsocketWaitGroup.Add(1) api.WebsocketWaitMutex.Unlock() @@ -530,7 +566,8 @@ func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { go func() { defer close(bufferedLogs) - for { + keepGoing := true + for keepGoing { select { case <-ctx.Done(): return @@ -539,6 +576,18 @@ func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { t.Reset(recheckInterval) } + agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + if xerrors.Is(err, context.Canceled) { + return + } + logger.Warn(ctx, "failed to get workspace agents in latest build", slog.Error(err)) + continue + } + // If the agent is no longer in the latest build, we can stop after + // checking once. + keepGoing = slices.ContainsFunc(agents, func(agent database.WorkspaceAgent) bool { return agent.ID == workspaceAgent.ID }) + logs, err := api.Database.GetWorkspaceAgentLogsAfter(ctx, database.GetWorkspaceAgentLogsAfterParams{ AgentID: workspaceAgent.ID, CreatedAfter: lastSentLogID, @@ -705,10 +754,11 @@ func (api *API) _dialWorkspaceAgentTailnet(agentID uuid.UUID) (*codersdk.Workspa derpMap := api.DERPMap() conn, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, - DERPMap: api.DERPMap(), - Logger: api.Logger.Named("net.tailnet"), - BlockEndpoints: api.DeploymentValues.DERP.Config.BlockDirect.Value(), + Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, + DERPMap: api.DERPMap(), + DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), + Logger: api.Logger.Named("net.tailnet"), + BlockEndpoints: api.DeploymentValues.DERP.Config.BlockDirect.Value(), }) if err != nil { _ = clientConn.Close() @@ -803,6 +853,7 @@ func (api *API) workspaceAgentConnection(rw http.ResponseWriter, r *http.Request httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentConnectionInfo{ DERPMap: api.DERPMap(), + DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), DisableDirectConnections: api.DeploymentValues.DERP.Config.BlockDirect.Value(), }) } @@ -823,6 +874,7 @@ func (api *API) workspaceAgentConnectionGeneric(rw http.ResponseWriter, r *http. httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentConnectionInfo{ DERPMap: api.DERPMap(), + DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), DisableDirectConnections: api.DeploymentValues.DERP.Config.BlockDirect.Value(), }) } @@ -849,13 +901,15 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) { }) return } - nconn := websocket.NetConn(ctx, ws, websocket.MessageBinary) + ctx, nconn := websocketNetConn(ctx, ws, websocket.MessageBinary) defer nconn.Close() // Slurp all packets from the connection into io.Discard so pongs get sent - // by the websocket package. + // by the websocket package. We don't do any reads ourselves so this is + // necessary. go func() { _, _ = io.Copy(io.Discard, nconn) + _ = nconn.Close() }() go func(ctx context.Context) { @@ -870,13 +924,11 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) { return } - // We don't need a context that times out here because the ping will - // eventually go through. If the context times out, then other - // websocket read operations will receive an error, obfuscating the - // actual problem. + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) err := ws.Ping(ctx) + cancel() if err != nil { - _ = ws.Close(websocket.StatusInternalError, err.Error()) + _ = nconn.Close() return } } @@ -891,7 +943,7 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) { if lastDERPMap == nil || !tailnet.CompareDERPMaps(lastDERPMap, derpMap) { err := json.NewEncoder(nconn).Encode(derpMap) if err != nil { - _ = ws.Close(websocket.StatusInternalError, err.Error()) + _ = nconn.Close() return } lastDERPMap = derpMap @@ -1277,6 +1329,11 @@ func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordin if dbAgent.TroubleshootingURL != "" { troubleshootingURL = dbAgent.TroubleshootingURL } + subsystems := make([]codersdk.AgentSubsystem, len(dbAgent.Subsystems)) + for i, subsystem := range dbAgent.Subsystems { + subsystems[i] = codersdk.AgentSubsystem(subsystem) + } + workspaceAgent := codersdk.WorkspaceAgent{ ID: dbAgent.ID, CreatedAt: dbAgent.CreatedAt, @@ -1302,7 +1359,7 @@ func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordin LoginBeforeReady: dbAgent.StartupScriptBehavior != database.StartupScriptBehaviorBlocking, ShutdownScript: dbAgent.ShutdownScript.String, ShutdownScriptTimeoutSeconds: dbAgent.ShutdownScriptTimeoutSeconds, - Subsystem: codersdk.AgentSubsystem(dbAgent.Subsystem), + Subsystems: subsystems, } node := coordinator.Node(dbAgent.ID) if node != nil { @@ -1410,36 +1467,12 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques activityBumpWorkspace(ctx, api.Logger.Named("activity_bump"), api.Database, workspace.ID) } - payload, err := json.Marshal(req.ConnectionsByProto) - if err != nil { - api.Logger.Error(ctx, "marshal agent connections by proto", slog.F("workspace_agent_id", workspaceAgent.ID), slog.Error(err)) - payload = json.RawMessage("{}") - } - now := database.Now() var errGroup errgroup.Group errGroup.Go(func() error { - _, err = api.Database.InsertWorkspaceAgentStat(ctx, database.InsertWorkspaceAgentStatParams{ - ID: uuid.New(), - CreatedAt: now, - AgentID: workspaceAgent.ID, - WorkspaceID: workspace.ID, - UserID: workspace.OwnerID, - TemplateID: workspace.TemplateID, - ConnectionsByProto: payload, - ConnectionCount: req.ConnectionCount, - RxPackets: req.RxPackets, - RxBytes: req.RxBytes, - TxPackets: req.TxPackets, - TxBytes: req.TxBytes, - SessionCountVSCode: req.SessionCountVSCode, - SessionCountJetBrains: req.SessionCountJetBrains, - SessionCountReconnectingPTY: req.SessionCountReconnectingPTY, - SessionCountSSH: req.SessionCountSSH, - ConnectionMedianLatencyMS: req.ConnectionMedianLatencyMS, - }) - if err != nil { + if err := api.statsBatcher.Add(time.Now(), workspaceAgent.ID, workspace.TemplateID, workspace.OwnerID, workspace.ID, req); err != nil { + api.Logger.Error(ctx, "failed to add stats to batcher", slog.Error(err)) return xerrors.Errorf("can't insert workspace agent stat: %w", err) } return nil @@ -1476,6 +1509,13 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques }) } +func ellipse(v string, n int) string { + if len(v) > n { + return v[:n] + "..." + } + return v +} + // @Summary Submit workspace agent metadata // @ID submit-workspace-agent-metadata // @Security CoderSessionToken @@ -1508,7 +1548,11 @@ func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Reque key := chi.URLParam(r, "key") const ( - maxValueLen = 32 << 10 + // maxValueLen is set to 2048 to stay under the 8000 byte Postgres + // NOTIFY limit. Since both value and error can be set, the real + // payload limit is 2 * 2048 * 4/3 = 5461 bytes + a few hundred bytes for JSON + // syntax, key names, and metadata. + maxValueLen = 2048 maxErrorLen = maxValueLen ) @@ -1548,9 +1592,16 @@ func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Reque slog.F("workspace_id", workspace.ID), slog.F("collected_at", datum.CollectedAt), slog.F("key", datum.Key), + slog.F("value", ellipse(datum.Value, 16)), ) - err = api.Pubsub.Publish(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), []byte(datum.Key)) + datumJSON, err := json.Marshal(datum) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + err = api.Pubsub.Publish(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), datumJSON) if err != nil { httpapi.InternalServerError(rw, err) return @@ -1571,9 +1622,47 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ var ( ctx = r.Context() workspaceAgent = httpmw.WorkspaceAgentParam(r) + log = api.Logger.Named("workspace_metadata_watcher").With( + slog.F("workspace_agent_id", workspaceAgent.ID), + ) + ) + + // We avoid channel-based synchronization here to avoid backpressure problems. + var ( + metadataMapMu sync.Mutex + metadataMap = make(map[string]database.WorkspaceAgentMetadatum) + // pendingChanges must only be mutated when metadataMapMu is held. + pendingChanges atomic.Bool ) - sendEvent, senderClosed, err := httpapi.ServerSentEventSender(rw, r) + // Send metadata on updates, we must ensure subscription before sending + // initial metadata to guarantee that events in-between are not missed. + cancelSub, err := api.Pubsub.Subscribe(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), func(_ context.Context, byt []byte) { + var update database.UpdateWorkspaceAgentMetadataParams + err := json.Unmarshal(byt, &update) + if err != nil { + api.Logger.Error(ctx, "failed to unmarshal pubsub message", slog.Error(err)) + return + } + + log.Debug(ctx, "received metadata update", "key", update.Key) + + metadataMapMu.Lock() + defer metadataMapMu.Unlock() + md := metadataMap[update.Key] + md.Value = update.Value + md.Error = update.Error + md.CollectedAt = update.CollectedAt + metadataMap[update.Key] = md + pendingChanges.Store(true) + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + defer cancelSub() + + sseSendEvent, sseSenderClosed, err := httpapi.ServerSentEventSender(rw, r) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error setting up server-sent events.", @@ -1583,87 +1672,61 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ } // Prevent handler from returning until the sender is closed. defer func() { - <-senderClosed + <-sseSenderClosed }() - const refreshInterval = time.Second * 5 - refreshTicker := time.NewTicker(refreshInterval) - defer refreshTicker.Stop() + // We send updates exactly every second. + const sendInterval = time.Second * 1 + sendTicker := time.NewTicker(sendInterval) + defer sendTicker.Stop() - var ( - lastDBMetaMu sync.Mutex - lastDBMeta []database.WorkspaceAgentMetadatum - ) + // We always use the original Request context because it contains + // the RBAC actor. + md, err := api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID) + if err != nil { + // If we can't successfully pull the initial metadata, pubsub + // updates will be no-op so we may as well terminate the + // connection early. + httpapi.InternalServerError(rw, err) + return + } - sendMetadata := func(pull bool) { - lastDBMetaMu.Lock() - defer lastDBMetaMu.Unlock() + metadataMapMu.Lock() + for _, datum := range md { + metadataMap[datum.Key] = datum + } + metadataMapMu.Unlock() - var err error - if pull { - // We always use the original Request context because it contains - // the RBAC actor. - lastDBMeta, err = api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID) - if err != nil { - _ = sendEvent(ctx, codersdk.ServerSentEvent{ - Type: codersdk.ServerSentEventTypeError, - Data: codersdk.Response{ - Message: "Internal error getting metadata.", - Detail: err.Error(), - }, - }) - return - } - slices.SortFunc(lastDBMeta, func(i, j database.WorkspaceAgentMetadatum) bool { - return i.Key < j.Key - }) + // Send initial metadata. - // Avoid sending refresh if the client is about to get a - // fresh update. - refreshTicker.Reset(refreshInterval) - } + var lastSend time.Time + sendMetadata := func() { + metadataMapMu.Lock() + values := maps.Values(metadataMap) + pendingChanges.Store(false) + metadataMapMu.Unlock() - _ = sendEvent(ctx, codersdk.ServerSentEvent{ + lastSend = time.Now() + _ = sseSendEvent(ctx, codersdk.ServerSentEvent{ Type: codersdk.ServerSentEventTypeData, - Data: convertWorkspaceAgentMetadata(lastDBMeta), + Data: convertWorkspaceAgentMetadata(values), }) } - // We debounce metadata updates to avoid overloading the frontend when - // an agent is sending a lot of updates. - pubsubDebounce := debounce.New(time.Second) - if flag.Lookup("test.v") != nil { - pubsubDebounce = debounce.New(time.Millisecond * 100) - } - - // Send metadata on updates, we must ensure subscription before sending - // initial metadata to guarantee that events in-between are not missed. - cancelSub, err := api.Pubsub.Subscribe(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), func(_ context.Context, _ []byte) { - pubsubDebounce(func() { - sendMetadata(true) - }) - }) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } - defer cancelSub() - - // Send initial metadata. - sendMetadata(true) + sendMetadata() for { select { - case <-senderClosed: + case <-sendTicker.C: + // We send an update even if there's no change every 5 seconds + // to ensure that the frontend always has an accurate "Result.Age". + if !pendingChanges.Load() && time.Since(lastSend) < time.Second*5 { + continue + } + sendMetadata() + case <-sseSenderClosed: return - case <-refreshTicker.C: } - - // Avoid spamming the DB with reads we know there are no updates. We want - // to continue sending updates to the frontend so that "Result.Age" - // is always accurate. This way, the frontend doesn't need to - // sync its own clock with the backend. - sendMetadata(false) } } @@ -1687,6 +1750,10 @@ func convertWorkspaceAgentMetadata(db []database.WorkspaceAgentMetadatum) []code }, }) } + // Sorting prevents the metadata from jumping around in the frontend. + sort.Slice(result, func(i, j int) bool { + return result[i].Description.Key < result[j].Description.Key + }) return result } @@ -2138,11 +2205,23 @@ func convertWorkspaceAgentLog(logEntry database.WorkspaceAgentLog) codersdk.Work } } -func convertWorkspaceAgentSubsystem(ss codersdk.AgentSubsystem) database.WorkspaceAgentSubsystem { - switch ss { - case codersdk.AgentSubsystemEnvbox: - return database.WorkspaceAgentSubsystemEnvbox - default: - return database.WorkspaceAgentSubsystemNone +func convertWorkspaceAgentSubsystems(ss []codersdk.AgentSubsystem) []database.WorkspaceAgentSubsystem { + out := make([]database.WorkspaceAgentSubsystem, 0, len(ss)) + for _, s := range ss { + switch s { + case codersdk.AgentSubsystemEnvbox: + out = append(out, database.WorkspaceAgentSubsystemEnvbox) + case codersdk.AgentSubsystemEnvbuilder: + out = append(out, database.WorkspaceAgentSubsystemEnvbuilder) + case codersdk.AgentSubsystemExectrace: + out = append(out, database.WorkspaceAgentSubsystemExectrace) + default: + // Invalid, drop it. + } } + + sort.Slice(out, func(i, j int) bool { + return out[i] < out[j] + }) + return out } diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 12cb19efac012..2e51687afafa6 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -20,15 +20,15 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "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/tailnet/tailnettest" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent" + "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/codersdk/agentsdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/tailnet/tailnettest" + "github.com/coder/coder/v2/testutil" ) func TestWorkspaceAgent(t *testing.T) { @@ -43,10 +43,10 @@ func TestWorkspaceAgent(t *testing.T) { tmpDir := t.TempDir() 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: "example", Type: "aws_instance", @@ -87,10 +87,10 @@ func TestWorkspaceAgent(t *testing.T) { tmpDir := t.TempDir() 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: "example", Type: "aws_instance", @@ -132,10 +132,10 @@ func TestWorkspaceAgent(t *testing.T) { 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: "example", Type: "aws_instance", @@ -188,10 +188,10 @@ func TestWorkspaceAgentStartupLogs(t *testing.T) { authToken := 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: "example", Type: "aws_instance", @@ -242,6 +242,91 @@ func TestWorkspaceAgentStartupLogs(t *testing.T) { require.Equal(t, "testing", logChunk[0].Output) require.Equal(t, "testing2", logChunk[1].Output) }) + t.Run("Close logs on outdated build", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + err := agentClient.PatchLogs(ctx, agentsdk.PatchLogs{ + Logs: []agentsdk.Log{ + { + CreatedAt: database.Now(), + Output: "testing", + }, + }, + }) + require.NoError(t, err) + + logs, closer, err := client.WorkspaceAgentLogsAfter(ctx, build.Resources[0].Agents[0].ID, 0, true) + require.NoError(t, err) + defer func() { + _ = closer.Close() + }() + + first := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + assert.Fail(t, "context done while waiting in goroutine") + case <-logs: + close(first) + } + }() + select { + case <-ctx.Done(): + require.FailNow(t, "context done while waiting for first log") + case <-first: + } + + _ = coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart) + + // Send a new log message to trigger a re-check. + err = agentClient.PatchLogs(ctx, agentsdk.PatchLogs{ + Logs: []agentsdk.Log{ + { + CreatedAt: database.Now(), + Output: "testing2", + }, + }, + }) + require.NoError(t, err) + + select { + case <-ctx.Done(): + require.FailNow(t, "context done while waiting for logs close") + case <-logs: + } + }) t.Run("PublishesOnOverflow", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) @@ -252,10 +337,10 @@ func TestWorkspaceAgentStartupLogs(t *testing.T) { authToken := 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: "example", Type: "aws_instance", @@ -319,7 +404,7 @@ func TestWorkspaceAgentListen(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -360,7 +445,7 @@ func TestWorkspaceAgentListen(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) @@ -371,10 +456,10 @@ func TestWorkspaceAgentListen(t *testing.T) { version = coderdtest.UpdateTemplateVersion(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: "example", Type: "aws_instance", @@ -406,7 +491,9 @@ func TestWorkspaceAgentListen(t *testing.T) { _, err = agentClient.Listen(ctx) require.Error(t, err) - require.ErrorContains(t, err, "build is outdated") + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) }) } @@ -417,7 +504,7 @@ func TestWorkspaceAgentTailnet(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -469,7 +556,7 @@ func TestWorkspaceAgentTailnetDirectDisabled(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -546,10 +633,10 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { authToken := 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: "example", Type: "aws_instance", @@ -804,9 +891,9 @@ func TestWorkspaceAgentAppHealth(t *testing.T) { } 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: "example", Type: "aws_instance", @@ -891,7 +978,7 @@ func TestWorkspaceAgentReportStats(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -940,7 +1027,7 @@ func TestWorkspaceAgent_LifecycleState(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1012,10 +1099,10 @@ func TestWorkspaceAgent_Metadata(t *testing.T) { authToken := 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: "example", Type: "aws_instance", @@ -1151,7 +1238,7 @@ func TestWorkspaceAgent_Metadata(t *testing.T) { require.Len(t, update, 3) check(wantMetadata1, update[0], true) - const maxValueLen = 32 << 10 + const maxValueLen = 2048 tooLongValueMetadata := wantMetadata1 tooLongValueMetadata.Value = strings.Repeat("a", maxValueLen*2) tooLongValueMetadata.Error = "" @@ -1182,7 +1269,7 @@ func TestWorkspaceAgent_Startup(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1195,16 +1282,23 @@ func TestWorkspaceAgent_Startup(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) - const ( - expectedVersion = "v1.2.3" - expectedDir = "/home/coder" - expectedSubsystem = codersdk.AgentSubsystemEnvbox + var ( + expectedVersion = "v1.2.3" + expectedDir = "/home/coder" + expectedSubsystems = []codersdk.AgentSubsystem{ + codersdk.AgentSubsystemEnvbox, + codersdk.AgentSubsystemExectrace, + } ) err := agentClient.PostStartup(ctx, agentsdk.PostStartupRequest{ Version: expectedVersion, ExpandedDirectory: expectedDir, - Subsystem: expectedSubsystem, + Subsystems: []codersdk.AgentSubsystem{ + // Not sorted. + expectedSubsystems[1], + expectedSubsystems[0], + }, }) require.NoError(t, err) @@ -1215,7 +1309,8 @@ func TestWorkspaceAgent_Startup(t *testing.T) { require.NoError(t, err) require.Equal(t, expectedVersion, wsagent.Version) require.Equal(t, expectedDir, wsagent.ExpandedDirectory) - require.Equal(t, expectedSubsystem, wsagent.Subsystem) + // Sorted + require.Equal(t, expectedSubsystems, wsagent.Subsystems) }) t.Run("InvalidSemver", func(t *testing.T) { @@ -1228,7 +1323,7 @@ func TestWorkspaceAgent_Startup(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1283,7 +1378,7 @@ func TestWorkspaceAgent_UpdatedDERP(t *testing.T) { 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) diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index df33889467b80..56567fb633ee4 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -11,14 +11,14 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/coderd/apikey" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/workspaceapps" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/apikey" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/workspaceapps" + "github.com/coder/coder/v2/codersdk" ) // @Summary Get applications host diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index f64ba7c30bf31..7e15f188e70bc 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -15,6 +15,7 @@ import ( "runtime" "strconv" "strings" + "sync" "testing" "time" @@ -23,11 +24,11 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/workspaceapps" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/workspaceapps" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) // Run runs the entire workspace app test suite against deployments minted @@ -51,23 +52,8 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { t.Skip("ConPTY appears to be inconsistent on Windows.") } - expectLine := func(t *testing.T, r *bufio.Reader, matcher func(string) bool) { - for { - line, err := r.ReadString('\n') - require.NoError(t, err) - if matcher(line) { - break - } - } - } - 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") - } - t.Run("OK", func(t *testing.T) { + t.Parallel() appDetails := setupProxyTest(t, nil) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -76,40 +62,13 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { // Run the test against the path app hostname since that's where the // reconnecting-pty proxy server we want to test is mounted. client := appDetails.AppClient(t) - conn, err := client.WorkspaceAgentReconnectingPTY(ctx, codersdk.WorkspaceAgentReconnectingPTYOpts{ + testReconnectingPTY(ctx, t, client, codersdk.WorkspaceAgentReconnectingPTYOpts{ AgentID: appDetails.Agent.ID, Reconnect: uuid.New(), - Height: 80, - Width: 80, - Command: "/bin/bash", + Height: 100, + Width: 100, + Command: "bash", }) - require.NoError(t, err) - defer conn.Close() - - // First attempt to resize the TTY. - // The websocket will close if it fails! - data, err := json.Marshal(codersdk.ReconnectingPTYRequest{ - Height: 250, - Width: 250, - }) - require.NoError(t, err) - _, err = conn.Write(data) - require.NoError(t, err) - bufRead := bufio.NewReader(conn) - - // Brief pause to reduce the likelihood that we send keystrokes while - // the shell is simultaneously sending a prompt. - time.Sleep(100 * time.Millisecond) - - data, err = json.Marshal(codersdk.ReconnectingPTYRequest{ - Data: "echo test\r\n", - }) - require.NoError(t, err) - _, err = conn.Write(data) - require.NoError(t, err) - - expectLine(t, bufRead, matchEchoCommand) - expectLine(t, bufRead, matchEchoOutput) }) t.Run("SignedTokenQueryParameter", func(t *testing.T) { @@ -137,41 +96,14 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { // Make an unauthenticated client. unauthedAppClient := codersdk.New(appDetails.AppClient(t).URL) - conn, err := unauthedAppClient.WorkspaceAgentReconnectingPTY(ctx, codersdk.WorkspaceAgentReconnectingPTYOpts{ + testReconnectingPTY(ctx, t, unauthedAppClient, codersdk.WorkspaceAgentReconnectingPTYOpts{ AgentID: appDetails.Agent.ID, Reconnect: uuid.New(), - Height: 80, - Width: 80, - Command: "/bin/bash", + Height: 100, + Width: 100, + Command: "bash", SignedToken: issueRes.SignedToken, }) - require.NoError(t, err) - defer conn.Close() - - // First attempt to resize the TTY. - // The websocket will close if it fails! - data, err := json.Marshal(codersdk.ReconnectingPTYRequest{ - Height: 250, - Width: 250, - }) - require.NoError(t, err) - _, err = conn.Write(data) - require.NoError(t, err) - bufRead := bufio.NewReader(conn) - - // Brief pause to reduce the likelihood that we send keystrokes while - // the shell is simultaneously sending a prompt. - time.Sleep(100 * time.Millisecond) - - data, err = json.Marshal(codersdk.ReconnectingPTYRequest{ - Data: "echo test\r\n", - }) - require.NoError(t, err) - _, err = conn.Write(data) - require.NoError(t, err) - - expectLine(t, bufRead, matchEchoCommand) - expectLine(t, bufRead, matchEchoOutput) }) }) @@ -1406,4 +1338,123 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { require.Equal(t, []string{"Origin", "X-Foobar"}, deduped) require.Equal(t, []string{"baz"}, resp.Header.Values("X-Foobar")) }) + + t.Run("ReportStats", func(t *testing.T) { + t.Parallel() + + flush := make(chan chan<- struct{}, 1) + + reporter := &fakeStatsReporter{} + appDetails := setupProxyTest(t, &DeploymentOptions{ + StatsCollectorOptions: workspaceapps.StatsCollectorOptions{ + Reporter: reporter, + ReportInterval: time.Hour, + RollupWindow: time.Minute, + + Flush: flush, + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + u := appDetails.PathAppURL(appDetails.Apps.Owner) + resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + _, err = io.Copy(io.Discard, resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var stats []workspaceapps.StatsReport + require.Eventually(t, func() bool { + // Keep flushing until we get a non-empty stats report. + flushDone := make(chan struct{}, 1) + flush <- flushDone + <-flushDone + + stats = reporter.stats() + return len(stats) > 0 + }, testutil.WaitLong, testutil.IntervalFast, "stats not reported") + + assert.Equal(t, workspaceapps.AccessMethodPath, stats[0].AccessMethod) + assert.Equal(t, "test-app-owner", stats[0].SlugOrPort) + assert.Equal(t, 1, stats[0].Requests) + }) +} + +type fakeStatsReporter struct { + mu sync.Mutex + s []workspaceapps.StatsReport +} + +func (r *fakeStatsReporter) stats() []workspaceapps.StatsReport { + r.mu.Lock() + defer r.mu.Unlock() + return r.s +} + +func (r *fakeStatsReporter) Report(_ context.Context, stats []workspaceapps.StatsReport) error { + r.mu.Lock() + r.s = append(r.s, stats...) + r.mu.Unlock() + return nil +} + +func testReconnectingPTY(ctx context.Context, t *testing.T, client *codersdk.Client, opts codersdk.WorkspaceAgentReconnectingPTYOpts) { + 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") + } + + conn, err := client.WorkspaceAgentReconnectingPTY(ctx, opts) + require.NoError(t, err) + defer conn.Close() + + // First attempt to resize the TTY. + // The websocket will close if it fails! + data, err := json.Marshal(codersdk.ReconnectingPTYRequest{ + Height: 80, + Width: 80, + }) + require.NoError(t, err) + _, err = conn.Write(data) + require.NoError(t, err) + + // Brief pause to reduce the likelihood that we send keystrokes while + // the shell is simultaneously sending a prompt. + time.Sleep(100 * time.Millisecond) + + data, err = json.Marshal(codersdk.ReconnectingPTYRequest{ + Data: "echo test\r\n", + }) + require.NoError(t, err) + _, err = conn.Write(data) + require.NoError(t, err) + + require.NoError(t, testutil.ReadUntil(ctx, t, conn, matchEchoCommand), "find echo command") + require.NoError(t, testutil.ReadUntil(ctx, t, conn, matchEchoOutput), "find echo output") + + // Exit should cause the connection to close. + data, err = json.Marshal(codersdk.ReconnectingPTYRequest{ + Data: "exit\r\n", + }) + require.NoError(t, err) + _, err = conn.Write(data) + require.NoError(t, err) + + // Once for the input and again for the output. + require.NoError(t, testutil.ReadUntil(ctx, t, conn, matchExitCommand), "find exit command") + require.NoError(t, testutil.ReadUntil(ctx, t, conn, matchExitOutput), "find exit output") + + // Ensure the connection closes. + require.ErrorIs(t, testutil.ReadUntil(ctx, t, conn, nil), io.EOF) } diff --git a/coderd/workspaceapps/apptest/setup.go b/coderd/workspaceapps/apptest/setup.go index 4245204268391..6ab541f078072 100644 --- a/coderd/workspaceapps/apptest/setup.go +++ b/coderd/workspaceapps/apptest/setup.go @@ -19,13 +19,14 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "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/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/workspaceapps" + "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/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" ) const ( @@ -51,6 +52,8 @@ type DeploymentOptions struct { DangerousAllowPathAppSiteOwnerAccess bool ServeHTTPS bool + StatsCollectorOptions workspaceapps.StatsCollectorOptions + // The following fields are only used by setupProxyTestWithFactory. noWorkspace bool port uint16 @@ -285,10 +288,10 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U appURL := fmt.Sprintf("%s://127.0.0.1:%d?%s", scheme, port, proxyTestAppQuery) version := coderdtest.CreateTemplateVersion(t, client, orgID, &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: "example", Type: "aws_instance", diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 34851fb1559e1..5f15b52d0e6f3 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -13,12 +13,12 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" ) // DBTokenProvider provides authentication and authorization for workspace apps diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index bab2d8ae3b9dd..163247f6d4e4f 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -18,15 +18,15 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/workspaceapps" - "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/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/workspaceapps" + "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/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" ) func Test_ResolveRequest(t *testing.T) { @@ -94,10 +94,10 @@ func Test_ResolveRequest(t *testing.T) { agentAuthToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, firstUser.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: "example", Type: "aws_instance", diff --git a/coderd/workspaceapps/errors.go b/coderd/workspaceapps/errors.go index b9724c6f79f69..0dd8cc270d3cb 100644 --- a/coderd/workspaceapps/errors.go +++ b/coderd/workspaceapps/errors.go @@ -5,7 +5,7 @@ import ( "net/url" "cdr.dev/slog" - "github.com/coder/coder/site" + "github.com/coder/coder/v2/site" ) // WriteWorkspaceApp404 writes a HTML 404 error page for a workspace app. If diff --git a/coderd/workspaceapps/provider.go b/coderd/workspaceapps/provider.go index 62b6da02a6050..25c18effd5f7a 100644 --- a/coderd/workspaceapps/provider.go +++ b/coderd/workspaceapps/provider.go @@ -7,8 +7,8 @@ import ( "time" "cdr.dev/slog" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" ) const ( diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index 9b2d9c4bfa297..5e9babcb85048 100644 --- a/coderd/workspaceapps/proxy.go +++ b/coderd/workspaceapps/proxy.go @@ -18,13 +18,14 @@ import ( "nhooyr.io/websocket" "cdr.dev/slog" - "github.com/coder/coder/agent/agentssh" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/coderd/util/slice" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/site" + "github.com/coder/coder/v2/agent/agentssh" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/site" ) const ( @@ -109,7 +110,8 @@ type Server struct { DisablePathApps bool SecureAuthCookie bool - AgentProvider AgentProvider + AgentProvider AgentProvider + StatsCollector *StatsCollector websocketWaitMutex sync.Mutex websocketWaitGroup sync.WaitGroup @@ -122,6 +124,10 @@ func (s *Server) Close() error { s.websocketWaitGroup.Wait() s.websocketWaitMutex.Unlock() + if s.StatsCollector != nil { + _ = s.StatsCollector.Close() + } + // The caller must close the SignedTokenProvider and the AgentProvider (if // necessary). @@ -586,6 +592,14 @@ func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appT // end span so we don't get long lived trace data tracing.EndHTTPSpan(r, http.StatusOK, trace.SpanFromContext(ctx)) + report := newStatsReportFromSignedToken(appToken) + s.collectStats(report) + defer func() { + // We must use defer here because ServeHTTP may panic. + report.SessionEndedAt = database.Now() + s.collectStats(report) + }() + proxy.ServeHTTP(rw, r) } @@ -669,7 +683,6 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { return } defer release() - defer agentConn.Close() log.Debug(ctx, "dialed workspace agent") ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command")) if err != nil { @@ -679,10 +692,24 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { } defer ptNetConn.Close() log.Debug(ctx, "obtained PTY") + + report := newStatsReportFromSignedToken(*appToken) + s.collectStats(report) + defer func() { + report.SessionEndedAt = database.Now() + s.collectStats(report) + }() + agentssh.Bicopy(ctx, wsNetConn, ptNetConn) log.Debug(ctx, "pty Bicopy finished") } +func (s *Server) collectStats(stats StatsReport) { + if s.StatsCollector != nil { + s.StatsCollector.Collect(stats) + } +} + // wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func // is called if a read or write error is encountered. type wsNetConn struct { diff --git a/coderd/workspaceapps/request.go b/coderd/workspaceapps/request.go index e9d0ff9ffcc3a..fbfa010aeda40 100644 --- a/coderd/workspaceapps/request.go +++ b/coderd/workspaceapps/request.go @@ -12,8 +12,8 @@ import ( "github.com/google/uuid" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" ) type AccessMethod string diff --git a/coderd/workspaceapps/request_test.go b/coderd/workspaceapps/request_test.go index e6fb0279d3ecd..03ecccd4d7dc1 100644 --- a/coderd/workspaceapps/request_test.go +++ b/coderd/workspaceapps/request_test.go @@ -6,7 +6,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/workspaceapps" + "github.com/coder/coder/v2/coderd/workspaceapps" ) func Test_RequestValidate(t *testing.T) { diff --git a/coderd/workspaceapps/stats.go b/coderd/workspaceapps/stats.go new file mode 100644 index 0000000000000..c9b11117126c2 --- /dev/null +++ b/coderd/workspaceapps/stats.go @@ -0,0 +1,403 @@ +package workspaceapps + +import ( + "context" + "sync" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" +) + +const ( + DefaultStatsCollectorReportInterval = 30 * time.Second + DefaultStatsCollectorRollupWindow = 1 * time.Minute + DefaultStatsDBReporterBatchSize = 1024 +) + +// StatsReport is a report of a workspace app session. +type StatsReport struct { + UserID uuid.UUID `json:"user_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + AgentID uuid.UUID `json:"agent_id"` + AccessMethod AccessMethod `json:"access_method"` + SlugOrPort string `json:"slug_or_port"` + SessionID uuid.UUID `json:"session_id"` + SessionStartedAt time.Time `json:"session_started_at"` + SessionEndedAt time.Time `json:"session_ended_at"` // Updated periodically while app is in use active and when the last connection is closed. + Requests int `json:"requests"` + + rolledUp bool // Indicates if this report has been rolled up. +} + +func newStatsReportFromSignedToken(token SignedToken) StatsReport { + return StatsReport{ + UserID: token.UserID, + WorkspaceID: token.WorkspaceID, + AgentID: token.AgentID, + AccessMethod: token.AccessMethod, + SlugOrPort: token.AppSlugOrPort, + SessionID: uuid.New(), + SessionStartedAt: database.Now(), + Requests: 1, + } +} + +// StatsReporter reports workspace app StatsReports. +type StatsReporter interface { + Report(context.Context, []StatsReport) error +} + +var _ StatsReporter = (*StatsDBReporter)(nil) + +// StatsDBReporter writes workspace app StatsReports to the database. +type StatsDBReporter struct { + db database.Store + batchSize int +} + +// NewStatsDBReporter returns a new StatsDBReporter. +func NewStatsDBReporter(db database.Store, batchSize int) *StatsDBReporter { + return &StatsDBReporter{ + db: db, + batchSize: batchSize, + } +} + +// Report writes the given StatsReports to the database. +func (r *StatsDBReporter) Report(ctx context.Context, stats []StatsReport) error { + err := r.db.InTx(func(tx database.Store) error { + maxBatchSize := r.batchSize + if len(stats) < maxBatchSize { + maxBatchSize = len(stats) + } + batch := database.InsertWorkspaceAppStatsParams{ + UserID: make([]uuid.UUID, 0, maxBatchSize), + WorkspaceID: make([]uuid.UUID, 0, maxBatchSize), + AgentID: make([]uuid.UUID, 0, maxBatchSize), + AccessMethod: make([]string, 0, maxBatchSize), + SlugOrPort: make([]string, 0, maxBatchSize), + SessionID: make([]uuid.UUID, 0, maxBatchSize), + SessionStartedAt: make([]time.Time, 0, maxBatchSize), + SessionEndedAt: make([]time.Time, 0, maxBatchSize), + Requests: make([]int32, 0, maxBatchSize), + } + for _, stat := range stats { + batch.UserID = append(batch.UserID, stat.UserID) + batch.WorkspaceID = append(batch.WorkspaceID, stat.WorkspaceID) + batch.AgentID = append(batch.AgentID, stat.AgentID) + batch.AccessMethod = append(batch.AccessMethod, string(stat.AccessMethod)) + batch.SlugOrPort = append(batch.SlugOrPort, stat.SlugOrPort) + batch.SessionID = append(batch.SessionID, stat.SessionID) + batch.SessionStartedAt = append(batch.SessionStartedAt, stat.SessionStartedAt) + batch.SessionEndedAt = append(batch.SessionEndedAt, stat.SessionEndedAt) + batch.Requests = append(batch.Requests, int32(stat.Requests)) + + if len(batch.UserID) >= r.batchSize { + err := tx.InsertWorkspaceAppStats(ctx, batch) + if err != nil { + return err + } + + // Reset batch. + batch.UserID = batch.UserID[:0] + batch.WorkspaceID = batch.WorkspaceID[:0] + batch.AgentID = batch.AgentID[:0] + batch.AccessMethod = batch.AccessMethod[:0] + batch.SlugOrPort = batch.SlugOrPort[:0] + batch.SessionID = batch.SessionID[:0] + batch.SessionStartedAt = batch.SessionStartedAt[:0] + batch.SessionEndedAt = batch.SessionEndedAt[:0] + batch.Requests = batch.Requests[:0] + } + } + if len(batch.UserID) > 0 { + err := tx.InsertWorkspaceAppStats(ctx, batch) + if err != nil { + return err + } + } + + return nil + }, nil) + if err != nil { + return xerrors.Errorf("insert workspace app stats failed: %w", err) + } + + return nil +} + +// This should match the database unique constraint. +type statsGroupKey struct { + StartTimeTrunc time.Time + UserID uuid.UUID + WorkspaceID uuid.UUID + AgentID uuid.UUID + AccessMethod AccessMethod + SlugOrPort string +} + +func (s StatsReport) groupKey(windowSize time.Duration) statsGroupKey { + return statsGroupKey{ + StartTimeTrunc: s.SessionStartedAt.Truncate(windowSize), + UserID: s.UserID, + WorkspaceID: s.WorkspaceID, + AgentID: s.AgentID, + AccessMethod: s.AccessMethod, + SlugOrPort: s.SlugOrPort, + } +} + +// StatsCollector collects workspace app StatsReports and reports them +// in batches, stats compaction is performed for short-lived sessions. +type StatsCollector struct { + opts StatsCollectorOptions + + ctx context.Context + cancel context.CancelFunc + done chan struct{} + + mu sync.Mutex // Protects following. + statsBySessionID map[uuid.UUID]*StatsReport // Track unique sessions. + groupedStats map[statsGroupKey][]*StatsReport // Rolled up stats for sessions in close proximity. + backlog []StatsReport // Stats that have not been reported yet (due to error). +} + +type StatsCollectorOptions struct { + Logger *slog.Logger + Reporter StatsReporter + // ReportInterval is the interval at which stats are reported, both partial + // and fully formed stats. + ReportInterval time.Duration + // RollupWindow is the window size for rolling up stats, session shorter + // than this will be rolled up and longer than this will be tracked + // individually. + RollupWindow time.Duration + + // Options for tests. + Flush <-chan chan<- struct{} + Now func() time.Time +} + +func NewStatsCollector(opts StatsCollectorOptions) *StatsCollector { + if opts.Logger == nil { + opts.Logger = &slog.Logger{} + } + if opts.ReportInterval == 0 { + opts.ReportInterval = DefaultStatsCollectorReportInterval + } + if opts.RollupWindow == 0 { + opts.RollupWindow = DefaultStatsCollectorRollupWindow + } + if opts.Now == nil { + opts.Now = time.Now + } + + ctx, cancel := context.WithCancel(context.Background()) + sc := &StatsCollector{ + ctx: ctx, + cancel: cancel, + done: make(chan struct{}), + opts: opts, + + statsBySessionID: make(map[uuid.UUID]*StatsReport), + groupedStats: make(map[statsGroupKey][]*StatsReport), + } + + go sc.start() + return sc +} + +// Collect the given StatsReport for later reporting (non-blocking). +func (sc *StatsCollector) Collect(report StatsReport) { + sc.mu.Lock() + defer sc.mu.Unlock() + + r := &report + if _, ok := sc.statsBySessionID[report.SessionID]; !ok { + groupKey := r.groupKey(sc.opts.RollupWindow) + sc.groupedStats[groupKey] = append(sc.groupedStats[groupKey], r) + } + + if r.SessionEndedAt.IsZero() { + sc.statsBySessionID[report.SessionID] = r + } else { + if stat, ok := sc.statsBySessionID[report.SessionID]; ok { + // Update in-place. + *stat = *r + } + delete(sc.statsBySessionID, report.SessionID) + } +} + +// rollup performs stats rollup for sessions that fall within the +// configured rollup window. For sessions longer than the window, +// we report them individually. +func (sc *StatsCollector) rollup(now time.Time) []StatsReport { + sc.mu.Lock() + defer sc.mu.Unlock() + + var report []StatsReport + + for g, group := range sc.groupedStats { + if len(group) == 0 { + // Safety check, this should not happen. + sc.opts.Logger.Error(sc.ctx, "empty stats group", "group", g) + delete(sc.groupedStats, g) + continue + } + + var rolledUp *StatsReport + if group[0].rolledUp { + rolledUp = group[0] + group = group[1:] + } else { + rolledUp = &StatsReport{ + UserID: g.UserID, + WorkspaceID: g.WorkspaceID, + AgentID: g.AgentID, + AccessMethod: g.AccessMethod, + SlugOrPort: g.SlugOrPort, + SessionStartedAt: g.StartTimeTrunc, + SessionEndedAt: g.StartTimeTrunc.Add(sc.opts.RollupWindow), + Requests: 0, + rolledUp: true, + } + } + rollupChanged := false + newGroup := []*StatsReport{rolledUp} // Must be first in slice for future iterations (see group[0] above). + for _, stat := range group { + if !stat.SessionEndedAt.IsZero() && stat.SessionEndedAt.Sub(stat.SessionStartedAt) <= sc.opts.RollupWindow { + // This is a short-lived session, roll it up. + if rolledUp.SessionID == uuid.Nil { + rolledUp.SessionID = stat.SessionID // Borrow the first session ID, useful in tests. + } + rolledUp.Requests += stat.Requests + rollupChanged = true + continue + } + if stat.SessionEndedAt.IsZero() && now.Sub(stat.SessionStartedAt) <= sc.opts.RollupWindow { + // This is an incomplete session, wait and see if it'll be rolled up or not. + newGroup = append(newGroup, stat) + continue + } + + // This is a long-lived session, report it individually. + // Make a copy of stat for reporting. + r := *stat + if r.SessionEndedAt.IsZero() { + // Report an end time for incomplete sessions, it will + // be updated later. This ensures that data in the DB + // will have an end time even if the service is stopped. + r.SessionEndedAt = now.UTC() // Use UTC like database.Now(). + } + report = append(report, r) // Report it (ended or incomplete). + if stat.SessionEndedAt.IsZero() { + newGroup = append(newGroup, stat) // Keep it for future updates. + } + } + if rollupChanged { + report = append(report, *rolledUp) + } + + // Future rollups should only consider the compacted group. + sc.groupedStats[g] = newGroup + + // Keep the group around until the next rollup window has passed + // in case data was collected late. + if len(newGroup) == 1 && rolledUp.SessionEndedAt.Add(sc.opts.RollupWindow).Before(now) { + delete(sc.groupedStats, g) + } + } + + return report +} + +func (sc *StatsCollector) flush(ctx context.Context) (err error) { + sc.opts.Logger.Debug(ctx, "flushing workspace app stats") + defer func() { + if err != nil { + sc.opts.Logger.Error(ctx, "failed to flush workspace app stats", "error", err) + } else { + sc.opts.Logger.Debug(ctx, "flushed workspace app stats") + } + }() + + // We keep the backlog as a simple slice so that we don't need to + // attempt to merge it with the stats we're about to report. This + // is because the rollup is a one-way operation and the backlog may + // contain stats that are still in the statsBySessionID map and will + // be reported again in the future. It is possible to merge the + // backlog and the stats we're about to report, but it's not worth + // the complexity. + if len(sc.backlog) > 0 { + err = sc.opts.Reporter.Report(ctx, sc.backlog) + if err != nil { + return xerrors.Errorf("report workspace app stats from backlog failed: %w", err) + } + sc.backlog = nil + } + + now := sc.opts.Now() + stats := sc.rollup(now) + if len(stats) == 0 { + return nil + } + + err = sc.opts.Reporter.Report(ctx, stats) + if err != nil { + sc.backlog = stats + return xerrors.Errorf("report workspace app stats failed: %w", err) + } + + return nil +} + +func (sc *StatsCollector) Close() error { + sc.cancel() + <-sc.done + return nil +} + +func (sc *StatsCollector) start() { + defer func() { + close(sc.done) + sc.opts.Logger.Debug(sc.ctx, "workspace app stats collector stopped") + }() + sc.opts.Logger.Debug(sc.ctx, "workspace app stats collector started") + + t := time.NewTimer(sc.opts.ReportInterval) + defer t.Stop() + + var reportFlushDone chan<- struct{} + done := false + for !done { + select { + case <-sc.ctx.Done(): + t.Stop() + done = true + case <-t.C: + case reportFlushDone = <-sc.opts.Flush: + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + //nolint:gocritic // Inserting app stats is a system function. + _ = sc.flush(dbauthz.AsSystemRestricted(ctx)) + cancel() + + if !done { + t.Reset(sc.opts.ReportInterval) + } + + // For tests. + if reportFlushDone != nil { + reportFlushDone <- struct{}{} + reportFlushDone = nil + } + } +} diff --git a/coderd/workspaceapps/stats_test.go b/coderd/workspaceapps/stats_test.go new file mode 100644 index 0000000000000..bf8444c04f62e --- /dev/null +++ b/coderd/workspaceapps/stats_test.go @@ -0,0 +1,426 @@ +package workspaceapps_test + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/workspaceapps" + "github.com/coder/coder/v2/testutil" +) + +type fakeReporter struct { + mu sync.Mutex + s []workspaceapps.StatsReport + err error + errN int +} + +func (r *fakeReporter) stats() []workspaceapps.StatsReport { + r.mu.Lock() + defer r.mu.Unlock() + return r.s +} + +func (r *fakeReporter) errors() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.errN +} + +func (r *fakeReporter) setError(err error) { + r.mu.Lock() + defer r.mu.Unlock() + r.err = err +} + +func (r *fakeReporter) Report(_ context.Context, stats []workspaceapps.StatsReport) error { + r.mu.Lock() + if r.err != nil { + r.errN++ + r.mu.Unlock() + return r.err + } + r.s = append(r.s, stats...) + r.mu.Unlock() + return nil +} + +func TestStatsCollector(t *testing.T) { + t.Parallel() + + rollupUUID := uuid.New() + rollupUUID2 := uuid.New() + someUUID := uuid.New() + + rollupWindow := time.Minute + start := database.Now().Truncate(time.Minute).UTC() + end := start.Add(10 * time.Second) + + tests := []struct { + name string + flushIncrement time.Duration + flushCount int + stats []workspaceapps.StatsReport + want []workspaceapps.StatsReport + }{ + { + name: "Single stat rolled up and reported once", + flushIncrement: 2*rollupWindow + time.Second, + flushCount: 10, // Only reported once. + stats: []workspaceapps.StatsReport{ + { + SessionID: rollupUUID, + SessionStartedAt: start, + SessionEndedAt: end, + Requests: 1, + }, + }, + want: []workspaceapps.StatsReport{ + { + SessionID: rollupUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(rollupWindow), + Requests: 1, + }, + }, + }, + { + name: "Two unique stat rolled up", + flushIncrement: 2*rollupWindow + time.Second, + flushCount: 10, // Only reported once. + stats: []workspaceapps.StatsReport{ + { + AccessMethod: workspaceapps.AccessMethodPath, + SlugOrPort: "code-server", + SessionID: rollupUUID, + SessionStartedAt: start, + SessionEndedAt: end, + Requests: 1, + }, + { + AccessMethod: workspaceapps.AccessMethodTerminal, + SessionID: rollupUUID2, + SessionStartedAt: start, + SessionEndedAt: end, + Requests: 1, + }, + }, + want: []workspaceapps.StatsReport{ + { + AccessMethod: workspaceapps.AccessMethodPath, + SlugOrPort: "code-server", + SessionID: rollupUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(rollupWindow), + Requests: 1, + }, + { + AccessMethod: workspaceapps.AccessMethodTerminal, + SessionID: rollupUUID2, + SessionStartedAt: start, + SessionEndedAt: start.Add(rollupWindow), + Requests: 1, + }, + }, + }, + { + name: "Multiple stats rolled up", + flushIncrement: 2*rollupWindow + time.Second, + flushCount: 2, + stats: []workspaceapps.StatsReport{ + { + SessionID: rollupUUID, + SessionStartedAt: start, + SessionEndedAt: end, + Requests: 1, + }, + { + SessionID: uuid.New(), + SessionStartedAt: start, + SessionEndedAt: end, + Requests: 1, + }, + }, + want: []workspaceapps.StatsReport{ + { + SessionID: rollupUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(rollupWindow), + Requests: 2, + }, + }, + }, + { + name: "Long sessions not rolled up but reported multiple times", + flushIncrement: rollupWindow + time.Second, + flushCount: 4, + stats: []workspaceapps.StatsReport{ + { + SessionID: rollupUUID, + SessionStartedAt: start, + Requests: 1, + }, + }, + want: []workspaceapps.StatsReport{ + { + SessionID: rollupUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(rollupWindow + time.Second), + Requests: 1, + }, + { + SessionID: rollupUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(2 * (rollupWindow + time.Second)), + Requests: 1, + }, + { + SessionID: rollupUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(3 * (rollupWindow + time.Second)), + Requests: 1, + }, + { + SessionID: rollupUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(4 * (rollupWindow + time.Second)), + Requests: 1, + }, + }, + }, + { + name: "Incomplete stats not reported until it exceeds rollup window", + flushIncrement: rollupWindow / 4, + flushCount: 6, + stats: []workspaceapps.StatsReport{ + { + SessionID: someUUID, + SessionStartedAt: start, + Requests: 1, + }, + }, + want: []workspaceapps.StatsReport{ + { + SessionID: someUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(rollupWindow / 4 * 5), + Requests: 1, + }, + { + SessionID: someUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(rollupWindow / 4 * 6), + Requests: 1, + }, + }, + }, + { + name: "Same stat reported without and with end time and rolled up", + flushIncrement: rollupWindow + time.Second, + flushCount: 1, + stats: []workspaceapps.StatsReport{ + { + SessionID: someUUID, + SessionStartedAt: start, + Requests: 1, + }, + { + SessionID: someUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(10 * time.Second), + Requests: 1, + }, + }, + want: []workspaceapps.StatsReport{ + { + SessionID: someUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(rollupWindow), + Requests: 1, + }, + }, + }, + { + name: "Same non-rolled up stat reported without and with end time", + flushIncrement: rollupWindow * 2, + flushCount: 1, + stats: []workspaceapps.StatsReport{ + { + SessionID: someUUID, + SessionStartedAt: start, + Requests: 1, + }, + { + SessionID: someUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(rollupWindow * 2), + Requests: 1, + }, + }, + want: []workspaceapps.StatsReport{ + { + SessionID: someUUID, + SessionStartedAt: start, + SessionEndedAt: start.Add(rollupWindow * 2), + Requests: 1, + }, + }, + }, + } + + // Run tests. + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + flush := make(chan chan<- struct{}, 1) + var now atomic.Pointer[time.Time] + now.Store(&start) + + reporter := &fakeReporter{} + collector := workspaceapps.NewStatsCollector(workspaceapps.StatsCollectorOptions{ + Reporter: reporter, + ReportInterval: time.Hour, + RollupWindow: rollupWindow, + + Flush: flush, + Now: func() time.Time { return *now.Load() }, + }) + + // Collect reports. + for _, report := range tt.stats { + collector.Collect(report) + } + + // Advance time. + flushTime := start.Add(tt.flushIncrement) + for i := 0; i < tt.flushCount; i++ { + now.Store(&flushTime) + flushDone := make(chan struct{}, 1) + flush <- flushDone + <-flushDone + flushTime = flushTime.Add(tt.flushIncrement) + } + + var gotStats []workspaceapps.StatsReport + require.Eventually(t, func() bool { + gotStats = reporter.stats() + return len(gotStats) == len(tt.want) + }, testutil.WaitMedium, testutil.IntervalFast) + + // Order is not guaranteed. + sortBySessionID := func(a, b workspaceapps.StatsReport) int { + if a.SessionID == b.SessionID { + return int(a.SessionEndedAt.Sub(b.SessionEndedAt)) + } + if a.SessionID.String() < b.SessionID.String() { + return -1 + } + return 1 + } + slices.SortFunc(tt.want, sortBySessionID) + slices.SortFunc(gotStats, sortBySessionID) + + // Verify reported stats. + for i, got := range gotStats { + want := tt.want[i] + + assert.Equal(t, want.SessionID, got.SessionID, "session ID; i = %d", i) + assert.Equal(t, want.SessionStartedAt, got.SessionStartedAt, "session started at; i = %d", i) + assert.Equal(t, want.SessionEndedAt, got.SessionEndedAt, "session ended at; i = %d", i) + assert.Equal(t, want.Requests, got.Requests, "requests; i = %d", i) + } + }) + } +} + +func TestStatsCollector_backlog(t *testing.T) { + t.Parallel() + + rollupWindow := time.Minute + flush := make(chan chan<- struct{}, 1) + + start := database.Now().Truncate(time.Minute).UTC() + var now atomic.Pointer[time.Time] + now.Store(&start) + + reporter := &fakeReporter{} + collector := workspaceapps.NewStatsCollector(workspaceapps.StatsCollectorOptions{ + Reporter: reporter, + ReportInterval: time.Hour, + RollupWindow: rollupWindow, + + Flush: flush, + Now: func() time.Time { return *now.Load() }, + }) + + reporter.setError(xerrors.New("some error")) + + // The first collected stat is "rolled up" and moved into the + // backlog during the first flush. On the second flush nothing is + // rolled up due to being unable to report the backlog. + for i := 0; i < 2; i++ { + collector.Collect(workspaceapps.StatsReport{ + SessionID: uuid.New(), + SessionStartedAt: start, + SessionEndedAt: start.Add(10 * time.Second), + Requests: 1, + }) + start = start.Add(time.Minute) + now.Store(&start) + + flushDone := make(chan struct{}, 1) + flush <- flushDone + <-flushDone + } + + // Flush was performed 2 times, 2 reports should have failed. + wantErrors := 2 + assert.Equal(t, wantErrors, reporter.errors()) + assert.Empty(t, reporter.stats()) + + reporter.setError(nil) + + // Flush again, this time the backlog should be reported in addition + // to the second collected stat being rolled up and reported. + flushDone := make(chan struct{}, 1) + flush <- flushDone + <-flushDone + + assert.Equal(t, wantErrors, reporter.errors()) + assert.Len(t, reporter.stats(), 2) +} + +func TestStatsCollector_Close(t *testing.T) { + t.Parallel() + + reporter := &fakeReporter{} + collector := workspaceapps.NewStatsCollector(workspaceapps.StatsCollectorOptions{ + Reporter: reporter, + ReportInterval: time.Hour, + RollupWindow: time.Minute, + }) + + collector.Collect(workspaceapps.StatsReport{ + SessionID: uuid.New(), + SessionStartedAt: database.Now(), + SessionEndedAt: database.Now(), + Requests: 1, + }) + + collector.Close() + + // Verify that stats are reported after close. + assert.NotEmpty(t, reporter.stats()) +} diff --git a/coderd/workspaceapps/token.go b/coderd/workspaceapps/token.go index 3811602a2314c..96181340fc147 100644 --- a/coderd/workspaceapps/token.go +++ b/coderd/workspaceapps/token.go @@ -11,8 +11,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" ) const ( diff --git a/coderd/workspaceapps/token_test.go b/coderd/workspaceapps/token_test.go index 8b20180cabaf0..e674fedb9cbf7 100644 --- a/coderd/workspaceapps/token_test.go +++ b/coderd/workspaceapps/token_test.go @@ -9,10 +9,10 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/workspaceapps" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/workspaceapps" + "github.com/coder/coder/v2/cryptorand" ) func Test_TokenMatchesRequest(t *testing.T) { diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 8f31bed4d27d2..2018e1d8dde4e 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -9,16 +9,16 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/database/dbtestutil" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/workspaceapps" - "github.com/coder/coder/coderd/workspaceapps/apptest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/workspaceapps" + "github.com/coder/coder/v2/coderd/workspaceapps/apptest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func TestGetAppHost(t *testing.T) { @@ -275,6 +275,7 @@ func TestWorkspaceApps(t *testing.T) { "CF-Connecting-IP", }, }, + WorkspaceAppsStatsCollectorOptions: opts.StatsCollectorOptions, }) user := coderdtest.CreateFirstUser(t, client) diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index c9156506b30c1..6f03d63dff785 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -16,14 +16,14 @@ import ( "cdr.dev/slog" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/db2sdk" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/wsbuilder" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/wsbuilder" + "github.com/coder/coder/v2/codersdk" ) // @Summary Get workspace build @@ -303,7 +303,6 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ // @Param request body codersdk.CreateWorkspaceBuildRequest true "Create workspace build request" // @Success 200 {object} codersdk.WorkspaceBuild // @Router /workspaces/{workspace}/builds [post] -// nolint:gocyclo func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index b838e39e3b251..2c32f9ac39b7a 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -14,14 +14,16 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/testutil" + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/audit" + "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/testutil" ) func TestWorkspaceBuild(t *testing.T) { @@ -376,12 +378,12 @@ func TestPatchCancelWorkspaceBuild(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_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }}, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -401,26 +403,30 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) { require.Eventually(t, func() bool { var err error build, err = client.WorkspaceBuild(ctx, build.ID) + // job gets marked Failed when there is an Error; in practice we never get to Status = Canceled + // because provisioners report an Error when canceled. We check the Error string to ensure we don't mask + // other errors in this test. return assert.NoError(t, err) && - // The job will never actually cancel successfully because it will never send a - // provision complete response. - assert.Empty(t, build.Job.Error) && - build.Job.Status == codersdk.ProvisionerJobCanceling + build.Job.Error == "canceled" && + build.Job.Status == codersdk.ProvisionerJobFailed }, testutil.WaitShort, testutil.IntervalFast) }) t.Run("User is not allowed to cancel", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + // need to include our own logger because the provisioner (rightly) drops error logs when we shut down the + // test with a build in progress. + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Logger: &logger}) owner := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }}, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) @@ -452,9 +458,9 @@ func TestWorkspaceBuildResources(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: "some", Type: "example", @@ -494,16 +500,16 @@ func TestWorkspaceBuildLogs(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_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "example", }, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "some", Type: "example", @@ -548,10 +554,10 @@ func TestWorkspaceBuildState(t *testing.T) { wantState := []byte("some kinda state") 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{ State: wantState, }, }, @@ -764,31 +770,31 @@ func TestWorkspaceBuildDebugMode(t *testing.T) { // Interact as template admin echoResponses := &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_DEBUG, Output: "want-it", }, }, }, { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_TRACE, Output: "dont-want-it", }, }, }, { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_DEBUG, Output: "done", }, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, } @@ -831,7 +837,10 @@ func TestWorkspaceBuildDebugMode(t *testing.T) { if !ok { break processingLogs } - + t.Logf("got log: %s -- %s | %s | %s", log.Level, log.Stage, log.Source, log.Output) + if log.Source != "provisioner" { + continue + } logsProcessed++ require.NotEqual(t, "dont-want-it", log.Output, "unexpected log message", "%s log message shouldn't be logged: %s") @@ -841,7 +850,6 @@ func TestWorkspaceBuildDebugMode(t *testing.T) { } } } - - require.Len(t, echoResponses.ProvisionApply, logsProcessed) + require.Equal(t, 2, logsProcessed) }) } diff --git a/coderd/workspaceproxies.go b/coderd/workspaceproxies.go index f861233507cf6..fca096819575f 100644 --- a/coderd/workspaceproxies.go +++ b/coderd/workspaceproxies.go @@ -8,10 +8,10 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) // PrimaryRegion exposes the user facing values of a workspace proxy to diff --git a/coderd/workspaceproxies_test.go b/coderd/workspaceproxies_test.go index d11ab8fbdd975..60718f8a22277 100644 --- a/coderd/workspaceproxies_test.go +++ b/coderd/workspaceproxies_test.go @@ -6,10 +6,10 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "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/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func TestRegions(t *testing.T) { diff --git a/coderd/workspaceresourceauth.go b/coderd/workspaceresourceauth.go index b43434c1d6c0f..3642822b18d77 100644 --- a/coderd/workspaceresourceauth.go +++ b/coderd/workspaceresourceauth.go @@ -5,14 +5,14 @@ import ( "fmt" "net/http" - "github.com/coder/coder/coderd/awsidentity" - "github.com/coder/coder/coderd/azureidentity" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/provisionerdserver" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/v2/coderd/awsidentity" + "github.com/coder/coder/v2/coderd/azureidentity" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/provisionerdserver" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/mitchellh/mapstructure" ) diff --git a/coderd/workspaceresourceauth_test.go b/coderd/workspaceresourceauth_test.go index d6c088de58507..fdf1bd2335034 100644 --- a/coderd/workspaceresourceauth_test.go +++ b/coderd/workspaceresourceauth_test.go @@ -7,12 +7,12 @@ import ( "github.com/stretchr/testify/require" - "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/testutil" + "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/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" ) func TestPostWorkspaceAuthAzureInstanceIdentity(t *testing.T) { @@ -26,9 +26,9 @@ func TestPostWorkspaceAuthAzureInstanceIdentity(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", @@ -71,9 +71,9 @@ func TestPostWorkspaceAuthAWSInstanceIdentity(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", @@ -157,9 +157,9 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(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", diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0aa1cb0675155..9384d2b7ecb00 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -15,19 +15,19 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/searchquery" - "github.com/coder/coder/coderd/telemetry" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/coderd/wsbuilder" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/searchquery" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/wsbuilder" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" ) var ( @@ -86,6 +86,11 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { return } + if len(data.templates) == 0 { + httpapi.Forbidden(rw) + return + } + httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace( workspace, data.builds[0], @@ -760,46 +765,43 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } -// @Summary Update workspace lock by id. -// @ID update-workspace-lock-by-id +// @Summary Update workspace dormancy status by id. +// @ID update-workspace-dormancy-status-by-id // @Security CoderSessionToken // @Accept json // @Produce json // @Tags Workspaces // @Param workspace path string true "Workspace ID" format(uuid) -// @Param request body codersdk.UpdateWorkspaceLock true "Lock or unlock a workspace" -// @Success 200 {object} codersdk.Response -// @Router /workspaces/{workspace}/lock [put] -func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { +// @Param request body codersdk.UpdateWorkspaceDormancy true "Make a workspace dormant or active" +// @Success 200 {object} codersdk.Workspace +// @Router /workspaces/{workspace}/dormant [put] +func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspace := httpmw.WorkspaceParam(r) - var req codersdk.UpdateWorkspaceLock + var req codersdk.UpdateWorkspaceDormancy if !httpapi.Read(ctx, rw, r, &req) { return } - code := http.StatusOK - resp := codersdk.Response{} - // If the workspace is already in the desired state do nothing! - if workspace.LockedAt.Valid == req.Lock { + if workspace.DormantAt.Valid == req.Dormant { httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{ Message: "Nothing to do!", }) return } - lockedAt := sql.NullTime{ - Valid: req.Lock, + dormantAt := sql.NullTime{ + Valid: req.Dormant, } - if req.Lock { - lockedAt.Time = database.Now() + if req.Dormant { + dormantAt.Time = database.Now() } - err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{ - ID: workspace.ID, - LockedAt: lockedAt, + workspace, err := api.Database.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{ + ID: workspace.ID, + DormantAt: dormantAt, }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -809,10 +811,26 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { return } - // TODO should we kick off a build to stop the workspace if it's started - // from this endpoint? I'm leaning no to keep things simple and kick - // the responsibility back to the client. - httpapi.Write(ctx, rw, code, resp) + data, err := api.workspaceData(ctx, []database.Workspace{workspace}) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace resources.", + Detail: err.Error(), + }) + return + } + + if len(data.templates) == 0 { + httpapi.Forbidden(rw) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace( + workspace, + data.builds[0], + data.templates[0], + findUser(workspace.OwnerID, data.users), + )) } // @Summary Extend workspace deadline by ID @@ -956,6 +974,16 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) { }) return } + if len(data.templates) == 0 { + _ = sendEvent(ctx, codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypeError, + Data: codersdk.Response{ + Message: "Forbidden reading template of selected workspace.", + Detail: err.Error(), + }, + }) + return + } _ = sendEvent(ctx, codersdk.ServerSentEvent{ Type: codersdk.ServerSentEventTypeData, @@ -1000,6 +1028,9 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) { _ = sendEvent(ctx, codersdk.ServerSentEvent{ Type: codersdk.ServerSentEventTypePing, }) + // Send updated workspace info after connection is established. This avoids + // missing updates if the client connects after an update. + sendUpdate(ctx, nil) for { select { @@ -1017,6 +1048,10 @@ type workspaceData struct { users []database.User } +// workspacesData only returns the data the caller can access. If the caller +// does not have the correct perms to read a given template, the template will +// not be returned. +// So the caller must check the templates & users exist before using them. func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspace) (workspaceData, error) { workspaceIDs := make([]uuid.UUID, 0, len(workspaces)) templateIDs := make([]uuid.UUID, 0, len(workspaces)) @@ -1082,17 +1117,22 @@ func convertWorkspaces(workspaces []database.Workspace, data workspaceData) ([]c apiWorkspaces := make([]codersdk.Workspace, 0, len(workspaces)) for _, workspace := range workspaces { + // If any data is missing from the workspace, just skip returning + // this workspace. This is not ideal, but the user cannot read + // all the workspace's data, so do not show them. + // Ideally we could just return some sort of "unknown" for the missing + // fields? build, exists := buildByWorkspaceID[workspace.ID] if !exists { - return nil, xerrors.Errorf("build not found for workspace %q", workspace.Name) + continue } template, exists := templateByID[workspace.TemplateID] if !exists { - return nil, xerrors.Errorf("template not found for workspace %q", workspace.Name) + continue } owner, exists := userByID[workspace.OwnerID] if !exists { - return nil, xerrors.Errorf("owner not found for workspace: %q", workspace.Name) + continue } apiWorkspaces = append(apiWorkspaces, convertWorkspace( @@ -1116,14 +1156,14 @@ func convertWorkspace( autostartSchedule = &workspace.AutostartSchedule.String } - var lockedAt *time.Time - if workspace.LockedAt.Valid { - lockedAt = &workspace.LockedAt.Time + var dormantAt *time.Time + if workspace.DormantAt.Valid { + dormantAt = &workspace.DormantAt.Time } - var deletedAt *time.Time + var deletingAt *time.Time if workspace.DeletingAt.Valid { - deletedAt = &workspace.DeletingAt.Time + deletingAt = &workspace.DeletingAt.Time } failingAgents := []uuid.UUID{} @@ -1150,13 +1190,14 @@ func convertWorkspace( TemplateIcon: template.Icon, TemplateDisplayName: template.DisplayName, TemplateAllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + TemplateActiveVersionID: template.ActiveVersionID, Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(), Name: workspace.Name, AutostartSchedule: autostartSchedule, TTLMillis: ttlMillis, LastUsedAt: workspace.LastUsedAt, - DeletingAt: deletedAt, - LockedAt: lockedAt, + DeletingAt: deletingAt, + DormantAt: dormantAt, Health: codersdk.WorkspaceHealth{ Healthy: len(failingAgents) == 0, FailingAgents: failingAgents, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index db5db020488b7..8da37158b1e3e 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -19,23 +19,23 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbauthz" - "github.com/coder/coder/coderd/database/dbgen" - "github.com/coder/coder/coderd/database/dbtestutil" - "github.com/coder/coder/coderd/parameter" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/schedule" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/cryptorand" - "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/provisionersdk/proto" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/coderd/audit" + "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/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/parameter" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" ) func TestWorkspace(t *testing.T) { @@ -174,9 +174,9 @@ func TestWorkspace(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: "some", Type: "example", @@ -214,9 +214,9 @@ func TestWorkspace(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: "some", Type: "example", @@ -258,9 +258,9 @@ func TestWorkspace(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: "some", Type: "example", @@ -1248,7 +1248,7 @@ func TestWorkspaceFilterManual(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1276,7 +1276,7 @@ func TestWorkspaceFilterManual(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1316,10 +1316,10 @@ func TestWorkspaceFilterManual(t *testing.T) { authToken := 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: "example", Type: "aws_instance", @@ -1363,9 +1363,9 @@ func TestWorkspaceFilterManual(t *testing.T) { TemplateScheduleStore: schedule.MockTemplateScheduleStore{ SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { if atomic.AddInt64(&setCalled, 1) == 2 { - assert.Equal(t, inactivityTTL, options.InactivityTTL) + assert.Equal(t, inactivityTTL, options.TimeTilDormant) } - template.InactivityTTL = int64(options.InactivityTTL) + template.TimeTilDormant = int64(options.TimeTilDormant) return template, nil }, }, @@ -1374,7 +1374,7 @@ func TestWorkspaceFilterManual(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1385,11 +1385,11 @@ func TestWorkspaceFilterManual(t *testing.T) { defer cancel() template, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - InactivityTTLMillis: inactivityTTL.Milliseconds(), + TimeTilDormantMillis: inactivityTTL.Milliseconds(), }) assert.NoError(t, err) - assert.Equal(t, inactivityTTL.Milliseconds(), template.InactivityTTLMillis) + assert.Equal(t, inactivityTTL.Milliseconds(), template.TimeTilDormantMillis) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) @@ -1404,9 +1404,105 @@ func TestWorkspaceFilterManual(t *testing.T) { assert.NoError(t, err) // we are expecting that no workspaces are returned as user is unlicensed - // and template.InactivityTTL should be 0 + // and template.TimeTilDormant should be 0 assert.Len(t, res.Workspaces, 0) }) + + t.Run("DormantAt", func(t *testing.T) { + // this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + // update template with inactivity ttl + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + dormantWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, dormantWorkspace.LatestBuild.ID) + + // Create another workspace to validate that we do not return active workspaces. + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, dormantWorkspace.LatestBuild.ID) + + err := client.UpdateWorkspaceDormancy(ctx, dormantWorkspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, + }) + require.NoError(t, err) + + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("dormant_at:%s", time.Now().Add(-time.Minute).Format("2006-01-02")), + }) + require.NoError(t, err) + require.Len(t, res.Workspaces, 1) + require.NotNil(t, res.Workspaces[0].DormantAt) + }) + + t.Run("LastUsed", func(t *testing.T) { + t.Parallel() + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + // update template with inactivity ttl + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + now := database.Now() + before := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, before.LatestBuild.ID) + + after := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, after.LatestBuild.ID) + + //nolint:gocritic // Unit testing context + err := api.Database.UpdateWorkspaceLastUsedAt(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceLastUsedAtParams{ + ID: before.ID, + LastUsedAt: now.UTC().Add(time.Hour * -1), + }) + require.NoError(t, err) + + // Unit testing context + //nolint:gocritic // Unit testing context + err = api.Database.UpdateWorkspaceLastUsedAt(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceLastUsedAtParams{ + ID: after.ID, + LastUsedAt: now.UTC().Add(time.Hour * 1), + }) + require.NoError(t, err) + + beforeRes, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("last_used_before:%q", now.Format(time.RFC3339)), + }) + require.NoError(t, err) + require.Len(t, beforeRes.Workspaces, 1) + require.Equal(t, before.ID, beforeRes.Workspaces[0].ID) + + afterRes, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("last_used_after:%q", now.Format(time.RFC3339)), + }) + require.NoError(t, err) + require.Len(t, afterRes.Workspaces, 1) + require.Equal(t, after.ID, afterRes.Workspaces[0].ID) + }) } func TestOffsetLimit(t *testing.T) { @@ -1479,7 +1575,7 @@ func TestPostWorkspaceBuild(t *testing.T) { client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - ProvisionApply: []*proto.Provision_Response{{}}, + ProvisionApply: []*proto.Response{{}}, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) @@ -2042,10 +2138,10 @@ func TestWorkspaceWatcher(t *testing.T) { authToken := 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: "example", Type: "aws_instance", @@ -2081,9 +2177,14 @@ func TestWorkspaceWatcher(t *testing.T) { case w, ok := <-wc: require.True(t, ok, "watch channel closed: %s", event) if ready == nil || ready(w) { - logger.Info(ctx, "done waiting for event", slog.F("event", event)) + logger.Info(ctx, "done waiting for event", + slog.F("event", event), + slog.F("workspace", w)) return } + logger.Info(ctx, "skipped update for event", + slog.F("event", event), + slog.F("workspace", w)) } } } @@ -2130,10 +2231,10 @@ func TestWorkspaceWatcher(t *testing.T) { // Add a new version that will fail. badVersion := 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{ Error: "test error", }, }, @@ -2154,12 +2255,23 @@ func TestWorkspaceWatcher(t *testing.T) { }) // We want to verify pending state here, but it's possible that we reach // failed state fast enough that we never see pending. + sawFailed := false wait("workspace build pending or failed", func(w codersdk.Workspace) bool { - return w.LatestBuild.Status == codersdk.WorkspaceStatusPending || w.LatestBuild.Status == codersdk.WorkspaceStatusFailed - }) - wait("workspace build failed", func(w codersdk.Workspace) bool { - return w.LatestBuild.Status == codersdk.WorkspaceStatusFailed + switch w.LatestBuild.Status { + case codersdk.WorkspaceStatusPending: + return true + case codersdk.WorkspaceStatusFailed: + sawFailed = true + return true + default: + return false + } }) + if !sawFailed { + wait("workspace build failed", func(w codersdk.Workspace) bool { + return w.LatestBuild.Status == codersdk.WorkspaceStatusFailed + }) + } closeFunc.Close() build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart) @@ -2187,9 +2299,9 @@ func TestWorkspaceResource(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: "beta", Type: "example", @@ -2255,9 +2367,9 @@ func TestWorkspaceResource(t *testing.T) { } 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: "some", Type: "example", @@ -2312,9 +2424,9 @@ func TestWorkspaceResource(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: "some", Type: "example", @@ -2385,10 +2497,10 @@ func TestWorkspaceWithRichParameters(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &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: firstParameterName, @@ -2409,9 +2521,9 @@ func TestWorkspaceWithRichParameters(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, }) @@ -2478,10 +2590,10 @@ func TestWorkspaceWithOptionalRichParameters(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &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: firstParameterName, @@ -2500,9 +2612,9 @@ func TestWorkspaceWithOptionalRichParameters(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, }) @@ -2569,10 +2681,10 @@ func TestWorkspaceWithEphemeralRichParameters(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &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: firstParameterName, @@ -2594,9 +2706,9 @@ func TestWorkspaceWithEphemeralRichParameters(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, }) @@ -2670,21 +2782,21 @@ func TestWorkspaceWithEphemeralRichParameters(t *testing.T) { require.ElementsMatch(t, expectedBuildParameters, workspaceBuildParameters) } -func TestWorkspaceLock(t *testing.T) { +func TestWorkspaceDormant(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { t.Parallel() var ( - 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) - lockedTTL = time.Minute + 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) + timeTilDormantAutoDelete = time.Minute ) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.LockedTTLMillis = ptr.Ref[int64](lockedTTL.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](timeTilDormantAutoDelete.Milliseconds()) }) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) @@ -2693,32 +2805,32 @@ func TestWorkspaceLock(t *testing.T) { defer cancel() lastUsedAt := workspace.LastUsedAt - err := client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: true, + err := client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, }) require.NoError(t, err) workspace = coderdtest.MustWorkspace(t, client, workspace.ID) require.NoError(t, err, "fetch provisioned workspace") - // The template doesn't have a locked_ttl set so this should be nil. + // The template doesn't have a time_til_dormant_autodelete set so this should be nil. require.Nil(t, workspace.DeletingAt) - require.NotNil(t, workspace.LockedAt) - require.WithinRange(t, *workspace.LockedAt, time.Now().Add(-time.Second*10), time.Now()) + require.NotNil(t, workspace.DormantAt) + require.WithinRange(t, *workspace.DormantAt, time.Now().Add(-time.Second*10), time.Now()) require.Equal(t, lastUsedAt, workspace.LastUsedAt) workspace = coderdtest.MustWorkspace(t, client, workspace.ID) lastUsedAt = workspace.LastUsedAt - err = client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: false, + err = client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: false, }) require.NoError(t, err) workspace, err = client.Workspace(ctx, workspace.ID) require.NoError(t, err, "fetch provisioned workspace") - require.Nil(t, workspace.LockedAt) - // The template doesn't have a locked_ttl set so this should be nil. + require.Nil(t, workspace.DormantAt) + // The template doesn't have a time_til_dormant_autodelete set so this should be nil. require.Nil(t, workspace.DeletingAt) - // The last_used_at should get updated when we unlock the workspace. + // The last_used_at should get updated when we activate the workspace. require.True(t, workspace.LastUsedAt.After(lastUsedAt)) }) @@ -2737,23 +2849,23 @@ func TestWorkspaceLock(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - err := client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: true, + err := client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, }) require.NoError(t, err) - // Should be able to stop a workspace while it is locked. + // Should be able to stop a workspace while it is dormant. coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - // Should not be able to start a workspace while it is locked. + // Should not be able to start a workspace while it is dormant. _, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: template.ActiveVersionID, Transition: codersdk.WorkspaceTransition(database.WorkspaceTransitionStart), }) require.Error(t, err) - err = client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: false, + err = client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: false, }) require.NoError(t, err) coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 06b4d8c167cf9..26090a4f1be99 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -14,13 +14,13 @@ import ( "github.com/sqlc-dev/pqtype" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/db2sdk" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/provisionerdserver" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/provisionerdserver" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/codersdk" ) // Builder encapsulates the business logic of inserting a new workspace build into the database. @@ -525,7 +525,7 @@ func (b *Builder) getParameters() (names, values []string, err error) { // At this point, we've queried all the data we need from the database, // so the only errors are problems with the request (missing data, failed // validation, immutable parameters, etc.) - return nil, nil, BuildError{http.StatusBadRequest, err.Error(), err} + return nil, nil, BuildError{http.StatusBadRequest, fmt.Sprintf("Unable to validate parameter %q", templateVersionParameter.Name), err} } names = append(names, templateVersionParameter.Name) values = append(values, value) diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index afb7c5af75579..a8b6ef21f0285 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -13,11 +13,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/dbmock" - "github.com/coder/coder/coderd/provisionerdserver" - "github.com/coder/coder/coderd/wsbuilder" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/provisionerdserver" + "github.com/coder/coder/v2/coderd/wsbuilder" + "github.com/coder/coder/v2/codersdk" ) var ( diff --git a/coderd/wsconncache/wsconncache.go b/coderd/wsconncache/wsconncache.go index 4ff7f30e049eb..b9d362eac3163 100644 --- a/coderd/wsconncache/wsconncache.go +++ b/coderd/wsconncache/wsconncache.go @@ -16,8 +16,8 @@ import ( "golang.org/x/sync/singleflight" "golang.org/x/xerrors" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/site" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/site" ) type AgentProvider struct { diff --git a/coderd/wsconncache/wsconncache_test.go b/coderd/wsconncache/wsconncache_test.go index d0769cf057316..68e41b17517fa 100644 --- a/coderd/wsconncache/wsconncache_test.go +++ b/coderd/wsconncache/wsconncache_test.go @@ -23,13 +23,13 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/agent" - "github.com/coder/coder/coderd/wsconncache" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "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/coderd/wsconncache" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/tailnettest" + "github.com/coder/coder/v2/testutil" ) func TestMain(m *testing.M) { @@ -179,9 +179,10 @@ func setupAgent(t *testing.T, manifest agentsdk.Manifest, ptyTimeout time.Durati _ = closer.Close() }) conn, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, - DERPMap: manifest.DERPMap, - Logger: slogtest.Make(t, nil).Named("tailnet").Leveled(slog.LevelDebug), + Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, + DERPMap: manifest.DERPMap, + DERPForceWebSockets: manifest.DERPForceWebSockets, + Logger: slogtest.Make(t, nil).Named("tailnet").Leveled(slog.LevelDebug), }) require.NoError(t, err) clientConn, serverConn := net.Pipe() diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index e2189e1cc53d2..fb1b2f497410b 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -19,7 +19,7 @@ import ( "tailscale.com/tailcfg" "cdr.dev/slog" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" "github.com/coder/retry" ) @@ -89,6 +89,7 @@ type Manifest struct { VSCodePortProxyURI string `json:"vscode_port_proxy_uri"` Apps []codersdk.WorkspaceApp `json:"apps"` DERPMap *tailcfg.DERPMap `json:"derpmap"` + DERPForceWebSockets bool `json:"derp_force_websockets"` EnvironmentVariables map[string]string `json:"environment_variables"` StartupScript string `json:"startup_script"` StartupScriptTimeout time.Duration `json:"startup_script_timeout"` @@ -612,9 +613,9 @@ func (c *Client) PostLifecycle(ctx context.Context, req PostLifecycleRequest) er } type PostStartupRequest struct { - Version string `json:"version"` - ExpandedDirectory string `json:"expanded_directory"` - Subsystem codersdk.AgentSubsystem `json:"subsystem"` + Version string `json:"version"` + ExpandedDirectory string `json:"expanded_directory"` + Subsystems []codersdk.AgentSubsystem `json:"subsystems"` } func (c *Client) PostStartup(ctx context.Context, req PostStartupRequest) error { diff --git a/codersdk/agentsdk/logs.go b/codersdk/agentsdk/logs.go index 2eb7d4856f27e..52f30f9fe3989 100644 --- a/codersdk/agentsdk/logs.go +++ b/codersdk/agentsdk/logs.go @@ -11,7 +11,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" "github.com/coder/retry" ) diff --git a/codersdk/agentsdk/logs_test.go b/codersdk/agentsdk/logs_test.go index 1cca86242e065..7c1d7b0bf814c 100644 --- a/codersdk/agentsdk/logs_test.go +++ b/codersdk/agentsdk/logs_test.go @@ -13,9 +13,9 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/testutil" ) func TestStartupLogsWriter_Write(t *testing.T) { diff --git a/codersdk/apikey.go b/codersdk/apikey.go index 514b519f5ffda..32c97cf538417 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -28,6 +28,7 @@ type APIKey struct { type LoginType string const ( + LoginTypeUnknown LoginType = "" LoginTypePassword LoginType = "password" LoginTypeGithub LoginType = "github" LoginTypeOIDC LoginType = "oidc" diff --git a/codersdk/audit.go b/codersdk/audit.go index 1ff2fb8ac97a1..5ceae81a21c42 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -24,6 +24,8 @@ const ( ResourceTypeGroup ResourceType = "group" ResourceTypeLicense ResourceType = "license" ResourceTypeConvertLogin ResourceType = "convert_login" + ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" + ResourceTypeOrganization ResourceType = "organization" ) func (r ResourceType) FriendlyString() string { @@ -50,6 +52,10 @@ func (r ResourceType) FriendlyString() string { return "license" case ResourceTypeConvertLogin: return "login type conversion" + case ResourceTypeWorkspaceProxy: + return "workspace proxy" + case ResourceTypeOrganization: + return "organization" default: return "unknown" } diff --git a/codersdk/client.go b/codersdk/client.go index ad9e46ccf7f4a..9c34245bcba76 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -20,7 +20,7 @@ import ( "go.opentelemetry.io/otel/semconv/v1.14.0/httpconv" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/tracing" "cdr.dev/slog" ) @@ -71,6 +71,9 @@ const ( // command that was invoked to produce the request. It is for internal use // only. CLITelemetryHeader = "Coder-CLI-Telemetry" + + // ProvisionerDaemonPSK contains the authentication pre-shared key for an external provisioner daemon + ProvisionerDaemonPSK = "Coder-Provisioner-Daemon-PSK" ) // loggableMimeTypes is a list of MIME types that are safe to log diff --git a/codersdk/client_internal_test.go b/codersdk/client_internal_test.go index 60e61c9309afb..ae86ce81ef3b7 100644 --- a/codersdk/client_internal_test.go +++ b/codersdk/client_internal_test.go @@ -27,7 +27,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/testutil" ) const jsonCT = "application/json" diff --git a/codersdk/deployment.go b/codersdk/deployment.go index af861138e6d7d..a8356b6816554 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -16,8 +16,8 @@ import ( "github.com/coreos/go-oidc/v3/oidc" - "github.com/coder/coder/buildinfo" - "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/cli/clibase" ) // Entitlement represents whether a feature is licensed. @@ -103,6 +103,7 @@ type Entitlements struct { HasLicense bool `json:"has_license"` Trial bool `json:"trial"` RequireTelemetry bool `json:"require_telemetry"` + RefreshedAt time.Time `json:"refreshed_at" format:"date-time"` } func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) { @@ -228,9 +229,10 @@ type DERPServerConfig struct { } type DERPConfig struct { - BlockDirect clibase.Bool `json:"block_direct" typescript:",notnull"` - URL clibase.String `json:"url" typescript:",notnull"` - Path clibase.String `json:"path" typescript:",notnull"` + BlockDirect clibase.Bool `json:"block_direct" typescript:",notnull"` + ForceWebSockets clibase.Bool `json:"force_websockets" typescript:",notnull"` + URL clibase.String `json:"url" typescript:",notnull"` + Path clibase.String `json:"path" typescript:",notnull"` } type PrometheusConfig struct { @@ -260,9 +262,12 @@ type OAuth2GithubConfig struct { } type OIDCConfig struct { - AllowSignups clibase.Bool `json:"allow_signups" typescript:",notnull"` - ClientID clibase.String `json:"client_id" typescript:",notnull"` - ClientSecret clibase.String `json:"client_secret" typescript:",notnull"` + AllowSignups clibase.Bool `json:"allow_signups" typescript:",notnull"` + ClientID clibase.String `json:"client_id" typescript:",notnull"` + ClientSecret clibase.String `json:"client_secret" typescript:",notnull"` + // ClientKeyFile & ClientCertFile are used in place of ClientSecret for PKI auth. + ClientKeyFile clibase.String `json:"client_key_file" typescript:",notnull"` + ClientCertFile clibase.String `json:"client_cert_file" typescript:",notnull"` EmailDomain clibase.StringArray `json:"email_domain" typescript:",notnull"` IssuerURL clibase.String `json:"issuer_url" typescript:",notnull"` Scopes clibase.StringArray `json:"scopes" typescript:",notnull"` @@ -271,6 +276,8 @@ type OIDCConfig struct { EmailField clibase.String `json:"email_field" typescript:",notnull"` AuthURLParams clibase.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"` IgnoreUserInfo clibase.Bool `json:"ignore_user_info" typescript:",notnull"` + GroupAutoCreate clibase.Bool `json:"group_auto_create" typescript:",notnull"` + GroupRegexFilter clibase.Regexp `json:"group_regex_filter" typescript:",notnull"` GroupField clibase.String `json:"groups_field" typescript:",notnull"` GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"` UserRoleField clibase.String `json:"user_role_field" typescript:",notnull"` @@ -328,6 +335,7 @@ type ProvisionerConfig struct { DaemonPollInterval clibase.Duration `json:"daemon_poll_interval" typescript:",notnull"` DaemonPollJitter clibase.Duration `json:"daemon_poll_jitter" typescript:",notnull"` ForceCancelInterval clibase.Duration `json:"force_cancel_interval" typescript:",notnull"` + DaemonPSK clibase.String `json:"daemon_psk" typescript:",notnull"` } type RateLimitConfig struct { @@ -730,6 +738,7 @@ when required by your organization's security policy.`, Value: &c.DERP.Server.RegionID, Group: &deploymentGroupNetworkingDERP, YAML: "regionID", + Hidden: true, // Does not apply to external proxies as this value is generated. }, { @@ -741,6 +750,7 @@ when required by your organization's security policy.`, Value: &c.DERP.Server.RegionCode, Group: &deploymentGroupNetworkingDERP, YAML: "regionCode", + Hidden: true, // Does not apply to external proxies as we use the proxy name. }, { @@ -756,10 +766,10 @@ when required by your organization's security policy.`, }, { Name: "DERP Server STUN Addresses", - Description: "Addresses for STUN servers to establish P2P connections. Use special value 'disable' to turn off STUN.", + Description: "Addresses for STUN servers to establish P2P connections. It's recommended to have at least two STUN servers to give users the best chance of connecting P2P to workspaces. Each STUN server will get it's own DERP region, with region IDs starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn off STUN completely.", Flag: "derp-server-stun-addresses", Env: "CODER_DERP_SERVER_STUN_ADDRESSES", - Default: "stun.l.google.com:19302", + Default: "stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302", Value: &c.DERP.Server.STUNAddresses, Group: &deploymentGroupNetworkingDERP, YAML: "stunAddresses", @@ -788,6 +798,15 @@ when required by your organization's security policy.`, Group: &deploymentGroupNetworkingDERP, YAML: "blockDirect", }, + { + Name: "DERP Force WebSockets", + Description: "Force clients and agents to always use WebSocket to connect to DERP relay servers. By default, DERP uses `Upgrade: derp`, which may cause issues with some reverse proxies. Clients may automatically fallback to WebSocket if they detect an issue with `Upgrade: derp`, but this does not work in all situations.", + Flag: "derp-force-websockets", + Env: "CODER_DERP_FORCE_WEBSOCKETS", + Value: &c.DERP.Config.ForceWebSockets, + Group: &deploymentGroupNetworkingDERP, + YAML: "forceWebSockets", + }, { Name: "DERP Config URL", Description: "URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/.", @@ -963,6 +982,26 @@ when required by your organization's security policy.`, Value: &c.OIDC.ClientSecret, Group: &deploymentGroupOIDC, }, + { + Name: "OIDC Client Key File", + Description: "Pem encoded RSA private key to use for oauth2 PKI/JWT authorization. " + + "This can be used instead of oidc-client-secret if your IDP supports it.", + Flag: "oidc-client-key-file", + Env: "CODER_OIDC_CLIENT_KEY_FILE", + YAML: "oidcClientKeyFile", + Value: &c.OIDC.ClientKeyFile, + Group: &deploymentGroupOIDC, + }, + { + Name: "OIDC Client Cert File", + Description: "Pem encoded certificate file to use for oauth2 PKI/JWT authorization. " + + "The public certificate that accompanies oidc-client-key-file. A standard x509 certificate is expected.", + Flag: "oidc-client-cert-file", + Env: "CODER_OIDC_CLIENT_CERT_FILE", + YAML: "oidcClientCertFile", + Value: &c.OIDC.ClientCertFile, + Group: &deploymentGroupOIDC, + }, { Name: "OIDC Email Domain", Description: "Email domains that clients logging in with OIDC must match.", @@ -1065,6 +1104,26 @@ when required by your organization's security policy.`, Group: &deploymentGroupOIDC, YAML: "groupMapping", }, + { + Name: "Enable OIDC Group Auto Create", + Description: "Automatically creates missing groups from a user's groups claim.", + Flag: "oidc-group-auto-create", + Env: "CODER_OIDC_GROUP_AUTO_CREATE", + Default: "false", + Value: &c.OIDC.GroupAutoCreate, + Group: &deploymentGroupOIDC, + YAML: "enableGroupAutoCreate", + }, + { + Name: "OIDC Regex Group Filter", + Description: "If provided any group name not matching the regex is ignored. This allows for filtering out groups that are not needed. This filter is applied after the group mapping.", + Flag: "oidc-group-regex-filter", + Env: "CODER_OIDC_GROUP_REGEX_FILTER", + Default: ".*", + Value: &c.OIDC.GroupRegexFilter, + Group: &deploymentGroupOIDC, + YAML: "groupRegexFilter", + }, { Name: "OIDC User Role Field", Description: "This field must be set if using the user roles sync feature. Set this to the name of the claim used to store the user's role. The roles should be sent as an array of strings.", @@ -1230,6 +1289,15 @@ when required by your organization's security policy.`, Group: &deploymentGroupProvisioning, YAML: "forceCancelInterval", }, + { + Name: "Provisioner Daemon Pre-shared Key (PSK)", + Description: "Pre-shared key to authenticate external provisioner daemons to Coder server.", + Flag: "provisioner-daemon-psk", + Env: "CODER_PROVISIONER_DAEMON_PSK", + Value: &c.Provisioner.DaemonPSK, + Group: &deploymentGroupProvisioning, + YAML: "daemonPSK", + }, // RateLimit settings { Name: "Disable All Rate Limits", @@ -1871,6 +1939,9 @@ const ( // Deployment health page ExperimentDeploymentHealthPage Experiment = "deployment_health_page" + // Workspaces batch actions + ExperimentWorkspacesBatchActions Experiment = "workspaces_batch_actions" + // Add new experiments here! // ExperimentExample Experiment = "example" ) @@ -1881,6 +1952,7 @@ const ( // not be included here and will be essentially hidden. var ExperimentsAll = Experiments{ ExperimentDeploymentHealthPage, + ExperimentWorkspacesBatchActions, } // Experiments is a list of experiments that are enabled for the deployment. diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index eb4e267364c98..408aa4fd21ae5 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -7,8 +7,8 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/codersdk" ) type exclusion struct { diff --git a/codersdk/groups.go b/codersdk/groups.go index c04267e4e0eb2..2796a776a960a 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -10,6 +10,13 @@ import ( "golang.org/x/xerrors" ) +type GroupSource string + +const ( + GroupSourceUser GroupSource = "user" + GroupSourceOIDC GroupSource = "oidc" +) + type CreateGroupRequest struct { Name string `json:"name"` DisplayName string `json:"display_name"` @@ -18,13 +25,18 @@ type CreateGroupRequest struct { } type Group struct { - ID uuid.UUID `json:"id" format:"uuid"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` - Members []User `json:"members"` - AvatarURL string `json:"avatar_url"` - QuotaAllowance int `json:"quota_allowance"` + ID uuid.UUID `json:"id" format:"uuid"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + Members []User `json:"members"` + AvatarURL string `json:"avatar_url"` + QuotaAllowance int `json:"quota_allowance"` + Source GroupSource `json:"source"` +} + +func (g Group) IsEveryone() bool { + return g.ID == g.OrganizationID } func (c *Client) CreateGroup(ctx context.Context, orgID uuid.UUID, req CreateGroupRequest) (Group, error) { diff --git a/codersdk/insights.go b/codersdk/insights.go index bfc51bd208dd6..6780b82d45d43 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strings" "time" @@ -61,18 +62,18 @@ type UserLatencyInsightsRequest struct { } func (c *Client) UserLatencyInsights(ctx context.Context, req UserLatencyInsightsRequest) (UserLatencyInsightsResponse, error) { - var qp []string - qp = append(qp, fmt.Sprintf("start_time=%s", req.StartTime.Format(insightsTimeLayout))) - qp = append(qp, fmt.Sprintf("end_time=%s", req.EndTime.Format(insightsTimeLayout))) + qp := url.Values{} + qp.Add("start_time", req.StartTime.Format(insightsTimeLayout)) + qp.Add("end_time", req.EndTime.Format(insightsTimeLayout)) if len(req.TemplateIDs) > 0 { var templateIDs []string for _, id := range req.TemplateIDs { templateIDs = append(templateIDs, id.String()) } - qp = append(qp, fmt.Sprintf("template_ids=%s", strings.Join(templateIDs, ","))) + qp.Add("template_ids", strings.Join(templateIDs, ",")) } - reqURL := fmt.Sprintf("/api/v2/insights/user-latency?%s", strings.Join(qp, "&")) + reqURL := fmt.Sprintf("/api/v2/insights/user-latency?%s", qp.Encode()) resp, err := c.Request(ctx, http.MethodGet, reqURL, nil) if err != nil { return UserLatencyInsightsResponse{}, xerrors.Errorf("make request: %w", err) @@ -118,8 +119,7 @@ type TemplateAppsType string // TemplateAppsType enums. const ( TemplateAppsTypeBuiltin TemplateAppsType = "builtin" - // TODO(mafredri): To be introduced in a future pull request. - // TemplateAppsTypeApp TemplateAppsType = "app" + TemplateAppsTypeApp TemplateAppsType = "app" ) // TemplateAppUsage shows the usage of an app for one or more templates. @@ -138,6 +138,8 @@ type TemplateParameterUsage struct { TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` DisplayName string `json:"display_name"` Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` Options []TemplateVersionParameterOption `json:"options,omitempty"` Values []TemplateParameterValue `json:"values"` } @@ -157,21 +159,21 @@ type TemplateInsightsRequest struct { } func (c *Client) TemplateInsights(ctx context.Context, req TemplateInsightsRequest) (TemplateInsightsResponse, error) { - var qp []string - qp = append(qp, fmt.Sprintf("start_time=%s", req.StartTime.Format(insightsTimeLayout))) - qp = append(qp, fmt.Sprintf("end_time=%s", req.EndTime.Format(insightsTimeLayout))) + qp := url.Values{} + qp.Add("start_time", req.StartTime.Format(insightsTimeLayout)) + qp.Add("end_time", req.EndTime.Format(insightsTimeLayout)) if len(req.TemplateIDs) > 0 { var templateIDs []string for _, id := range req.TemplateIDs { templateIDs = append(templateIDs, id.String()) } - qp = append(qp, fmt.Sprintf("template_ids=%s", strings.Join(templateIDs, ","))) + qp.Add("template_ids", strings.Join(templateIDs, ",")) } if req.Interval != "" { - qp = append(qp, fmt.Sprintf("interval=%s", req.Interval)) + qp.Add("interval", string(req.Interval)) } - reqURL := fmt.Sprintf("/api/v2/insights/templates?%s", strings.Join(qp, "&")) + reqURL := fmt.Sprintf("/api/v2/insights/templates?%s", qp.Encode()) resp, err := c.Request(ctx, http.MethodGet, reqURL, nil) if err != nil { return TemplateInsightsResponse{}, xerrors.Errorf("make request: %w", err) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 26290fd4f4761..0b4af0e67056a 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -108,12 +108,12 @@ type CreateTemplateRequest struct { // FailureTTLMillis allows optionally specifying the max lifetime before Coder // stops all resources for failed workspaces created from this template. FailureTTLMillis *int64 `json:"failure_ttl_ms,omitempty"` - // InactivityTTLMillis allows optionally specifying the max lifetime before Coder + // TimeTilDormantMillis allows optionally specifying the max lifetime before Coder // locks inactive workspaces created from this template. - InactivityTTLMillis *int64 `json:"inactivity_ttl_ms,omitempty"` - // LockedTTLMillis allows optionally specifying the max lifetime before Coder - // permanently deletes locked workspaces created from this template. - LockedTTLMillis *int64 `json:"locked_ttl_ms,omitempty"` + TimeTilDormantMillis *int64 `json:"dormant_ttl_ms,omitempty"` + // TimeTilDormantAutoDeleteMillis allows optionally specifying the max lifetime before Coder + // permanently deletes dormant workspaces created from this template. + TimeTilDormantAutoDeleteMillis *int64 `json:"delete_ttl_ms,omitempty"` // DisableEveryoneGroupAccess allows optionally disabling the default // behavior of granting the 'everyone' group access to use the template. @@ -149,10 +149,11 @@ func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, return organization, json.NewDecoder(res.Body).Decode(&organization) } -// ProvisionerDaemonsByOrganization returns provisioner daemons available for an organization. +// ProvisionerDaemons returns provisioner daemons available. func (c *Client) ProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error) { res, err := c.Request(ctx, http.MethodGet, - "/api/v2/provisionerdaemons", + // TODO: the organization path parameter is currently ignored. + "/api/v2/organizations/default/provisionerdaemons", nil, ) if err != nil { diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 1c9378f718b3a..4a3e280697f74 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -16,8 +16,8 @@ import ( "golang.org/x/xerrors" "nhooyr.io/websocket" - "github.com/coder/coder/provisionerd/proto" - "github.com/coder/coder/provisionersdk" + "github.com/coder/coder/v2/provisionerd/proto" + "github.com/coder/coder/v2/provisionersdk" ) type LogSource string @@ -69,7 +69,6 @@ const ( type JobErrorCode string const ( - MissingTemplateParameter JobErrorCode = "MISSING_TEMPLATE_PARAMETER" RequiredTemplateVariables JobErrorCode = "REQUIRED_TEMPLATE_VARIABLES" ) @@ -81,7 +80,7 @@ type ProvisionerJob struct { CompletedAt *time.Time `json:"completed_at,omitempty" format:"date-time"` CanceledAt *time.Time `json:"canceled_at,omitempty" format:"date-time"` Error string `json:"error,omitempty"` - ErrorCode JobErrorCode `json:"error_code,omitempty" enums:"MISSING_TEMPLATE_PARAMETER,REQUIRED_TEMPLATE_VARIABLES"` + ErrorCode JobErrorCode `json:"error_code,omitempty" enums:"REQUIRED_TEMPLATE_VARIABLES"` Status ProvisionerJobStatus `json:"status" enums:"pending,running,succeeded,canceling,canceled,failed"` WorkerID *uuid.UUID `json:"worker_id,omitempty" format:"uuid"` FileID uuid.UUID `json:"file_id" format:"uuid"` @@ -164,38 +163,61 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after }), nil } -// ListenProvisionerDaemon returns the gRPC service for a provisioner daemon +// ServeProvisionerDaemonRequest are the parameters to call ServeProvisionerDaemon with +// @typescript-ignore ServeProvisionerDaemonRequest +type ServeProvisionerDaemonRequest struct { + // Organization is the organization for the URL. At present provisioner daemons ARE NOT scoped to organizations + // and so the organization ID is optional. + Organization uuid.UUID `json:"organization" format:"uuid"` + // Provisioners is a list of provisioner types hosted by the provisioner daemon + Provisioners []ProvisionerType `json:"provisioners"` + // Tags is a map of key-value pairs that tag the jobs this provisioner daemon can handle + Tags map[string]string `json:"tags"` + // PreSharedKey is an authentication key to use on the API instead of the normal session token from the client. + PreSharedKey string `json:"pre_shared_key"` +} + +// ServeProvisionerDaemon returns the gRPC service for a provisioner daemon // implementation. The context is during dial, not during the lifetime of the // client. Client should be closed after use. -func (c *Client) ServeProvisionerDaemon(ctx context.Context, organization uuid.UUID, provisioners []ProvisionerType, tags map[string]string) (proto.DRPCProvisionerDaemonClient, error) { - serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/organizations/%s/provisionerdaemons/serve", organization)) +func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisionerDaemonRequest) (proto.DRPCProvisionerDaemonClient, error) { + serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/organizations/%s/provisionerdaemons/serve", req.Organization)) if err != nil { return nil, xerrors.Errorf("parse url: %w", err) } query := serverURL.Query() - for _, provisioner := range provisioners { + for _, provisioner := range req.Provisioners { query.Add("provisioner", string(provisioner)) } - for key, value := range tags { + for key, value := range req.Tags { query.Add("tag", fmt.Sprintf("%s=%s", key, value)) } serverURL.RawQuery = query.Encode() - jar, err := cookiejar.New(nil) - if err != nil { - return nil, xerrors.Errorf("create cookie jar: %w", err) - } - jar.SetCookies(serverURL, []*http.Cookie{{ - Name: SessionTokenCookie, - Value: c.SessionToken(), - }}) httpClient := &http.Client{ - Jar: jar, Transport: c.HTTPClient.Transport, } + headers := http.Header{} + + if req.PreSharedKey == "" { + // use session token if we don't have a PSK. + jar, err := cookiejar.New(nil) + if err != nil { + return nil, xerrors.Errorf("create cookie jar: %w", err) + } + jar.SetCookies(serverURL, []*http.Cookie{{ + Name: SessionTokenCookie, + Value: c.SessionToken(), + }}) + httpClient.Jar = jar + } else { + headers.Set(ProvisionerDaemonPSK, req.PreSharedKey) + } + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ HTTPClient: httpClient, // Need to disable compression to avoid a data-race. CompressionMode: websocket.CompressionDisabled, + HTTPHeader: headers, }) if err != nil { if res == nil { diff --git a/codersdk/richparameters_test.go b/codersdk/richparameters_test.go index df70c9c10e164..a7ab416b98bff 100644 --- a/codersdk/richparameters_test.go +++ b/codersdk/richparameters_test.go @@ -5,8 +5,8 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" ) func TestParameterResolver_ValidateResolve_New(t *testing.T) { diff --git a/codersdk/serversentevents.go b/codersdk/serversentevents.go index 56457a0c9224e..8c026524c7d92 100644 --- a/codersdk/serversentevents.go +++ b/codersdk/serversentevents.go @@ -9,7 +9,7 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/tracing" ) type ServerSentEvent struct { diff --git a/codersdk/templates.go b/codersdk/templates.go index 2022c876db360..406933c72b75f 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -44,12 +44,12 @@ type Template struct { AllowUserAutostop bool `json:"allow_user_autostop"` AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs"` - // FailureTTLMillis, InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their + // FailureTTLMillis, TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their // values are used if your license is entitled to use the advanced // template scheduling feature. - FailureTTLMillis int64 `json:"failure_ttl_ms"` - InactivityTTLMillis int64 `json:"inactivity_ttl_ms"` - LockedTTLMillis int64 `json:"locked_ttl_ms"` + FailureTTLMillis int64 `json:"failure_ttl_ms"` + TimeTilDormantMillis int64 `json:"time_til_dormant_ms"` + TimeTilDormantAutoDeleteMillis int64 `json:"time_til_dormant_autodelete_ms"` } // WeekdaysToBitmap converts a list of weekdays to a bitmap in accordance with @@ -185,13 +185,22 @@ type UpdateTemplateMeta struct { // RestartRequirement can only be set if your license includes the advanced // template scheduling feature. If you attempt to set this value while // unlicensed, it will be ignored. - RestartRequirement *TemplateRestartRequirement `json:"restart_requirement,omitempty"` - AllowUserAutostart bool `json:"allow_user_autostart,omitempty"` - AllowUserAutostop bool `json:"allow_user_autostop,omitempty"` - AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` - FailureTTLMillis int64 `json:"failure_ttl_ms,omitempty"` - InactivityTTLMillis int64 `json:"inactivity_ttl_ms,omitempty"` - LockedTTLMillis int64 `json:"locked_ttl_ms,omitempty"` + RestartRequirement *TemplateRestartRequirement `json:"restart_requirement,omitempty"` + AllowUserAutostart bool `json:"allow_user_autostart,omitempty"` + AllowUserAutostop bool `json:"allow_user_autostop,omitempty"` + AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` + FailureTTLMillis int64 `json:"failure_ttl_ms,omitempty"` + TimeTilDormantMillis int64 `json:"time_til_dormant_ms,omitempty"` + TimeTilDormantAutoDeleteMillis int64 `json:"time_til_dormant_autodelete_ms,omitempty"` + // UpdateWorkspaceLastUsedAt updates the last_used_at field of workspaces + // spawned from the template. This is useful for preventing workspaces being + // immediately locked when updating the inactivity_ttl field to a new, shorter + // value. + UpdateWorkspaceLastUsedAt bool `json:"update_workspace_last_used_at"` + // UpdateWorkspaceDormant updates the dormant_at field of workspaces spawned + // from the template. This is useful for preventing dormant workspaces being immediately + // deleted when updating the dormant_ttl field to a new, shorter value. + UpdateWorkspaceDormantAt bool `json:"update_workspace_dormant_at"` } type TemplateExample struct { diff --git a/codersdk/time_test.go b/codersdk/time_test.go index c9f5ca05839f3..a2d3b20622ba7 100644 --- a/codersdk/time_test.go +++ b/codersdk/time_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/codersdk" + "github.com/coder/coder/v2/codersdk" ) func TestNullTime_MarshalJSON(t *testing.T) { diff --git a/codersdk/users.go b/codersdk/users.go index daeefee5f12bf..c11846ebdac2b 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -78,9 +78,12 @@ type CreateFirstUserResponse struct { type CreateUserRequest struct { Email string `json:"email" validate:"required,email" format:"email"` Username string `json:"username" validate:"required,username"` - Password string `json:"password" validate:"required_if=DisableLogin false"` + Password string `json:"password"` + // UserLoginType defaults to LoginTypePassword. + UserLoginType LoginType `json:"login_type"` // DisableLogin sets the user's login type to 'none'. This prevents the user // from being able to use a password or any other authentication method to login. + // Deprecated: Set UserLoginType=LoginTypeDisabled instead. DisableLogin bool `json:"disable_login"` OrganizationID uuid.UUID `json:"organization_id" validate:"" format:"uuid"` } diff --git a/codersdk/workspaceagentconn.go b/codersdk/workspaceagentconn.go index 6b9b6f0d33f44..e38b4f2a47f06 100644 --- a/codersdk/workspaceagentconn.go +++ b/codersdk/workspaceagentconn.go @@ -21,8 +21,8 @@ import ( "tailscale.com/ipn/ipnstate" "tailscale.com/net/speedtest" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/tailnet" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/tailnet" ) // WorkspaceAgentIP is a static IPv6 address with the Tailscale prefix that is used to route diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index e9aad8421e36a..bbb6c373c6984 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -20,8 +20,8 @@ import ( "tailscale.com/tailcfg" "cdr.dev/slog" - "github.com/coder/coder/coderd/tracing" - "github.com/coder/coder/tailnet" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/tailnet" "github.com/coder/retry" ) @@ -167,7 +167,7 @@ type WorkspaceAgent struct { LoginBeforeReady bool `json:"login_before_ready"` ShutdownScript string `json:"shutdown_script,omitempty"` ShutdownScriptTimeoutSeconds int32 `json:"shutdown_script_timeout_seconds"` - Subsystem AgentSubsystem `json:"subsystem"` + Subsystems []AgentSubsystem `json:"subsystems"` Health WorkspaceAgentHealth `json:"health"` // Health reports the health of the agent. } @@ -186,6 +186,7 @@ type DERPRegion struct { // @typescript-ignore WorkspaceAgentConnectionInfo type WorkspaceAgentConnectionInfo struct { DERPMap *tailcfg.DERPMap `json:"derp_map"` + DERPForceWebSockets bool `json:"derp_force_websockets"` DisableDirectConnections bool `json:"disable_direct_connections"` } @@ -247,11 +248,12 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti header = headerTransport.Header() } conn, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)}, - DERPMap: connInfo.DERPMap, - DERPHeader: &header, - Logger: options.Logger, - BlockEndpoints: c.DisableDirectConnections || options.BlockEndpoints, + Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)}, + DERPMap: connInfo.DERPMap, + DERPHeader: &header, + DERPForceWebSockets: connInfo.DERPForceWebSockets, + Logger: options.Logger, + BlockEndpoints: c.DisableDirectConnections || options.BlockEndpoints, }) if err != nil { return nil, xerrors.Errorf("create tailnet: %w", err) @@ -754,9 +756,20 @@ type WorkspaceAgentLog struct { type AgentSubsystem string const ( - AgentSubsystemEnvbox AgentSubsystem = "envbox" + AgentSubsystemEnvbox AgentSubsystem = "envbox" + AgentSubsystemEnvbuilder AgentSubsystem = "envbuilder" + AgentSubsystemExectrace AgentSubsystem = "exectrace" ) +func (s AgentSubsystem) Valid() bool { + switch s { + case AgentSubsystemEnvbox, AgentSubsystemEnvbuilder, AgentSubsystemExectrace: + return true + default: + return false + } +} + type WorkspaceAgentLogSource string const ( diff --git a/codersdk/workspaceagents_test.go b/codersdk/workspaceagents_test.go index 6373160c299e1..766203268c20a 100644 --- a/codersdk/workspaceagents_test.go +++ b/codersdk/workspaceagents_test.go @@ -15,9 +15,9 @@ import ( "tailscale.com/tailcfg" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/codersdk/agentsdk" - "github.com/coder/coder/testutil" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/testutil" ) func TestWorkspaceAgentMetadata(t *testing.T) { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index a2ef823fcb87e..05e1a156d1122 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -11,7 +11,7 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/v2/coderd/tracing" ) // Workspace is a deployment of a template. It references a specific @@ -28,6 +28,7 @@ type Workspace struct { TemplateDisplayName string `json:"template_display_name"` TemplateIcon string `json:"template_icon"` TemplateAllowUserCancelWorkspaceJobs bool `json:"template_allow_user_cancel_workspace_jobs"` + TemplateActiveVersionID uuid.UUID `json:"template_active_version_id" format:"uuid"` LatestBuild WorkspaceBuild `json:"latest_build"` Outdated bool `json:"outdated"` Name string `json:"name"` @@ -35,19 +36,24 @@ type Workspace struct { TTLMillis *int64 `json:"ttl_ms,omitempty"` LastUsedAt time.Time `json:"last_used_at" format:"date-time"` - // DeletingAt indicates the time of the upcoming workspace deletion, if applicable; otherwise it is nil. - // Workspaces may have impending deletions if Template.InactivityTTL feature is turned on and the workspace is inactive. + // DeletingAt indicates the time at which the workspace will be permanently deleted. + // A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) + // and a value has been specified for time_til_dormant_autodelete on its template. DeletingAt *time.Time `json:"deleting_at" format:"date-time"` - // LockedAt being non-nil indicates a workspace that has been locked. - // A locked workspace is no longer accessible by a user and must be - // unlocked by an admin. It is subject to deletion if it breaches - // the duration of the locked_ttl field on its template. - LockedAt *time.Time `json:"locked_at" format:"date-time"` + // DormantAt being non-nil indicates a workspace that is dormant. + // A dormant workspace is no longer accessible must be activated. + // It is subject to deletion if it breaches + // the duration of the time_til_ field on its template. + DormantAt *time.Time `json:"dormant_at" format:"date-time"` // Health shows the health of the workspace and information about // what is causing an unhealthy status. Health WorkspaceHealth `json:"health"` } +func (w Workspace) FullName() string { + return fmt.Sprintf("%s/%s", w.OwnerName, w.Name) +} + type WorkspaceHealth struct { Healthy bool `json:"healthy" example:"false"` // Healthy is true if the workspace is healthy. FailingAgents []uuid.UUID `json:"failing_agents" format:"uuid"` // FailingAgents lists the IDs of the agents that are failing, if any. @@ -289,14 +295,16 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx return nil } -// UpdateWorkspaceLock is a request to lock or unlock a workspace. -type UpdateWorkspaceLock struct { - Lock bool `json:"lock"` +// UpdateWorkspaceDormancy is a request to activate or make a workspace dormant. +// A value of false will activate a dormant workspace. +type UpdateWorkspaceDormancy struct { + Dormant bool `json:"dormant"` } -// UpdateWorkspaceLock locks or unlocks a workspace. -func (c *Client) UpdateWorkspaceLock(ctx context.Context, id uuid.UUID, req UpdateWorkspaceLock) error { - path := fmt.Sprintf("/api/v2/workspaces/%s/lock", id.String()) +// UpdateWorkspaceDormancy sets a workspace as dormant if dormant=true and activates a dormant workspace +// if dormant=false. +func (c *Client) UpdateWorkspaceDormancy(ctx context.Context, id uuid.UUID, req UpdateWorkspaceDormancy) error { + path := fmt.Sprintf("/api/v2/workspaces/%s/dormant", id.String()) res, err := c.Request(ctx, http.MethodPut, path, req) if err != nil { return xerrors.Errorf("update workspace lock: %w", err) diff --git a/cryptorand/errors_test.go b/cryptorand/errors_test.go index 36ce2faf6beab..6abc2143875e2 100644 --- a/cryptorand/errors_test.go +++ b/cryptorand/errors_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/cryptorand" ) // TestRandError checks that the code handles errors when reading from diff --git a/cryptorand/numbers_test.go b/cryptorand/numbers_test.go index 0ffedf78b9d9e..aec9c89a7476c 100644 --- a/cryptorand/numbers_test.go +++ b/cryptorand/numbers_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/cryptorand" ) func TestInt63(t *testing.T) { diff --git a/cryptorand/slices_test.go b/cryptorand/slices_test.go index f4c7be248c0cc..1838bcf6119da 100644 --- a/cryptorand/slices_test.go +++ b/cryptorand/slices_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/cryptorand" ) func TestRandomElement(t *testing.T) { diff --git a/cryptorand/strings_test.go b/cryptorand/strings_test.go index 3f6025e0f9588..60be57ce0f400 100644 --- a/cryptorand/strings_test.go +++ b/cryptorand/strings_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/cryptorand" + "github.com/coder/coder/v2/cryptorand" ) func TestString(t *testing.T) { diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e794828520e81..710152a9f38bb 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -2,13 +2,17 @@ ## Requirements -We recommend using the [Nix](https://nix.dev/) package manager as it makes any pain related to maintaining dependency versions [just disappear](https://twitter.com/mitchellh/status/1491102567296040961). Once nix [has been installed](https://nixos.org/download.html) the development environment can be _manually instantiated_ through the `nix-shell` command: +We recommend using the [Nix](https://nix.dev/) package manager as it makes any +pain related to maintaining dependency versions +[just disappear](https://twitter.com/mitchellh/status/1491102567296040961). Once +nix [has been installed](https://nixos.org/download.html) the development +environment can be _manually instantiated_ through the `nix-shell` command: -``` -$ cd ~/code/coder +```shell +cd ~/code/coder # https://nix.dev/tutorials/declarative-and-reproducible-developer-environments -$ nix-shell +nix-shell ... copying path '/nix/store/3ms6cs5210n8vfb5a7jkdvzrzdagqzbp-iana-etc-20210225' from 'https://cache.nixos.org'... @@ -17,20 +21,24 @@ copying path '/nix/store/v2gvj8whv241nj4lzha3flq8pnllcmvv-ignore-5.2.0.tgz' from ... ``` -If [direnv](https://direnv.net/) is installed and the [hooks are configured](https://direnv.net/docs/hook.html) then the development environment can be _automatically instantiated_ by creating the following `.envrc`, thus removing the need to run `nix-shell` by hand! +If [direnv](https://direnv.net/) is installed and the +[hooks are configured](https://direnv.net/docs/hook.html) then the development +environment can be _automatically instantiated_ by creating the following +`.envrc`, thus removing the need to run `nix-shell` by hand! -``` -$ cd ~/code/coder -$ echo "use nix" >.envrc -$ direnv allow +```shell +cd ~/code/coder +echo "use nix" >.envrc +direnv allow ``` -Now, whenever you enter the project folder, `direnv` will prepare the environment for you: +Now, whenever you enter the project folder, +[`direnv`](https://direnv.net/docs/hook.html) will prepare the environment for +you: -``` -$ cd ~/code/coder +```shell +cd ~/code/coder -# https://direnv.net/docs/hook.html direnv: loading ~/code/coder/.envrc direnv: using nix direnv: export +AR +AS +CC +CONFIG_SHELL +CXX +HOST_PATH +IN_NIX_SHELL +LD +NIX_BINTOOLS +NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_BUILD_CORES +NIX_BUILD_TOP +NIX_CC +NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_CFLAGS_COMPILE +NIX_ENFORCE_NO_NATIVE +NIX_HARDENING_ENABLE +NIX_INDENT_MAKE +NIX_LDFLAGS +NIX_STORE +NM +NODE_PATH +OBJCOPY +OBJDUMP +RANLIB +READELF +SIZE +SOURCE_DATE_EPOCH +STRINGS +STRIP +TEMP +TEMPDIR +TMP +TMPDIR +XDG_DATA_DIRS +buildInputs +buildPhase +builder +cmakeFlags +configureFlags +depsBuildBuild +depsBuildBuildPropagated +depsBuildTarget +depsBuildTargetPropagated +depsHostHost +depsHostHostPropagated +depsTargetTarget +depsTargetTargetPropagated +doCheck +doInstallCheck +mesonFlags +name +nativeBuildInputs +out +outputs +patches +phases +propagatedBuildInputs +propagatedNativeBuildInputs +shell +shellHook +stdenv +strictDeps +system ~PATH @@ -38,7 +46,8 @@ direnv: export +AR +AS +CC +CONFIG_SHELL +CXX +HOST_PATH +IN_NIX_SHELL +LD +NIX_ 🎉 ``` -Alternatively if you do not want to use nix then you'll need to install the need the following tools by hand: +Alternatively if you do not want to use nix then you'll need to install the need +the following tools by hand: - Go 1.18+ - on macOS, run `brew install go` @@ -53,15 +62,15 @@ Alternatively if you do not want to use nix then you'll need to install the need - [`pg_dump`](https://stackoverflow.com/a/49689589) - on macOS, run `brew install libpq zstd` - on Linux, install [`zstd`](https://github.com/horta/zstd.install) -- [`pkg-config`]() +- `pkg-config` - on macOS, run `brew install pkg-config` -- [`pixman`]() +- `pixman` - on macOS, run `brew install pixman` -- [`cairo`]() +- `cairo` - on macOS, run `brew install cairo` -- [`pango`]() +- `pango` - on macOS, run `brew install pango` -- [`pandoc`]() +- `pandoc` - on macOS, run `brew install pandocomatic` ### Development workflow @@ -77,44 +86,57 @@ Use the following `make` commands and scripts in development: - Run `./scripts/develop.sh` - Access `http://localhost:8080` -- The default user is `admin@coder.com` and the default password is `SomeSecurePassword!` +- The default user is `admin@coder.com` and the default password is + `SomeSecurePassword!` ### Deploying a PR -You can test your changes by creating a PR deployment. There are two ways to do this: +You can test your changes by creating a PR deployment. There are two ways to do +this: 1. By running `./scripts/deploy-pr.sh` -2. By manually triggering the [`pr-deploy.yaml`](https://github.com/coder/coder/actions/workflows/pr-deploy.yaml) GitHub Action workflow - ![Deploy PR manually](./images/pr-deploy-manual.png) +2. By manually triggering the + [`pr-deploy.yaml`](https://github.com/coder/coder/actions/workflows/pr-deploy.yaml) + GitHub Action workflow ![Deploy PR manually](./images/deploy-pr-manually.png) #### Available options -- `-s` or `--skip-build`, force prevents the build of the Docker image.(generally not needed as we are intelligently checking if the image needs to be built) -- `-e EXPERIMENT1,EXPERIMENT2` or `--experiments EXPERIMENT1,EXPERIMENT2`, will enable the specified experiments. (defaults to `*`) -- `-n` or `--dry-run` will display the context without deployment. e.g., branch name and PR number, etc. +- `-d` or `--deploy`, force deploys the PR by deleting the existing deployment. +- `-b` or `--build`, force builds the Docker image. (generally not needed as we + are intelligently checking if the image needs to be built) +- `-e EXPERIMENT1,EXPERIMENT2` or `--experiments EXPERIMENT1,EXPERIMENT2`, will + enable the specified experiments. (defaults to `*`) +- `-n` or `--dry-run` will display the context without deployment. e.g., branch + name and PR number, etc. - `-y` or `--yes`, will skip the CLI confirmation prompt. -> Note: PR deployment will be re-deployed automatically when the PR is updated. It will use the last values automatically for redeployment. +> Note: PR deployment will be re-deployed automatically when the PR is updated. +> It will use the last values automatically for redeployment. -> You need to be a member or collaborator of the of [coder](github.com/coder) GitHub organization to be able to deploy a PR. +> You need to be a member or collaborator of the of [coder](github.com/coder) +> GitHub organization to be able to deploy a PR. -Once the deployment is finished, a unique link and credentials will be posted in the [#pr-deployments](https://codercom.slack.com/archives/C05DNE982E8) Slack channel. +Once the deployment is finished, a unique link and credentials will be posted in +the [#pr-deployments](https://codercom.slack.com/archives/C05DNE982E8) Slack +channel. ### Adding database migrations and fixtures #### Database migrations -Database migrations are managed with [`migrate`](https://github.com/golang-migrate/migrate). +Database migrations are managed with +[`migrate`](https://github.com/golang-migrate/migrate). To add new migrations, use the following command: -``` -$ ./coderd/database/migrations/create_fixture.sh my name +```shell +./coderd/database/migrations/create_fixture.sh my name /home/coder/src/coder/coderd/database/migrations/000070_my_name.up.sql /home/coder/src/coder/coderd/database/migrations/000070_my_name.down.sql -Run "make gen" to generate models. ``` +Run "make gen" to generate models. + Then write queries into the generated `.up.sql` and `.down.sql` files and commit them into the repository. The down script should make a best-effort to retain as much data as possible. @@ -124,11 +146,15 @@ much data as possible. There are two types of fixtures that are used to test that migrations don't break existing Coder deployments: -- Partial fixtures [`migrations/testdata/fixtures`](../coderd/database/migrations/testdata/fixtures) -- Full database dumps [`migrations/testdata/full_dumps`](../coderd/database/migrations/testdata/full_dumps) +- Partial fixtures + [`migrations/testdata/fixtures`](../coderd/database/migrations/testdata/fixtures) +- Full database dumps + [`migrations/testdata/full_dumps`](../coderd/database/migrations/testdata/full_dumps) -Both types behave like database migrations (they also [`migrate`](https://github.com/golang-migrate/migrate)). Their behavior mirrors Coder migrations such that when migration -number `000022` is applied, fixture `000022` is applied afterwards. +Both types behave like database migrations (they also +[`migrate`](https://github.com/golang-migrate/migrate)). Their behavior mirrors +Coder migrations such that when migration number `000022` is applied, fixture +`000022` is applied afterwards. Partial fixtures are used to conveniently add data to newly created tables so that we can ensure that this data is migrated without issue. @@ -140,8 +166,8 @@ migration of multiple features or complex configurations. To add a new partial fixture, run the following command: -``` -$ ./coderd/database/migrations/create_fixture.sh my fixture +```shell +./coderd/database/migrations/create_fixture.sh my fixture /home/coder/src/coder/coderd/database/migrations/testdata/fixtures/000070_my_fixture.up.sql ``` @@ -153,9 +179,9 @@ To create a full dump, run a fully fledged Coder deployment and use it to generate data in the database. Then shut down the deployment and take a snapshot of the database. -``` -$ mkdir -p coderd/database/migrations/testdata/full_dumps/v0.12.2 && cd $_ -$ pg_dump "postgres://coder@localhost:..." -a --inserts >000069_dump_v0.12.2.up.sql +```shell +mkdir -p coderd/database/migrations/testdata/full_dumps/v0.12.2 && cd $_ +pg_dump "postgres://coder@localhost:..." -a --inserts >000069_dump_v0.12.2.up.sql ``` Make sure sensitive data in the dump is desensitized, for instance names, @@ -164,8 +190,8 @@ emails, OAuth tokens and other secrets. Then commit the dump to the project. To find out what the latest migration for a version of Coder is, use the following command: -``` -$ git ls-files v0.12.2 -- coderd/database/migrations/*.up.sql +```shell +git ls-files v0.12.2 -- coderd/database/migrations/*.up.sql ``` This helps in naming the dump (e.g. `000069` above). @@ -174,19 +200,20 @@ This helps in naming the dump (e.g. `000069` above). ### Documentation -Our style guide for authoring documentation can be found [here](./contributing/documentation.md). +Our style guide for authoring documentation can be found +[here](./contributing/documentation.md). ### Backend #### Use Go style -Contributions must adhere to the guidelines outlined in [Effective -Go](https://go.dev/doc/effective_go). We prefer linting rules over documenting -styles (run ours with `make lint`); humans are error-prone! +Contributions must adhere to the guidelines outlined in +[Effective Go](https://go.dev/doc/effective_go). We prefer linting rules over +documenting styles (run ours with `make lint`); humans are error-prone! -Read [Go's Code Review Comments -Wiki](https://github.com/golang/go/wiki/CodeReviewComments) for information on -common comments made during reviews of Go code. +Read +[Go's Code Review Comments Wiki](https://github.com/golang/go/wiki/CodeReviewComments) +for information on common comments made during reviews of Go code. #### Avoid unused packages @@ -201,8 +228,8 @@ Our frontend guide can be found [here](./contributing/frontend.md). ## Reviews -> The following information has been borrowed from [Go's review -> philosophy](https://go.dev/doc/contribute#reviews). +> The following information has been borrowed from +> [Go's review philosophy](https://go.dev/doc/contribute#reviews). Coder values thorough reviews. For each review comment that you receive, please "close" it by implementing the suggestion or providing an explanation on why the @@ -219,27 +246,45 @@ be applied selectively or to discourage anyone from contributing. ## Releases -Coder releases are initiated via [`./scripts/release.sh`](../scripts/release.sh) and automated via GitHub Actions. Specifically, the [`release.yaml`](../.github/workflows/release.yaml) workflow. They are created based on the current [`main`](https://github.com/coder/coder/tree/main) branch. +Coder releases are initiated via [`./scripts/release.sh`](../scripts/release.sh) +and automated via GitHub Actions. Specifically, the +[`release.yaml`](../.github/workflows/release.yaml) workflow. They are created +based on the current [`main`](https://github.com/coder/coder/tree/main) branch. -The release notes for a release are automatically generated from commit titles and metadata from PRs that are merged into `main`. +The release notes for a release are automatically generated from commit titles +and metadata from PRs that are merged into `main`. ### Creating a release -The creation of a release is initiated via [`./scripts/release.sh`](../scripts/release.sh). This script will show a preview of the release that will be created, and if you choose to continue, create and push the tag which will trigger the creation of the release via GitHub Actions. +The creation of a release is initiated via +[`./scripts/release.sh`](../scripts/release.sh). This script will show a preview +of the release that will be created, and if you choose to continue, create and +push the tag which will trigger the creation of the release via GitHub Actions. See `./scripts/release.sh --help` for more information. ### Creating a release (via workflow dispatch) -Typically the workflow dispatch is only used to test (dry-run) a release, meaning no actual release will take place. The workflow can be dispatched manually from [Actions: Release](https://github.com/coder/coder/actions/workflows/release.yaml). Simply press "Run workflow" and choose dry-run. +Typically the workflow dispatch is only used to test (dry-run) a release, +meaning no actual release will take place. The workflow can be dispatched +manually from +[Actions: Release](https://github.com/coder/coder/actions/workflows/release.yaml). +Simply press "Run workflow" and choose dry-run. -If a release has failed after the tag has been created and pushed, it can be retried by again, pressing "Run workflow", changing "Use workflow from" from "Branch: main" to "Tag: vX.X.X" and not selecting dry-run. +If a release has failed after the tag has been created and pushed, it can be +retried by again, pressing "Run workflow", changing "Use workflow from" from +"Branch: main" to "Tag: vX.X.X" and not selecting dry-run. ### Commit messages -Commit messages should follow the [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) specification. +Commit messages should follow the +[Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) +specification. -Allowed commit types (`feat`, `fix`, etc.) are listed in [conventional-commit-types](https://github.com/commitizen/conventional-commit-types/blob/c3a9be4c73e47f2e8197de775f41d981701407fb/index.json). Note that these types are also used to automatically sort and organize the release notes. +Allowed commit types (`feat`, `fix`, etc.) are listed in +[conventional-commit-types](https://github.com/commitizen/conventional-commit-types/blob/c3a9be4c73e47f2e8197de775f41d981701407fb/index.json). +Note that these types are also used to automatically sort and organize the +release notes. A good commit message title uses the imperative, present tense and is ~50 characters long (no more than 72). @@ -249,21 +294,34 @@ Examples: - Good: `feat(api): add feature X` - Bad: `feat(api): added feature X` (past tense) -A good rule of thumb for writing good commit messages is to recite: [If applied, this commit will ...](https://reflectoring.io/meaningful-commit-messages/). +A good rule of thumb for writing good commit messages is to recite: +[If applied, this commit will ...](https://reflectoring.io/meaningful-commit-messages/). -**Note:** We lint PR titles to ensure they follow the Conventional Commits specification, however, it's still possible to merge PRs on GitHub with a badly formatted title. Take care when merging single-commit PRs as GitHub may prefer to use the original commit title instead of the PR title. +**Note:** We lint PR titles to ensure they follow the Conventional Commits +specification, however, it's still possible to merge PRs on GitHub with a badly +formatted title. Take care when merging single-commit PRs as GitHub may prefer +to use the original commit title instead of the PR title. ### Breaking changes Breaking changes can be triggered in two ways: -- Add `!` to the commit message title, e.g. `feat(api)!: remove deprecated endpoint /test` -- Add the [`release/breaking`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Arelease%2Fbreaking) label to a PR that has, or will be, merged into `main`. +- Add `!` to the commit message title, e.g. + `feat(api)!: remove deprecated endpoint /test` +- Add the + [`release/breaking`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Arelease%2Fbreaking) + label to a PR that has, or will be, merged into `main`. ### Security -The [`security`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Asecurity) label can be added to PRs that have, or will be, merged into `main`. Doing so will make sure the change stands out in the release notes. +The +[`security`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Asecurity) +label can be added to PRs that have, or will be, merged into `main`. Doing so +will make sure the change stands out in the release notes. ### Experimental -The [`release/experimental`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Arelease%2Fexperimental) label can be used to move the note to the bottom of the release notes under a separate title. +The +[`release/experimental`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Arelease%2Fexperimental) +label can be used to move the note to the bottom of the release notes under a +separate title. diff --git a/docs/about/architecture.md b/docs/about/architecture.md index 45ef36b99b891..9489ee7fc8e16 100644 --- a/docs/about/architecture.md +++ b/docs/about/architecture.md @@ -8,9 +8,9 @@ This document provides a high level overview of Coder's architecture. ## coderd -coderd is the service created by running `coder server`. It is a thin -API that connects workspaces, provisioners and users. coderd stores its state in -Postgres and is the only service that communicates with Postgres. +coderd is the service created by running `coder server`. It is a thin API that +connects workspaces, provisioners and users. coderd stores its state in Postgres +and is the only service that communicates with Postgres. It offers: @@ -22,16 +22,18 @@ It offers: ## provisionerd -provisionerd is the execution context for infrastructure modifying providers. -At the moment, the only provider is Terraform (running `terraform`). +provisionerd is the execution context for infrastructure modifying providers. At +the moment, the only provider is Terraform (running `terraform`). -By default, the Coder server runs multiple provisioner daemons. [External provisioners](../admin/provisioners.md) can be added for security or scalability purposes. +By default, the Coder server runs multiple provisioner daemons. +[External provisioners](../admin/provisioners.md) can be added for security or +scalability purposes. ## Agents -An agent is the Coder service that runs within a user's remote workspace. -It provides a consistent interface for coderd and clients to communicate -with workspaces regardless of operating system, architecture, or cloud. +An agent is the Coder service that runs within a user's remote workspace. It +provides a consistent interface for coderd and clients to communicate with +workspaces regardless of operating system, architecture, or cloud. It offers the following services along with much more: @@ -40,15 +42,20 @@ It offers the following services along with much more: - Liveness checks - `startup_script` automation -Templates are responsible for [creating and running agents](../templates/index.md#coder-agent) within workspaces. +Templates are responsible for +[creating and running agents](../templates/index.md#coder-agent) within +workspaces. ## Service Bundling -While coderd and Postgres can be orchestrated independently,our default installation -paths bundle them all together into one system service. It's perfectly fine to run a production deployment this way, but there are certain situations that necessitate decomposition: +While coderd and Postgres can be orchestrated independently,our default +installation paths bundle them all together into one system service. It's +perfectly fine to run a production deployment this way, but there are certain +situations that necessitate decomposition: - Reducing global client latency (distribute coderd and centralize database) -- Achieving greater availability and efficiency (horizontally scale individual services) +- Achieving greater availability and efficiency (horizontally scale individual + services) ## Workspaces diff --git a/docs/admin/app-logs.md b/docs/admin/app-logs.md index 87efe05ae6061..8235fda06eda8 100644 --- a/docs/admin/app-logs.md +++ b/docs/admin/app-logs.md @@ -1,21 +1,28 @@ # Application Logs -In Coderd, application logs refer to the records of events, messages, and activities generated by the application during its execution. -These logs provide valuable information about the application's behavior, performance, and any issues that may have occurred. +In Coderd, application logs refer to the records of events, messages, and +activities generated by the application during its execution. These logs provide +valuable information about the application's behavior, performance, and any +issues that may have occurred. -Application logs include entries that capture events on different levels of severity: +Application logs include entries that capture events on different levels of +severity: - Informational messages - Warnings - Errors - Debugging information -By analyzing application logs, system administrators can gain insights into the application's behavior, identify and diagnose problems, track performance metrics, and make informed decisions to improve the application's stability and efficiency. +By analyzing application logs, system administrators can gain insights into the +application's behavior, identify and diagnose problems, track performance +metrics, and make informed decisions to improve the application's stability and +efficiency. ## Error logs -To ensure effective monitoring and timely response to critical events in the Coder application, it is recommended to configure log alerts -that specifically watch for the following log entries: +To ensure effective monitoring and timely response to critical events in the +Coder application, it is recommended to configure log alerts that specifically +watch for the following log entries: | Log Level | Module | Log message | Potential issues | | --------- | ---------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------- | diff --git a/docs/admin/appearance.md b/docs/admin/appearance.md index 5d061b3bb1f6d..f80ffc8c1bcfe 100644 --- a/docs/admin/appearance.md +++ b/docs/admin/appearance.md @@ -2,12 +2,15 @@ ## Support Links -Support links let admins adjust the user dropdown menu to include links referring to internal company resources. The menu section replaces the original menu positions: documentation, report a bug to GitHub, or join the Discord server. +Support links let admins adjust the user dropdown menu to include links +referring to internal company resources. The menu section replaces the original +menu positions: documentation, report a bug to GitHub, or join the Discord +server. ![support links](../images/admin/support-links.png) -Custom links can be set in the deployment configuration using the `-c ` -flag to `coder server`. +Custom links can be set in the deployment configuration using the +`-c ` flag to `coder server`. ```yaml supportLinks: @@ -27,7 +30,8 @@ The link icons are optional, and limited to: `bug`, `chat`, and `docs`. ## Service Banners (enterprise) -Service Banners let admins post important messages to all site users. Only Site Owners may set the service banner. +Service Banners let admins post important messages to all site users. Only Site +Owners may set the service banner. ![service banners](../images/admin/service-banners.png) diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 882a7274c737f..6d7293731f6cf 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -1,7 +1,6 @@ # Audit Logs -Audit Logs allows **Auditors** to monitor user operations in -their deployment. +Audit Logs allows **Auditors** to monitor user operations in their deployment. ## Tracked Events @@ -9,52 +8,66 @@ We track the following resources: -| Resource | | -| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_days_of_weektrue
restart_requirement_weekstrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
git_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
idtrue
last_used_atfalse
locked_attrue
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_days_of_weektrue
restart_requirement_weekstrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
git_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| ## Filtering logs -In the Coder UI you can filter your audit logs using the pre-defined filter or by using the Coder's filter query like the examples below: +In the Coder UI you can filter your audit logs using the pre-defined filter or +by using the Coder's filter query like the examples below: - `resource_type:workspace action:delete` to find deleted workspaces - `resource_type:template action:create` to find created templates The supported filters are: -- `resource_type` - The type of the resource. It can be a workspace, template, user, etc. You can [find here](https://pkg.go.dev/github.com/coder/coder/codersdk#ResourceType) all the resource types that are supported. +- `resource_type` - The type of the resource. It can be a workspace, template, + user, etc. You can + [find here](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ResourceType) + all the resource types that are supported. - `resource_id` - The ID of the resource. -- `resource_target` - The name of the resource. Can be used instead of `resource_id`. -- `action`- The action applied to a resource. You can [find here](https://pkg.go.dev/github.com/coder/coder/codersdk#AuditAction) all the actions that are supported. -- `username` - The username of the user who triggered the action. You can also use `me` as a convenient alias for the logged-in user. +- `resource_target` - The name of the resource. Can be used instead of + `resource_id`. +- `action`- The action applied to a resource. You can + [find here](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#AuditAction) + all the actions that are supported. +- `username` - The username of the user who triggered the action. You can also + use `me` as a convenient alias for the logged-in user. - `email` - The email of the user who triggered the action. - `date_from` - The inclusive start date with format `YYYY-MM-DD`. - `date_to` - The inclusive end date with format `YYYY-MM-DD`. -- `build_reason` - To be used with `resource_type:workspace_build`, the [initiator](https://pkg.go.dev/github.com/coder/coder/codersdk#BuildReason) behind the build start or stop. +- `build_reason` - To be used with `resource_type:workspace_build`, the + [initiator](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#BuildReason) + behind the build start or stop. ## Capturing/Exporting Audit Logs -In addition to the user interface, there are multiple ways to consume or query audit trails. +In addition to the user interface, there are multiple ways to consume or query +audit trails. ## REST API -Audit logs can be accessed through our REST API. You can find detailed information about this in our [endpoint documentation](../api/audit.md#get-audit-logs). +Audit logs can be accessed through our REST API. You can find detailed +information about this in our +[endpoint documentation](../api/audit.md#get-audit-logs). ## Service Logs -Audit trails are also dispatched as service logs and can be captured and categorized using any log management tool such as [Splunk](https://splunk.com). +Audit trails are also dispatched as service logs and can be captured and +categorized using any log management tool such as [Splunk](https://splunk.com). Example of a [JSON formatted](../cli/server.md#--log-json) audit log entry: @@ -93,10 +106,11 @@ Example of a [JSON formatted](../cli/server.md#--log-json) audit log entry: Example of a [human readable](../cli/server.md#--log-human) audit log entry: -```sh +```console 2023-06-13 03:43:29.233 [info] coderd: audit_log ID=95f7c392-da3e-480c-a579-8909f145fbe2 Time="2023-06-13T03:43:29.230422Z" UserID=6c405053-27e3-484a-9ad7-bcb64e7bfde6 OrganizationID=00000000-0000-0000-0000-000000000000 Ip= UserAgent= ResourceType=workspace_build ResourceID=988ae133-5b73-41e3-a55e-e1e9d3ef0b66 ResourceTarget="" Action=start Diff="{}" StatusCode=200 AdditionalFields="{\"workspace_name\":\"linux-container\",\"build_number\":\"7\",\"build_reason\":\"initiator\",\"workspace_owner\":\"\"}" RequestID=9682b1b5-7b9f-4bf2-9a39-9463f8e41cd6 ResourceIcon="" ``` ## Enabling this feature -This feature is only available with an enterprise license. [Learn more](../enterprise.md) +This feature is only available with an enterprise license. +[Learn more](../enterprise.md) diff --git a/docs/admin/auth.md b/docs/admin/auth.md index 16807c159fc37..fb278cf09b058 100644 --- a/docs/admin/auth.md +++ b/docs/admin/auth.md @@ -1,5 +1,7 @@ # Authentication +[OIDC with Coder Sequence Diagram](https://raw.githubusercontent.com/coder/coder/138ee55abb3635cb2f3d12661f8caef2ca9d0961/docs/images/oidc-sequence-diagram.svg). + By default, Coder is accessible via password authentication. Coder does not recommend using password authentication in production, and recommends using an authentication provider with properly configured multi-factor authentication @@ -12,12 +14,19 @@ The following steps explain how to set up GitHub OAuth or OpenID Connect. ### Step 1: Configure the OAuth application in GitHub -First, [register a GitHub OAuth app](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). GitHub will ask you for the following Coder parameters: +First, +[register a GitHub OAuth app](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). +GitHub will ask you for the following Coder parameters: -- **Homepage URL**: Set to your Coder deployments [`CODER_ACCESS_URL`](https://coder.com/docs/v2/latest/cli/server#--access-url) (e.g. `https://coder.domain.com`) +- **Homepage URL**: Set to your Coder deployments + [`CODER_ACCESS_URL`](../cli/server.md#--access-url) (e.g. + `https://coder.domain.com`) - **User Authorization Callback URL**: Set to `https://coder.domain.com` -> Note: If you want to allow multiple coder deployments hosted on subdomains e.g. coder1.domain.com, coder2.domain.com, to be able to authenticate with the same GitHub OAuth app, then you can set **User Authorization Callback URL** to the `https://domain.com` +> Note: If you want to allow multiple coder deployments hosted on subdomains +> e.g. coder1.domain.com, coder2.domain.com, to be able to authenticate with the +> same GitHub OAuth app, then you can set **User Authorization Callback URL** to +> the `https://domain.com` Note the Client ID and Client Secret generated by GitHub. You will use these values in the next step. @@ -27,17 +36,18 @@ values in the next step. Navigate to your Coder host and run the following command to start up the Coder server: -```console +```shell coder server --oauth2-github-allow-signups=true --oauth2-github-allowed-orgs="your-org" --oauth2-github-client-id="8d1...e05" --oauth2-github-client-secret="57ebc9...02c24c" ``` -> For GitHub Enterprise support, specify the `--oauth2-github-enterprise-base-url` flag. +> For GitHub Enterprise support, specify the +> `--oauth2-github-enterprise-base-url` flag. Alternatively, if you are running Coder as a system service, you can achieve the same result as the command above by adding the following environment variables to the `/etc/coder.d/coder.env` file: -```console +```env CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=true CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org" CODER_OAUTH2_GITHUB_CLIENT_ID="8d1...e05" @@ -46,7 +56,7 @@ CODER_OAUTH2_GITHUB_CLIENT_SECRET="57ebc9...02c24c" **Note:** To allow everyone to signup using GitHub, set: -```console +```env CODER_OAUTH2_GITHUB_ALLOW_EVERYONE=true ``` @@ -59,20 +69,22 @@ If deploying Coder via Helm, you can set the above environment variables in the coder: env: - name: CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS - value: true - - name: CODER_OAUTH2_GITHUB_ALLOWED_ORGS - value: "your-org" + value: "true" - name: CODER_OAUTH2_GITHUB_CLIENT_ID value: "533...des" - name: CODER_OAUTH2_GITHUB_CLIENT_SECRET value: "G0CSP...7qSM" - - name: CODER_OAUTH2_GITHUB_ALLOW_EVERYONE - value: true + # If setting allowed orgs, comment out CODER_OAUTH2_GITHUB_ALLOW_EVERYONE and its value + - name: CODER_OAUTH2_GITHUB_ALLOWED_ORGS + value: "your-org" + # If allowing everyone, comment out CODER_OAUTH2_GITHUB_ALLOWED_ORGS and it's value + #- name: CODER_OAUTH2_GITHUB_ALLOW_EVERYONE + # value: "true" ``` To upgrade Coder, run: -```console +```shell helm upgrade coder-v2/coder -n -f values.yaml ``` @@ -82,7 +94,8 @@ helm upgrade coder-v2/coder -n -f values.yaml ## OpenID Connect -The following steps through how to integrate any OpenID Connect provider (Okta, Active Directory, etc.) to Coder. +The following steps through how to integrate any OpenID Connect provider (Okta, +Active Directory, etc.) to Coder. ### Step 1: Set Redirect URI with your OIDC provider @@ -95,15 +108,15 @@ Your OIDC provider will ask you for the following parameter: Navigate to your Coder host and run the following command to start up the Coder server: -```console +```shell coder server --oidc-issuer-url="https://issuer.corp.com" --oidc-email-domain="your-domain-1,your-domain-2" --oidc-client-id="533...des" --oidc-client-secret="G0CSP...7qSM" ``` -If you are running Coder as a system service, you can achieve the -same result as the command above by adding the following environment variables -to the `/etc/coder.d/coder.env` file: +If you are running Coder as a system service, you can achieve the same result as +the command above by adding the following environment variables to the +`/etc/coder.d/coder.env` file: -```console +```env CODER_OIDC_ISSUER_URL="https://issuer.corp.com" CODER_OIDC_EMAIL_DOMAIN="your-domain-1,your-domain-2" CODER_OIDC_CLIENT_ID="533...des" @@ -130,46 +143,46 @@ coder: To upgrade Coder, run: -```console +```shell helm upgrade coder-v2/coder -n -f values.yaml ``` ## OIDC Claims -When a user logs in for the first time via OIDC, Coder will merge both -the claims from the ID token and the claims obtained from hitting the -upstream provider's `userinfo` endpoint, and use the resulting data -as a basis for creating a new user or looking up an existing user. +When a user logs in for the first time via OIDC, Coder will merge both the +claims from the ID token and the claims obtained from hitting the upstream +provider's `userinfo` endpoint, and use the resulting data as a basis for +creating a new user or looking up an existing user. -To troubleshoot claims, set `CODER_VERBOSE=true` and follow the logs -while signing in via OIDC as a new user. Coder will log the claim fields -returned by the upstream identity provider in a message containing the -string `got oidc claims`, as well as the user info returned. +To troubleshoot claims, set `CODER_VERBOSE=true` and follow the logs while +signing in via OIDC as a new user. Coder will log the claim fields returned by +the upstream identity provider in a message containing the string +`got oidc claims`, as well as the user info returned. -> **Note:** If you need to ensure that Coder only uses information from -> the ID token and does not hit the UserInfo endpoint, you can set the -> configuration option `CODER_OIDC_IGNORE_USERINFO=true`. +> **Note:** If you need to ensure that Coder only uses information from the ID +> token and does not hit the UserInfo endpoint, you can set the configuration +> option `CODER_OIDC_IGNORE_USERINFO=true`. ### Email Addresses -By default, Coder will look for the OIDC claim named `email` and use that -value for the newly created user's email address. +By default, Coder will look for the OIDC claim named `email` and use that value +for the newly created user's email address. If your upstream identity provider users a different claim, you can set `CODER_OIDC_EMAIL_FIELD` to the desired claim. -> **Note:** If this field is not present, Coder will attempt to use the -> claim field configured for `username` as an email address. If this field -> is not a valid email address, OIDC logins will fail. +> **Note** If this field is not present, Coder will attempt to use the claim +> field configured for `username` as an email address. If this field is not a +> valid email address, OIDC logins will fail. ### Email Address Verification -Coder requires all OIDC email addresses to be verified by default. If -the `email_verified` claim is present in the token response from the identity +Coder requires all OIDC email addresses to be verified by default. If the +`email_verified` claim is present in the token response from the identity provider, Coder will validate that its value is `true`. If needed, you can disable this behavior with the following setting: -```console +```env CODER_OIDC_IGNORE_EMAIL_VERIFIED=true ``` @@ -178,14 +191,14 @@ CODER_OIDC_IGNORE_EMAIL_VERIFIED=true ### Usernames -When a new user logs in via OIDC, Coder will by default use the value -of the claim field named `preferred_username` as the the username. +When a new user logs in via OIDC, Coder will by default use the value of the +claim field named `preferred_username` as the the username. -If your upstream identity provider uses a different claim, you can -set `CODER_OIDC_USERNAME_FIELD` to the desired claim. +If your upstream identity provider uses a different claim, you can set +`CODER_OIDC_USERNAME_FIELD` to the desired claim. -> **Note:** If this claim is empty, the email address will be stripped of -> the domain, and become the username (e.g. `example@coder.com` becomes `example`). +> **Note:** If this claim is empty, the email address will be stripped of the +> domain, and become the username (e.g. `example@coder.com` becomes `example`). > To avoid conflicts, Coder may also append a random word to the resulting > username. @@ -194,36 +207,38 @@ set `CODER_OIDC_USERNAME_FIELD` to the desired claim. If you'd like to change the OpenID Connect button text and/or icon, you can configure them like so: -```console +```env CODER_OIDC_SIGN_IN_TEXT="Sign in with Gitea" CODER_OIDC_ICON_URL=https://gitea.io/images/gitea.png ``` ## Disable Built-in Authentication -To remove email and password login, set the following environment variable on your -Coder deployment: +To remove email and password login, set the following environment variable on +your Coder deployment: -```console +```env CODER_DISABLE_PASSWORD_AUTH=true ``` ## SCIM (enterprise) Coder supports user provisioning and deprovisioning via SCIM 2.0 with header -authentication. Upon deactivation, users are [suspended](./users.md#suspend-a-user) -and are not deleted. [Configure](./configure.md) your SCIM application with an -auth key and supply it the Coder server. +authentication. Upon deactivation, users are +[suspended](./users.md#suspend-a-user) and are not deleted. +[Configure](./configure.md) your SCIM application with an auth key and supply it +the Coder server. -```console +```env CODER_SCIM_API_KEY="your-api-key" ``` ## TLS -If your OpenID Connect provider requires client TLS certificates for authentication, you can configure them like so: +If your OpenID Connect provider requires client TLS certificates for +authentication, you can configure them like so: -```console +```env CODER_TLS_CLIENT_CERT_FILE=/path/to/cert.pem CODER_TLS_CLIENT_KEY_FILE=/path/to/key.pem ``` @@ -233,22 +248,31 @@ CODER_TLS_CLIENT_KEY_FILE=/path/to/key.pem If your OpenID Connect provider supports group claims, you can configure Coder to synchronize groups in your auth provider to groups within Coder. -To enable group sync, ensure that the `groups` claim is set by adding the correct scope to request. If group sync is -enabled, the user's groups will be controlled by the OIDC provider. This means -manual group additions/removals will be overwritten on the next login. +To enable group sync, ensure that the `groups` claim is set by adding the +correct scope to request. If group sync is enabled, the user's groups will be +controlled by the OIDC provider. This means manual group additions/removals will +be overwritten on the next login. -```console +```env # as an environment variable CODER_OIDC_SCOPES=openid,profile,email,groups +``` + +```shell # as a flag --oidc-scopes openid,profile,email,groups ``` -With the `groups` scope requested, we also need to map the `groups` claim name. Coder recommends using `groups` for the claim name. This step is necessary if your **scope's name** is something other than `groups`. +With the `groups` scope requested, we also need to map the `groups` claim name. +Coder recommends using `groups` for the claim name. This step is necessary if +your **scope's name** is something other than `groups`. -```console +```env # as an environment variable CODER_OIDC_GROUP_FIELD=groups +``` + +```shell # as a flag --oidc-group-field groups ``` @@ -260,9 +284,12 @@ For cases when an OIDC provider only returns group IDs ([Azure AD][azure-gids]) or you want to have different group names in Coder than in your OIDC provider, you can configure mapping between the two. -```console +```env # as an environment variable CODER_OIDC_GROUP_MAPPING='{"myOIDCGroupID": "myCoderGroupName"}' +``` + +```shell # as a flag --oidc-group-mapping '{"myOIDCGroupID": "myCoderGroupName"}' ``` @@ -282,12 +309,47 @@ OIDC provider will be added to the `myCoderGroupName` group in Coder. > **Note:** Groups are only updated on login. -[azure-gids]: https://github.com/MicrosoftDocs/azure-docs/issues/59766#issuecomment-664387195 +[azure-gids]: + https://github.com/MicrosoftDocs/azure-docs/issues/59766#issuecomment-664387195 ### Troubleshooting Some common issues when enabling group sync. +#### User not being assigned / Group does not exist + +If you want Coder to create groups that do not exist, you can set the following +environment variable. If you enable this, your OIDC provider might be sending +over many unnecessary groups. Use filtering options on the OIDC provider to +limit the groups sent over to prevent creating excess groups. + +```env +# as an environment variable +CODER_OIDC_GROUP_AUTO_CREATE=true +``` + +```shell +# as a flag +--oidc-group-auto-create=true +``` + +A basic regex filtering option on the Coder side is available. This is applied +**after** the group mapping (`CODER_OIDC_GROUP_MAPPING`), meaning if the group +is remapped, the remapped value is tested in the regex. This is useful if you +want to filter out groups that do not match a certain pattern. For example, if +you want to only allow groups that start with `my-group-` to be created, you can +set the following environment variable. + +```env +# as an environment variable +CODER_OIDC_GROUP_REGEX_FILTER="^my-group-.*$" +``` + +```shell +# as a flag +--oidc-group-regex-filter="^my-group-.*$" +``` + #### Invalid Scope If you see an error like the following, you may have an invalid scope. @@ -296,28 +358,39 @@ If you see an error like the following, you may have an invalid scope. The application '' asked for scope 'groups' that doesn't exist on the resource... ``` -This can happen because the identity provider has a different name for the scope. For example, Azure AD uses `GroupMember.Read.All` instead of `groups`. You can find the correct scope name in the IDP's documentation. Some IDP's allow configuring the name of this scope. +This can happen because the identity provider has a different name for the +scope. For example, Azure AD uses `GroupMember.Read.All` instead of `groups`. +You can find the correct scope name in the IDP's documentation. Some IDP's allow +configuring the name of this scope. -The solution is to update the value of `CODER_OIDC_SCOPES` to the correct value for the identity provider. +The solution is to update the value of `CODER_OIDC_SCOPES` to the correct value +for the identity provider. #### No `group` claim in the `got oidc claims` log Steps to troubleshoot. -1. Ensure the user is a part of a group in the IDP. If the user has 0 groups, no `groups` claim will be sent. -2. Check if another claim appears to be the correct claim with a different name. A common name is `memberOf` instead of `groups`. If this is present, update `CODER_OIDC_GROUP_FIELD=memberOf`. -3. Make sure the number of groups being sent is under the limit of the IDP. Some IDPs will return an error, while others will just omit the `groups` claim. A common solution is to create a filter on the identity provider that returns less than the limit for your IDP. +1. Ensure the user is a part of a group in the IDP. If the user has 0 groups, no + `groups` claim will be sent. +2. Check if another claim appears to be the correct claim with a different name. + A common name is `memberOf` instead of `groups`. If this is present, update + `CODER_OIDC_GROUP_FIELD=memberOf`. +3. Make sure the number of groups being sent is under the limit of the IDP. Some + IDPs will return an error, while others will just omit the `groups` claim. A + common solution is to create a filter on the identity provider that returns + less than the limit for your IDP. - [Azure AD limit is 200, and omits groups if exceeded.](https://learn.microsoft.com/en-us/azure/active-directory/hybrid/connect/how-to-connect-fed-group-claims#options-for-applications-to-consume-group-information) - [Okta limit is 100, and returns an error if exceeded.](https://developer.okta.com/docs/reference/api/oidc/#scope-dependent-claims-not-always-returned) ## Role sync (enterprise) If your OpenID Connect provider supports roles claims, you can configure Coder -to synchronize roles in your auth provider to deployment-wide roles within Coder. +to synchronize roles in your auth provider to deployment-wide roles within +Coder. Set the following in your Coder server [configuration](./configure.md). -```console +```env # Depending on your identity provider configuration, you may need to explicitly request a "roles" scope CODER_OIDC_SCOPES=openid,profile,email,roles @@ -326,7 +399,8 @@ CODER_OIDC_USER_ROLE_FIELD=roles CODER_OIDC_USER_ROLE_MAPPING='{"TemplateAuthor":["template-admin","user-admin"]}' ``` -> One role from your identity provider can be mapped to many roles in Coder (e.g. the example above maps to 2 roles in Coder.) +> One role from your identity provider can be mapped to many roles in Coder +> (e.g. the example above maps to 2 roles in Coder.) ## Provider-Specific Guides @@ -336,17 +410,20 @@ Below are some details specific to individual OIDC providers. > **Note:** Tested on ADFS 4.0, Windows Server 2019 -1. In your Federation Server, create a new application group for Coder. Follow the - steps as described [here.](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/development/msal/adfs-msal-web-app-web-api#app-registration-in-ad-fs) +1. In your Federation Server, create a new application group for Coder. Follow + the steps as described + [here.](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/development/msal/adfs-msal-web-app-web-api#app-registration-in-ad-fs) - **Server Application**: Note the Client ID. - **Configure Application Credentials**: Note the Client Secret. - **Configure Web API**: Set the Client ID as the relying party identifier. - - **Application Permissions**: Allow access to the claims `openid`, `email`, `profile`, and `allatclaims`. -1. Visit your ADFS server's `/.well-known/openid-configuration` URL and note - the value for `issuer`. - > **Note:** This is usually of the form `https://adfs.corp/adfs/.well-known/openid-configuration` -1. In Coder's configuration file (or Helm values as appropriate), set the following - environment variables or their corresponding CLI arguments: + - **Application Permissions**: Allow access to the claims `openid`, `email`, + `profile`, and `allatclaims`. +1. Visit your ADFS server's `/.well-known/openid-configuration` URL and note the + value for `issuer`. + > **Note:** This is usually of the form + > `https://adfs.corp/adfs/.well-known/openid-configuration` +1. In Coder's configuration file (or Helm values as appropriate), set the + following environment variables or their corresponding CLI arguments: - `CODER_OIDC_ISSUER_URL`: the `issuer` value from the previous step. - `CODER_OIDC_CLIENT_ID`: the Client ID from step 1. @@ -357,28 +434,44 @@ Below are some details specific to individual OIDC providers. {"resource":"$CLIENT_ID"} ``` - where `$CLIENT_ID` is the Client ID from step 1 ([see here](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-openid-connect-oauth-flows-scenarios#:~:text=scope%E2%80%AFopenid.-,resource,-optional)). - This is required for the upstream OIDC provider to return the requested claims. + where `$CLIENT_ID` is the Client ID from step 1 + ([see here](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-openid-connect-oauth-flows-scenarios#:~:text=scope%E2%80%AFopenid.-,resource,-optional)). + This is required for the upstream OIDC provider to return the requested + claims. - `CODER_OIDC_IGNORE_USERINFO`: Set to `true`. -1. Configure [Issuance Transform Rules](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-rule-to-send-ldap-attributes-as-claims) +1. Configure + [Issuance Transform Rules](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-rule-to-send-ldap-attributes-as-claims) on your federation server to send the following claims: - `preferred_username`: You can use e.g. "Display Name" as required. - - `email`: You can use e.g. the LDAP attribute "E-Mail-Addresses" as required. + - `email`: You can use e.g. the LDAP attribute "E-Mail-Addresses" as + required. - `email_verified`: Create a custom claim rule: ```console => issue(Type = "email_verified", Value = "true") ``` - - (Optional) If using Group Sync, send the required groups in the configured groups claim field. See [here](https://stackoverflow.com/a/55570286) for an example. + - (Optional) If using Group Sync, send the required groups in the configured + groups claim field. See [here](https://stackoverflow.com/a/55570286) for an + example. ### Keycloak -The access_type parameter has two possible values: "online" and "offline." By default, the value is set to "offline". This means that when a user authenticates using OIDC, the application requests offline access to the user's resources, including the ability to refresh access tokens without requiring the user to reauthenticate. - -To enable the `offline_access` scope, which allows for the refresh token functionality, you need to add it to the list of requested scopes during the authentication flow. Including the `offline_access` scope in the requested scopes ensures that the user is granted the necessary permissions to obtain refresh tokens. - -By combining the `{"access_type":"offline"}` parameter in the OIDC Auth URL with the `offline_access` scope, you can achieve the desired behavior of obtaining refresh tokens for offline access to the user's resources. +The access_type parameter has two possible values: "online" and "offline." By +default, the value is set to "offline". This means that when a user +authenticates using OIDC, the application requests offline access to the user's +resources, including the ability to refresh access tokens without requiring the +user to reauthenticate. + +To enable the `offline_access` scope, which allows for the refresh token +functionality, you need to add it to the list of requested scopes during the +authentication flow. Including the `offline_access` scope in the requested +scopes ensures that the user is granted the necessary permissions to obtain +refresh tokens. + +By combining the `{"access_type":"offline"}` parameter in the OIDC Auth URL with +the `offline_access` scope, you can achieve the desired behavior of obtaining +refresh tokens for offline access to the user's resources. diff --git a/docs/admin/automation.md b/docs/admin/automation.md index c564903d55361..c9fc78833033b 100644 --- a/docs/admin/automation.md +++ b/docs/admin/automation.md @@ -1,22 +1,24 @@ # Automation -All actions possible through the Coder dashboard can also be automated as it utilizes the same public REST API. There are several ways to extend/automate Coder: +All actions possible through the Coder dashboard can also be automated as it +utilizes the same public REST API. There are several ways to extend/automate +Coder: - [CLI](../cli.md) - [REST API](../api/) -- [Coder SDK](https://pkg.go.dev/github.com/coder/coder/codersdk) +- [Coder SDK](https://pkg.go.dev/github.com/coder/coder/v2/codersdk) ## Quickstart Generate a token on your Coder deployment by visiting: -```sh +```shell https://coder.example.com/settings/tokens ``` List your workspaces -```sh +```shell # CLI coder ls \ --url https://coder.example.com \ @@ -30,23 +32,34 @@ curl https://coder.example.com/api/v2/workspaces?q=owner:me \ ## Documentation -We publish an [API reference](../api/index.md) in our documentation. You can also enable a [Swagger endpoint](../cli/server.md#--swagger-enable) on your Coder deployment. +We publish an [API reference](../api/index.md) in our documentation. You can +also enable a [Swagger endpoint](../cli/server.md#--swagger-enable) on your +Coder deployment. ## Use cases -We strive to keep the following use cases up to date, but please note that changes to API queries and routes can occur. For the most recent queries and payloads, we recommend checking the CLI and API documentation. +We strive to keep the following use cases up to date, but please note that +changes to API queries and routes can occur. For the most recent queries and +payloads, we recommend checking the CLI and API documentation. ### Templates -- [Update templates in CI](../templates/change-management.md): Store all templates and git and update templates in CI/CD pipelines. +- [Update templates in CI](../templates/change-management.md): Store all + templates and git and update templates in CI/CD pipelines. ### Workspace agents -Workspace agents have a special token that can send logs, metrics, and workspace activity. +Workspace agents have a special token that can send logs, metrics, and workspace +activity. -- [Custom workspace logs](../api/agents.md#patch-workspace-agent-logs): Expose messages prior to the Coder init script running (e.g. pulling image, VM starting, restoring snapshot). [coder-logstream-kube](https://github.com/coder/coder-logstream-kube) uses this to show Kubernetes events, such as image pulls or ResourceQuota restrictions. +- [Custom workspace logs](../api/agents.md#patch-workspace-agent-logs): Expose + messages prior to the Coder init script running (e.g. pulling image, VM + starting, restoring snapshot). + [coder-logstream-kube](https://github.com/coder/coder-logstream-kube) uses + this to show Kubernetes events, such as image pulls or ResourceQuota + restrictions. - ```sh + ```shell curl -X PATCH https://coder.example.com/api/v2/workspaceagents/me/logs \ -H "Coder-Session-Token: $CODER_AGENT_TOKEN" \ -d "{ @@ -60,9 +73,11 @@ Workspace agents have a special token that can send logs, metrics, and workspace }" ``` -- [Manually send workspace activity](../api/agents.md#submit-workspace-agent-stats): Keep a workspace "active," even if there is not an open connection (e.g. for a long-running machine learning job). +- [Manually send workspace activity](../api/agents.md#submit-workspace-agent-stats): + Keep a workspace "active," even if there is not an open connection (e.g. for a + long-running machine learning job). - ```sh + ```shell #!/bin/bash # Send workspace activity as long as the job is still running diff --git a/docs/admin/configure.md b/docs/admin/configure.md index e74d447c0b4e1..17ce483cb2f0f 100644 --- a/docs/admin/configure.md +++ b/docs/admin/configure.md @@ -1,23 +1,26 @@ -Coder server's primary configuration is done via environment variables. For a full list of the options, run `coder server --help` or see our [CLI documentation](../cli/server.md). +Coder server's primary configuration is done via environment variables. For a +full list of the options, run `coder server --help` or see our +[CLI documentation](../cli/server.md). ## Access URL -`CODER_ACCESS_URL` is required if you are not using the tunnel. Set this to the external URL -that users and workspaces use to connect to Coder (e.g. ). This -should not be localhost. +`CODER_ACCESS_URL` is required if you are not using the tunnel. Set this to the +external URL that users and workspaces use to connect to Coder (e.g. +). This should not be localhost. -> Access URL should be a external IP address or domain with DNS records pointing to Coder. +> Access URL should be a external IP address or domain with DNS records pointing +> to Coder. ### Tunnel -If an access URL is not specified, Coder will create -a publicly accessible URL to reverse proxy your deployment for simple setup. +If an access URL is not specified, Coder will create a publicly accessible URL +to reverse proxy your deployment for simple setup. ## Address You can change which port(s) Coder listens on. -```sh +```shell # Listen on port 80 export CODER_HTTP_ADDRESS=0.0.0.0:80 @@ -34,37 +37,76 @@ coder server ## Wildcard access URL -`CODER_WILDCARD_ACCESS_URL` is necessary for [port forwarding](../networking/port-forwarding.md#dashboard) -via the dashboard or running [coder_apps](../templates/index.md#coder-apps) on an absolute path. Set this to a wildcard -subdomain that resolves to Coder (e.g. `*.coder.example.com`). +`CODER_WILDCARD_ACCESS_URL` is necessary for +[port forwarding](../networking/port-forwarding.md#dashboard) via the dashboard +or running [coder_apps](../templates/index.md#coder-apps) on an absolute path. +Set this to a wildcard subdomain that resolves to Coder (e.g. +`*.coder.example.com`). If you are providing TLS certificates directly to the Coder server, either 1. Use a single certificate and key for both the root and wildcard domains. 2. Configure multiple certificates and keys via - [`coder.tls.secretNames`](https://github.com/coder/coder/blob/main/helm/values.yaml) in the Helm Chart, or - [`--tls-cert-file`](../cli/server.md#--tls-cert-file) and [`--tls-key-file`](../cli/server.md#--tls-key-file) command - line options (these both take a comma separated list of files; list certificates and their respective keys in the - same order). + [`coder.tls.secretNames`](https://github.com/coder/coder/blob/main/helm/coder/values.yaml) + in the Helm Chart, or [`--tls-cert-file`](../cli/server.md#--tls-cert-file) + and [`--tls-key-file`](../cli/server.md#--tls-key-file) command line options + (these both take a comma separated list of files; list certificates and their + respective keys in the same order). ## TLS & Reverse Proxy -The Coder server can directly use TLS certificates with `CODER_TLS_ENABLE` and accompanying configuration flags. However, Coder can also run behind a reverse-proxy to terminate TLS certificates from LetsEncrypt, for example. +The Coder server can directly use TLS certificates with `CODER_TLS_ENABLE` and +accompanying configuration flags. However, Coder can also run behind a +reverse-proxy to terminate TLS certificates from LetsEncrypt, for example. - [Apache](https://github.com/coder/coder/tree/main/examples/web-server/apache) - [Caddy](https://github.com/coder/coder/tree/main/examples/web-server/caddy) - [NGINX](https://github.com/coder/coder/tree/main/examples/web-server/nginx) +### Kubernetes TLS configuration + +Below are the steps to configure Coder to terminate TLS when running on +Kubernetes. You must have the certificate `.key` and `.crt` files in your +working directory prior to step 1. + +1. Create the TLS secret in your Kubernetes cluster + +```shell +kubectl create secret tls coder-tls -n --key="tls.key" --cert="tls.crt" +``` + +> You can use a single certificate for the both the access URL and wildcard +> access URL. The certificate CN must match the wildcard domain, such as +> `*.example.coder.com`. + +1. Reference the TLS secret in your Coder Helm chart values + +```yaml +coder: + tls: + secretName: + - coder-tls + + # Alternatively, if you use an Ingress controller to terminate TLS, + # set the following values: + ingress: + enable: true + secretName: coder-tls + wildcardSecretName: coder-tls +``` + ## PostgreSQL Database -Coder uses a PostgreSQL database to store users, workspace metadata, and other deployment information. -Use `CODER_PG_CONNECTION_URL` to set the database that Coder connects to. If unset, PostgreSQL binaries will be -downloaded from Maven () and store all data in the config root. +Coder uses a PostgreSQL database to store users, workspace metadata, and other +deployment information. Use `CODER_PG_CONNECTION_URL` to set the database that +Coder connects to. If unset, PostgreSQL binaries will be downloaded from Maven +() and store all data in the config root. > Postgres 13 is the minimum supported version. If you are using the built-in PostgreSQL deployment and need to use `psql` (aka -the PostgreSQL interactive terminal), output the connection URL with the following command: +the PostgreSQL interactive terminal), output the connection URL with the +following command: ```console coder server postgres-builtin-url @@ -73,21 +115,26 @@ psql "postgres://coder@localhost:49627/coder?sslmode=disable&password=feU...yI1" ### Migrating from the built-in database to an external database -To migrate from the built-in database to an external database, follow these steps: +To migrate from the built-in database to an external database, follow these +steps: 1. Stop your Coder deployment. 2. Run `coder server postgres-builtin-serve` in a background terminal. 3. Run `coder server postgres-builtin-url` and copy its output command. -4. Run `pg_dump > coder.sql` to dump the internal database to a file. -5. Restore that content to an external database with `psql < coder.sql`. -6. Start your Coder deployment with `CODER_PG_CONNECTION_URL=`. +4. Run `pg_dump > coder.sql` to dump the internal + database to a file. +5. Restore that content to an external database with + `psql < coder.sql`. +6. Start your Coder deployment with + `CODER_PG_CONNECTION_URL=`. ## System packages -If you've installed Coder via a [system package](../install/packages.md) Coder, you can -configure the server by setting the following variables in `/etc/coder.d/coder.env`: +If you've installed Coder via a [system package](../install/packages.md) Coder, +you can configure the server by setting the following variables in +`/etc/coder.d/coder.env`: -```console +```env # String. Specifies the external URL (HTTP/S) to access Coder. CODER_ACCESS_URL=https://coder.example.com @@ -115,7 +162,7 @@ CODER_TLS_KEY_FILE= To run Coder as a system service on the host: -```console +```shell # Use systemd to start Coder now and on reboot sudo systemctl enable --now coder @@ -125,15 +172,15 @@ journalctl -u coder.service -b To restart Coder after applying system changes: -```console +```shell sudo systemctl restart coder ``` ## Configuring Coder behind a proxy -To configure Coder behind a corporate proxy, set the environment variables `HTTP_PROXY` and -`HTTPS_PROXY`. Be sure to restart the server. Lowercase values (e.g. `http_proxy`) are also -respected in this case. +To configure Coder behind a corporate proxy, set the environment variables +`HTTP_PROXY` and `HTTPS_PROXY`. Be sure to restart the server. Lowercase values +(e.g. `http_proxy`) are also respected in this case. ## Up Next diff --git a/docs/admin/git-providers.md b/docs/admin/git-providers.md index 293c88ab3cabb..0cbd0e00c94fa 100644 --- a/docs/admin/git-providers.md +++ b/docs/admin/git-providers.md @@ -1,10 +1,13 @@ # Git Providers -Coder integrates with git providers to automate away the need for developers to authenticate with repositories within their workspace. +Coder integrates with git providers to automate away the need for developers to +authenticate with repositories within their workspace. ## How it works -When developers use `git` inside their workspace, they are prompted to authenticate. After that, Coder will store and refresh tokens for future operations. +When developers use `git` inside their workspace, they are prompted to +authenticate. After that, Coder will store and refresh tokens for future +operations.