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/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 = <> $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..e85e6f363f527 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,63 @@ 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 }} 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/.golangci.yaml b/.golangci.yaml index e3f3797d06b81..156d6649890b3 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -211,6 +211,7 @@ issues: run: skip-dirs: - node_modules + - .git skip-files: - scripts/rules.go timeout: 10m diff --git a/.prettierignore b/.prettierignore index 9296d15d8802e..d68357703d7ce 100644 --- a/.prettierignore +++ b/.prettierignore @@ -67,7 +67,7 @@ scaletest/terraform/secrets.tfvars # .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/Makefile b/Makefile index c9089a9d4e452..4ec4157cd79ff 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' \)) @@ -553,7 +557,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 .PHONY: update-golden-files cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go) @@ -564,8 +568,12 @@ 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 "$@" scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go) diff --git a/agent/agent.go b/agent/agent.go index 52c423787fb44..9517ee0808671 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" @@ -35,12 +35,12 @@ import ( "cdr.dev/slog" "github.com/coder/coder/agent/agentssh" + "github.com/coder/coder/agent/reconnectingpty" "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/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) @@ -761,6 +758,7 @@ func (a *agent) trackConnGoroutine(fn func()) error { func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *tailcfg.DERPMap, disableDirectConnections bool) (_ *tailnet.Conn, err error) { network, err := tailnet.NewConn(&tailnet.Options{ + ID: agentID, Addresses: a.wireguardAddresses(agentID), DERPMap: derpMap, Logger: a.logger.Named("net.tailnet"), @@ -1074,8 +1072,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 +1084,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 +1109,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 +1125,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 +1261,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 +1393,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..1621b0075b14e 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" @@ -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/api.go b/agent/api.go index c2cea963fbe66..600ead326a6cf 100644 --- a/agent/api.go +++ b/agent/api.go @@ -5,7 +5,7 @@ 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" diff --git a/agent/reconnectingpty/buffered.go b/agent/reconnectingpty/buffered.go new file mode 100644 index 0000000000000..47d74595472a5 --- /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/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..60f347c81ea72 --- /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/codersdk" + "github.com/coder/coder/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..94854a8b8bf81 --- /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/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/cli/agent.go b/cli/agent.go index 1d9a2ba02d51c..a217c9b0acc67 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -12,6 +12,7 @@ import ( "path/filepath" "runtime" "strconv" + "strings" "sync" "time" @@ -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..34f04b70705b2 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" @@ -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..3e4328dbc05e4 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/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/option_test.go b/cli/clibase/option_test.go index cacd8d3a10793..9affda90668e4 100644 --- a/cli/clibase/option_test.go +++ b/cli/clibase/option_test.go @@ -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/configssh.go b/cli/configssh.go index 162c3c2a95855..897742fd5a7bb 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" ) @@ -367,8 +368,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/create.go b/cli/create.go index 602b7b40a45bc..9ed55af0b853c 100644 --- a/cli/create.go +++ b/cli/create.go @@ -13,16 +13,18 @@ import ( "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/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..a86113eea854d 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" @@ -357,6 +358,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) { diff --git a/cli/delete.go b/cli/delete.go index 867abe0326a30..3a440c6840381 100644 --- a/cli/delete.go +++ b/cli/delete.go @@ -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..3530a60097094 100644 --- a/cli/delete_test.go +++ b/cli/delete_test.go @@ -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/exp_scaletest.go b/cli/exp_scaletest.go index d2ee36c1819eb..e947aa1260100 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -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..a70fba0443b10 100644 --- a/cli/exp_scaletest_test.go +++ b/cli/exp_scaletest_test.go @@ -1,7 +1,6 @@ package cli_test import ( - "bytes" "context" "path/filepath" "testing" @@ -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") } @@ -98,9 +98,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..3d2654507ce00 100644 --- a/cli/gitaskpass.go +++ b/cli/gitaskpass.go @@ -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/login.go b/cli/login.go index e16118dfec0d6..2fa7eb231ce9c 100644 --- a/cli/login.go +++ b/cli/login.go @@ -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/netcheck_test.go b/cli/netcheck_test.go index 890260c1a704e..be0b50e1a5bac 100644 --- a/cli/netcheck_test.go +++ b/cli/netcheck_test.go @@ -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+5) // 1 built-in region + 5 STUN regions by default 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..efc415692eae0 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" ) -// 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..d2f551a17ff4f --- /dev/null +++ b/cli/parameterresolver.go @@ -0,0 +1,224 @@ +package cli + +import ( + "fmt" + + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/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 + } + + 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 + } + + firstTimeUse := pr.isFirstTimeUse(tvp.Name) + + if (tvp.Ephemeral && pr.promptBuildOptions) || + tvp.Required || + (action == WorkspaceUpdate && !tvp.Mutable && firstTimeUse) || + (action == WorkspaceUpdate && tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) || + (action == WorkspaceCreate && !tvp.Ephemeral) { + 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 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 +} diff --git a/cli/publickey.go b/cli/publickey.go index 43537eec428a1..cbe67fca23c1a 100644 --- a/cli/publickey.go +++ b/cli/publickey.go @@ -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/restart.go b/cli/restart.go index 4cff7ac7571d7..f03b1ab0c4755 100644 --- a/cli/restart.go +++ b/cli/restart.go @@ -4,6 +4,8 @@ import ( "fmt" "time" + "golang.org/x/xerrors" + "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" @@ -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..be2c6ea423416 100644 --- a/cli/restart_test.go +++ b/cli/restart_test.go @@ -2,6 +2,7 @@ package cli_test import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -126,4 +127,57 @@ 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, + }) + }) } diff --git a/cli/root.go b/cli/root.go index 4c268235a0f96..3197a3e3fce21 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" @@ -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 } 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..fb19aae6884a8 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "runtime" "strings" "sync/atomic" "testing" @@ -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,8 +107,8 @@ 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() @@ -129,8 +139,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 +170,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/server.go b/cli/server.go index 170b7c5eb9f00..41f4aa6c17c5c 100644 --- a/cli/server.go +++ b/cli/server.go @@ -63,6 +63,7 @@ import ( "github.com/coder/coder/cli/config" "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/autobuild" + "github.com/coder/coder/coderd/batchstats" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbfake" "github.com/coder/coder/coderd/database/dbmetrics" @@ -75,6 +76,7 @@ import ( "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/oauthpki" "github.com/coder/coder/coderd/prometheusmetrics" "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" @@ -550,9 +552,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } } - if cfg.OIDC.ClientSecret != "" { + if cfg.OIDC.ClientKeyFile != "" || cfg.OIDC.ClientSecret != "" { if cfg.OIDC.ClientID == "" { - return xerrors.Errorf("OIDC client ID be set!") + return xerrors.Errorf("OIDC client ID must be set!") } if cfg.OIDC.IssuerURL == "" { return xerrors.Errorf("OIDC issuer URL must be set!") @@ -577,15 +579,33 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. if slice.Contains(cfg.OIDC.Scopes, "groups") && cfg.OIDC.GroupField == "" { cfg.OIDC.GroupField = "groups" } + oauthCfg := &oauth2.Config{ + ClientID: cfg.OIDC.ClientID.String(), + ClientSecret: cfg.OIDC.ClientSecret.String(), + RedirectURL: redirectURL.String(), + Endpoint: oidcProvider.Endpoint(), + Scopes: cfg.OIDC.Scopes, + } + + var useCfg httpmw.OAuth2Config = oauthCfg + if cfg.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 cfg.OIDC.ClientSecret != "" { + return xerrors.Errorf("cannot specify both oidc client secret and oidc client key file") + } + + pkiCfg, err := configureOIDCPKI(oauthCfg, cfg.OIDC.ClientKeyFile.Value(), cfg.OIDC.ClientCertFile.Value()) + if err != nil { + return xerrors.Errorf("configure oauth pki authentication: %w", err) + } + useCfg = pkiCfg + } 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, + OAuth2Config: useCfg, + Provider: oidcProvider, Verifier: oidcProvider.Verifier(&oidc.Config{ ClientID: cfg.OIDC.ClientID.String(), }), @@ -596,6 +616,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. AuthURLParams: cfg.OIDC.AuthURLParams.Value, IgnoreUserInfo: cfg.OIDC.IgnoreUserInfo.Value(), GroupField: cfg.OIDC.GroupField.String(), + GroupFilter: cfg.OIDC.GroupRegexFilter.Value(), + CreateMissingGroups: cfg.OIDC.GroupAutoCreate.Value(), GroupMapping: cfg.OIDC.GroupMapping.Value, UserRoleField: cfg.OIDC.UserRoleField.String(), UserRoleMapping: cfg.OIDC.UserRoleMapping.Value, @@ -813,6 +835,16 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. 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() @@ -1017,7 +1049,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. defer wg.Done() if ok, _ := inv.ParsedFlags().GetBool(varVerbose); ok { - cliui.Infof(inv.Stdout, "Shutting down provisioner daemon %d...\n", id) + cliui.Infof(inv.Stdout, "Shutting down provisioner daemon %d...", id) } err := shutdownWithTimeout(provisionerDaemon.Shutdown, 5*time.Second) if err != nil { @@ -1030,7 +1062,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return } if ok, _ := inv.ParsedFlags().GetBool(varVerbose); ok { - cliui.Infof(inv.Stdout, "Gracefully shut down provisioner daemon %d\n", id) + cliui.Infof(inv.Stdout, "Gracefully shut down provisioner daemon %d", id) } }() } @@ -1327,7 +1359,7 @@ 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, @@ -1481,6 +1513,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() diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index fbdfed6b8016e..4eb16343318e2 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -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_test.go b/cli/server_test.go index ee00499c4d2a6..ad971398c8e02 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -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, diff --git a/cli/speedtest.go b/cli/speedtest.go index 150605b3330ce..e38581404ecf0 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -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/start.go b/cli/start.go index 5bd35867fd105..212ee1abbcb85 100644 --- a/cli/start.go +++ b/cli/start.go @@ -11,21 +11,6 @@ import ( "github.com/coder/coder/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..b532500fb0777 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -99,4 +100,43 @@ 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, + }) + }) } diff --git a/cli/stat.go b/cli/stat.go index 3e32c4187f93b..6a49ffb8437b5 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -12,31 +12,43 @@ import ( "github.com/coder/coder/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..001177f9d95dd 100644 --- a/cli/stat_test.go +++ b/cli/stat_test.go @@ -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/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_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..46fddeed2d6cc 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -175,18 +175,15 @@ backed by Tailscale and WireGuard. --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 +295,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 +344,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 +388,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_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..381920662cf05 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) @@ -235,6 +244,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 +289,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 +353,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/update.go b/cli/update.go index 64710217bb996..02ef238b36e21 100644 --- a/cli/update.go +++ b/cli/update.go @@ -3,14 +3,15 @@ package cli import ( "fmt" + "golang.org/x/xerrors" + "github.com/coder/coder/cli/clibase" "github.com/coder/coder/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..e830aabbde435 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -159,7 +159,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 +236,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) { @@ -545,14 +594,11 @@ func TestUpdateValidateRichParameters(t *testing.T) { }() matches := []string{ - "added_parameter", "", - `Enter a value (default: "foobar")`, "abc", + "Planning workspace...", "", } for i := 0; i < len(matches); i += 2 { match := matches[i] - value := matches[i+1] pty.ExpectMatch(match) - pty.WriteLine(value) } <-doneChan }) diff --git a/cli/usercreate.go b/cli/usercreate.go index b38bbb2d6401f..80118d7fced0e 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "golang.org/x/xerrors" @@ -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/vscodessh.go b/cli/vscodessh.go index 136a0d727c17a..7a576581fa05c 100644 --- a/cli/vscodessh.go +++ b/cli/vscodessh.go @@ -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/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2c115a30c3261..ea0979333b451 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": { @@ -5479,6 +5491,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": [ @@ -6097,7 +6145,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Response" + "$ref": "#/definitions/codersdk.Workspace" } } } @@ -6447,8 +6495,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 +6675,9 @@ const docTemplate = `{ } } }, + "clibase.Regexp": { + "type": "object" + }, "clibase.Struct-array_codersdk_GitAuthConfig": { "type": "object", "properties": { @@ -6900,10 +6954,14 @@ const docTemplate = `{ "codersdk.AgentSubsystem": { "type": "string", "enum": [ - "envbox" + "envbox", + "envbuilder", + "exectrace" ], "x-enum-varnames": [ - "AgentSubsystemEnvbox" + "AgentSubsystemEnvbox", + "AgentSubsystemEnvbuilder", + "AgentSubsystemExectrace" ] }, "codersdk.AppHostResponse": { @@ -7526,13 +7584,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" @@ -8024,7 +8090,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 +8099,8 @@ const docTemplate = `{ "ExperimentTailnetPGCoordinator", "ExperimentSingleTailnet", "ExperimentTemplateRestartRequirement", - "ExperimentDeploymentHealthPage" + "ExperimentDeploymentHealthPage", + "ExperimentWorkspacesBatchActions" ] }, "codersdk.Feature": { @@ -8272,9 +8340,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": { @@ -8423,6 +8505,7 @@ const docTemplate = `{ "codersdk.LoginType": { "type": "string", "enum": [ + "", "password", "github", "oidc", @@ -8430,6 +8513,7 @@ const docTemplate = `{ "none" ], "x-enum-varnames": [ + "LoginTypeUnknown", "LoginTypePassword", "LoginTypeGithub", "LoginTypeOIDC", @@ -8566,9 +8650,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 +8672,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 +8775,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 +8879,9 @@ const docTemplate = `{ "daemon_poll_jitter": { "type": "integer" }, + "daemon_psk": { + "type": "string" + }, "daemons": { "type": "integer" }, @@ -9582,6 +9711,9 @@ const docTemplate = `{ "codersdk.TemplateParameterUsage": { "type": "object", "properties": { + "description": { + "type": "string" + }, "display_name": { "type": "string" }, @@ -9601,6 +9733,9 @@ const docTemplate = `{ "format": "uuid" } }, + "type": { + "type": "string" + }, "values": { "type": "array", "items": { @@ -10522,8 +10657,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" @@ -11499,11 +11637,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 +11676,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 +11832,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": { @@ -11760,6 +11955,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..375be4c013fff 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": { @@ -4831,6 +4841,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": [ @@ -5379,7 +5421,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Response" + "$ref": "#/definitions/codersdk.Workspace" } } } @@ -5697,8 +5739,11 @@ "expanded_directory": { "type": "string" }, - "subsystem": { - "$ref": "#/definitions/codersdk.AgentSubsystem" + "subsystems": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentSubsystem" + } }, "version": { "type": "string" @@ -5874,6 +5919,9 @@ } } }, + "clibase.Regexp": { + "type": "object" + }, "clibase.Struct-array_codersdk_GitAuthConfig": { "type": "object", "properties": { @@ -6127,8 +6175,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", @@ -6705,13 +6757,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" @@ -7185,7 +7245,8 @@ "tailnet_pg_coordinator", "single_tailnet", "template_restart_requirement", - "deployment_health_page" + "deployment_health_page", + "workspaces_batch_actions" ], "x-enum-varnames": [ "ExperimentMoons", @@ -7193,7 +7254,8 @@ "ExperimentTailnetPGCoordinator", "ExperimentSingleTailnet", "ExperimentTemplateRestartRequirement", - "ExperimentDeploymentHealthPage" + "ExperimentDeploymentHealthPage", + "ExperimentWorkspacesBatchActions" ] }, "codersdk.Feature": { @@ -7428,9 +7490,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": { @@ -7556,8 +7626,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 +7757,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 +7779,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 +7877,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 +7976,9 @@ "daemon_poll_jitter": { "type": "integer" }, + "daemon_psk": { + "type": "string" + }, "daemons": { "type": "integer" }, @@ -8662,6 +8778,9 @@ "codersdk.TemplateParameterUsage": { "type": "object", "properties": { + "description": { + "type": "string" + }, "display_name": { "type": "string" }, @@ -8681,6 +8800,9 @@ "format": "uuid" } }, + "type": { + "type": "string" + }, "values": { "type": "array", "items": { @@ -9543,8 +9665,11 @@ "status": { "$ref": "#/definitions/codersdk.WorkspaceAgentStatus" }, - "subsystem": { - "$ref": "#/definitions/codersdk.AgentSubsystem" + "subsystems": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentSubsystem" + } }, "troubleshooting_url": { "type": "string" @@ -10483,11 +10608,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 +10647,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 +10799,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": { @@ -10740,6 +10922,17 @@ } } } + }, + "wsproxysdk.ReportAppStatsRequest": { + "type": "object", + "properties": { + "stats": { + "type": "array", + "items": { + "$ref": "#/definitions/workspaceapps.StatsReport" + } + } + } } }, "securityDefinitions": { diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index f7176ae8cd721..b36a265b7ee98 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -178,7 +178,7 @@ func (e *Executor) runOnce(t time.Time) Stats { // Lock the workspace if it has breached the template's // threshold for inactivity. if reason == database.BuildReasonAutolock { - err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{ + ws, err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{ ID: ws.ID, LockedAt: sql.NullTime{ Time: database.Now(), diff --git a/coderd/batchstats/batcher.go b/coderd/batchstats/batcher.go new file mode 100644 index 0000000000000..c5b7cf20d5b5b --- /dev/null +++ b/coderd/batchstats/batcher.go @@ -0,0 +1,289 @@ +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/coderd/database" + "github.com/coder/coder/coderd/database/dbauthz" + "github.com/coder/coder/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 + } + + 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) { + b.initBuf(b.batchSize) + // 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") + b.flush(authCtx, 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..8288442400a3e --- /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/coderd/database" + "github.com/coder/coder/coderd/database/dbgen" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/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) + + 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/coderd.go b/coderd/coderd.go index d7b80ff273097..6ffe623699aa4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -43,6 +43,7 @@ import ( "github.com/coder/coder/buildinfo" "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/awsidentity" + "github.com/coder/coder/coderd/batchstats" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/database/pubsub" @@ -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 @@ -180,6 +184,8 @@ type Options struct { // @in header // @name Coder-Session-Token // New constructs a Coder API handler. +// +//nolint:gocyclo func New(options *Options) *API { if options == nil { options = &Options{} @@ -258,16 +264,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 +294,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") @@ -399,6 +409,7 @@ func New(options *Options) *API { 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 +420,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 +441,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 +483,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 +706,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 +713,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) }) @@ -994,6 +1015,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 +1032,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/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 4d4fbb8c5e78e..a53fc75353cae 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -51,11 +51,13 @@ 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/batchstats" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/database/dbtestutil" @@ -140,7 +142,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 +246,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 { @@ -379,36 +396,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, } } @@ -497,7 +516,11 @@ func NewExternalProvisionerDaemon(t *testing.T, client *codersdk.Client, org uui }() 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), @@ -568,14 +591,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 +601,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 { @@ -1002,9 +1025,31 @@ func NewAWSInstanceIdentity(t *testing.T, instanceID string) (awsidentity.Certif type OIDCConfig struct { key *rsa.PrivateKey issuer string + // These are optional + refreshToken string + oidcTokenExpires func() time.Time + tokenSource func() (*oauth2.Token, error) } -func NewOIDCConfig(t *testing.T, issuer string) *OIDCConfig { +func WithRefreshToken(token string) func(cfg *OIDCConfig) { + return func(cfg *OIDCConfig) { + cfg.refreshToken = token + } +} + +func WithTokenExpires(expFunc func() time.Time) func(cfg *OIDCConfig) { + return func(cfg *OIDCConfig) { + cfg.oidcTokenExpires = expFunc + } +} + +func WithTokenSource(src func() (*oauth2.Token, error)) func(cfg *OIDCConfig) { + return func(cfg *OIDCConfig) { + cfg.tokenSource = src + } +} + +func NewOIDCConfig(t *testing.T, issuer string, opts ...func(cfg *OIDCConfig)) *OIDCConfig { t.Helper() block, _ := pem.Decode([]byte(testRSAPrivateKey)) @@ -1015,33 +1060,58 @@ func NewOIDCConfig(t *testing.T, issuer string) *OIDCConfig { issuer = "https://coder.com" } - return &OIDCConfig{ + cfg := &OIDCConfig{ key: pkey, issuer: issuer, } + for _, opt := range opts { + opt(cfg) + } + return cfg } func (*OIDCConfig) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string { return "/?state=" + url.QueryEscape(state) } -func (*OIDCConfig) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { - return nil +type tokenSource struct { + src func() (*oauth2.Token, error) +} + +func (s tokenSource) Token() (*oauth2.Token, error) { + return s.src() +} + +func (cfg *OIDCConfig) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { + if cfg.tokenSource == nil { + return nil + } + return tokenSource{ + src: cfg.tokenSource, + } } -func (*OIDCConfig) Exchange(_ context.Context, code string, _ ...oauth2.AuthCodeOption) (*oauth2.Token, error) { +func (cfg *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) } + + var exp time.Time + if cfg.oidcTokenExpires != nil { + exp = cfg.oidcTokenExpires() + } + return (&oauth2.Token{ - AccessToken: "token", + AccessToken: "token", + RefreshToken: cfg.refreshToken, + Expiry: exp, }).WithExtra(map[string]interface{}{ "id_token": string(token), }), nil } -func (o *OIDCConfig) EncodeClaims(t *testing.T, claims jwt.MapClaims) string { +func (cfg *OIDCConfig) EncodeClaims(t *testing.T, claims jwt.MapClaims) string { t.Helper() if _, ok := claims["exp"]; !ok { @@ -1049,20 +1119,20 @@ func (o *OIDCConfig) EncodeClaims(t *testing.T, claims jwt.MapClaims) string { } if _, ok := claims["iss"]; !ok { - claims["iss"] = o.issuer + claims["iss"] = cfg.issuer } if _, ok := claims["sub"]; !ok { claims["sub"] = "testme" } - signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(o.key) + signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(cfg.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 { +func (cfg *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{} @@ -1079,10 +1149,10 @@ func (o *OIDCConfig) OIDCConfig(t *testing.T, userInfoClaims jwt.MapClaims, opts } provider = cfg.NewProvider(context.Background()) } - cfg := &coderd.OIDCConfig{ - OAuth2Config: o, - Verifier: oidc.NewVerifier(o.issuer, &oidc.StaticKeySet{ - PublicKeys: []crypto.PublicKey{o.key.Public()}, + newCFG := &coderd.OIDCConfig{ + OAuth2Config: cfg, + Verifier: oidc.NewVerifier(cfg.issuer, &oidc.StaticKeySet{ + PublicKeys: []crypto.PublicKey{cfg.key.Public()}, }, &oidc.Config{ SkipClientIDCheck: true, }), @@ -1093,9 +1163,9 @@ func (o *OIDCConfig) OIDCConfig(t *testing.T, userInfoClaims jwt.MapClaims, opts GroupField: "groups", } for _, opt := range opts { - opt(cfg) + opt(newCFG) } - return cfg + return newCFG } // NewAzureInstanceIdentity returns a metadata client and ID token validator for faking diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 263611d5b168b..893cfdadea336 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -3,6 +3,7 @@ package db2sdk import ( "encoding/json" + "sort" "github.com/google/uuid" @@ -29,20 +30,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 +123,61 @@ func Role(role rbac.Role) codersdk.Role { Name: role.Name, } } + +func TemplateInsightsParameters(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, err + } + + plaintextDescription, err := parameter.Plaintext(param.Description) + if err != nil { + return nil, err + } + + parametersByNum[param.Num] = &codersdk.TemplateParameterUsage{ + TemplateIDs: param.TemplateIDs, + Name: param.Name, + Type: param.Type, + DisplayName: param.DisplayName, + Description: plaintextDescription, + Options: opts, + } + } + parametersByNum[param.Num].Values = append(parametersByNum[param.Num].Values, codersdk.TemplateParameterValue{ + Value: param.Value, + Count: param.Count, + }) + } + 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 +} + +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/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ab865e2cc0f70..38766121c443c 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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) { @@ -1853,6 +1861,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 +2031,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 +2046,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 { @@ -2587,11 +2617,11 @@ func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.Up return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg) } -func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { +func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { fetch := func(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { return q.db.GetWorkspaceByID(ctx, arg.ID) } - return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg) + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg) } func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index f3313c768053f..d6ad41f51408a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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) { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 0d5c19fa7656d..818656496184f 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -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{} @@ -605,12 +607,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) getOrganizationMember(orgID uuid.UUID) []database.OrganizationMember { + var members []database.OrganizationMember + for _, member := range q.organizationMembers { + if member.OrganizationID == orgID { + members = append(members, member) + } + } + + return members +} + +// getEveryoneGroupMembers fetches all the users in an organization. +func (q *FakeQuerier) getEveryoneGroupMembers(orgID uuid.UUID) []database.User { + var ( + everyone []database.User + orgMembers = q.getOrganizationMember(orgID) + ) + for _, member := range orgMembers { + user, err := q.GetUserByID(context.TODO(), 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 +958,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 +1416,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.getEveryoneGroupMembers(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 +1445,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 +1881,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 } @@ -2060,8 +2139,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, @@ -2119,8 +2198,8 @@ 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()) }) result := database.GetTemplateInsightsRow{ TemplateIDs: templateIDs, @@ -2186,6 +2265,7 @@ func (q *FakeQuerier) GetTemplateParameterInsights(ctx context.Context, arg data uniqueTemplateParams[key] = &database.GetTemplateParameterInsightsRow{ Num: num, Name: tvp.Name, + Type: tvp.Type, DisplayName: tvp.DisplayName, Description: tvp.Description, Options: tvp.Options, @@ -2220,6 +2300,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, @@ -2341,13 +2422,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 +2490,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 +2607,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 +2624,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 +2672,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.. @@ -2797,21 +2881,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 @@ -3126,9 +3214,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 { @@ -3510,7 +3597,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 +3614,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 +3728,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 +3781,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 +4310,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 +4387,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 @@ -5114,6 +5328,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 +5355,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 } @@ -5250,9 +5481,9 @@ func (q *FakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database. return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { +func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { if err := validateDatabaseType(arg); err != nil { - return err + return database.Workspace{}, err } q.mutex.Lock() defer q.mutex.Unlock() @@ -5274,7 +5505,7 @@ 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 { workspace.DeletingAt = sql.NullTime{ @@ -5284,9 +5515,9 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat } } q.workspaces[index] = workspace - return nil + return workspace, nil } - return sql.ErrNoRows + return database.Workspace{}, sql.ErrNoRows } func (q *FakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { @@ -5482,11 +5713,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 } @@ -5730,6 +5961,16 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } } + // We omit locked workspaces by default. + if arg.LockedAt.IsZero() && workspace.LockedAt.Valid { + continue + } + + // Filter out workspaces that are locked after the timestamp. + if !arg.LockedAt.IsZero() && workspace.LockedAt.Time.Before(arg.LockedAt) { + continue + } + if len(arg.TemplateIDs) > 0 { match := false for _, id := range arg.TemplateIDs { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index f78d9b44a46c0..f2c98f6594c43 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -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) @@ -1110,6 +1117,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 +1250,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 +1264,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) @@ -1572,11 +1600,11 @@ func (m metricsStore) UpdateWorkspaceLastUsedAt(ctx context.Context, arg databas return err } -func (m metricsStore) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { +func (m metricsStore) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { start := time.Now() - r0 := m.s.UpdateWorkspaceLockedDeletingAt(ctx, arg) + ws, r0 := m.s.UpdateWorkspaceLockedDeletingAt(ctx, arg) m.queryLatencies.WithLabelValues("UpdateWorkspaceLockedDeletingAt").Observe(time.Since(start).Seconds()) - return r0 + return ws, r0 } func (m metricsStore) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index adf573cb99e82..45db2e6bd4f58 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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() @@ -2332,6 +2347,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 +2628,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 +2657,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() @@ -3307,11 +3365,12 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceLastUsedAt(arg0, arg1 interface{ } // UpdateWorkspaceLockedDeletingAt mocks base method. -func (m *MockStore) UpdateWorkspaceLockedDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceLockedDeletingAtParams) error { +func (m *MockStore) UpdateWorkspaceLockedDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateWorkspaceLockedDeletingAt", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(database.Workspace) + ret1, _ := ret[1].(error) + return ret0, ret1 } // UpdateWorkspaceLockedDeletingAt indicates an expected call of UpdateWorkspaceLockedDeletingAt. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f121fccf8cebb..71106e5da771d 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, @@ -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, @@ -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/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/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..760d63fae84a0 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -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 { @@ -362,3 +362,7 @@ func ConvertWorkspaceRows(rows []GetWorkspacesRow) []Workspace { 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..c75f38fc3b596 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -217,6 +217,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, + arg.LockedAt, arg.Offset, arg.Limit, ) diff --git a/coderd/database/models.go b/coderd/database/models.go index 4e34989b09ae9..e795049c16413 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 { @@ -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/querier.go b/coderd/database/querier.go index 5e9da77b66c0a..3a8f97307114d 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) @@ -206,6 +209,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 +233,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) @@ -277,7 +287,7 @@ type sqlcQuerier interface { UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error - UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) error + UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) (Workspace, 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 diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ff22cc3120193..1a8c7598f7f59 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,6 +1456,7 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ) return i, err } @@ -1582,16 +1656,18 @@ WITH latest_workspace_builds AS ( tvp.name, tvp.display_name, tvp.description, - tvp.options + tvp.options, + tvp.type 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.display_name, tvp.description, tvp.options, tvp.type ) SELECT utp.num, utp.template_ids, utp.name, + utp.type, utp.display_name, utp.description, utp.options, @@ -1599,7 +1675,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.name, utp.display_name, utp.description, utp.options, utp.template_ids, utp.type, wbp.value ` type GetTemplateParameterInsightsParams struct { @@ -1612,6 +1688,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 +1713,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 +3407,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) { @@ -6170,7 +6250,7 @@ func (q *sqlQuerier) DeleteOldWorkspaceAgentLogs(ctx context.Context) error { const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :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 @@ -6212,17 +6292,17 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ) 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 +6342,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 +6394,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 +6517,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 +6563,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 +6582,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 +6624,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 +6644,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 +6702,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 +6746,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 +6828,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 +7051,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 +7068,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 +7498,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 +7842,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 +7961,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 @@ -9001,6 +9297,14 @@ WHERE ) > 0 ELSE true END + -- Filter by locked workspaces. By default we do not return locked + -- workspaces since they are considered soft-deleted. + AND CASE + WHEN $10 :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN + locked_at IS NOT NULL AND locked_at >= $10 + ELSE + locked_at IS NULL + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY @@ -9012,11 +9316,11 @@ ORDER BY LOWER(workspaces.name) ASC LIMIT CASE - WHEN $11 :: integer > 0 THEN - $11 + WHEN $12 :: integer > 0 THEN + $12 END OFFSET - $10 + $11 ` type GetWorkspacesParams struct { @@ -9029,6 +9333,7 @@ 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"` + LockedAt time.Time `db:"locked_at" json:"locked_at"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` } @@ -9064,6 +9369,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, + arg.LockedAt, arg.Offset, arg.Limit, ) @@ -9366,7 +9672,7 @@ func (q *sqlQuerier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWo return err } -const updateWorkspaceLockedDeletingAt = `-- name: UpdateWorkspaceLockedDeletingAt :exec +const updateWorkspaceLockedDeletingAt = `-- name: UpdateWorkspaceLockedDeletingAt :one UPDATE workspaces SET @@ -9383,6 +9689,7 @@ WHERE 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.locked_at, workspaces.deleting_at ` type UpdateWorkspaceLockedDeletingAtParams struct { @@ -9390,9 +9697,25 @@ type UpdateWorkspaceLockedDeletingAtParams struct { LockedAt sql.NullTime `db:"locked_at" json:"locked_at"` } -func (q *sqlQuerier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceLockedDeletingAt, arg.ID, arg.LockedAt) - return err +func (q *sqlQuerier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) (Workspace, error) { + row := q.db.QueryRowContext(ctx, updateWorkspaceLockedDeletingAt, arg.ID, arg.LockedAt) + 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.LockedAt, + &i.DeletingAt, + ) + return i, err } const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec 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..ad8c581161e99 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -149,16 +149,18 @@ WITH latest_workspace_builds AS ( tvp.name, tvp.display_name, tvp.description, - tvp.options + tvp.options, + tvp.type 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.display_name, tvp.description, tvp.options, tvp.type ) SELECT utp.num, utp.template_ids, utp.name, + utp.type, utp.display_name, utp.description, utp.options, @@ -166,4 +168,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.name, utp.display_name, utp.description, utp.options, utp.template_ids, utp.type, 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/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index 4025ac7e59a1b..dcc15081615e2 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -83,7 +83,7 @@ UPDATE SET version = $2, expanded_directory = $3, - subsystem = $4 + subsystems = $4 WHERE id = $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..9dd8aa00b5f55 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -259,6 +259,14 @@ WHERE ) > 0 ELSE true END + -- Filter by locked workspaces. By default we do not return locked + -- workspaces since they are considered soft-deleted. + AND CASE + WHEN @locked_at :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN + locked_at IS NOT NULL AND locked_at >= @locked_at + ELSE + locked_at IS NULL + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY @@ -474,7 +482,7 @@ WHERE ) ) AND workspaces.deleted = 'false'; --- name: UpdateWorkspaceLockedDeletingAt :exec +-- name: UpdateWorkspaceLockedDeletingAt :one UPDATE workspaces SET @@ -490,7 +498,8 @@ FROM WHERE workspaces.template_id = templates.id AND - workspaces.id = $1; + workspaces.id = $1 +RETURNING workspaces.*; -- name: UpdateWorkspacesDeletingAtByTemplateID :exec UPDATE 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..cd89a64cb8092 --- /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/coderd/database" + "github.com/coder/coder/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/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/devtunnel/servers.go b/coderd/devtunnel/servers.go index 1ac1b6ce26a7c..e2b92d4982eb8 100644 --- a/coderd/devtunnel/servers.go +++ b/coderd/devtunnel/servers.go @@ -10,6 +10,7 @@ import ( "golang.org/x/sync/errgroup" "golang.org/x/xerrors" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/cryptorand" ) @@ -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/gitauth/config.go b/coderd/gitauth/config.go index 29d4804dcd538..1387acee7ebf1 100644 --- a/coderd/gitauth/config.go +++ b/coderd/gitauth/config.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "regexp" + "time" "golang.org/x/oauth2" "golang.org/x/xerrors" @@ -17,6 +18,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/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..f58531fdf773f 100644 --- a/coderd/gitauth/config_test.go +++ b/coderd/gitauth/config_test.go @@ -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/healthcheck/derp.go b/coderd/healthcheck/derp.go index 9fc88a37e40db..fd718b55031fa 100644 --- a/coderd/healthcheck/derp.go +++ b/coderd/healthcheck/derp.go @@ -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/httpapi/queryparams.go b/coderd/httpapi/queryparams.go index 3f16565e1dd20..b3ae12c7de11c 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -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/httpmw/apikey.go b/coderd/httpmw/apikey.go index 5f0ec0dc263c7..b359999f35be3 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -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 ( @@ -232,7 +242,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon ) if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC { //nolint:gocritic // System needs to fetch UserLink to check if it's valid. - link, err = cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{ + link, err := cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{ UserID: key.UserID, LoginType: key.LoginType, }) @@ -298,6 +308,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 +440,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..b0c7cab9aadae 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" @@ -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/userparam.go b/coderd/httpmw/userparam.go index f565687e00bdd..b895d17aafb6c 100644 --- a/coderd/httpmw/userparam.go +++ b/coderd/httpmw/userparam.go @@ -2,6 +2,7 @@ package httpmw import ( "context" + "fmt" "net/http" "github.com/go-chi/chi/v5" @@ -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/insights.go b/coderd/insights.go index b643303dd0df2..b452b963277ed 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -2,10 +2,8 @@ package coderd import ( "context" - "encoding/json" "fmt" "net/http" - "sort" "time" "github.com/google/uuid" @@ -13,8 +11,10 @@ import ( "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/util/slice" "github.com/coder/coder/codersdk" ) @@ -132,8 +132,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{ @@ -244,7 +244,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { 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.", @@ -315,39 +315,6 @@ func convertTemplateInsightsBuiltinApps(usage database.GetTemplateInsightsRow) [ } } -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, - } - } - parametersByNum[param.Num].Values = append(parametersByNum[param.Num].Values, codersdk.TemplateParameterValue{ - Value: param.Value, - Count: param.Count, - }) - } - 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 -} - // parseInsightsStartAndEndTime parses the start and end time query parameters // and returns the parsed values. The client provided timezone must be preserved // when parsing the time. Verification is performed so that the start and end diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 2469cc0f4b362..91bca4b364a08 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -13,6 +13,7 @@ import ( "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" @@ -258,7 +259,7 @@ func TestTemplateInsights(t *testing.T) { thirdParameterOptionValue3 = "ccc" ) - logger := slogtest.Make(t, nil) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) opts := &coderdtest.Options{ IncludeProvisionerDaemon: true, AgentStatsRefreshInterval: time.Millisecond * 100, @@ -405,6 +406,8 @@ func TestTemplateInsights(t *testing.T) { // The workspace uses 3 parameters require.Len(t, resp.Report.ParametersUsage, 3) assert.Equal(t, firstParameterName, resp.Report.ParametersUsage[0].Name) + assert.Equal(t, firstParameterType, resp.Report.ParametersUsage[0].Type) + assert.Equal(t, firstParameterDescription, resp.Report.ParametersUsage[0].Description) assert.Equal(t, firstParameterDisplayName, resp.Report.ParametersUsage[0].DisplayName) assert.Contains(t, resp.Report.ParametersUsage[0].Values, codersdk.TemplateParameterValue{ Value: firstParameterValue, @@ -414,6 +417,8 @@ func TestTemplateInsights(t *testing.T) { assert.Empty(t, resp.Report.ParametersUsage[0].Options) assert.Equal(t, secondParameterName, resp.Report.ParametersUsage[1].Name) + assert.Equal(t, secondParameterType, resp.Report.ParametersUsage[1].Type) + assert.Equal(t, secondParameterDescription, resp.Report.ParametersUsage[1].Description) assert.Equal(t, secondParameterDisplayName, resp.Report.ParametersUsage[1].DisplayName) assert.Contains(t, resp.Report.ParametersUsage[1].Values, codersdk.TemplateParameterValue{ Value: secondParameterValue, @@ -423,6 +428,8 @@ func TestTemplateInsights(t *testing.T) { assert.Empty(t, resp.Report.ParametersUsage[1].Options) assert.Equal(t, thirdParameterName, resp.Report.ParametersUsage[2].Name) + assert.Equal(t, thirdParameterType, resp.Report.ParametersUsage[2].Type) + assert.Equal(t, thirdParameterDescription, resp.Report.ParametersUsage[2].Description) assert.Equal(t, thirdParameterDisplayName, resp.Report.ParametersUsage[2].DisplayName) assert.Contains(t, resp.Report.ParametersUsage[2].Values, codersdk.TemplateParameterValue{ Value: thirdParameterValue, diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index d25b716f2df15..c99c72702a3d6 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -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/oauthpki/oidcpki.go b/coderd/oauthpki/oidcpki.go new file mode 100644 index 0000000000000..6e0b00193d22b --- /dev/null +++ b/coderd/oauthpki/oidcpki.go @@ -0,0 +1,273 @@ +package oauthpki + +import ( + "context" + "crypto/rsa" + "crypto/sha1" //#nosec // Not used for cryptography. + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "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/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 { + oauth2.Token + // 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, fmt.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, fmt.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..52f1223bcab50 --- /dev/null +++ b/coderd/oauthpki/okidcpki_test.go @@ -0,0 +1,296 @@ +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" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/oauthpki" + "github.com/coder/coder/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") +} + +// 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.T, 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..17e7f458ebb04 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -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/prometheusmetrics/collector_test.go b/coderd/prometheusmetrics/collector_test.go index 9d63f6669113d..df50182e61618 100644 --- a/coderd/prometheusmetrics/collector_test.go +++ b/coderd/prometheusmetrics/collector_test.go @@ -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_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index 3ea774df1186d..ad39ec840c526 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -11,6 +11,9 @@ import ( "testing" "time" + "github.com/coder/coder/coderd/batchstats" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" @@ -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{ diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index 6934640045506..64016677e1204 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/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/template.go b/coderd/schedule/template.go index de97dffc9ac2a..29809fab854ba 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -8,6 +8,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/tracing" ) const MaxTemplateRestartRequirementWeeks = 16 @@ -122,6 +123,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 @@ -148,6 +152,9 @@ func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, te } 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 diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 9b216d0180e15..3518f0744947e 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -114,9 +114,15 @@ 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.LockedAt = parser.Time(values, time.Time{}, "locked_at", "2006-01-02") 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 locked workspaces since they + // are omitted by default. + if filter.LockedAt.IsZero() { + filter.LockedAt = 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..f30cc44d9498a 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -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..60c47ca7fe086 100644 --- a/coderd/tailnet.go +++ b/coderd/tailnet.go @@ -14,11 +14,13 @@ 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/tracing" "github.com/coder/coder/coderd/wsconncache" "github.com/coder/coder/codersdk" "github.com/coder/coder/site" @@ -45,6 +47,7 @@ func NewServerTailnet( derpMap *tailcfg.DERPMap, getMultiAgent func(context.Context) (tailnet.MultiAgentConn, error), cache *wsconncache.Cache, + traceProvider trace.TracerProvider, ) (*ServerTailnet, error) { logger = logger.Named("servertailnet") conn, err := tailnet.NewConn(&tailnet.Options{ @@ -58,15 +61,16 @@ func NewServerTailnet( serverCtx, cancel := context.WithCancel(ctx) 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, + 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 +143,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 +225,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)) @@ -212,14 +241,16 @@ type ServerTailnet struct { cancel func() 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 +299,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 +310,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 +343,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 +362,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 +380,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 } diff --git a/coderd/tailnet_test.go b/coderd/tailnet_test.go index 05adbcc4fb597..97d2cb9cf5742 100644 --- a/coderd/tailnet_test.go +++ b/coderd/tailnet_test.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" @@ -232,6 +233,7 @@ func setupAgent(t *testing.T, agentAddresses []netip.Prefix) (uuid.UUID, agent.A manifest.DERPMap, 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..615be6949da02 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -22,7 +22,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/buildinfo" "github.com/coder/coder/coderd/database" ) @@ -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..c4839670d6949 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" @@ -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/userauth.go b/coderd/userauth.go index 9b6ba7992bad5..1ea28efede6bd 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/mail" + "regexp" "sort" "strconv" "strings" @@ -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..8910bce286818 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -9,6 +9,7 @@ import ( "net/http/cookiejar" "strings" "testing" + "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt" @@ -24,12 +25,97 @@ import ( "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/codersdk" "github.com/coder/coder/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. +func TestOIDCOauthLoginWithExisting(t *testing.T) { + t.Parallel() + + conf := coderdtest.NewOIDCConfig(t, "", + // Provide a refresh token so we use the refresh token flow + coderdtest.WithRefreshToken("refresh_token"), + // We need to set the expire in the future for the first api calls. + coderdtest.WithTokenExpires(func() time.Time { + return time.Now().Add(time.Hour).UTC() + }), + // No refresh should actually happen in this test. + coderdtest.WithTokenSource(func() (*oauth2.Token, error) { + return nil, xerrors.New("token should not require refresh") + }), + ) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + auditor := audit.NewMock() + const username = "alice" + claims := jwt.MapClaims{ + "email": "alice@coder.com", + "email_verified": true, + "preferred_username": username, + } + config := conf.OIDCConfig(t, claims) + + config.AllowSignups = true + config.IgnoreUserInfo = true + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Auditor: auditor, + OIDCConfig: config, + Logger: &logger, + }) + + // Signup alice + resp := oidcCallback(t, client, conf.EncodeClaims(t, claims)) + // Set the client to use this OIDC context + authCookie := authCookieValue(resp.Cookies()) + client.SetSessionToken(authCookie) + _ = resp.Body.Close() + + ctx := testutil.Context(t, testutil.WaitLong) + // Verify the user and oauth link + user, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, username, user.Username) + + // nolint:gocritic + link, err := api.Database.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginType(user.LoginType), + }) + require.NoError(t, err, "failed to get user link") + + // Expire the link + // nolint:gocritic + _, err = api.Database.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{ + OAuthAccessToken: link.OAuthAccessToken, + OAuthRefreshToken: link.OAuthRefreshToken, + OAuthExpiry: time.Now().Add(time.Hour * -1).UTC(), + UserID: link.UserID, + LoginType: link.LoginType, + }) + require.NoError(t, err, "failed to update user link") + + // Log in again with OIDC + loginAgain := oidcCallbackWithState(t, client, conf.EncodeClaims(t, claims), "seconds_login", func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: codersdk.SessionTokenCookie, + Value: authCookie, + Path: "/", + }) + }) + require.Equal(t, http.StatusTemporaryRedirect, loginAgain.StatusCode) + _ = loginAgain.Body.Close() + + // Try to use new login + client.SetSessionToken(authCookieValue(resp.Cookies())) + _, err = client.User(ctx, "me") + require.NoError(t, err, "use new session") +} + func TestUserLogin(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { @@ -59,7 +145,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 +160,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) { @@ -819,7 +921,7 @@ func TestUserOIDC(t *testing.T) { }) require.NoError(t, err) - resp := oidcCallbackWithState(t, user, code, convertResponse.StateString) + resp := oidcCallbackWithState(t, user, code, convertResponse.StateString, nil) require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) }) @@ -1045,10 +1147,10 @@ func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { } func oidcCallback(t *testing.T, client *codersdk.Client, code string) *http.Response { - return oidcCallbackWithState(t, client, code, "somestate") + return oidcCallbackWithState(t, client, code, "somestate", nil) } -func oidcCallbackWithState(t *testing.T, client *codersdk.Client, code, state string) *http.Response { +func oidcCallbackWithState(t *testing.T, client *codersdk.Client, code, state string, modify func(r *http.Request)) *http.Response { t.Helper() client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { @@ -1062,6 +1164,9 @@ func oidcCallbackWithState(t *testing.T, client *codersdk.Client, code, state st Name: codersdk.OAuth2StateCookie, Value: state, }) + if modify != nil { + modify(req) + } res, err := client.HTTPClient.Do(req) require.NoError(t, err) defer res.Body.Close() diff --git a/coderd/users.go b/coderd/users.go index 017e20d408586..0da749d135dba 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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..c2564e2ff8c29 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -4,14 +4,15 @@ import ( "context" "fmt" "net/http" - "sort" "strings" "testing" "time" + "github.com/golang-jwt/jwt" "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" @@ -20,6 +21,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" "github.com/coder/coder/testutil" ) @@ -565,6 +567,71 @@ 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" + conf := coderdtest.NewOIDCConfig(t, "") + config := conf.OIDCConfig(t, jwt.MapClaims{ + "email": email, + }) + config.AllowSignups = false + config.IgnoreUserInfo = true + + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: config, + }) + 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 := codersdk.New(client.URL) + resp := oidcCallback(t, userClient, conf.EncodeClaims(t, jwt.MapClaims{ + "email": email, + })) + require.Equal(t, resp.StatusCode, http.StatusTemporaryRedirect) + // Set the client to use this OIDC context + authCookie := authCookieValue(resp.Cookies()) + userClient.SetSessionToken(authCookie) + _ = resp.Body.Close() + + found, err := userClient.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, found.LoginType, codersdk.LoginTypeOIDC) + }) } func TestUpdateUserProfile(t *testing.T) { @@ -1804,8 +1871,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/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..73f38e9a3f255 100644 --- a/coderd/util/slice/slice_test.go +++ b/coderd/util/slice/slice_test.go @@ -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/workspaceagents.go b/coderd/workspaceagents.go index 8567ff1d895b3..875410b2ede87 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -14,6 +14,7 @@ import ( "net/netip" "net/url" "runtime/pprof" + "sort" "strconv" "strings" "sync" @@ -38,6 +39,7 @@ import ( "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" "github.com/coder/coder/tailnet" @@ -209,7 +211,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 +227,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", @@ -1277,6 +1305,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 +1335,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 +1443,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 @@ -1614,8 +1623,8 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ }) return } - slices.SortFunc(lastDBMeta, func(i, j database.WorkspaceAgentMetadatum) bool { - return i.Key < j.Key + slices.SortFunc(lastDBMeta, func(a, b database.WorkspaceAgentMetadatum) int { + return slice.Ascending(a.Key, b.Key) }) // Avoid sending refresh if the client is about to get a @@ -2138,11 +2147,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..b85dd9d3550d9 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1195,16 +1195,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 +1222,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) { diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index f64ba7c30bf31..6d192735b4117 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -15,6 +15,7 @@ import ( "runtime" "strconv" "strings" + "sync" "testing" "time" @@ -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..dc3b0e0e1c40f 100644 --- a/coderd/workspaceapps/apptest/setup.go +++ b/coderd/workspaceapps/apptest/setup.go @@ -21,6 +21,7 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/workspaceapps" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" "github.com/coder/coder/provisioner/echo" @@ -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 diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index 9b2d9c4bfa297..da2f5e6a098f1 100644 --- a/coderd/workspaceapps/proxy.go +++ b/coderd/workspaceapps/proxy.go @@ -19,6 +19,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/agent/agentssh" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/tracing" @@ -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/stats.go b/coderd/workspaceapps/stats.go new file mode 100644 index 0000000000000..72a8154d5e89c --- /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/coderd/database" + "github.com/coder/coder/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..2ad0c5556c52c --- /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/coderd/database" + "github.com/coder/coder/coderd/workspaceapps" + "github.com/coder/coder/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_test.go b/coderd/workspaceapps_test.go index 8f31bed4d27d2..1312a9d1e3198 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -275,6 +275,7 @@ func TestWorkspaceApps(t *testing.T) { "CF-Connecting-IP", }, }, + WorkspaceAppsStatsCollectorOptions: opts.StatsCollectorOptions, }) user := coderdtest.CreateFirstUser(t, client) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0aa1cb0675155..b71e630be6a42 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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], @@ -768,7 +773,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { // @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 +// @Success 200 {object} codersdk.Workspace // @Router /workspaces/{workspace}/lock [put] func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -779,9 +784,6 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { return } - code := http.StatusOK - resp := codersdk.Response{} - // If the workspace is already in the desired state do nothing! if workspace.LockedAt.Valid == req.Lock { httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{ @@ -797,7 +799,7 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { lockedAt.Time = database.Now() } - err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{ + workspace, err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{ ID: workspace.ID, LockedAt: lockedAt, }) @@ -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, @@ -1017,6 +1045,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 +1114,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( diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index db5db020488b7..5fecd02dbea88 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1407,6 +1407,46 @@ func TestWorkspaceFilterManual(t *testing.T) { // and template.InactivityTTL should be 0 assert.Len(t, res.Workspaces, 0) }) + + t.Run("LockedAt", 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.ProvisionComplete, + 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() + + lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + + // Create another workspace to validate that we do not return unlocked workspaces. + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + + err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{ + Lock: true, + }) + require.NoError(t, err) + + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("locked_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].LockedAt) + }) } func TestOffsetLimit(t *testing.T) { @@ -2081,9 +2121,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)) } } } @@ -2154,12 +2199,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) diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index e2189e1cc53d2..d5e038bdf6b39 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -612,9 +612,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/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/client.go b/codersdk/client.go index ad9e46ccf7f4a..98a109aa725e1 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -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/deployment.go b/codersdk/deployment.go index af861138e6d7d..fca5d89278c21 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -260,9 +260,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 +274,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 +333,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 +736,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 +748,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 +764,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", @@ -963,6 +971,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 +1093,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 +1278,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 +1928,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 +1941,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/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..24e2ba140dd76 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -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"` } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 26290fd4f4761..96b026a3197a5 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -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..674523055e06f 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -164,38 +164,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/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/workspaceagents.go b/codersdk/workspaceagents.go index e9aad8421e36a..71153a0e89b8a 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -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. } @@ -754,9 +754,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/workspaces.go b/codersdk/workspaces.go index a2ef823fcb87e..2ef9840ac4be0 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -48,6 +48,10 @@ type Workspace struct { 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. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e794828520e81..291f4e1444e4b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -4,11 +4,11 @@ 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'... @@ -19,18 +19,17 @@ 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! -``` -$ 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 @@ -53,15 +52,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 @@ -85,11 +84,12 @@ You can test your changes by creating a PR deployment. There are two ways to do 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) + ![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) +- `-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. @@ -108,13 +108,14 @@ Database migrations are managed with [`migrate`](https://github.com/golang-migra 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. @@ -140,8 +141,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 +154,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 +165,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). diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 882a7274c737f..230e99be53f7e 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -13,7 +13,7 @@ We track the following resources: | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 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
| +| 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
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_days_of_weektrue
restart_requirement_weekstrue
updated_atfalse
user_acltrue
| diff --git a/docs/admin/auth.md b/docs/admin/auth.md index 16807c159fc37..4a512bfc3672d 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 @@ -59,15 +61,17 @@ 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: @@ -288,6 +292,28 @@ OIDC provider will be added to the `myCoderGroupName` group in Coder. 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. + +```console +# as an environment variable +CODER_OIDC_GROUP_AUTO_CREATE=true + +# 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. + +```console +# as an environment variable +CODER_OIDC_GROUP_REGEX_FILTER="^my-group-.*$" + +# 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. diff --git a/docs/admin/configure.md b/docs/admin/configure.md index e74d447c0b4e1..2240ef4ed5d62 100644 --- a/docs/admin/configure.md +++ b/docs/admin/configure.md @@ -42,7 +42,7 @@ 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 + [`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). @@ -55,6 +55,36 @@ The Coder server can directly use TLS certificates with `CODER_TLS_ENABLE` and a - [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 + +```console +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. diff --git a/docs/admin/high-availability.md b/docs/admin/high-availability.md index 430c0c8dbb4c4..1ab9cc861cb6c 100644 --- a/docs/admin/high-availability.md +++ b/docs/admin/high-availability.md @@ -6,19 +6,27 @@ endpoint. [GCP](https://cloud.google.com/sql/docs/postgres/high-availability), [ and other cloud vendors offer fully-managed HA Postgres services that pair nicely with Coder. -For Coder to operate correctly, every node must be within 10ms of each other -and Postgres. We make a best-effort attempt to warn the user when inter-Coder -latency is too high, but if requests start dropping, this is one metric to investigate. +For Coder to operate correctly, Coderd instances should have low-latency connections +to each other so that they can effectively relay traffic between users and workspaces no +matter which Coderd instance users or workspaces connect to. We make a best-effort attempt +to warn the user when inter-Coderd latency is too high, but if requests start dropping, this +is one metric to investigate. + +We also recommend that you deploy all Coderd instances such that they have low-latency +connections to Postgres. Coderd often makes several database round-trips while processing +a single API request, so prioritizing low-latency between Coderd and Postgres is more important +than low-latency between users and Coderd. + Note that this latency requirement applies _only_ to Coder services. Coder will -operate correctly even with few seconds of latency on -workspace <-> Coder and user <-> Coder connections. +operate correctly even with few seconds of latency on workspace <-> Coder and user <-> Coder +connections. ## Setup Coder automatically enters HA mode when multiple instances simultaneously connect to the same Postgres endpoint. -HA brings one configuration variable to set in each Coder +HA brings one configuration variable to set in each Coderd node: `CODER_DERP_SERVER_RELAY_URL`. The HA nodes use these URLs to communicate with each other. Inter-node communication is only required while using the embedded relay (default). If you're using [custom relays](../networking/index.md#custom-relays), Coder ignores `CODER_DERP_SERVER_RELAY_URL` since Postgres is the sole rendezvous for the Coder nodes. diff --git a/docs/admin/provisioners.md b/docs/admin/provisioners.md index 8f733fdccaa7d..acaa77688542d 100644 --- a/docs/admin/provisioners.md +++ b/docs/admin/provisioners.md @@ -10,18 +10,23 @@ By default, the Coder server runs [built-in provisioner daemons](../cli/server.m - **Reduce server load**: External provisioners reduce load and build queue times from the Coder server. See [Scaling Coder](./scale.md#concurrent-workspace-builds) for more details. -> External provisioners are in an [alpha state](../contributing/feature-stages.md#alpha-features) and the behavior is subject to change. Use [GitHub issues](https://github.com/coder/coder) to leave feedback. +Each provisioner can run a single [concurrent workspace build](./scale.md#concurrent-workspace-builds). For example, running 30 provisioner containers will allow 30 users to start workspaces at the same time. -## Running external provisioners +Provisioners are started with the [coder provisionerd start](../cli/provisionerd_start.md) command. -Each provisioner can run a single [concurrent workspace build](./scale.md#concurrent-workspace-builds). For example, running 30 provisioner containers will allow 30 users to start workspaces at the same time. +## Authentication + +The provisioner daemon must authenticate with your Coder deployment. -### Requirements +Set a [provisioner daemon pre-shared key (PSK)](../cli/server.md#--provisioner-daemon-psk) on the Coder server and start the provisioner with +`coder provisionerd start --psk `. If you are [installing with Helm](../install/kubernetes#install-coder-with-helm), +see the [Helm example](#example-running-an-external-provisioner-with-helm) below. -- The [Coder CLI](../cli.md) must installed on and authenticated as a user with the Owner or Template Admin role. -- Your environment must be [authenticated](../templates/authentication.md) against the cloud environments templates need to provision against. +> Coder still supports authenticating the provisioner daemon with a [token](../cli.md#--token) from a user with the +> Template Admin or Owner role. This method is deprecated in favor of the PSK, which only has permission to access +> provisioner daemon APIs. We recommend migrating to the PSK as soon as practical. -### Types of provisioners +## Types of provisioners - **Generic provisioners** can pick up any build job from templates without provisioner tags. @@ -61,7 +66,68 @@ Each provisioner can run a single [concurrent workspace build](./scale.md#concur --provisioner-tag scope=user ``` -### Example: Running an external provisioner on a VM +## Example: Running an external provisioner with Helm + +Coder provides a Helm chart for running external provisioner daemons, which you will use in concert with the Helm chart +for deploying the Coder server. + +1. Create a long, random pre-shared key (PSK) and store it in a Kubernetes secret + + ```shell + kubectl create secret generic coder-provisioner-psk --from-literal=psk=`head /dev/urandom | tr -dc A-Za-z0-9 | head -c 26` + ``` + +1. Modify your Coder `values.yaml` to include + + ```yaml + provisionerDaemon: + pskSecretName: "coder-provisioner-psk" + ``` + +1. Redeploy Coder with the new `values.yaml` to roll out the PSK. You can omit `--version ` to also upgrade + Coder to the latest version. + + ```shell + helm upgrade coder coder-v2/coder \ + --namespace coder \ + --version \ + --values values.yaml + ``` + +1. Create a `provisioner-values.yaml` file for the provisioner daemons Helm chart. For example + + ```yaml + coder: + env: + - name: CODER_URL + value: "https://coder.example.com" + replicaCount: 10 + provisionerDaemon: + pskSecretName: "coder-provisioner-psk" + tags: + location: auh + kind: k8s + ``` + + This example creates a deployment of 10 provisioner daemons (for 10 concurrent builds) with the listed tags. For + generic provisioners, remove the tags. + + > Refer to the [values.yaml](https://github.com/coder/coder/blob/main/helm/provisioner/values.yaml) file for the + > coder-provisioner chart for information on what values can be specified. + +1. Install the provisioner daemon chart + + ```shell + helm install coder-provisioner coder-v2/coder-provisioner \ + --namespace coder \ + --version \ + --values provisioner-values.yaml + ``` + + You can verify that your provisioner daemons have successfully connected to Coderd by looking for a debug log message that says + `provisionerd: successfully connected to coderd` from each Pod. + +## Example: Running an external provisioner on a VM ```sh curl -L https://coder.com/install.sh | sh @@ -70,7 +136,7 @@ export CODER_SESSION_TOKEN=your_token coder provisionerd start ``` -### Example: Running an external provisioner via Docker +## Example: Running an external provisioner via Docker ```sh docker run --rm -it \ diff --git a/docs/admin/scale.md b/docs/admin/scale.md index 999a30aeae44a..998314061fd52 100644 --- a/docs/admin/scale.md +++ b/docs/admin/scale.md @@ -42,7 +42,7 @@ Users accessing workspaces via SSH will consume fewer resources, as SSH connecti Workspace builds are CPU-intensive, as it relies on Terraform. Various [Terraform providers](https://registry.terraform.io/browse/providers) have different resource requirements. When tested with our [kubernetes](https://github.com/coder/coder/tree/main/examples/templates/kubernetes) template, `coderd` will consume roughly 0.25 cores per concurrent workspace build. -For effective provisioning, our helm chart prefers to schedule [one coderd replica per-node](https://github.com/coder/coder/blob/main/helm/values.yaml#L110-L121). +For effective provisioning, our helm chart prefers to schedule [one coderd replica per-node](https://github.com/coder/coder/blob/main/helm/coder/values.yaml#L188-L202). We recommend: diff --git a/docs/admin/users.md b/docs/admin/users.md index b8edeb1619f91..fef44a0877d5f 100644 --- a/docs/admin/users.md +++ b/docs/admin/users.md @@ -158,8 +158,10 @@ In the Coder UI, you can filter your users using pre-defined filters or by utili - To find active users, use the filter `status:active`. - To find admin users, use the filter `role:admin`. +- To find users have not been active since July 2023: `status:active last_seen_before:"2023-07-01T00:00:00Z"` The following filters are supported: - `status` - Indicates the status of the user. It can be either `active`, `dormant` or `suspended`. - `role` - Represents the role of the user. You can refer to the [TemplateRole documentation](https://pkg.go.dev/github.com/coder/coder/codersdk#TemplateRole) for a list of supported user roles. +- `last_seen_before` and `last_seen_after` - The last time a used has used the platform (e.g. logging in, any API requests, connecting to workspaces). Uses the RFC3339Nano format. diff --git a/docs/api/agents.md b/docs/api/agents.md index 2316fdaafde51..73b4abf8ec84b 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -393,6 +393,12 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/manifest \ } ], "derpmap": { + "homeParams": { + "regionScore": { + "property1": 0, + "property2": 0 + } + }, "omitDefaultRegions": true, "regions": { "property1": { @@ -400,6 +406,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/manifest \ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -423,6 +430,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/manifest \ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -695,7 +703,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent} \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -736,6 +744,12 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con ```json { "derp_map": { + "homeParams": { + "regionScore": { + "property1": 0, + "property2": 0 + } + }, "omitDefaultRegions": true, "regions": { "property1": { @@ -743,6 +757,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -766,6 +781,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, diff --git a/docs/api/audit.md b/docs/api/audit.md index d5aeb78665d31..5efe1f3410809 100644 --- a/docs/api/audit.md +++ b/docs/api/audit.md @@ -63,7 +63,7 @@ curl -X GET http://coder-server:8080/api/v2/audit?q=string \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { diff --git a/docs/api/authorization.md b/docs/api/authorization.md index d57a5e7542c35..17fc2e81d2299 100644 --- a/docs/api/authorization.md +++ b/docs/api/authorization.md @@ -129,7 +129,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/convert-login \ ```json { "password": "string", - "to_type": "password" + "to_type": "" } ``` @@ -148,7 +148,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/convert-login \ { "expires_at": "2019-08-24T14:15:22Z", "state_string": "string", - "to_type": "password", + "to_type": "", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } ``` diff --git a/docs/api/builds.md b/docs/api/builds.md index daf7958de387f..782e41a6f61aa 100644 --- a/docs/api/builds.md +++ b/docs/api/builds.md @@ -120,7 +120,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -282,7 +282,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -583,7 +583,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/res "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -672,7 +672,7 @@ Status Code **200** | `»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | -| `»» subsystem` | [codersdk.AgentSubsystem](schemas.md#codersdkagentsubsystem) | false | | | +| `»» subsystems` | array | false | | | | `»» troubleshooting_url` | string | false | | | | `»» updated_at` | string(date-time) | false | | | | `»» version` | string | false | | | @@ -716,7 +716,6 @@ Status Code **200** | `status` | `connected` | | `status` | `disconnected` | | `status` | `timeout` | -| `subsystem` | `envbox` | | `workspace_transition` | `start` | | `workspace_transition` | `stop` | | `workspace_transition` | `delete` | @@ -841,7 +840,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -1008,7 +1007,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -1133,7 +1132,7 @@ Status Code **200** | `»»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | | | `»»» startup_script_timeout_seconds` | integer | false | | »»startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | -| `»»» subsystem` | [codersdk.AgentSubsystem](schemas.md#codersdkagentsubsystem) | false | | | +| `»»» subsystems` | array | false | | | | `»»» troubleshooting_url` | string | false | | | | `»»» updated_at` | string(date-time) | false | | | | `»»» version` | string | false | | | @@ -1197,7 +1196,6 @@ Status Code **200** | `status` | `connected` | | `status` | `disconnected` | | `status` | `timeout` | -| `subsystem` | `envbox` | | `workspace_transition` | `start` | | `workspace_transition` | `stop` | | `workspace_transition` | `delete` | @@ -1356,7 +1354,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" diff --git a/docs/api/debug.md b/docs/api/debug.md index e3382c6586504..5016f6a87b256 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -102,6 +102,7 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -134,6 +135,7 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -164,6 +166,7 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -196,6 +199,7 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index d859ac59ad09b..6edfcafa385d7 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -148,7 +148,7 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get group by name +## Get group by ID ### Code samples @@ -165,7 +165,7 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \ | Name | In | Type | Required | Description | | ------- | ---- | ------ | -------- | ----------- | -| `group` | path | string | true | Group name | +| `group` | path | string | true | Group id | ### Example responses @@ -183,7 +183,7 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -197,7 +197,8 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` @@ -244,7 +245,7 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -258,7 +259,8 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group} \ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` @@ -277,17 +279,32 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \ + -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` `PATCH /groups/{group}` +> Body parameter + +```json +{ + "add_users": ["string"], + "avatar_url": "string", + "display_name": "string", + "name": "string", + "quota_allowance": 0, + "remove_users": ["string"] +} +``` + ### Parameters -| Name | In | Type | Required | Description | -| ------- | ---- | ------ | -------- | ----------- | -| `group` | path | string | true | Group name | +| Name | In | Type | Required | Description | +| ------- | ---- | ------------------------------------------------------------------ | -------- | ------------------- | +| `group` | path | string | true | Group name | +| `body` | body | [codersdk.PatchGroupRequest](schemas.md#codersdkpatchgrouprequest) | true | Patch group request | ### Example responses @@ -305,7 +322,7 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -319,7 +336,8 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` @@ -441,7 +459,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -455,7 +473,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ] ``` @@ -470,33 +489,35 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups Status Code **200** -| Name | Type | Required | Restrictions | Description | -| --------------------- | ---------------------------------------------------- | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» avatar_url` | string | false | | | -| `» display_name` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» members` | array | false | | | -| `»» avatar_url` | string(uri) | false | | | -| `»» created_at` | string(date-time) | true | | | -| `»» email` | string(email) | true | | | -| `»» id` | string(uuid) | true | | | -| `»» last_seen_at` | string(date-time) | false | | | -| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | -| `»» organization_ids` | array | false | | | -| `»» roles` | array | false | | | -| `»»» display_name` | string | false | | | -| `»»» name` | string | false | | | -| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `»» username` | string | true | | | -| `» name` | string | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» quota_allowance` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +| --------------------- | ------------------------------------------------------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» avatar_url` | string | false | | | +| `» display_name` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» members` | array | false | | | +| `»» avatar_url` | string(uri) | false | | | +| `»» created_at` | string(date-time) | true | | | +| `»» email` | string(email) | true | | | +| `»» id` | string(uuid) | true | | | +| `»» last_seen_at` | string(date-time) | false | | | +| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `»» organization_ids` | array | false | | | +| `»» roles` | array | false | | | +| `»»» display_name` | string | false | | | +| `»»» name` | string | false | | | +| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `»» username` | string | true | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» quota_allowance` | integer | false | | | +| `» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | | #### Enumerated Values | Property | Value | | ------------ | ----------- | +| `login_type` | `` | | `login_type` | `password` | | `login_type` | `github` | | `login_type` | `oidc` | @@ -504,6 +525,8 @@ Status Code **200** | `login_type` | `none` | | `status` | `active` | | `status` | `suspended` | +| `source` | `user` | +| `source` | `oidc` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -555,7 +578,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -569,7 +592,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` @@ -617,7 +641,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -631,7 +655,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` @@ -979,7 +1004,7 @@ curl -X PATCH http://coder-server:8080/api/v2/scim/v2/Users/{id} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1031,7 +1056,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "role": "admin", "roles": [ @@ -1077,6 +1102,7 @@ Status Code **200** | Property | Value | | ------------ | ----------- | +| `login_type` | `` | | `login_type` | `password` | | `login_type` | `github` | | `login_type` | `oidc` | @@ -1188,7 +1214,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1202,7 +1228,8 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ], "users": [ @@ -1212,7 +1239,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1238,35 +1265,37 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------------- | ---------------------------------------------------- | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» groups` | array | false | | | -| `»» avatar_url` | string | false | | | -| `»» display_name` | string | false | | | -| `»» id` | string(uuid) | false | | | -| `»» members` | array | false | | | -| `»»» avatar_url` | string(uri) | false | | | -| `»»» created_at` | string(date-time) | true | | | -| `»»» email` | string(email) | true | | | -| `»»» id` | string(uuid) | true | | | -| `»»» last_seen_at` | string(date-time) | false | | | -| `»»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | -| `»»» organization_ids` | array | false | | | -| `»»» roles` | array | false | | | -| `»»»» display_name` | string | false | | | -| `»»»» name` | string | false | | | -| `»»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `»»» username` | string | true | | | -| `»» name` | string | false | | | -| `»» organization_id` | string(uuid) | false | | | -| `»» quota_allowance` | integer | false | | | -| `» users` | array | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------- | ------------------------------------------------------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» groups` | array | false | | | +| `»» avatar_url` | string | false | | | +| `»» display_name` | string | false | | | +| `»» id` | string(uuid) | false | | | +| `»» members` | array | false | | | +| `»»» avatar_url` | string(uri) | false | | | +| `»»» created_at` | string(date-time) | true | | | +| `»»» email` | string(email) | true | | | +| `»»» id` | string(uuid) | true | | | +| `»»» last_seen_at` | string(date-time) | false | | | +| `»»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `»»» organization_ids` | array | false | | | +| `»»» roles` | array | false | | | +| `»»»» display_name` | string | false | | | +| `»»»» name` | string | false | | | +| `»»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `»»» username` | string | true | | | +| `»» name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» quota_allowance` | integer | false | | | +| `»» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | | +| `» users` | array | false | | | #### Enumerated Values | Property | Value | | ------------ | ----------- | +| `login_type` | `` | | `login_type` | `password` | | `login_type` | `github` | | `login_type` | `oidc` | @@ -1274,6 +1303,8 @@ Status Code **200** | `login_type` | `none` | | `status` | `active` | | `status` | `suspended` | +| `source` | `user` | +| `source` | `oidc` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/general.md b/docs/api/general.md index bb64823bd86c9..170d52a0f3260 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -256,11 +256,15 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "oidc": { "allow_signups": true, "auth_url_params": {}, + "client_cert_file": "string", "client_id": "string", + "client_key_file": "string", "client_secret": "string", "email_domain": ["string"], "email_field": "string", + "group_auto_create": true, "group_mapping": {}, + "group_regex_filter": {}, "groups_field": "string", "icon_url": { "forceQuery": true, @@ -305,6 +309,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "provisioner": { "daemon_poll_interval": 0, "daemon_poll_jitter": 0, + "daemon_psk": "string", "daemons": 0, "daemons_echo": true, "force_cancel_interval": 0 diff --git a/docs/api/insights.md b/docs/api/insights.md index 11843e63f0316..3c421c64173cd 100644 --- a/docs/api/insights.md +++ b/docs/api/insights.md @@ -80,6 +80,7 @@ curl -X GET http://coder-server:8080/api/v2/insights/templates \ "end_time": "2019-08-24T14:15:22Z", "parameters_usage": [ { + "description": "string", "display_name": "string", "name": "string", "options": [ @@ -91,6 +92,7 @@ curl -X GET http://coder-server:8080/api/v2/insights/templates \ } ], "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "string", "values": [ { "count": 0, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 39bc8d16b6e6f..8d73e64e84aa6 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -202,6 +202,12 @@ } ], "derpmap": { + "homeParams": { + "regionScore": { + "property1": 0, + "property2": 0 + } + }, "omitDefaultRegions": true, "regions": { "property1": { @@ -209,6 +215,7 @@ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -232,6 +239,7 @@ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -377,18 +385,18 @@ ```json { "expanded_directory": "string", - "subsystem": "envbox", + "subsystems": ["envbox"], "version": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------------- | -------------------------------------------------- | -------- | ------------ | ----------- | -| `expanded_directory` | string | false | | | -| `subsystem` | [codersdk.AgentSubsystem](#codersdkagentsubsystem) | false | | | -| `version` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------------- | ----------------------------------------------------------- | -------- | ------------ | ----------- | +| `expanded_directory` | string | false | | | +| `subsystems` | array of [codersdk.AgentSubsystem](#codersdkagentsubsystem) | false | | | +| `version` | string | false | | | ## agentsdk.Stats @@ -595,6 +603,16 @@ | `value_source` | [clibase.ValueSource](#clibasevaluesource) | false | | | | `yaml` | string | false | | Yaml is the YAML key used to configure this option. If unset, YAML configuring is disabled. | +## clibase.Regexp + +```json +{} +``` + +### Properties + +_None_ + ## clibase.Struct-array_codersdk_GitAuthConfig ```json @@ -774,7 +792,7 @@ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -788,7 +806,8 @@ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ], "users": [ @@ -798,7 +817,7 @@ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -902,9 +921,11 @@ #### Enumerated Values -| Value | -| -------- | -| `envbox` | +| Value | +| ------------ | +| `envbox` | +| `envbuilder` | +| `exectrace` | ## codersdk.AppHostResponse @@ -1065,7 +1086,7 @@ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1142,7 +1163,7 @@ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1367,7 +1388,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "password": "string", - "to_type": "password" + "to_type": "" } ``` @@ -1644,6 +1665,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "disable_login": true, "email": "user@example.com", + "login_type": "", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "password": "string", "username": "string" @@ -1652,13 +1674,14 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------- | ------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disable_login` | boolean | false | | Disable login 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. | -| `email` | string | true | | | -| `organization_id` | string | false | | | -| `password` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ----------------- | ---------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `disable_login` | boolean | false | | Disable login 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. | +| `email` | string | true | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | Login type defaults to LoginTypePassword. | +| `organization_id` | string | false | | | +| `password` | string | false | | | +| `username` | string | true | | | ## codersdk.CreateWorkspaceBuildRequest @@ -2050,11 +2073,15 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "oidc": { "allow_signups": true, "auth_url_params": {}, + "client_cert_file": "string", "client_id": "string", + "client_key_file": "string", "client_secret": "string", "email_domain": ["string"], "email_field": "string", + "group_auto_create": true, "group_mapping": {}, + "group_regex_filter": {}, "groups_field": "string", "icon_url": { "forceQuery": true, @@ -2099,6 +2126,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "provisioner": { "daemon_poll_interval": 0, "daemon_poll_jitter": 0, + "daemon_psk": "string", "daemons": 0, "daemons_echo": true, "force_cancel_interval": 0 @@ -2407,11 +2435,15 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "oidc": { "allow_signups": true, "auth_url_params": {}, + "client_cert_file": "string", "client_id": "string", + "client_key_file": "string", "client_secret": "string", "email_domain": ["string"], "email_field": "string", + "group_auto_create": true, "group_mapping": {}, + "group_regex_filter": {}, "groups_field": "string", "icon_url": { "forceQuery": true, @@ -2456,6 +2488,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "provisioner": { "daemon_poll_interval": 0, "daemon_poll_jitter": 0, + "daemon_psk": "string", "daemons": 0, "daemons_echo": true, "force_cancel_interval": 0 @@ -2677,6 +2710,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `single_tailnet` | | `template_restart_requirement` | | `deployment_health_page` | +| `workspaces_batch_actions` | ## codersdk.Feature @@ -2724,7 +2758,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -2942,7 +2976,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -2956,21 +2990,38 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------- | --------------------------------------- | -------- | ------------ | ----------- | -| `avatar_url` | string | false | | | -| `display_name` | string | false | | | -| `id` | string | false | | | -| `members` | array of [codersdk.User](#codersdkuser) | false | | | -| `name` | string | false | | | -| `organization_id` | string | false | | | -| `quota_allowance` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +| ----------------- | -------------------------------------------- | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | +| `display_name` | string | false | | | +| `id` | string | false | | | +| `members` | array of [codersdk.User](#codersdkuser) | false | | | +| `name` | string | false | | | +| `organization_id` | string | false | | | +| `quota_allowance` | integer | false | | | +| `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | | + +## codersdk.GroupSource + +```json +"user" +``` + +### Properties + +#### Enumerated Values + +| Value | +| ------ | +| `user` | +| `oidc` | ## codersdk.Healthcheck @@ -3143,7 +3194,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ## codersdk.LoginType ```json -"password" +"" ``` ### Properties @@ -3152,6 +3203,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Value | | ---------- | +| `` | | `password` | | `github` | | `oidc` | @@ -3260,7 +3312,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "expires_at": "2019-08-24T14:15:22Z", "state_string": "string", - "to_type": "password", + "to_type": "", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } ``` @@ -3298,11 +3350,15 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "allow_signups": true, "auth_url_params": {}, + "client_cert_file": "string", "client_id": "string", + "client_key_file": "string", "client_secret": "string", "email_domain": ["string"], "email_field": "string", + "group_auto_create": true, "group_mapping": {}, + "group_regex_filter": {}, "groups_field": "string", "icon_url": { "forceQuery": true, @@ -3331,26 +3387,30 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------------- | -------------------------- | -------- | ------------ | ----------- | -| `allow_signups` | boolean | false | | | -| `auth_url_params` | object | false | | | -| `client_id` | string | false | | | -| `client_secret` | string | false | | | -| `email_domain` | array of string | false | | | -| `email_field` | string | false | | | -| `group_mapping` | object | false | | | -| `groups_field` | string | false | | | -| `icon_url` | [clibase.URL](#clibaseurl) | false | | | -| `ignore_email_verified` | boolean | false | | | -| `ignore_user_info` | boolean | false | | | -| `issuer_url` | string | false | | | -| `scopes` | array of string | false | | | -| `sign_in_text` | string | false | | | -| `user_role_field` | string | false | | | -| `user_role_mapping` | object | false | | | -| `user_roles_default` | array of string | false | | | -| `username_field` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ----------------------- | -------------------------------- | -------- | ------------ | -------------------------------------------------------------------------------- | +| `allow_signups` | boolean | false | | | +| `auth_url_params` | object | false | | | +| `client_cert_file` | string | false | | | +| `client_id` | string | false | | | +| `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. | +| `client_secret` | string | false | | | +| `email_domain` | array of string | false | | | +| `email_field` | string | false | | | +| `group_auto_create` | boolean | false | | | +| `group_mapping` | object | false | | | +| `group_regex_filter` | [clibase.Regexp](#clibaseregexp) | false | | | +| `groups_field` | string | false | | | +| `icon_url` | [clibase.URL](#clibaseurl) | false | | | +| `ignore_email_verified` | boolean | false | | | +| `ignore_user_info` | boolean | false | | | +| `issuer_url` | string | false | | | +| `scopes` | array of string | false | | | +| `sign_in_text` | string | false | | | +| `user_role_field` | string | false | | | +| `user_role_mapping` | object | false | | | +| `user_roles_default` | array of string | false | | | +| `username_field` | string | false | | | ## codersdk.Organization @@ -3399,6 +3459,30 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `updated_at` | string | false | | | | `user_id` | string | false | | | +## codersdk.PatchGroupRequest + +```json +{ + "add_users": ["string"], + "avatar_url": "string", + "display_name": "string", + "name": "string", + "quota_allowance": 0, + "remove_users": ["string"] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------------- | --------------- | -------- | ------------ | ----------- | +| `add_users` | array of string | false | | | +| `avatar_url` | string | false | | | +| `display_name` | string | false | | | +| `name` | string | false | | | +| `quota_allowance` | integer | false | | | +| `remove_users` | array of string | false | | | + ## codersdk.PatchTemplateVersionRequest ```json @@ -3485,6 +3569,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "daemon_poll_interval": 0, "daemon_poll_jitter": 0, + "daemon_psk": "string", "daemons": 0, "daemons_echo": true, "force_cancel_interval": 0 @@ -3497,6 +3582,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | ----------------------- | ------- | -------- | ------------ | ----------- | | `daemon_poll_interval` | integer | false | | | | `daemon_poll_jitter` | integer | false | | | +| `daemon_psk` | string | false | | | | `daemons` | integer | false | | | | `daemons_echo` | boolean | false | | | | `force_cancel_interval` | integer | false | | | @@ -4317,6 +4403,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "end_time": "2019-08-24T14:15:22Z", "parameters_usage": [ { + "description": "string", "display_name": "string", "name": "string", "options": [ @@ -4328,6 +4415,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } ], "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "string", "values": [ { "count": 0, @@ -4380,6 +4468,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "end_time": "2019-08-24T14:15:22Z", "parameters_usage": [ { + "description": "string", "display_name": "string", "name": "string", "options": [ @@ -4391,6 +4480,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } ], "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "string", "values": [ { "count": 0, @@ -4416,6 +4506,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "description": "string", "display_name": "string", "name": "string", "options": [ @@ -4427,6 +4518,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } ], "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "string", "values": [ { "count": 0, @@ -4440,10 +4532,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | | -------------- | ------------------------------------------------------------------------------------------- | -------- | ------------ | ----------- | +| `description` | string | false | | | | `display_name` | string | false | | | | `name` | string | false | | | | `options` | array of [codersdk.TemplateVersionParameterOption](#codersdktemplateversionparameteroption) | false | | | | `template_ids` | array of string | false | | | +| `type` | string | false | | | | `values` | array of [codersdk.TemplateParameterValue](#codersdktemplateparametervalue) | false | | | ## codersdk.TemplateParameterValue @@ -4504,7 +4598,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "role": "admin", "roles": [ @@ -5012,7 +5106,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -5137,7 +5231,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { - "login_type": "password" + "login_type": "" } ``` @@ -5353,7 +5447,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -5494,7 +5588,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -5536,7 +5630,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](#codersdkworkspaceagentstartupscriptbehavior) | false | | | | `startup_script_timeout_seconds` | integer | false | | Startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `status` | [codersdk.WorkspaceAgentStatus](#codersdkworkspaceagentstatus) | false | | | -| `subsystem` | [codersdk.AgentSubsystem](#codersdkagentsubsystem) | false | | | +| `subsystems` | array of [codersdk.AgentSubsystem](#codersdkagentsubsystem) | false | | | | `troubleshooting_url` | string | false | | | | `updated_at` | string | false | | | | `version` | string | false | | | @@ -5546,6 +5640,12 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { "derp_map": { + "homeParams": { + "regionScore": { + "property1": 0, + "property2": 0 + } + }, "omitDefaultRegions": true, "regions": { "property1": { @@ -5553,6 +5653,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -5576,6 +5677,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -5952,7 +6054,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -6263,7 +6365,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -6475,7 +6577,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -6586,6 +6688,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6644,6 +6747,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6676,6 +6780,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6756,6 +6861,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6788,6 +6894,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6818,6 +6925,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6850,6 +6958,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6992,6 +7101,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7024,6 +7134,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7054,6 +7165,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7086,6 +7198,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7230,10 +7343,38 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `time` | string | false | | | | `valid` | boolean | false | | Valid is true if Time is not NULL | +## tailcfg.DERPHomeParams + +```json +{ + "regionScore": { + "property1": 0, + "property2": 0 + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------- | ------ | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `regionScore` | object | false | | Regionscore scales latencies of DERP regions by a given scaling factor when determining which region to use as the home ("preferred") DERP. Scores in the range (0, 1) will cause this region to be proportionally more preferred, and scores in the range (1, ∞) will penalize a region. | + +If a region is not present in this map, it is treated as having a score of 1.0. +Scores should not be 0 or negative; such scores will be ignored. +A nil map means no change from the previous value (if any); an empty non-nil map can be sent to reset all scores back to 1.0.| +|» `[any property]`|number|false||| + ## tailcfg.DERPMap ```json { + "homeParams": { + "regionScore": { + "property1": 0, + "property2": 0 + } + }, "omitDefaultRegions": true, "regions": { "property1": { @@ -7241,6 +7382,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7264,6 +7406,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7288,10 +7431,13 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------------- | ------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `omitDefaultRegions` | boolean | false | | Omitdefaultregions specifies to not use Tailscale's DERP servers, and only use those specified in this DERPMap. If there are none set outside of the defaults, this is a noop. | -| `regions` | object | false | | Regions is the set of geographic regions running DERP node(s). | +| Name | Type | Required | Restrictions | Description | +| ---------------------------------------------------------------------------------- | ------------------------------------------------ | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `homeParams` | [tailcfg.DERPHomeParams](#tailcfgderphomeparams) | false | | Homeparams if non-nil, is a change in home parameters. | +| The rest of the DEPRMap fields, if zero, means unchanged. | +| `omitDefaultRegions` | boolean | false | | Omitdefaultregions specifies to not use Tailscale's DERP servers, and only use those specified in this DERPMap. If there are none set outside of the defaults, this is a noop. | +| This field is only meaningful if the Regions map is non-nil (indicating a change). | +| `regions` | object | false | | Regions is the set of geographic regions running DERP node(s). | It's keyed by the DERPRegion.RegionID. The numbers are not necessarily contiguous.| @@ -7301,6 +7447,7 @@ The numbers are not necessarily contiguous.| ```json { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7320,6 +7467,7 @@ The numbers are not necessarily contiguous.| | Name | Type | Required | Restrictions | Description | | --------------------------------------------------------------------------------------------------------------------- | ------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `canPort80` | boolean | false | | Canport80 specifies whether this DERP node is accessible over HTTP on port 80 specifically. This is used for captive portal checks. | | `certName` | string | false | | Certname optionally specifies the expected TLS cert common name. If empty, HostName is used. If CertName is non-empty, HostName is only used for the TCP dial (if IPv4/IPv6 are not present) + TLS ClientHello. | | `derpport` | integer | false | | Derpport optionally provides an alternate TLS port number for the DERP HTTPS server. | | If zero, 443 is used. | @@ -7343,6 +7491,7 @@ The numbers are not necessarily contiguous.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7460,6 +7609,36 @@ _None_ | `username_or_id` | string | false | | For the following fields, if the AccessMethod is AccessMethodTerminal, then only AgentNameOrID may be set and it must be a UUID. The other fields must be left blank. | | `workspace_name_or_id` | string | false | | | +## workspaceapps.StatsReport + +```json +{ + "access_method": "path", + "agent_id": "string", + "requests": 0, + "session_ended_at": "string", + "session_id": "string", + "session_started_at": "string", + "slug_or_port": "string", + "user_id": "string", + "workspace_id": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------------- | -------------------------------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------- | +| `access_method` | [workspaceapps.AccessMethod](#workspaceappsaccessmethod) | false | | | +| `agent_id` | string | false | | | +| `requests` | integer | false | | | +| `session_ended_at` | string | false | | Updated periodically while app is in use active and when the last connection is closed. | +| `session_id` | string | false | | | +| `session_started_at` | string | false | | | +| `slug_or_port` | string | false | | | +| `user_id` | string | false | | | +| `workspace_id` | string | false | | | + ## wsproxysdk.AgentIsLegacyResponse ```json @@ -7564,3 +7743,29 @@ _None_ | `derp_mesh_key` | string | false | | | | `derp_region_id` | integer | false | | | | `sibling_replicas` | array of [codersdk.Replica](#codersdkreplica) | false | | Sibling replicas is a list of all other replicas of the proxy that have not timed out. | + +## wsproxysdk.ReportAppStatsRequest + +```json +{ + "stats": [ + { + "access_method": "path", + "agent_id": "string", + "requests": 0, + "session_ended_at": "string", + "session_id": "string", + "session_started_at": "string", + "slug_or_port": "string", + "user_id": "string", + "workspace_id": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------- | --------------------------------------------------------------- | -------- | ------------ | ----------- | +| `stats` | array of [workspaceapps.StatsReport](#workspaceappsstatsreport) | false | | | diff --git a/docs/api/templates.md b/docs/api/templates.md index f8a1612161f81..14ce5dec7d29f 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -1633,7 +1633,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/d "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -1722,7 +1722,7 @@ Status Code **200** | `»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | -| `»» subsystem` | [codersdk.AgentSubsystem](schemas.md#codersdkagentsubsystem) | false | | | +| `»» subsystems` | array | false | | | | `»» troubleshooting_url` | string | false | | | | `»» updated_at` | string(date-time) | false | | | | `»» version` | string | false | | | @@ -1766,7 +1766,6 @@ Status Code **200** | `status` | `connected` | | `status` | `disconnected` | | `status` | `timeout` | -| `subsystem` | `envbox` | | `workspace_transition` | `start` | | `workspace_transition` | `stop` | | `workspace_transition` | `delete` | @@ -2025,7 +2024,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/r "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -2114,7 +2113,7 @@ Status Code **200** | `»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | -| `»» subsystem` | [codersdk.AgentSubsystem](schemas.md#codersdkagentsubsystem) | false | | | +| `»» subsystems` | array | false | | | | `»» troubleshooting_url` | string | false | | | | `»» updated_at` | string(date-time) | false | | | | `»» version` | string | false | | | @@ -2158,7 +2157,6 @@ Status Code **200** | `status` | `connected` | | `status` | `disconnected` | | `status` | `timeout` | -| `subsystem` | `envbox` | | `workspace_transition` | `start` | | `workspace_transition` | `stop` | | `workspace_transition` | `delete` | diff --git a/docs/api/users.md b/docs/api/users.md index 3c583e15787db..fdeed691da48f 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -36,7 +36,7 @@ curl -X GET http://coder-server:8080/api/v2/users \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -79,6 +79,7 @@ curl -X POST http://coder-server:8080/api/v2/users \ { "disable_login": true, "email": "user@example.com", + "login_type": "", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "password": "string", "username": "string" @@ -102,7 +103,7 @@ curl -X POST http://coder-server:8080/api/v2/users \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -360,7 +361,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -411,7 +412,7 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -821,7 +822,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/login-type \ ```json { - "login_type": "password" + "login_type": "" } ``` @@ -1005,7 +1006,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/profile \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1056,7 +1057,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/roles \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1117,7 +1118,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/roles \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1168,7 +1169,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/activate \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1219,7 +1220,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/suspend \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 390a82448886c..f5c4aadd729c5 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -148,7 +148,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -336,7 +336,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -523,7 +523,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -712,7 +712,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -931,22 +931,164 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/lock \ ```json { - "detail": "string", - "message": "string", - "validations": [ - { - "detail": "string", - "field": "string" - } - ] + "autostart_schedule": "string", + "created_at": "2019-08-24T14:15:22Z", + "deleting_at": "2019-08-24T14:15:22Z", + "health": { + "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "healthy": false + }, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_used_at": "2019-08-24T14:15:22Z", + "latest_build": { + "build_number": 0, + "created_at": "2019-08-24T14:15:22Z", + "daily_cost": 0, + "deadline": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", + "initiator_name": "string", + "job": { + "canceled_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "error": "string", + "error_code": "MISSING_TEMPLATE_PARAMETER", + "file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "queue_position": 0, + "queue_size": 0, + "started_at": "2019-08-24T14:15:22Z", + "status": "pending", + "tags": { + "property1": "string", + "property2": "string" + }, + "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" + }, + "max_deadline": "2019-08-24T14:15:22Z", + "reason": "initiator", + "resources": [ + { + "agents": [ + { + "apps": [ + { + "command": "string", + "display_name": "string", + "external": true, + "health": "disabled", + "healthcheck": { + "interval": 0, + "threshold": 0, + "url": "string" + }, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "sharing_level": "owner", + "slug": "string", + "subdomain": true, + "url": "string" + } + ], + "architecture": "string", + "connection_timeout_seconds": 0, + "created_at": "2019-08-24T14:15:22Z", + "directory": "string", + "disconnected_at": "2019-08-24T14:15:22Z", + "environment_variables": { + "property1": "string", + "property2": "string" + }, + "expanded_directory": "string", + "first_connected_at": "2019-08-24T14:15:22Z", + "health": { + "healthy": false, + "reason": "agent has lost connection" + }, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "instance_id": "string", + "last_connected_at": "2019-08-24T14:15:22Z", + "latency": { + "property1": { + "latency_ms": 0, + "preferred": true + }, + "property2": { + "latency_ms": 0, + "preferred": true + } + }, + "lifecycle_state": "created", + "login_before_ready": true, + "logs_length": 0, + "logs_overflowed": true, + "name": "string", + "operating_system": "string", + "ready_at": "2019-08-24T14:15:22Z", + "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", + "shutdown_script": "string", + "shutdown_script_timeout_seconds": 0, + "started_at": "2019-08-24T14:15:22Z", + "startup_script": "string", + "startup_script_behavior": "blocking", + "startup_script_timeout_seconds": 0, + "status": "connecting", + "subsystems": ["envbox"], + "troubleshooting_url": "string", + "updated_at": "2019-08-24T14:15:22Z", + "version": "string" + } + ], + "created_at": "2019-08-24T14:15:22Z", + "daily_cost": 0, + "hide": true, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f", + "metadata": [ + { + "key": "string", + "sensitive": true, + "value": "string" + } + ], + "name": "string", + "type": "string", + "workspace_transition": "start" + } + ], + "status": "pending", + "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "template_version_name": "string", + "transition": "start", + "updated_at": "2019-08-24T14:15:22Z", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", + "workspace_name": "string", + "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", + "workspace_owner_name": "string" + }, + "locked_at": "2019-08-24T14:15:22Z", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "outdated": true, + "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", + "owner_name": "string", + "template_allow_user_cancel_workspace_jobs": true, + "template_display_name": "string", + "template_icon": "string", + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "template_name": "string", + "ttl_ms": 0, + "updated_at": "2019-08-24T14:15:22Z" } ``` ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Workspace](schemas.md#codersdkworkspace) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/changelogs/README.md b/docs/changelogs/README.md index e5740216e4f87..7b1945aa02a2d 100644 --- a/docs/changelogs/README.md +++ b/docs/changelogs/README.md @@ -1,8 +1,8 @@ # Changelogs -These are the changelogs used by [get-changelog.sh](https://github.com/coder/coder/blob/main/scripts/release/changelog.sh) for a release. +These are the changelogs used by [generate_release_notes.sh]https://github.com/coder/coder/blob/main/scripts/release/generate_release_notes.sh) for a release. -These changelogs are currently not kept in-sync with GitHub releases. Use [GitHub releases](https://github.com/coder/coder/releases) for the latest information! +These changelogs are currently not kept in sync with GitHub releases. Use [GitHub releases](https://github.com/coder/coder/releases) for the latest information! ## Writing a changelog diff --git a/docs/changelogs/v2.0.2.md b/docs/changelogs/v2.0.2.md new file mode 100644 index 0000000000000..01377772e2d48 --- /dev/null +++ b/docs/changelogs/v2.0.2.md @@ -0,0 +1,39 @@ +## Changelog + +### Features + +- [External provisioners](https://coder.com/docs/v2/latest/admin/provisioners) updates + - Added [PSK authentication](https://coder.com/docs/v2/latest/admin/provisioners#authentication) method (#8877) (@spikecurtis) + - Provisioner daemons can be deployed [via Helm](https://github.com/coder/coder/tree/main/helm/provisioner) (#8939) (@spikecurtis) +- Added login type (OIDC, GitHub, or built-in, or none) to users page (#8912) (@Emyrk) +- Groups can be [automatically created](https://coder.com/docs/v2/latest/admin/auth#user-not-being-assigned--group-does-not-exist) from OIDC group sync (#8884) (@Emyrk) +- Parameter values can be specified via the [command line](https://coder.com/docs/v2/latest/cli/create#--parameter) during workspace creation/updates (#8898) (@mtojek) +- Added date range picker for the template insights page (#8976) (@BrunoQuaresma) +- We now publish preview [container images](https://github.com/coder/coder/pkgs/container/coder-preview) on every commit to `main`. Only use these images for testing. They are automatically deleted after 7 days. +- Coder is [officially listed JetBrains Gateway](https://coder.com/blog/self-hosted-remote-development-in-jetbrains-ides-now-available-to-coder-users). + +### Bug fixes + +- Don't close other web terminal or `coder_app` sessions during a terminal close (#8917) +- Properly refresh OIDC tokens (#8950) (@Emyrk) +- Added backoff to validate fresh git auth tokens (#8956) (@kylecarbs) +- Make preferred region the first in list (#9014) (@matifali) +- `coder stat`: clistat: accept positional arg for stat disk cmd (#8911) +- Prompt for confirmation during `coder delete ` (#8579) +- Ensure SCIM create user can unsuspend (#8916) +- Set correct Prometheus port in Helm notes (#8888) +- Show user avatar on group page (#8997) (@BrunoQuaresma) +- Make deployment stats bar scrollable on smaller viewports (#8996) (@BrunoQuaresma) +- Add horizontal scroll to template viewer (#8998) (@BrunoQuaresma) +- Persist search parameters when user has to authenticate (#9005) (@BrunoQuaresma) +- Set default color and display error on appearance form (#9004) (@BrunoQuaresma) + +Compare: [`v2.0.1...v2.0.2`](https://github.com/coder/coder/compare/v2.0.1...v2.0.2) + +## Container image + +- `docker pull ghcr.io/coder/coder:v2.0.2` + +## Install/upgrade + +Refer to our docs to [install](https://coder.com/docs/v2/latest/install) or [upgrade](https://coder.com/docs/v2/latest/admin/upgrade) Coder, or use a release asset below. diff --git a/docs/changelogs/v2.1.0.md b/docs/changelogs/v2.1.0.md new file mode 100644 index 0000000000000..a530346835706 --- /dev/null +++ b/docs/changelogs/v2.1.0.md @@ -0,0 +1,53 @@ +## Changelog + +### Important changes + +- We removed `jq` from our base image. In the unlikely case you use `jq` for fetching Coder's database secret or other values, you'll need to build your own Coder image. Click [here](https://gist.github.com/bpmct/05cfb671d1d468ae3be46e93173a02ea) to learn more. (#8979) (@ericpaulsen) + +### Features + +- You can manually add OIDC or GitHub users (#9000) (@Emyrk) + ![Manual add user](https://user-images.githubusercontent.com/22407953/261455971-adf2707c-93a7-49c6-be5d-2ec177e224b9.png) + > Use this with the [CODER_OIDC_ALLOW_SIGNUPS](https://coder.com/docs/v2/latest/cli/server#--oidc-allow-signups) flag to manually onboard users before opening the floodgates to every user in your identity provider! +- CLI: The [--header-command](https://coder.com/docs/v2/latest/cli#--header-command) flag can leverage external services to provide dynamic headers to authenticate to a Coder deployment behind an application proxy or VPN (#9059) (@code-asher) +- OIDC: Add support for Azure OIDC PKI auth instead of client secret (#9054) (@Emyrk) +- Helm chart updates: + - Add terminationGracePeriodSeconds to provisioner chart (#9048) (@spikecurtis) + - Add support for NodePort service type (#8993) (@ffais) + - Published [external provisioner chart](https://coder.com/docs/v2/latest/admin/provisioners#example-running-an-external-provisioner-with-helm) to release and docs (#9050) (@spikecurtis) +- Exposed everyone group through UI. You can now set [quotas](https://coder.com/docs/v2/latest/admin/quotas) for the `Everyone` group. (#9117) (@sreya) +- Workspace build errors are shown as a tooltip (#9029) (@BrunoQuaresma) +- Add build log history to the build log page (#9150) (@BrunoQuaresma) + ![Build log history](https://user-images.githubusercontent.com/22407953/261457020-3fbbb274-1e32-4116-affb-4a5ac271110b.png) + +### Bug fixes + +- Correct GitHub oauth2 callback url (#9052) (@Emyrk) +- Remove duplication from language of query param error (#9069) (@kylecarbs) +- Remove unnecessary newlines from the end of cli output (#9068) (@kylecarbs) +- Change dashboard route `/settings/deployment` to `/deployment` (#9070) (@kylecarbs) +- Use screen for reconnecting terminal sessions on Linux if available (#8640) (@code-asher) +- Catch missing output with reconnecting PTY (#9094) (@code-asher) +- Fix deadlock on tailnet close (#9079) (@spikecurtis) +- Rename group GET request (#9097) (@ericpaulsen) +- Change oauth convert oidc cookie to SameSite=Lax (#9129) (@Emyrk) +- Make PGCoordinator close connections when unhealthy (#9125) (@spikecurtis) +- Don't navigate away from editor after publishing (#9153) (@aslilac) +- /workspaces should work even if missing template perms (#9152) (@Emyrk) +- Redirect to login upon authentication error (#9134) (@aslilac) +- Avoid showing disabled fields in group settings page (#9154) (@ammario) +- Disable wireguard trimming (#9098) (@coadler) + +### Documentation + +- Add [offline docs](https://www.jetbrains.com/help/idea/fully-offline-mode.html) for JetBrains Gateway (#9039) (@ericpaulsen) +- Add `coder login` to CI docs (#9038) (@ericpaulsen) +- Expand [JFrog platform](https://coder.com/docs/v2/latest/platforms/jfrog) and example template (#9073) (@matifali) + +## Container image + +- `docker pull ghcr.io/coder/coder:v2.1.0` + +## Install/upgrade + +Refer to our docs to [install](https://coder.com/docs/v2/latest/install) or [upgrade](https://coder.com/docs/v2/latest/admin/upgrade) Coder, or use a release asset below. diff --git a/docs/cli.md b/docs/cli.md index 1a0c40a22fca6..96acc1cf940db 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -96,6 +96,15 @@ Path to the global `coder` config directory. Additional HTTP headers added to all requests. Provide as key=value. Can be specified multiple times. +### --header-command + +| | | +| ----------- | ---------------------------------- | +| Type | string | +| Environment | $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 | | | diff --git a/docs/cli/create.md b/docs/cli/create.md index 3f7ff099b16c8..8c36ea44a3dd7 100644 --- a/docs/cli/create.md +++ b/docs/cli/create.md @@ -20,6 +20,15 @@ coder create [flags] [name] ## Options +### --parameter + +| | | +| ----------- | ---------------------------------- | +| Type | string-array | +| Environment | $CODER_RICH_PARAMETER | + +Rich parameter value in the format "name=value". + ### --rich-parameter-file | | | diff --git a/docs/cli/provisionerd_start.md b/docs/cli/provisionerd_start.md index 583e520389150..b129605933db3 100644 --- a/docs/cli/provisionerd_start.md +++ b/docs/cli/provisionerd_start.md @@ -42,6 +42,15 @@ How often to poll for provisioner jobs. How much to jitter the poll interval by. +### --psk + +| | | +| ----------- | ------------------------------------------ | +| Type | string | +| Environment | $CODER_PROVISIONER_DAEMON_PSK | + +Pre-shared key to authenticate with Coder server. + ### -t, --tag | | | diff --git a/docs/cli/restart.md b/docs/cli/restart.md index 72daa5dec405d..d3b6010a92c2e 100644 --- a/docs/cli/restart.md +++ b/docs/cli/restart.md @@ -12,6 +12,15 @@ coder restart [flags] ## Options +### --build-option + +| | | +| ----------- | -------------------------------- | +| Type | string-array | +| Environment | $CODER_BUILD_OPTION | + +Build option value in the format "name=value". + ### --build-options | | | diff --git a/docs/cli/server.md b/docs/cli/server.md index 90c60d7392f00..a2f29c39dca04 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -129,28 +129,6 @@ URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custo Whether to enable or disable the embedded DERP relay server. -### --derp-server-region-code - -| | | -| ----------- | ------------------------------------------- | -| Type | string | -| Environment | $CODER_DERP_SERVER_REGION_CODE | -| YAML | networking.derp.regionCode | -| Default | coder | - -Region code to use for the embedded DERP server. - -### --derp-server-region-id - -| | | -| ----------- | ----------------------------------------- | -| Type | int | -| Environment | $CODER_DERP_SERVER_REGION_ID | -| YAML | networking.derp.regionID | -| Default | 999 | - -Region ID to use for the embedded DERP server. - ### --derp-server-region-name | | | @@ -174,14 +152,14 @@ An HTTP URL that is accessible by other replicas to relay DERP traffic. Required ### --derp-server-stun-addresses -| | | -| ----------- | ---------------------------------------------- | -| Type | string-array | -| Environment | $CODER_DERP_SERVER_STUN_ADDRESSES | -| YAML | networking.derp.stunAddresses | -| Default | stun.l.google.com:19302 | +| | | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Type | string-array | +| Environment | $CODER_DERP_SERVER_STUN_ADDRESSES | +| YAML | networking.derp.stunAddresses | +| 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. Use special value 'disable' to turn off STUN. +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-quiet-hours-schedule @@ -243,6 +221,17 @@ Disable automatic session expiry bumping due to activity. This forces all sessio Specifies the custom docs URL. +### --oidc-group-auto-create + +| | | +| ----------- | ------------------------------------------ | +| Type | bool | +| Environment | $CODER_OIDC_GROUP_AUTO_CREATE | +| YAML | oidc.enableGroupAutoCreate | +| Default | false | + +Automatically creates missing groups from a user's groups claim. + ### --enable-terraform-debug-mode | | | @@ -429,6 +418,16 @@ Whether new users can sign up with OIDC. OIDC auth URL parameters to pass to the upstream provider. +### --oidc-client-cert-file + +| | | +| ----------- | ----------------------------------------- | +| Type | string | +| Environment | $CODER_OIDC_CLIENT_CERT_FILE | +| YAML | oidc.oidcClientCertFile | + +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 | | | @@ -439,6 +438,16 @@ OIDC auth URL parameters to pass to the upstream provider. Client ID to use for Login with OIDC. +### --oidc-client-key-file + +| | | +| ----------- | ---------------------------------------- | +| Type | string | +| Environment | $CODER_OIDC_CLIENT_KEY_FILE | +| YAML | oidc.oidcClientKeyFile | + +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 | | | @@ -521,6 +530,17 @@ Ignore the userinfo endpoint and only use the ID token for user information. Issuer URL to use for Login with OIDC. +### --oidc-group-regex-filter + +| | | +| ----------- | ------------------------------------------- | +| Type | regexp | +| Environment | $CODER_OIDC_GROUP_REGEX_FILTER | +| YAML | oidc.groupRegexFilter | +| 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 | | | @@ -668,6 +688,16 @@ Collect database metrics (may increase charges for metrics storage). Serve prometheus metrics on the address defined by prometheus address. +### --provisioner-daemon-psk + +| | | +| ----------- | ------------------------------------------ | +| Type | string | +| Environment | $CODER_PROVISIONER_DAEMON_PSK | +| YAML | provisioning.daemonPSK | + +Pre-shared key to authenticate external provisioner daemons to Coder server. + ### --provisioner-daemons | | | diff --git a/docs/cli/start.md b/docs/cli/start.md index 8c3fd7f71276e..120edfde679eb 100644 --- a/docs/cli/start.md +++ b/docs/cli/start.md @@ -12,6 +12,15 @@ coder start [flags] ## Options +### --build-option + +| | | +| ----------- | -------------------------------- | +| Type | string-array | +| Environment | $CODER_BUILD_OPTION | + +Build option value in the format "name=value". + ### --build-options | | | diff --git a/docs/cli/update.md b/docs/cli/update.md index 0b3d31a9755b8..b81172df6b9ca 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -26,6 +26,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 + +| | | +| ----------- | -------------------------------- | +| Type | string-array | +| Environment | $CODER_BUILD_OPTION | + +Build option value in the format "name=value". + ### --build-options | | | @@ -34,6 +43,15 @@ Always prompt all parameters. Does not pull parameter values from existing works Prompt for one-time build options defined with ephemeral parameters. +### --parameter + +| | | +| ----------- | ---------------------------------- | +| Type | string-array | +| Environment | $CODER_RICH_PARAMETER | + +Rich parameter value in the format "name=value". + ### --rich-parameter-file | | | diff --git a/docs/cli/users_create.md b/docs/cli/users_create.md index 2eb78318ffa0a..b89ff2aeb6d45 100644 --- a/docs/cli/users_create.md +++ b/docs/cli/users_create.md @@ -10,21 +10,21 @@ coder users create [flags] ## Options -### --disable-login +### -e, --email -| | | -| ---- | ----------------- | -| Type | bool | +| | | +| ---- | ------------------- | +| Type | string | -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. +Specifies an email address for the new user. -### -e, --email +### --login-type | | | | ---- | ------------------- | | Type | string | -Specifies an email address for the new user. +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 diff --git a/docs/contributing/frontend.md b/docs/contributing/frontend.md index 03ebd41fa28a0..cf06fe4f7b171 100644 --- a/docs/contributing/frontend.md +++ b/docs/contributing/frontend.md @@ -6,7 +6,7 @@ This is a guide to help the Coder community and also Coder members contribute to You can run the UI and access the dashboard in two ways: -- Build the UI pointing to an external Coder server: `CODER_HOST=https://mycoder.com yarn dev` inside of the `site` folder. This is helpful when you are building something in the UI and already have the data on your deployed server. +- Build the UI pointing to an external Coder server: `CODER_HOST=https://mycoder.com pnpm dev` inside of the `site` folder. This is helpful when you are building something in the UI and already have the data on your deployed server. - Build the entire Coder server + UI locally: `./scripts/develop.sh` in the root folder. It is useful when you have to contribute with features that are not deployed yet or when you have to work on both, frontend and backend. In both cases, you can access the dashboard on `http://localhost:8080`. If you are running the `./scripts/develop.sh` you can log in using the default credentials: `admin@coder.com` and `SomeSecurePassword!`. @@ -18,7 +18,7 @@ All our dependencies are described in `site/package.json` but here are the most - [React](https://reactjs.org/) as framework - [Typescript](https://www.typescriptlang.org/) to keep our sanity - [Vite](https://vitejs.dev/) to build the project -- [Material V4](https://v4.mui.com/) for UI components +- [Material V5](https://mui.com/material-ui/getting-started/) for UI components - [react-router](https://reactrouter.com/en/main) for routing - [TanStack Query v4](https://tanstack.com/query/v4/docs/react/overview) for fetching data - [XState](https://xstate.js.org/docs/) for handling complex state flows @@ -26,6 +26,7 @@ All our dependencies are described in `site/package.json` but here are the most - [Playwright](https://playwright.dev/) for E2E testing - [Jest](https://jestjs.io/) for integration testing - [Storybook](https://storybook.js.org/) and [Chromatic](https://www.chromatic.com/) for visual testing +- [PNPM](https://pnpm.io/) as package manager ## Structure diff --git a/docs/ides/gateway.md b/docs/ides/gateway.md index cb5b62be53f3f..45c9539c80456 100644 --- a/docs/ides/gateway.md +++ b/docs/ides/gateway.md @@ -1,43 +1,71 @@ # JetBrains Gateway -JetBrains Gateway is a compact desktop app that allows you to work remotely with a JetBrains IDE without even downloading one. [See JetBrains' website to learn about and Gateway.](https://www.jetbrains.com/remote-development/gateway/) +JetBrains Gateway is a compact desktop app that allows you to work remotely with +a JetBrains IDE without even downloading one. [See JetBrains' website to learn +about and Gateway.](https://www.jetbrains.com/remote-development/gateway/) -Gateway can connect to a Coder workspace by using Coder's Gateway plugin or manually setting up an SSH connection. +Gateway can connect to a Coder workspace by using Coder's Gateway plugin or +manually setting up an SSH connection. ## Using Coder's JetBrains Gateway Plugin -> If you experience problems, please [create a GitHub issue](https://github.com/coder/coder/issues) or share in [our Discord channel](https://discord.gg/coder). +> If you experience problems, please [create a GitHub +> issue](https://github.com/coder/coder/issues) or share in [our Discord +> channel](https://discord.gg/coder). 1. [Install Gateway](https://www.jetbrains.com/help/idea/jetbrains-gateway.html) -1. Open Gateway and click the gear icon at the bottom left and then "Settings" -1. In the Marketplace tab within Plugins, type Coder and then click "Install" and "OK" - ![Gateway Settings and Marketplace](../images/gateway/plugin-settings-marketplace.png) -1. Click the new "Coder" icon on the Gateway home screen +1. Open Gateway and click the Coder icon to install the Coder plugin. +1. Click the "Coder" icon under Install More Providers at the bottom of the + Gateway home screen +1. Click "Connect to Coder" at the top of the Gateway home screen to launch the + plugin + ![Gateway Connect to Coder](../images/gateway/plugin-connect-to-coder.png) + 1. Enter your Coder deployment's Access Url and click "Connect" then paste the Session Token and click "OK" + ![Gateway Session Token](../images/gateway/plugin-session-token.png) -1. Click the "+" icon to open a browser and go to the templates page in your Coder deployment to create a workspace -1. If a workspace already exists but is stopped, click the green arrow to start the workspace + +1. Click the "+" icon to open a browser and go to the templates page in your + Coder deployment to create a workspace + +1. If a workspace already exists but is stopped, click the green arrow to start + the workspace + 1. Once the workspace status says Running, click "Select IDE and Project" + ![Gateway IDE List](../images/gateway/plugin-select-ide.png) -1. Select the JetBrains IDE for your project and the project directory then click "Start IDE and connect" - ![Gateway Select IDE](../images/gateway/plugin-ide-list.png) + +1. Select the JetBrains IDE for your project and the project directory then + click "Start IDE and connect" ![Gateway Select IDE](../images/gateway/plugin-ide-list.png) + ![Gateway IDE Opened](../images/gateway/gateway-intellij-opened.png) -> Note the JetBrains IDE is remotely installed into `~/.cache/JetBrains/RemoteDev/dist` +> Note the JetBrains IDE is remotely installed into +> `~/.cache/JetBrains/RemoteDev/dist` + +### Update a Coder plugin version + +1. Click the gear icon at the bottom left of the Gateway home screen and then + "Settings" + +1. In the Marketplace tab within Plugins, type Coder and if a newer plugin + release is available, click "Update" and "OK" + + ![Gateway Settings and Marketplace](../images/gateway/plugin-settings-marketplace.png) ### Configuring the Gateway plugin to use internal certificates -When attempting to connect to a Coder deployment that uses internally signed certificates, -you may receive the following error in Gateway: +When attempting to connect to a Coder deployment that uses internally signed +certificates, you may receive the following error in Gateway: ```console Failed to configure connection to https://coder.internal.enterprise/: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target ``` -To resolve this issue, you will need to add Coder's certificate to the Java trust store -present on your local machine. Here is the default location of the trust store for -each OS: +To resolve this issue, you will need to add Coder's certificate to the Java +trust store present on your local machine. Here is the default location of the +trust store for each OS: ```console # Linux @@ -52,8 +80,8 @@ C:\Program Files (x86)\\jre\lib\security\cacerts %USERPROFILE%\AppData\Local\JetBrains\Toolbox\bin\jre\lib\security\cacerts # Path for Toolbox installation ``` -To add the certificate to the keystore, you can use the `keytool` utility that ships -with Java: +To add the certificate to the keystore, you can use the `keytool` utility that +ships with Java: ```console keytool -import -alias coder -file -keystore /path/to/trust/store @@ -77,36 +105,60 @@ keytool -import -alias coder -file cacert.pem -keystore /Applications/JetBrains\ ## Manually Configuring A JetBrains Gateway Connection -> This is in lieu of using Coder's Gateway plugin which automatically performs these steps. +> This is in lieu of using Coder's Gateway plugin which automatically performs +> these steps. 1. [Install Gateway](https://www.jetbrains.com/help/idea/jetbrains-gateway.html) + 1. [Configure the `coder` CLI](../ides.md#ssh-configuration) + 1. Open Gateway, make sure "SSH" is selected under "Remote Development" + 1. Click "New Connection" + ![Gateway Home](../images/gateway/gateway-home.png) + 1. In the resulting dialog, click the gear icon to the right of "Connection:" + ![Gateway New Connection](../images/gateway/gateway-new-connection.png) + 1. Hit the "+" button to add a new SSH connection + ![Gateway Add Connection](../images/gateway/gateway-add-ssh-configuration.png) 1. For the Host, enter `coder.` + 1. For the Port, enter `22` (this is ignored by Coder) + 1. For the Username, enter your workspace username -1. For the Authentication Type, select "OpenSSH config and authentication - agent" + +1. For the Authentication Type, select "OpenSSH config and authentication agent" + 1. Make sure the checkbox for "Parse config file ~/.ssh/config" is checked. + 1. Click "Test Connection" to validate these settings. + 1. Click "OK" + ![Gateway SSH Configuration](../images/gateway/gateway-create-ssh-configuration.png) + 1. Select the connection you just added - ![Gateway Welcome](../images/gateway/gateway-welcome.png) + + ![Gateway Welcome](../images/gaGteway/gateway-welcome.png) + 1. Click "Check Connection and Continue" + ![Gateway Continue](../images/gateway/gateway-continue.png) -1. Select the JetBrains IDE for your project and the project directory. - SSH into your server to create a directory or check out code if you haven't already. + +1. Select the JetBrains IDE for your project and the project directory. SSH into your server to create a directory or check out code if you haven't already. + ![Gateway Choose IDE](../images/gateway/gateway-choose-ide.png) - > Note the JetBrains IDE is remotely installed into `~/. cache/JetBrains/RemoteDev/dist` + + > Note the JetBrains IDE is remotely installed into `~/. +cache/JetBrains/RemoteDev/dist` + 1. Click "Download and Start IDE" to connect. + ![Gateway IDE Opened](../images/gateway/gateway-intellij-opened.png) ## Using an existing JetBrains installation in the workspace @@ -121,7 +173,15 @@ cd /opt/idea/bin ./remote-dev-server.sh registerBackendLocationForGateway ``` -> Gateway only works with paid versions of JetBrains IDEs so the script will not be located in the `bin` directory of JetBrains Community editions. +> Gateway only works with paid versions of JetBrains IDEs so the script will not +> be located in the `bin` directory of JetBrains Community editions. -[Here is the JetBrains article](https://www.jetbrains.com/help/idea/remote-development-troubleshooting.html#setup:~:text=Can%20I%20point%20Remote%20Development%20to%20an%20existing%20IDE%20on%20my%20remote%20server%3F%20Is%20it%20possible%20to%20install%20IDE%20manually%3F) +[Here is the JetBrains +article](https://www.jetbrains.com/help/idea/remote-development-troubleshooting.html#setup:~:text=Can%20I%20point%20Remote%20Development%20to%20an%20existing%20IDE%20on%20my%20remote%20server%3F%20Is%20it%20possible%20to%20install%20IDE%20manually%3F) explaining this IDE specification. + +## JetBrains Gateway in an offline environment + +In networks that restrict access to the internet, you will need to leverage the +JetBrains Client Installer to download and save the IDE clients locally. Please +see the [JetBrains documentation for more information](https://www.jetbrains.com/help/idea/fully-offline-mode.html). diff --git a/docs/images/deploy-pr-manually.png b/docs/images/deploy-pr-manually.png new file mode 100644 index 0000000000000..718d00c65d1cc Binary files /dev/null and b/docs/images/deploy-pr-manually.png differ diff --git a/docs/images/gateway/plugin-connect-to-coder.png b/docs/images/gateway/plugin-connect-to-coder.png index 11cecf4ad69fe..295efa7897386 100644 Binary files a/docs/images/gateway/plugin-connect-to-coder.png and b/docs/images/gateway/plugin-connect-to-coder.png differ diff --git a/docs/images/gateway/plugin-ide-list.png b/docs/images/gateway/plugin-ide-list.png index 40b9f2f71bcc8..0f767abfd0dd1 100644 Binary files a/docs/images/gateway/plugin-ide-list.png and b/docs/images/gateway/plugin-ide-list.png differ diff --git a/docs/images/gateway/plugin-select-ide.png b/docs/images/gateway/plugin-select-ide.png index b138b7b2a41d5..bc4e6cb1ee03b 100644 Binary files a/docs/images/gateway/plugin-select-ide.png and b/docs/images/gateway/plugin-select-ide.png differ diff --git a/docs/images/gateway/plugin-session-token.png b/docs/images/gateway/plugin-session-token.png index c233fd0c27759..b13d2fe12828e 100644 Binary files a/docs/images/gateway/plugin-session-token.png and b/docs/images/gateway/plugin-session-token.png differ diff --git a/docs/images/gateway/plugin-settings-marketplace.png b/docs/images/gateway/plugin-settings-marketplace.png index 58ccf8155992e..7973fa99811a3 100644 Binary files a/docs/images/gateway/plugin-settings-marketplace.png and b/docs/images/gateway/plugin-settings-marketplace.png differ diff --git a/docs/images/oidc-sequence-diagram.svg b/docs/images/oidc-sequence-diagram.svg new file mode 100644 index 0000000000000..72600e5193201 --- /dev/null +++ b/docs/images/oidc-sequence-diagram.svg @@ -0,0 +1,15136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/pr-deploy-manual.png b/docs/images/pr-deploy-manual.png deleted file mode 100644 index eab92cc2249e7..0000000000000 Binary files a/docs/images/pr-deploy-manual.png and /dev/null differ diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index 191ef1aba0338..4cfd49306dda0 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -7,14 +7,10 @@ to log in and manage templates. ## Install Coder with Helm -> **Warning**: Helm support is new and not yet complete. There may be changes -> to the Helm chart between releases which require manual values updates. Please -> file an issue if you run into any issues. - 1. Create a namespace for Coder, such as `coder`: ```console - $ kubectl create namespace coder + kubectl create namespace coder ``` 1. Create a PostgreSQL deployment. Coder does not manage a database server for @@ -57,12 +53,6 @@ to log in and manage templates. [Postgres operator](https://github.com/zalando/postgres-operator) to manage PostgreSQL deployments on your Kubernetes cluster. -1. Add the Coder Helm repo: - - ```console - helm repo add coder-v2 https://helm.coder.com/v2 - ``` - 1. Create a secret with the database URL: ```console @@ -72,6 +62,12 @@ to log in and manage templates. --from-literal=url="postgres://coder:coder@coder-db-postgresql.coder.svc.cluster.local:5432/coder?sslmode=disable" ``` +1. Add the Coder Helm repo: + + ```console + helm repo add coder-v2 https://helm.coder.com/v2 + ``` + 1. Create a `values.yaml` with the configuration settings you'd like for your deployment. For example: @@ -109,32 +105,62 @@ to log in and manage templates. > You can view our > [Helm README](https://github.com/coder/coder/blob/main/helm#readme) for > details on the values that are available, or you can view the - > [values.yaml](https://github.com/coder/coder/blob/main/helm/values.yaml) + > [values.yaml](https://github.com/coder/coder/blob/main/helm/coder/values.yaml) > file directly. - If you are deploying Coder on AWS EKS and service is set to `LoadBalancer`, AWS will default to the Classic load balancer. The load balancer external IP will be stuck in a pending status unless sessionAffinity is set to None. +1. Run the following command to install the chart in your cluster. - ```yaml - coder: - service: - type: LoadBalancer - sessionAffinity: None + ```console + helm install coder coder-v2/coder \ + --namespace coder \ + --values values.yaml ``` + You can watch Coder start up by running `kubectl get pods -n coder`. Once Coder has + started, the `coder-*` pods should enter the `Running` state. + +1. Log in to Coder + + Use `kubectl get svc -n coder` to get the IP address of the + LoadBalancer. Visit this in the browser to set up your first account. + + If you do not have a domain, you should set `CODER_ACCESS_URL` + to this URL in the Helm chart and upgrade Coder (see below). + This allows workspaces to connect to the proper Coder URL. + +## Upgrading Coder via Helm + +To upgrade Coder in the future or change values, +you can run the following command: + +```console +helm repo update +helm upgrade coder coder-v2/coder \ + --namespace coder \ + -f values.yaml +``` + ## Load balancing considerations ### AWS -AWS however recommends a Network load balancer in lieu of the Classic load balancer. Use the following `values.yaml` settings to request a Network load balancer: +If you are deploying Coder on AWS EKS and service is set to `LoadBalancer`, AWS will default to the Classic load balancer. The load balancer external IP will be stuck in a pending status unless sessionAffinity is set to None. + +```yaml +coder: + service: + type: LoadBalancer + sessionAffinity: None +``` + +AWS recommends a Network load balancer in lieu of the Classic load balancer. Use the following `values.yaml` settings to request a Network load balancer: ```yaml coder: - service: - externalTrafficPolicy: Local - sessionAffinity: None - annotations: { - service.beta.kubernetes.io/aws-load-balancer-type: "nlb" - } + service: + externalTrafficPolicy: Local + sessionAffinity: None + annotations: { service.beta.kubernetes.io/aws-load-balancer-type: "nlb" } ``` By default, Coder will set the `externalTrafficPolicy` to `Cluster` which will @@ -152,26 +178,6 @@ coder: value: 10.0.0.1/8 # this will be the CIDR range of your Load Balancer IP address ``` -1. Run the following command to install the chart in your cluster. - - ```console - helm install coder coder-v2/coder \ - --namespace coder \ - --values values.yaml - ``` - - You can watch Coder start up by running `kubectl get pods -n coder`. Once Coder has - started, the `coder-*` pods should enter the `Running` state. - -1. Log in to Coder - - Use `kubectl get svc -n coder` to get the IP address of the - LoadBalancer. Visit this in the browser to set up your first account. - - If you do not have a domain, you should set `CODER_ACCESS_URL` - to this URL in the Helm chart and upgrade Coder (see below). - This allows workspaces to connect to the proper Coder URL. - ### Azure In certain enterprise environments, the [Azure Application Gateway](https://learn.microsoft.com/en-us/azure/application-gateway/ingress-controller-overview) was needed. The Application Gateway supports: @@ -212,18 +218,6 @@ postgres://:@databasehost:/?sslmode=require&sslce > More information on connecting to PostgreSQL databases using certificates can be found [here](https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-CLIENTCERT). -## Upgrading Coder via Helm - -To upgrade Coder in the future or change values, -you can run the following command: - -```console -helm repo update -helm upgrade coder coder-v2/coder \ - --namespace coder \ - -f values.yaml -``` - ## Troubleshooting You can view Coder's logs by getting the pod name from `kubectl get pods` and then running `kubectl logs `. You can also diff --git a/docs/platforms/jfrog.md b/docs/platforms/jfrog.md index 966d1472f6bd5..a477375c85c4d 100644 --- a/docs/platforms/jfrog.md +++ b/docs/platforms/jfrog.md @@ -5,14 +5,14 @@ Use Coder and JFrog together to secure your development environments without dis This guide will demonstrate how to use JFrog Artifactory as a package registry within a workspace. We'll use Docker as the underlying compute. But, these concepts apply to any compute platform. -The full example template can be found [here](https://github.com/coder/coder/tree/main/examples/templates/jfrog-docker). +The full example template can be found [here](https://github.com/coder/coder/tree/main/examples/templates/jfrog/docker). ## Requirements - A JFrog Artifactory instance - An admin-level access token for Artifactory -- 1:1 mapping of users in Coder to users in Artifactory by email address -- An npm repository in Artifactory named "npm" +- 1:1 mapping of users in Coder to users in Artifactory by email address and username +- Repositories configured in Artifactory for each package manager you want to use
The admin-level access token is used to provision user tokens and is never exposed to @@ -40,14 +40,14 @@ terraform { } artifactory = { source = "registry.terraform.io/jfrog/artifactory" - version = "6.22.3" + version = "~> 8.4.0" } } } -variable "jfrog_url" { +variable "jfrog_host" { type = string - description = "The URL of the JFrog instance." + description = "JFrog instance hostname. e.g. YYY.jfrog.io" } variable "artifactory_access_token" { @@ -57,15 +57,15 @@ variable "artifactory_access_token" { # Configure the Artifactory provider provider "artifactory" { - url = "${var.jfrog_url}/artifactory" + url = "https://${var.jfrog_host}/artifactory" access_token = "${var.artifactory_access_token}" } ``` -When pushing the template, you can pass in the variables using the `-V` flag: +When pushing the template, you can pass in the variables using the `--var` flag: ```sh -coder templates push --var 'jfrog_url=https://YYY.jfrog.io' --var 'artifactory_access_token=XXX' +coder templates push --var 'jfrog_host=YYY.jfrog.io' --var 'artifactory_access_token=XXX' ``` ## Installing JFrog CLI @@ -74,7 +74,7 @@ coder templates push --var 'jfrog_url=https://YYY.jfrog.io' --var 'artifactory_a we'll focus on its ability to configure package managers, as that's the relevant functionality for most developers. -The generic method of installing the JFrog CLI is the following command: +Most users should be able to install `jf` by running the following command: ```sh curl -fL https://install-cli.jfrog.io | sh @@ -88,7 +88,21 @@ In our Docker-based example, we install `jf` by adding these lines to our `Docke RUN curl -fL https://install-cli.jfrog.io | sh && chmod 755 $(which jf) ``` -and use this `coder_agent` block: +## Configuring Coder workspace to use JFrog Artifactory repositories + +Create a `locals` block to store the Artifactory repository keys for each package manager you want to use in your workspace. For example, if you want to use artifactory repositories with keys `npm`, `pypi`, and `go`, you can create a `locals` block like this: + +```hcl +locals { + artifactory_repository_keys = { + npm = "npm" + python = "pypi" + go = "go" + } +} +``` + +To automatically configure `jf` CLI and Artifactory repositories for each user, add the following lines to your `startup_script` in the `coder_agent` block: ```hcl resource "coder_agent" "main" { @@ -107,9 +121,28 @@ resource "coder_agent" "main" { export CI=true jf c rm 0 || true - echo ${artifactory_access_token.me.access_token} | \ - jf c add --access-token-stdin --url ${var.jfrog_url} 0 + echo ${artifactory_scoped_token.me.access_token} | \ + jf c add --access-token-stdin --url https://${var.jfrog_host} 0 + + # Configure the `npm` CLI to use the Artifactory "npm" repository. + cat << EOF > ~/.npmrc + email = ${data.coder_workspace.me.owner_email} + registry = https://${var.jfrog_host}/artifactory/api/npm/${local.artifactory_repository_keys["npm"]} + EOF + jf rt curl /api/npm/auth >> .npmrc + + # Configure the `pip` to use the Artifactory "python" repository. + mkdir -p ~/.pip + cat << EOF > ~/.pip/pip.conf + [global] + index-url = https://${local.artifactory_username}:${artifactory_scoped_token.me.access_token}@${var.jfrog_host}/artifactory/api/pypi/${local.artifactory_repository_keys["python"]}/simple + EOF + EOT + # Set GOPROXY to use the Artifactory "go" repository. + env = { + GOPROXY : "https://${local.artifactory_username}:${artifactory_scoped_token.me.access_token}@${var.jfrog_host}/artifactory/api/go/${local.artifactory_repository_keys["go"]}" + } } ``` @@ -119,12 +152,12 @@ running `jf c show`. It should display output like: ```text coder@jf:~$ jf c show Server ID: 0 -JFrog Platform URL: https://cdr.jfrog.io/ -Artifactory URL: https://cdr.jfrog.io/artifactory/ -Distribution URL: https://cdr.jfrog.io/distribution/ -Xray URL: https://cdr.jfrog.io/xray/ -Mission Control URL: https://cdr.jfrog.io/mc/ -Pipelines URL: https://cdr.jfrog.io/pipelines/ +JFrog Platform URL: https://YYY.jfrog.io/ +Artifactory URL: https://YYY.jfrog.io/artifactory/ +Distribution URL: https://YYY.jfrog.io/distribution/ +Xray URL: https://YYY.jfrog.io/xray/ +Mission Control URL: https://YYY.jfrog.io/mc/ +Pipelines URL: https://YYY.jfrog.io/pipelines/ User: ammar@....com Access token: ... Default: true @@ -132,7 +165,7 @@ Default: true ## Installing the JFrog VS Code Extension -You can install the JFrog VS Code extension into workspaces automatically +You can install the JFrog VS Code extension into workspaces by inserting the following lines into your `startup_script`: ```sh @@ -151,11 +184,11 @@ Note that this method will only work if your developers use code-server. Add the following line to your `startup_script` to configure `npm` to use Artifactory: -```sh +```shell # Configure the `npm` CLI to use the Artifactory "npm" registry. cat << EOF > ~/.npmrc email = ${data.coder_workspace.me.owner_email} - registry=${var.jfrog_url}/artifactory/api/npm/npm/ + registry = https://${var.jfrog_host}/artifactory/api/npm/npm/ EOF jf rt curl /api/npm/auth >> .npmrc ``` @@ -165,10 +198,36 @@ use Artifactory as the package registry. You can verify that `npm` is configured correctly by running `npm install --loglevel=http react` and checking that npm is only hitting your Artifactory URL. -You can apply the same concepts to Docker, Go, Maven, and other package managers -supported by Artifactory. +## Configuring pip + +Add the following lines to your `startup_script` to configure `pip` to use +Artifactory: + +```shell + mkdir -p ~/.pip + cat << EOF > ~/.pip/pip.conf + [global] + index-url = https://${data.coder_workspace.me.owner}:${artifactory_scoped_token.me.access_token}@${var.jfrog_host}/artifactory/api/pypi/pypi/simple + EOF +``` + +Now, your developers can run `pip install` and transparently use Artifactory as the package registry. You can verify that `pip` is configured correctly by running `pip install --verbose requests` and checking that pip is only hitting your Artifactory URL. + +## Configuring Go + +Add the following environment variable to your `coder_agent` block to configure `go` to use Artifactory: + +```hcl + env = { + GOPROXY : "https://${data.coder_workspace.me.owner}:${artifactory_scoped_token.me.access_token}@${var.jfrog_host}/artifactory/api/go/go" + } +``` + +You can apply the same concepts to Docker, Maven, and other package managers +supported by Artifactory. See the [JFrog documentation](https://jfrog.com/help/r/jfrog-artifactory-documentation/package-management) for more information. ## More reading -- See the full example template [here](https://github.com/coder/coder/tree/main/examples/templates/jfrog-docker). +- See the full example template [here](https://github.com/coder/coder/tree/main/examples/templates/jfrog/docker). - To serve extensions from your own VS Code Marketplace, check out [code-marketplace](https://github.com/coder/code-marketplace#artifactory-storage). +- To store templates in Artifactory, check out our [Artifactory modules](../templates/modules.md#artifactory) docs. diff --git a/docs/templates/change-management.md b/docs/templates/change-management.md index a1add78ec64f8..f2781d9ee0711 100644 --- a/docs/templates/change-management.md +++ b/docs/templates/change-management.md @@ -20,6 +20,7 @@ export CODER_TEMPLATE_DIR=.coder/templates/kubernetes export CODER_TEMPLATE_VERSION=$(git rev-parse --short HEAD) # Push the new template version to Coder +coder login --url $CODER_URL --token $CODER_SESSION_TOKEN coder templates push --yes $CODER_TEMPLATE_NAME \ --directory $CODER_TEMPLATE_DIR \ --name=$CODER_TEMPLATE_VERSION # Version name is optional diff --git a/docs/templates/modules.md b/docs/templates/modules.md index 06827bc2c0fbd..a2f5e6c42555b 100644 --- a/docs/templates/modules.md +++ b/docs/templates/modules.md @@ -87,3 +87,50 @@ coder: subPath: .git-credentials readOnly: true ``` + +## Artifactory + +JFrog Artifactory can serve as a Terraform module registry, allowing you to simplify +a Coder-stored template to a `module` block and input variables. + +With this approach, you can: + +- Easily share templates across multiple Coder instances +- Store templates far larger than the 1MB limit of Coder's template storage +- Apply JFrog platform security policies to your templates + +### Basic Scaffolding + +For example, a template with: + +```hcl +module "frontend" { + source = "cdr.jfrog.io/tf__main/frontend/docker" +} +``` + +References the `frontend` module in the `main` namespace of the `tf` repository. +Remember to replace `cdr.jfrog.io` with your Artifactory instance URL. + +You can upload the underlying module to Artifactory with: + +```console +# one-time setup commands +# run this on the coder server (or external provisioners, if you have them) +terraform login cdr.jfrog.io; jf tfc --global + +# jf tf p assumes the module name is the same as the current directory name. +jf tf p --namespace=main --provider=docker --tag=v0.0.1 +``` + +### Example template + +We have an example template [here](https://github.com/coder/coder/tree/main/examples/templates/jfrog/remote) that uses our [JFrog Docker](../platforms/jfrog.md) template +as the underlying module. + +### Next up + +Learn more about + +- JFrog's Terraform Registry support [here](https://jfrog.com/help/r/jfrog-artifactory-documentation/terraform-registry). +- Configuring the JFrog toolchain inside a workspace [here](../platforms/jfrog.md). diff --git a/docs/templates/parameters.md b/docs/templates/parameters.md index c74413d48b392..ba6b49b6570f5 100644 --- a/docs/templates/parameters.md +++ b/docs/templates/parameters.md @@ -133,6 +133,21 @@ data "coder_parameter" "dotfiles_url" { } ``` +Terraform [conditional expressions](https://developer.hashicorp.com/terraform/language/expressions/conditionals) can be used to determine whether the user specified a value for an optional parameter: + +```hcl +resource "coder_agent" "main" { + # ... + startup_script_timeout = 180 + startup_script = <<-EOT + set -e + + echo "The optional parameter value is: ${data.coder_parameter.optional.value == "" ? "[empty]" : data.coder_parameter.optional.value}" + + EOT +} +``` + ## Mutability Immutable parameters can be only set before workspace creation, or during update on the first usage to set the initial value for required parameters. The idea is to prevent users from modifying fragile or persistent workspace resources like volumes, regions, etc.: diff --git a/dogfood/Dockerfile b/dogfood/Dockerfile index c5f1481679147..8f156bd5152e8 100644 --- a/dogfood/Dockerfile +++ b/dogfood/Dockerfile @@ -8,7 +8,7 @@ FROM ubuntu:jammy AS go RUN apt-get update && apt-get install --yes curl gcc # Install Go manually, so that we can control the version -ARG GO_VERSION=1.20.6 +ARG GO_VERSION=1.20.7 RUN mkdir --parents /usr/local/go # Boring Go is needed to build FIPS-compliant binaries. @@ -53,7 +53,7 @@ RUN mkdir --parents "$GOPATH" && \ # charts and values files go install github.com/norwoodj/helm-docs/cmd/helm-docs@v1.5.0 && \ # sqlc for Go code generation - go install github.com/kyleconroy/sqlc/cmd/sqlc@v1.19.1 && \ + go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.20.0 && \ # gcr-cleaner-cli used by CI to prune unused images go install github.com/sethvargo/gcr-cleaner/cmd/gcr-cleaner-cli@v0.5.1 && \ # ruleguard for checking custom rules, without needing to run all of @@ -162,6 +162,7 @@ RUN apt-get update --quiet && apt-get install --yes \ fish \ unzip \ zstd \ + screen \ gettext-base && \ # Delete package cache to avoid consuming space in layer apt-get clean && \ diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index b5ab50457c963..17c91ff8adfb6 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -156,6 +156,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "avatar_url": ActionTrack, "quota_allowance": ActionTrack, "members": ActionTrack, + "source": ActionIgnore, }, &database.APIKey{}: { "id": ActionIgnore, diff --git a/enterprise/cli/grouplist_test.go b/enterprise/cli/grouplist_test.go index 90e054a03faab..022f615a3d76e 100644 --- a/enterprise/cli/grouplist_test.go +++ b/enterprise/cli/grouplist_test.go @@ -74,10 +74,10 @@ func TestGroupList(t *testing.T) { } }) - t.Run("NoGroups", func(t *testing.T) { + t.Run("Everyone", func(t *testing.T) { t.Parallel() - client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ codersdk.FeatureTemplateRBAC: 1, }, @@ -87,13 +87,19 @@ func TestGroupList(t *testing.T) { pty := ptytest.New(t) - inv.Stderr = pty.Output() + inv.Stdout = pty.Output() clitest.SetupConfig(t, client, conf) err := inv.Run() require.NoError(t, err) - pty.ExpectMatch("No groups found") - pty.ExpectMatch("coder groups create ") + matches := []string{ + "NAME", "ORGANIZATION ID", "MEMBERS", " AVATAR URL", + "Everyone", user.OrganizationID.String(), coderdtest.FirstUserParams.Email, "", + } + + for _, match := range matches { + pty.ExpectMatch(match) + } }) } diff --git a/enterprise/cli/provisionerdaemons.go b/enterprise/cli/provisionerdaemons.go index f3dfc2ba367d7..f75172e966417 100644 --- a/enterprise/cli/provisionerdaemons.go +++ b/enterprise/cli/provisionerdaemons.go @@ -44,13 +44,14 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { rawTags []string pollInterval time.Duration pollJitter time.Duration + preSharedKey string ) client := new(codersdk.Client) cmd := &clibase.Cmd{ Use: "start", Short: "Run a provisioner daemon", Middleware: clibase.Chain( - r.InitClient(client), + r.InitClientMissingTokenOK(client), ), Handler: func(inv *clibase.Invocation) error { ctx, cancel := context.WithCancel(inv.Context()) @@ -59,11 +60,6 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { notifyCtx, notifyStop := signal.NotifyContext(ctx, agpl.InterruptSignals...) defer notifyStop() - org, err := agpl.CurrentOrganization(inv, client) - if err != nil { - return xerrors.Errorf("get current organization: %w", err) - } - tags, err := agpl.ParseProvisionerTags(rawTags) if err != nil { return err @@ -112,9 +108,13 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(terraformClient), } srv := provisionerd.New(func(ctx context.Context) (provisionerdproto.DRPCProvisionerDaemonClient, error) { - return client.ServeProvisionerDaemon(ctx, org.ID, []codersdk.ProvisionerType{ - codersdk.ProvisionerTypeTerraform, - }, tags) + return client.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeTerraform, + }, + Tags: tags, + PreSharedKey: preSharedKey, + }) }, &provisionerd.Options{ Logger: logger, JobPollInterval: pollInterval, @@ -137,9 +137,7 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { cliui.Errorf(inv.Stderr, "Unexpected error, shutting down server: %s\n", exitErr) } - shutdown, shutdownCancel := context.WithTimeout(ctx, time.Minute) - defer shutdownCancel() - err = srv.Shutdown(shutdown) + err = srv.Shutdown(ctx) if err != nil { return xerrors.Errorf("shutdown: %w", err) } @@ -182,6 +180,12 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { Default: (100 * time.Millisecond).String(), Value: clibase.DurationOf(&pollJitter), }, + { + Flag: "psk", + Env: "CODER_PROVISIONER_DAEMON_PSK", + Description: "Pre-shared key to authenticate with Coder server.", + Value: clibase.StringOf(&preSharedKey), + }, } return cmd diff --git a/enterprise/cli/provisionerdaemons_test.go b/enterprise/cli/provisionerdaemons_test.go new file mode 100644 index 0000000000000..69b23d870757c --- /dev/null +++ b/enterprise/cli/provisionerdaemons_test.go @@ -0,0 +1,56 @@ +package cli_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/enterprise/coderd/license" + "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/testutil" +) + +func TestProvisionerDaemon_PSK(t *testing.T) { + t.Parallel() + + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + ProvisionerDaemonPSK: "provisionersftw", + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + inv, conf := newCLI(t, "provisionerd", "start", "--psk=provisionersftw") + err := conf.URL().Write(client.URL.String()) + require.NoError(t, err) + pty := ptytest.New(t).Attach(inv) + ctx, cancel := context.WithTimeout(inv.Context(), testutil.WaitLong) + defer cancel() + clitest.Start(t, inv) + pty.ExpectMatchContext(ctx, "starting provisioner daemon") +} + +func TestProvisionerDaemon_SessionToken(t *testing.T) { + t.Parallel() + + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + ProvisionerDaemonPSK: "provisionersftw", + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + inv, conf := newCLI(t, "provisionerd", "start") + clitest.SetupConfig(t, client, conf) + pty := ptytest.New(t).Attach(inv) + ctx, cancel := context.WithTimeout(inv.Context(), testutil.WaitLong) + defer cancel() + clitest.Start(t, inv) + pty.ExpectMatchContext(ctx, "starting provisioner daemon") +} diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index b0561a0de1850..70a06ff0548e4 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -66,6 +66,7 @@ func (r *RootCmd) server() *clibase.Cmd { DERPServerRegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()), ProxyHealthInterval: options.DeploymentValues.ProxyHealthStatusInterval.Value(), DefaultQuietHoursSchedule: options.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(), + ProvisionerDaemonPSK: options.DeploymentValues.Provisioner.DaemonPSK.Value(), } api, err := coderd.New(ctx, o) diff --git a/enterprise/cli/testdata/coder_--help.golden b/enterprise/cli/testdata/coder_--help.golden index 7fc6962ded847..ae24592079a69 100644 --- a/enterprise/cli/testdata/coder_--help.golden +++ b/enterprise/cli/testdata/coder_--help.golden @@ -33,6 +33,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/enterprise/cli/testdata/coder_provisionerd_start_--help.golden b/enterprise/cli/testdata/coder_provisionerd_start_--help.golden index 1236cfb5ae7e1..5258c33125173 100644 --- a/enterprise/cli/testdata/coder_provisionerd_start_--help.golden +++ b/enterprise/cli/testdata/coder_provisionerd_start_--help.golden @@ -12,6 +12,9 @@ Run a provisioner daemon --poll-jitter duration, $CODER_PROVISIONERD_POLL_JITTER (default: 100ms) How much to jitter the poll interval by. + --psk string, $CODER_PROVISIONER_DAEMON_PSK + Pre-shared key to authenticate with Coder server. + -t, --tag string-array, $CODER_PROVISIONERD_TAGS Tags to filter provisioner jobs by. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index cb7ca61b4913a..46fddeed2d6cc 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -175,18 +175,15 @@ backed by Tailscale and WireGuard. --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 +295,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 +344,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 +388,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/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index c3cc5e0be5ccd..ea587d7393075 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -67,6 +67,10 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { AGPL: coderd.New(options.Options), Options: options, + provisionerDaemonAuth: &provisionerDaemonAuth{ + psk: options.ProvisionerDaemonPSK, + authorizer: options.Authorizer, + }, } defer func() { if err != nil { @@ -163,6 +167,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ) r.Get("/coordinate", api.workspaceProxyCoordinate) r.Post("/issue-signed-app-token", api.workspaceProxyIssueSignedAppToken) + r.Post("/app-stats", api.workspaceProxyReportAppStats) r.Post("/register", api.workspaceProxyRegister) r.Post("/deregister", api.workspaceProxyDeregister) }) @@ -193,14 +198,21 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/", api.groupByOrganization) }) }) + // TODO: provisioner daemons are not scoped to organizations in the database, so placing them + // under an organization route doesn't make sense. In order to allow the /serve endpoint to + // work with a pre-shared key (PSK) without an API key, these routes will simply ignore the + // value of {organization}. That is, the route will work with any organization ID, whether or + // not it exits. This doesn't leak any information about the existence of organizations, so is + // fine from a security perspective, but might be a little surprising. + // + // We may in future decide to scope provisioner daemons to organizations, so we'll keep the API + // route as is. r.Route("/organizations/{organization}/provisionerdaemons", func(r chi.Router) { r.Use( api.provisionerDaemonsEnabledMW, - apiKeyMiddleware, - httpmw.ExtractOrganizationParam(api.Database), ) - r.Get("/", api.provisionerDaemons) - r.Get("/serve", api.provisionerDaemonServe) + r.With(apiKeyMiddleware).Get("/", api.provisionerDaemons) + r.With(apiKeyMiddlewareOptional).Get("/serve", api.provisionerDaemonServe) }) r.Route("/templates/{template}/acl", func(r chi.Router) { r.Use( @@ -362,6 +374,9 @@ type Options struct { EntitlementsUpdateInterval time.Duration ProxyHealthInterval time.Duration Keys map[string]ed25519.PublicKey + + // optional pre-shared key for authentication of external provisioner daemons + ProvisionerDaemonPSK string } type API struct { @@ -383,6 +398,8 @@ type API struct { entitlementsUpdateMu sync.Mutex entitlementsMu sync.RWMutex entitlements codersdk.Entitlements + + provisionerDaemonAuth *provisionerDaemonAuth } func (api *API) Close() error { @@ -491,7 +508,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { if initial, changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); shouldUpdate(initial, changed, enabled) { if enabled { - templateStore := schedule.NewEnterpriseTemplateScheduleStore() + templateStore := schedule.NewEnterpriseTemplateScheduleStore(api.AGPL.UserQuietHoursScheduleStore) templateStoreInterface := agplschedule.TemplateScheduleStore(templateStore) api.AGPL.TemplateScheduleStore.Store(&templateStoreInterface) } else { @@ -578,7 +595,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { if initial, changed, enabled := featureChanged(codersdk.FeatureWorkspaceProxy); shouldUpdate(initial, changed, enabled) { if enabled { - fn := derpMapper(api.Logger, api.DeploymentValues, api.ProxyHealth) + fn := derpMapper(api.Logger, api.ProxyHealth) api.AGPL.DERPMapper.Store(&fn) } else { api.AGPL.DERPMapper.Store(nil) @@ -643,7 +660,7 @@ var ( lastDerpConflictLog time.Time ) -func derpMapper(logger slog.Logger, cfg *codersdk.DeploymentValues, proxyHealth *proxyhealth.ProxyHealth) func(*tailcfg.DERPMap) *tailcfg.DERPMap { +func derpMapper(logger slog.Logger, proxyHealth *proxyhealth.ProxyHealth) func(*tailcfg.DERPMap) *tailcfg.DERPMap { return func(derpMap *tailcfg.DERPMap) *tailcfg.DERPMap { derpMap = derpMap.Clone() @@ -737,43 +754,22 @@ func derpMapper(logger slog.Logger, cfg *codersdk.DeploymentValues, proxyHealth } } - var stunNodes []*tailcfg.DERPNode - if !cfg.DERP.Config.BlockDirect.Value() { - stunNodes, err = agpltailnet.STUNNodes(regionID, cfg.DERP.Server.STUNAddresses) - if err != nil { - // Log a warning if we haven't logged one in the last - // minute. - lastDerpConflictMutex.Lock() - shouldLog := lastDerpConflictLog.IsZero() || time.Since(lastDerpConflictLog) > time.Minute - if shouldLog { - lastDerpConflictLog = time.Now() - } - lastDerpConflictMutex.Unlock() - if shouldLog { - logger.Error(context.Background(), "failed to calculate STUN nodes", slog.Error(err)) - } - - // No continue because we can keep going. - stunNodes = []*tailcfg.DERPNode{} - } - } - - nodes := append(stunNodes, &tailcfg.DERPNode{ - Name: fmt.Sprintf("%da", regionID), - RegionID: regionID, - HostName: u.Hostname(), - DERPPort: portInt, - STUNPort: -1, - ForceHTTP: u.Scheme == "http", - }) - derpMap.Regions[regionID] = &tailcfg.DERPRegion{ // EmbeddedRelay ONLY applies to the primary. EmbeddedRelay: false, RegionID: regionID, RegionCode: regionCode, RegionName: regionName, - Nodes: nodes, + Nodes: []*tailcfg.DERPNode{ + { + Name: fmt.Sprintf("%da", regionID), + RegionID: regionID, + HostName: u.Hostname(), + DERPPort: portInt, + STUNPort: -1, + ForceHTTP: u.Scheme == "http", + }, + }, } } diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 92e0b627d60ae..64cb15c740fed 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -56,6 +56,7 @@ type Options struct { DontAddLicense bool DontAddFirstUser bool ReplicaSyncUpdateInterval time.Duration + ProvisionerDaemonPSK string } // New constructs a codersdk client connected to an in-memory Enterprise API instance. @@ -94,6 +95,7 @@ func NewWithAPI(t *testing.T, options *Options) ( Keys: Keys, ProxyHealthInterval: options.ProxyHealthInterval, DefaultQuietHoursSchedule: oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(), + ProvisionerDaemonPSK: options.ProvisionerDaemonPSK, }) require.NoError(t, err) setHandler(coderAPI.AGPL.RootHandler) diff --git a/enterprise/coderd/coderdenttest/proxytest.go b/enterprise/coderd/coderdenttest/proxytest.go index c544e14b44a46..a9031e90a5f32 100644 --- a/enterprise/coderd/coderdenttest/proxytest.go +++ b/enterprise/coderd/coderdenttest/proxytest.go @@ -110,6 +110,10 @@ func NewWorkspaceProxy(t *testing.T, coderdAPI *coderd.API, owner *codersdk.Clie }) require.NoError(t, err, "failed to create workspace proxy") + // Inherit collector options from coderd, but keep the wsproxy reporter. + statsCollectorOptions := coderdAPI.Options.WorkspaceAppsStatsCollectorOptions + statsCollectorOptions.Reporter = nil + wssrv, err := wsproxy.New(ctx, &wsproxy.Options{ Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), Experiments: options.Experiments, @@ -129,6 +133,7 @@ func NewWorkspaceProxy(t *testing.T, coderdAPI *coderd.API, owner *codersdk.Clie DERPEnabled: !options.DerpDisabled, DERPOnly: options.DerpOnly, DERPServerRelayAddress: accessURL.String(), + StatsCollectorOptions: statsCollectorOptions, }) require.NoError(t, err) t.Cleanup(func() { diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index b6f126e1f62e0..f927aa336179d 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -46,9 +46,9 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) return } - if req.Name == database.AllUsersGroup { + if req.Name == database.EveryoneGroup { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("%q is a reserved keyword and cannot be used for a group name.", database.AllUsersGroup), + Message: fmt.Sprintf("%q is a reserved keyword and cannot be used for a group name.", database.EveryoneGroup), }) return } @@ -81,9 +81,11 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) // @Summary Update group by name // @ID update-group-by-name // @Security CoderSessionToken +// @Accept json // @Produce json // @Tags Enterprise // @Param group path string true "Group name" +// @Param request body codersdk.PatchGroupRequest true "Patch group request" // @Success 200 {object} codersdk.Group // @Router /groups/{group} [patch] func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { @@ -100,36 +102,56 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { ) defer commitAudit() - currentMembers, currentMembersErr := api.Database.GetGroupMembers(ctx, group.ID) - if currentMembersErr != nil { - httpapi.InternalServerError(rw, currentMembersErr) + var req codersdk.PatchGroupRequest + if !httpapi.Read(ctx, rw, r, &req) { return } - aReq.Old = group.Auditable(currentMembers) + // If the name matches the existing group name pretend we aren't + // updating the name at all. + if req.Name == group.Name { + req.Name = "" + } - var req codersdk.PatchGroupRequest - if !httpapi.Read(ctx, rw, r, &req) { + if group.IsEveryone() && req.Name != "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Cannot rename the %q group!", database.EveryoneGroup), + }) return } - if req.Name != "" && req.Name == database.AllUsersGroup { + if group.IsEveryone() && (req.DisplayName != nil && *req.DisplayName != "") { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("%q is a reserved group name!", database.AllUsersGroup), + Message: fmt.Sprintf("Cannot update the Display Name for the %q group!", database.EveryoneGroup), }) return } - // If the name matches the existing group name pretend we aren't - // updating the name at all. - if req.Name == group.Name { - req.Name = "" + if req.Name == database.EveryoneGroup { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("%q is a reserved group name!", database.EveryoneGroup), + }) + return } users := make([]string, 0, len(req.AddUsers)+len(req.RemoveUsers)) users = append(users, req.AddUsers...) users = append(users, req.RemoveUsers...) + if len(users) > 0 && group.Name == database.EveryoneGroup { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: fmt.Sprintf("Cannot add or remove users from the %q group!", database.EveryoneGroup), + }) + return + } + + currentMembers, err := api.Database.GetGroupMembers(ctx, group.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + aReq.Old = group.Auditable(currentMembers) + for _, id := range users { if _, err := uuid.Parse(id); err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -154,6 +176,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { return } } + if req.Name != "" && req.Name != group.Name { _, err := api.Database.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{ OrganizationID: group.OrganizationID, @@ -167,8 +190,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { } } - err := api.Database.InTx(func(tx database.Store) error { - var err error + err = database.ReadModifyUpdate(api.Database, func(tx database.Store) error { group, err = tx.GetGroupByID(ctx, group.ID) if err != nil { return xerrors.Errorf("get group by ID: %w", err) @@ -228,7 +250,8 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { } } return nil - }, nil) + }) + if database.IsUniqueViolation(err) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Cannot add the same user to a group twice!", @@ -281,6 +304,13 @@ func (api *API) deleteGroup(rw http.ResponseWriter, r *http.Request) { ) defer commitAudit() + if group.Name == database.EveryoneGroup { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("%q is a reserved group and cannot be deleted!", database.EveryoneGroup), + }) + return + } + groupMembers, getMembersErr := api.Database.GetGroupMembers(ctx, group.ID) if getMembersErr != nil { httpapi.InternalServerError(rw, getMembersErr) @@ -289,13 +319,6 @@ func (api *API) deleteGroup(rw http.ResponseWriter, r *http.Request) { aReq.Old = group.Auditable(groupMembers) - if group.Name == database.AllUsersGroup { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("%q is a reserved group and cannot be deleted!", database.AllUsersGroup), - }) - return - } - err := api.Database.DeleteGroupByID(ctx, group.ID) if err != nil { httpapi.InternalServerError(rw, err) @@ -320,12 +343,12 @@ func (api *API) groupByOrganization(rw http.ResponseWriter, r *http.Request) { api.group(rw, r) } -// @Summary Get group by name -// @ID get-group-by-name +// @Summary Get group by ID +// @ID get-group-by-id // @Security CoderSessionToken // @Produce json // @Tags Enterprise -// @Param group path string true "Group name" +// @Param group path string true "Group id" // @Success 200 {object} codersdk.Group // @Router /groups/{group} [get] func (api *API) group(rw http.ResponseWriter, r *http.Request) { @@ -409,6 +432,7 @@ func convertGroup(g database.Group, users []database.User) codersdk.Group { AvatarURL: g.AvatarURL, QuotaAllowance: int(g.QuotaAllowance), Members: convertUsers(users, orgs), + Source: codersdk.GroupSource(g.Source), } } diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 5999fa47b1f6d..3794829746c6c 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -105,7 +105,7 @@ func TestCreateGroup(t *testing.T) { }}) ctx := testutil.Context(t, testutil.WaitLong) _, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ - Name: database.AllUsersGroup, + Name: database.EveryoneGroup, }) require.Error(t, err) cerr, ok := codersdk.AsError(err) @@ -399,7 +399,7 @@ func TestPatchGroup(t *testing.T) { require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) }) - t.Run("allUsers", func(t *testing.T) { + t.Run("ReservedName", func(t *testing.T) { t.Parallel() client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ @@ -414,13 +414,114 @@ func TestPatchGroup(t *testing.T) { require.NoError(t, err) group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ - Name: database.AllUsersGroup, + Name: database.EveryoneGroup, }) require.Error(t, err) cerr, ok := codersdk.AsError(err) require.True(t, ok) require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) }) + + t.Run("Everyone", func(t *testing.T) { + t.Parallel() + t.Run("NoUpdateName", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + ctx := testutil.Context(t, testutil.WaitLong) + _, err := client.PatchGroup(ctx, user.OrganizationID, codersdk.PatchGroupRequest{ + Name: "hi", + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) + + t.Run("NoUpdateDisplayName", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + ctx := testutil.Context(t, testutil.WaitLong) + _, err := client.PatchGroup(ctx, user.OrganizationID, codersdk.PatchGroupRequest{ + DisplayName: ptr.Ref("hi"), + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) + + t.Run("NoAddUsers", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + _, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := client.PatchGroup(ctx, user.OrganizationID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String()}, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusForbidden, cerr.StatusCode()) + }) + + t.Run("NoRmUsers", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := client.PatchGroup(ctx, user.OrganizationID, codersdk.PatchGroupRequest{ + RemoveUsers: []string{user.UserID.String()}, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusForbidden, cerr.StatusCode()) + }) + + t.Run("UpdateQuota", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + + ctx := testutil.Context(t, testutil.WaitLong) + group, err := client.Group(ctx, user.OrganizationID) + require.NoError(t, err) + + require.Equal(t, 0, group.QuotaAllowance) + + expectedQuota := 123 + group, err = client.PatchGroup(ctx, user.OrganizationID, codersdk.PatchGroupRequest{ + QuotaAllowance: ptr.Ref(expectedQuota), + }) + require.NoError(t, err) + require.Equal(t, expectedQuota, group.QuotaAllowance) + }) + }) } // TODO: test auth. @@ -591,13 +692,17 @@ func TestGroup(t *testing.T) { codersdk.FeatureTemplateRBAC: 1, }, }}) + _, user1 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + _, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + ctx := testutil.Context(t, testutil.WaitLong) // The 'Everyone' group always has an ID that matches the organization ID. group, err := client.Group(ctx, user.OrganizationID) require.NoError(t, err) - require.Len(t, group.Members, 0) + require.Len(t, group.Members, 3) require.Equal(t, "Everyone", group.Name) require.Equal(t, user.OrganizationID, group.OrganizationID) + require.Contains(t, group.Members, user1, user2) }) } @@ -641,7 +746,8 @@ func TestGroups(t *testing.T) { groups, err := client.GroupsByOrganization(ctx, user.OrganizationID) require.NoError(t, err) - require.Len(t, groups, 2) + // 'Everyone' group + 2 custom groups. + require.Len(t, groups, 3) require.Contains(t, groups, group1) require.Contains(t, groups, group2) }) diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 055704a6bcb11..1b3010d833200 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -2,6 +2,7 @@ package coderd import ( "context" + "crypto/subtle" "database/sql" "encoding/json" "errors" @@ -87,6 +88,40 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, apiDaemons) } +type provisionerDaemonAuth struct { + psk string + authorizer rbac.Authorizer +} + +// authorize returns mutated tags and true if the given HTTP request is authorized to access the provisioner daemon +// protobuf API, and returns nil, false otherwise. +func (p *provisionerDaemonAuth) authorize(r *http.Request, tags map[string]string) (map[string]string, bool) { + ctx := r.Context() + apiKey, ok := httpmw.APIKeyOptional(r) + if ok { + tags = provisionerdserver.MutateTags(apiKey.UserID, tags) + if tags[provisionerdserver.TagScope] == provisionerdserver.ScopeUser { + // Any authenticated user can create provisioner daemons scoped + // for jobs that they own, + return tags, true + } + ua := httpmw.UserAuthorization(r) + if err := p.authorizer.Authorize(ctx, ua.Actor, rbac.ActionCreate, rbac.ResourceProvisionerDaemon); err == nil { + // User is allowed to create provisioner daemons + return tags, true + } + } + + // Check for PSK + if p.psk != "" { + psk := r.Header.Get(codersdk.ProvisionerDaemonPSK) + if subtle.ConstantTimeCompare([]byte(p.psk), []byte(psk)) == 1 { + return tags, true + } + } + return nil, false +} + // Serves the provisioner daemon protobuf API over a WebSocket. // // @Summary Serve provisioner daemon @@ -134,19 +169,11 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) } } - // Any authenticated user can create provisioner daemons scoped - // for jobs that they own, but only authorized users can create - // globally scoped provisioners that attach to all jobs. - apiKey := httpmw.APIKey(r) - tags = provisionerdserver.MutateTags(apiKey.UserID, tags) - - if tags[provisionerdserver.TagScope] == provisionerdserver.ScopeOrganization { - if !api.AGPL.Authorize(r, rbac.ActionCreate, rbac.ResourceProvisionerDaemon) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "You aren't allowed to create provisioner daemons for the organization.", - }) - return - } + tags, authorized := api.provisionerDaemonAuth.authorize(r, tags) + if !authorized { + httpapi.Write(ctx, rw, http.StatusForbidden, + codersdk.Response{Message: "You aren't allowed to create provisioner daemons"}) + return } provisioners := make([]database.ProvisionerType, 0) diff --git a/enterprise/coderd/provisionerdaemons_test.go b/enterprise/coderd/provisionerdaemons_test.go index 28a89431b4f00..1586a92773e73 100644 --- a/enterprise/coderd/provisionerdaemons_test.go +++ b/enterprise/coderd/provisionerdaemons_test.go @@ -17,6 +17,7 @@ import ( "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/testutil" ) func TestProvisionerDaemonServe(t *testing.T) { @@ -28,23 +29,43 @@ func TestProvisionerDaemonServe(t *testing.T) { codersdk.FeatureExternalProvisionerDaemons: 1, }, }}) - srv, err := client.ServeProvisionerDaemon(context.Background(), user.OrganizationID, []codersdk.ProvisionerType{ - codersdk.ProvisionerTypeEcho, - }, map[string]string{}) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + srv, err := client.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{}, + }) require.NoError(t, err) srv.DRPCConn().Close() + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 1) }) t.Run("NoLicense", func(t *testing.T) { t.Parallel() client, user := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) - _, err := client.ServeProvisionerDaemon(context.Background(), user.OrganizationID, []codersdk.ProvisionerType{ - codersdk.ProvisionerTypeEcho, - }, map[string]string{}) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + _, err := client.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{}, + }) require.Error(t, err) var apiError *codersdk.Error require.ErrorAs(t, err, &apiError) require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + + // querying provisioner daemons is forbidden without license + _, err = client.ProvisionerDaemons(ctx) + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) }) t.Run("Organization", func(t *testing.T) { @@ -55,15 +76,24 @@ func TestProvisionerDaemonServe(t *testing.T) { }, }}) another, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleOrgAdmin(user.OrganizationID)) - _, err := another.ServeProvisionerDaemon(context.Background(), user.OrganizationID, []codersdk.ProvisionerType{ - codersdk.ProvisionerTypeEcho, - }, map[string]string{ - provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + _, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, }) require.Error(t, err) var apiError *codersdk.Error require.ErrorAs(t, err, &apiError) require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 0) }) t.Run("OrganizationNoPerms", func(t *testing.T) { @@ -74,15 +104,24 @@ func TestProvisionerDaemonServe(t *testing.T) { }, }}) another, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) - _, err := another.ServeProvisionerDaemon(context.Background(), user.OrganizationID, []codersdk.ProvisionerType{ - codersdk.ProvisionerTypeEcho, - }, map[string]string{ - provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + _, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, }) require.Error(t, err) var apiError *codersdk.Error require.ErrorAs(t, err, &apiError) require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 0) }) t.Run("UserLocal", func(t *testing.T) { @@ -141,4 +180,129 @@ func TestProvisionerDaemonServe(t *testing.T) { workspace := coderdtest.CreateWorkspace(t, another, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) }) + + t.Run("PSK", func(t *testing.T) { + t.Parallel() + client, user := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + ProvisionerDaemonPSK: "provisionersftw", + }) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + another := codersdk.New(client.URL) + srv, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, + PreSharedKey: "provisionersftw", + }) + require.NoError(t, err) + err = srv.DRPCConn().Close() + require.NoError(t, err) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 1) + }) + + t.Run("BadPSK", func(t *testing.T) { + t.Parallel() + client, user := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + ProvisionerDaemonPSK: "provisionersftw", + }) + another := codersdk.New(client.URL) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + _, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, + PreSharedKey: "the wrong key", + }) + require.Error(t, err) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 0) + }) + + t.Run("NoAuth", func(t *testing.T) { + t.Parallel() + client, user := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + ProvisionerDaemonPSK: "provisionersftw", + }) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + another := codersdk.New(client.URL) + _, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, + }) + require.Error(t, err) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 0) + }) + + t.Run("NoPSK", func(t *testing.T) { + t.Parallel() + client, user := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + another := codersdk.New(client.URL) + _, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, + PreSharedKey: "provisionersftw", + }) + require.Error(t, err) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 0) + }) } diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 278e315dda3af..c0169fcfc66ec 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -6,10 +6,16 @@ import ( "time" "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/db2sdk" + "github.com/coder/coder/coderd/database/dbauthz" agpl "github.com/coder/coder/coderd/schedule" + "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/codersdk" ) // EnterpriseTemplateScheduleStore provides an agpl.TemplateScheduleStore that @@ -20,16 +26,35 @@ type EnterpriseTemplateScheduleStore struct { // workspace build. This value is determined by a feature flag, licensing, // and whether a default user quiet hours schedule is set. UseRestartRequirement atomic.Bool + + // UserQuietHoursScheduleStore is used when recalculating build deadlines on + // update. + UserQuietHoursScheduleStore *atomic.Pointer[agpl.UserQuietHoursScheduleStore] + + // Custom time.Now() function to use in tests. Defaults to database.Now(). + TimeNowFn func() time.Time } var _ agpl.TemplateScheduleStore = &EnterpriseTemplateScheduleStore{} -func NewEnterpriseTemplateScheduleStore() *EnterpriseTemplateScheduleStore { - return &EnterpriseTemplateScheduleStore{} +func NewEnterpriseTemplateScheduleStore(userQuietHoursStore *atomic.Pointer[agpl.UserQuietHoursScheduleStore]) *EnterpriseTemplateScheduleStore { + return &EnterpriseTemplateScheduleStore{ + UserQuietHoursScheduleStore: userQuietHoursStore, + } +} + +func (s *EnterpriseTemplateScheduleStore) now() time.Time { + if s.TimeNowFn != nil { + return s.TimeNowFn() + } + return database.Now() } // Get implements agpl.TemplateScheduleStore. func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (agpl.TemplateScheduleOptions, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + tpl, err := db.GetTemplateByID(ctx, templateID) if err != nil { return agpl.TemplateScheduleOptions{}, err @@ -65,7 +90,10 @@ func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.S } // Set implements agpl.TemplateScheduleStore. -func (*EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts agpl.TemplateScheduleOptions) (database.Template, error) { +func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts agpl.TemplateScheduleOptions) (database.Template, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + if int64(opts.DefaultTTL) == tpl.DefaultTTL && int64(opts.MaxTTL) == tpl.MaxTTL && int16(opts.RestartRequirement.DaysOfWeek) == tpl.RestartRequirementDaysOfWeek && @@ -86,9 +114,12 @@ func (*EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Sto var template database.Template err = db.InTx(func(db database.Store) error { + ctx, span := tracing.StartSpanWithName(ctx, "(*schedule.EnterpriseTemplateScheduleStore).Set()-InTx()") + defer span.End() + err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ ID: tpl.ID, - UpdatedAt: database.Now(), + UpdatedAt: s.now(), AllowUserAutostart: opts.UserAutostartEnabled, AllowUserAutostop: opts.UserAutostopEnabled, DefaultTTL: int64(opts.DefaultTTL), @@ -115,12 +146,20 @@ func (*EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Sto return xerrors.Errorf("update deleting_at of all workspaces for new locked_ttl %q: %w", opts.LockedTTL, err) } - // TODO: update all workspace max_deadlines to be within new bounds template, err = db.GetTemplateByID(ctx, tpl.ID) if err != nil { return xerrors.Errorf("get updated template schedule: %w", err) } + // Recalculate max_deadline and deadline for all running workspace + // builds on this template. + if s.UseRestartRequirement.Load() { + err = s.updateWorkspaceBuilds(ctx, db, template) + if err != nil { + return xerrors.Errorf("update workspace builds: %w", err) + } + } + return nil }, nil) if err != nil { @@ -129,3 +168,98 @@ func (*EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Sto return template, nil } + +func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuilds(ctx context.Context, db database.Store, template database.Template) error { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + + //nolint:gocritic // This function will retrieve all workspace builds on + // the template and update their max deadline to be within the new + // policy parameters. + ctx = dbauthz.AsSystemRestricted(ctx) + + builds, err := db.GetActiveWorkspaceBuildsByTemplateID(ctx, template.ID) + if err != nil { + return xerrors.Errorf("get active workspace builds: %w", err) + } + + for _, build := range builds { + err := s.updateWorkspaceBuild(ctx, db, build) + if err != nil { + return xerrors.Errorf("update workspace build %q: %w", build.ID, err) + } + } + + return nil +} + +func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuild(ctx context.Context, db database.Store, build database.WorkspaceBuild) error { + ctx, span := tracing.StartSpan(ctx, + trace.WithAttributes(attribute.String("coder.workspace_id", build.WorkspaceID.String())), + trace.WithAttributes(attribute.String("coder.workspace_build_id", build.ID.String())), + ) + defer span.End() + + if !build.MaxDeadline.IsZero() && build.MaxDeadline.Before(s.now().Add(2*time.Hour)) { + // Skip this since it's already too close to the max_deadline. + return nil + } + + workspace, err := db.GetWorkspaceByID(ctx, build.WorkspaceID) + if err != nil { + return xerrors.Errorf("get workspace %q: %w", build.WorkspaceID, err) + } + + job, err := db.GetProvisionerJobByID(ctx, build.JobID) + if err != nil { + return xerrors.Errorf("get provisioner job %q: %w", build.JobID, err) + } + if db2sdk.ProvisionerJobStatus(job) != codersdk.ProvisionerJobSucceeded { + // Only touch builds that are completed. + return nil + } + + // If the job completed before the autostop epoch, then it must be skipped + // to avoid failures below. Add a week to account for timezones. + if job.CompletedAt.Time.Before(agpl.TemplateRestartRequirementEpoch(time.UTC).Add(time.Hour * 7 * 24)) { + return nil + } + + autostop, err := agpl.CalculateAutostop(ctx, agpl.CalculateAutostopParams{ + Database: db, + TemplateScheduleStore: s, + UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(), + // Use the job completion time as the time we calculate autostop from. + Now: job.CompletedAt.Time, + Workspace: workspace, + }) + if err != nil { + return xerrors.Errorf("calculate new autostop for workspace %q: %w", workspace.ID, err) + } + + // If max deadline is before now()+2h, then set it to that. + now := s.now() + if autostop.MaxDeadline.Before(now.Add(2 * time.Hour)) { + autostop.MaxDeadline = now.Add(time.Hour * 2) + } + + // If the current deadline on the build is after the new max_deadline, then + // set it to the max_deadline. + autostop.Deadline = build.Deadline + if autostop.Deadline.After(autostop.MaxDeadline) { + autostop.Deadline = autostop.MaxDeadline + } + + // Update the workspace build. + err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ + ID: build.ID, + UpdatedAt: now, + Deadline: autostop.Deadline, + MaxDeadline: autostop.MaxDeadline, + }) + if err != nil { + return xerrors.Errorf("update workspace build %q: %w", build.ID, err) + } + + return nil +} diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go new file mode 100644 index 0000000000000..bc97014e0ea0e --- /dev/null +++ b/enterprise/coderd/schedule/template_test.go @@ -0,0 +1,523 @@ +package schedule_test + +import ( + "database/sql" + "encoding/json" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "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" + agplschedule "github.com/coder/coder/coderd/schedule" + "github.com/coder/coder/enterprise/coderd/schedule" + "github.com/coder/coder/testutil" +) + +func TestTemplateUpdateBuildDeadlines(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + + var ( + org = dbgen.Organization(t, db, database.Organization{}) + user = dbgen.User(t, db, database.User{}) + file = dbgen.File(t, db, database.File{ + CreatedBy: user.ID, + }) + templateJob = dbgen.ProvisionerJob(t, db, database.ProvisionerJob{ + OrganizationID: org.ID, + FileID: file.ID, + InitiatorID: user.ID, + Tags: database.StringMap{ + "foo": "bar", + }, + }) + templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + JobID: templateJob.ID, + }) + ) + + const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC + ctx := testutil.Context(t, testutil.WaitLong) + user, err := db.UpdateUserQuietHoursSchedule(ctx, database.UpdateUserQuietHoursScheduleParams{ + ID: user.ID, + QuietHoursSchedule: userQuietHoursSchedule, + }) + require.NoError(t, err) + + realNow := time.Now().UTC() + nowY, nowM, nowD := realNow.Date() + buildTime := time.Date(nowY, nowM, nowD, 12, 0, 0, 0, time.UTC) // noon today UTC + nextQuietHours := time.Date(nowY, nowM, nowD+1, 0, 0, 0, 0, time.UTC) // midnight tomorrow UTC + + // Workspace old max_deadline too soon + cases := []struct { + name string + now time.Time + deadline time.Time + maxDeadline time.Time + newDeadline time.Time // 0 for no change + newMaxDeadline time.Time + }{ + { + name: "SkippedWorkspaceMaxDeadlineTooSoon", + now: buildTime, + deadline: buildTime, + maxDeadline: buildTime.Add(1 * time.Hour), + // Unchanged since the max deadline is too soon. + newDeadline: time.Time{}, + newMaxDeadline: buildTime.Add(1 * time.Hour), + }, + { + name: "NewWorkspaceMaxDeadlineBeforeNow", + // After the new max deadline... + now: nextQuietHours.Add(6 * time.Hour), + deadline: buildTime, + // Far into the future... + maxDeadline: nextQuietHours.Add(24 * time.Hour), + newDeadline: time.Time{}, + // We will use now() + 2 hours if the newly calculated max deadline + // from the workspace build time is before now. + newMaxDeadline: nextQuietHours.Add(8 * time.Hour), + }, + { + name: "NewWorkspaceMaxDeadlineSoon", + // Right before the new max deadline... + now: nextQuietHours.Add(-1 * time.Hour), + deadline: buildTime, + // Far into the future... + maxDeadline: nextQuietHours.Add(24 * time.Hour), + newDeadline: time.Time{}, + // We will use now() + 2 hours if the newly calculated max deadline + // from the workspace build time is within the next 2 hours. + newMaxDeadline: nextQuietHours.Add(1 * time.Hour), + }, + { + name: "NewWorkspaceMaxDeadlineFuture", + // Well before the new max deadline... + now: nextQuietHours.Add(-6 * time.Hour), + deadline: buildTime, + // Far into the future... + maxDeadline: nextQuietHours.Add(24 * time.Hour), + newDeadline: time.Time{}, + newMaxDeadline: nextQuietHours, + }, + { + name: "DeadlineAfterNewWorkspaceMaxDeadline", + // Well before the new max deadline... + now: nextQuietHours.Add(-6 * time.Hour), + // Far into the future... + deadline: nextQuietHours.Add(24 * time.Hour), + maxDeadline: nextQuietHours.Add(24 * time.Hour), + // The deadline should match since it is after the new max deadline. + newDeadline: nextQuietHours, + newMaxDeadline: nextQuietHours, + }, + } + + for _, c := range cases { + c := c + + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + t.Log("buildTime", buildTime) + t.Log("nextQuietHours", nextQuietHours) + t.Log("now", c.now) + t.Log("deadline", c.deadline) + t.Log("maxDeadline", c.maxDeadline) + t.Log("newDeadline", c.newDeadline) + t.Log("newMaxDeadline", c.newMaxDeadline) + + var ( + template = dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + ActiveVersionID: templateVersion.ID, + CreatedBy: user.ID, + }) + ws = dbgen.Workspace(t, db, database.Workspace{ + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateID: template.ID, + }) + job = dbgen.ProvisionerJob(t, db, database.ProvisionerJob{ + OrganizationID: org.ID, + FileID: file.ID, + InitiatorID: user.ID, + Provisioner: database.ProvisionerTypeEcho, + Tags: database.StringMap{ + c.name: "yeah", + }, + }) + wsBuild = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: ws.ID, + BuildNumber: 1, + JobID: job.ID, + InitiatorID: user.ID, + TemplateVersionID: templateVersion.ID, + }) + ) + + acquiredJob, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + StartedAt: sql.NullTime{ + Time: buildTime, + Valid: true, + }, + WorkerID: uuid.NullUUID{ + UUID: uuid.New(), + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: json.RawMessage(fmt.Sprintf(`{%q: "yeah"}`, c.name)), + }) + require.NoError(t, err) + require.Equal(t, job.ID, acquiredJob.ID) + err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ + ID: job.ID, + CompletedAt: sql.NullTime{ + Time: buildTime, + Valid: true, + }, + UpdatedAt: buildTime, + }) + require.NoError(t, err) + + err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ + ID: wsBuild.ID, + UpdatedAt: buildTime, + ProvisionerState: []byte{}, + Deadline: c.deadline, + MaxDeadline: c.maxDeadline, + }) + require.NoError(t, err) + + wsBuild, err = db.GetWorkspaceBuildByID(ctx, wsBuild.ID) + require.NoError(t, err) + + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule) + require.NoError(t, err) + userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} + userQuietHoursStorePtr.Store(&userQuietHoursStore) + + // Set the template policy. + templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr) + templateScheduleStore.UseRestartRequirement.Store(true) + templateScheduleStore.TimeNowFn = func() time.Time { + return c.now + } + _, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{ + UserAutostartEnabled: false, + UserAutostopEnabled: false, + DefaultTTL: 0, + MaxTTL: 0, + UseRestartRequirement: true, + RestartRequirement: agplschedule.TemplateRestartRequirement{ + // Every day + DaysOfWeek: 0b01111111, + Weeks: 0, + }, + FailureTTL: 0, + InactivityTTL: 0, + LockedTTL: 0, + }) + require.NoError(t, err) + + // Check that the workspace build has the expected deadlines. + newBuild, err := db.GetWorkspaceBuildByID(ctx, wsBuild.ID) + require.NoError(t, err) + + if c.newDeadline.IsZero() { + c.newDeadline = wsBuild.Deadline + } + require.WithinDuration(t, c.newDeadline, newBuild.Deadline, time.Second) + require.WithinDuration(t, c.newMaxDeadline, newBuild.MaxDeadline, time.Second) + }) + } +} + +func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + + var ( + org = dbgen.Organization(t, db, database.Organization{}) + user = dbgen.User(t, db, database.User{}) + file = dbgen.File(t, db, database.File{ + CreatedBy: user.ID, + }) + templateJob = dbgen.ProvisionerJob(t, db, database.ProvisionerJob{ + OrganizationID: org.ID, + FileID: file.ID, + InitiatorID: user.ID, + Tags: database.StringMap{ + "foo": "bar", + }, + }) + templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + JobID: templateJob.ID, + }) + template = dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + ActiveVersionID: templateVersion.ID, + CreatedBy: user.ID, + }) + otherTemplate = dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + ActiveVersionID: templateVersion.ID, + CreatedBy: user.ID, + }) + ) + + // Create a workspace that will be shared by two builds. + ws := dbgen.Workspace(t, db, database.Workspace{ + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateID: template.ID, + }) + + const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC + ctx := testutil.Context(t, testutil.WaitLong) + user, err := db.UpdateUserQuietHoursSchedule(ctx, database.UpdateUserQuietHoursScheduleParams{ + ID: user.ID, + QuietHoursSchedule: userQuietHoursSchedule, + }) + require.NoError(t, err) + + realNow := time.Now().UTC() + nowY, nowM, nowD := realNow.Date() + buildTime := time.Date(nowY, nowM, nowD, 12, 0, 0, 0, time.UTC) // noon today UTC + now := time.Date(nowY, nowM, nowD, 18, 0, 0, 0, time.UTC) // 6pm today UTC + nextQuietHours := time.Date(nowY, nowM, nowD+1, 0, 0, 0, 0, time.UTC) // midnight tomorrow UTC + + // A date very far in the future which would definitely be updated. + originalMaxDeadline := time.Date(nowY+1, nowM, nowD, 0, 0, 0, 0, time.UTC) + + _ = otherTemplate + + builds := []struct { + name string + templateID uuid.UUID + // Nil workspaceID means create a new workspace. + workspaceID uuid.UUID + buildNumber int32 + buildStarted bool + buildCompleted bool + buildError bool + + shouldBeUpdated bool + + // Set below: + wsBuild database.WorkspaceBuild + }{ + { + name: "DifferentTemplate", + templateID: otherTemplate.ID, + workspaceID: uuid.Nil, + buildNumber: 1, + buildStarted: true, + buildCompleted: true, + buildError: false, + shouldBeUpdated: false, + }, + { + name: "NonStartedBuild", + templateID: template.ID, + workspaceID: uuid.Nil, + buildNumber: 1, + buildStarted: false, + buildCompleted: false, + buildError: false, + shouldBeUpdated: false, + }, + { + name: "InProgressBuild", + templateID: template.ID, + workspaceID: uuid.Nil, + buildNumber: 1, + buildStarted: true, + buildCompleted: false, + buildError: false, + shouldBeUpdated: false, + }, + { + name: "FailedBuild", + templateID: template.ID, + workspaceID: uuid.Nil, + buildNumber: 1, + buildStarted: true, + buildCompleted: true, + buildError: true, + shouldBeUpdated: false, + }, + { + name: "NonLatestBuild", + templateID: template.ID, + workspaceID: ws.ID, + buildNumber: 1, + buildStarted: true, + buildCompleted: true, + buildError: false, + // This build was successful but is not the latest build for this + // workspace, see the next build. + shouldBeUpdated: false, + }, + { + name: "LatestBuild", + templateID: template.ID, + workspaceID: ws.ID, + buildNumber: 2, + buildStarted: true, + buildCompleted: true, + buildError: false, + shouldBeUpdated: true, + }, + { + name: "LatestBuildOtherWorkspace", + templateID: template.ID, + workspaceID: uuid.Nil, + buildNumber: 1, + buildStarted: true, + buildCompleted: true, + buildError: false, + shouldBeUpdated: true, + }, + } + + for i, b := range builds { + wsID := b.workspaceID + if wsID == uuid.Nil { + ws := dbgen.Workspace(t, db, database.Workspace{ + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateID: b.templateID, + }) + wsID = ws.ID + } + job := dbgen.ProvisionerJob(t, db, database.ProvisionerJob{ + OrganizationID: org.ID, + FileID: file.ID, + InitiatorID: user.ID, + Provisioner: database.ProvisionerTypeEcho, + Tags: database.StringMap{ + wsID.String(): "yeah", + }, + }) + wsBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: wsID, + BuildNumber: b.buildNumber, + JobID: job.ID, + InitiatorID: user.ID, + TemplateVersionID: templateVersion.ID, + }) + + err := db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ + ID: wsBuild.ID, + UpdatedAt: buildTime, + ProvisionerState: []byte{}, + Deadline: originalMaxDeadline, + MaxDeadline: originalMaxDeadline, + }) + require.NoError(t, err) + + wsBuild, err = db.GetWorkspaceBuildByID(ctx, wsBuild.ID) + require.NoError(t, err) + + builds[i].wsBuild = wsBuild + + if !b.buildStarted { + continue + } + + acquiredJob, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + StartedAt: sql.NullTime{ + Time: buildTime, + Valid: true, + }, + WorkerID: uuid.NullUUID{ + UUID: uuid.New(), + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: json.RawMessage(fmt.Sprintf(`{%q: "yeah"}`, wsID)), + }) + require.NoError(t, err) + require.Equal(t, job.ID, acquiredJob.ID) + + if !b.buildCompleted { + continue + } + + buildError := "" + if b.buildError { + buildError = "error" + } + err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ + ID: job.ID, + CompletedAt: sql.NullTime{ + Time: buildTime, + Valid: true, + }, + Error: sql.NullString{ + String: buildError, + Valid: b.buildError, + }, + UpdatedAt: buildTime, + }) + require.NoError(t, err) + } + + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule) + require.NoError(t, err) + userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} + userQuietHoursStorePtr.Store(&userQuietHoursStore) + + // Set the template policy. + templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr) + templateScheduleStore.UseRestartRequirement.Store(true) + templateScheduleStore.TimeNowFn = func() time.Time { + return now + } + _, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{ + UserAutostartEnabled: false, + UserAutostopEnabled: false, + DefaultTTL: 0, + MaxTTL: 0, + UseRestartRequirement: true, + RestartRequirement: agplschedule.TemplateRestartRequirement{ + // Every day + DaysOfWeek: 0b01111111, + Weeks: 0, + }, + FailureTTL: 0, + InactivityTTL: 0, + LockedTTL: 0, + }) + require.NoError(t, err) + + // Check each build. + for i, b := range builds { + msg := fmt.Sprintf("build %d: %s", i, b.name) + newBuild, err := db.GetWorkspaceBuildByID(ctx, b.wsBuild.ID) + require.NoError(t, err) + + if b.shouldBeUpdated { + assert.WithinDuration(t, nextQuietHours, newBuild.Deadline, time.Second, msg) + assert.WithinDuration(t, nextQuietHours, newBuild.MaxDeadline, time.Second, msg) + } else { + assert.WithinDuration(t, originalMaxDeadline, newBuild.Deadline, time.Second, msg) + assert.WithinDuration(t, originalMaxDeadline, newBuild.MaxDeadline, time.Second, msg) + } + } +} diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go index c7d76f86119c8..002965c8e70db 100644 --- a/enterprise/coderd/schedule/user.go +++ b/enterprise/coderd/schedule/user.go @@ -9,6 +9,7 @@ import ( "github.com/coder/coder/coderd/database" agpl "github.com/coder/coder/coderd/schedule" + "github.com/coder/coder/coderd/tracing" ) // enterpriseUserQuietHoursScheduleStore provides an @@ -29,7 +30,8 @@ func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string) (agpl.User defaultSchedule: defaultSchedule, } - _, err := s.parseSchedule(defaultSchedule) + // The context is only used for tracing so using a background ctx is fine. + _, err := s.parseSchedule(context.Background(), defaultSchedule) if err != nil { return nil, xerrors.Errorf("parse default schedule: %w", err) } @@ -37,7 +39,10 @@ func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string) (agpl.User return s, nil } -func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) { +func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(ctx context.Context, rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) { + _, span := tracing.StartSpan(ctx) + defer span.End() + userSet := true if strings.TrimSpace(rawSchedule) == "" { userSet = false @@ -64,16 +69,22 @@ func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(rawSchedule string } func (s *enterpriseUserQuietHoursScheduleStore) Get(ctx context.Context, db database.Store, userID uuid.UUID) (agpl.UserQuietHoursScheduleOptions, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + user, err := db.GetUserByID(ctx, userID) if err != nil { return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("get user by ID: %w", err) } - return s.parseSchedule(user.QuietHoursSchedule) + return s.parseSchedule(ctx, user.QuietHoursSchedule) } func (s *enterpriseUserQuietHoursScheduleStore) Set(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) { - opts, err := s.parseSchedule(rawSchedule) + ctx, span := tracing.StartSpan(ctx) + defer span.End() + + opts, err := s.parseSchedule(ctx, rawSchedule) if err != nil { return opts, err } @@ -91,8 +102,12 @@ func (s *enterpriseUserQuietHoursScheduleStore) Set(ctx context.Context, db data return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("update user quiet hours schedule: %w", err) } - // TODO(@dean): update max_deadline for all active builds for this user to clamp to - // the new schedule. + // We don't update workspace build deadlines when the user changes their own + // quiet hours schedule, because they could potentially keep their workspace + // running forever. + // + // Workspace build deadlines are updated when the template admin changes the + // template's settings however. return opts, nil } diff --git a/enterprise/coderd/scim.go b/enterprise/coderd/scim.go index efba55b932684..801ca61349ae3 100644 --- a/enterprise/coderd/scim.go +++ b/enterprise/coderd/scim.go @@ -155,7 +155,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { } //nolint:gocritic - user, err := api.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{ + dbUser, err := api.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{ Email: email, Username: sUser.UserName, }) @@ -164,8 +164,22 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { return } if err == nil { - sUser.ID = user.ID.String() - sUser.UserName = user.Username + sUser.ID = dbUser.ID.String() + sUser.UserName = dbUser.Username + + if sUser.Active && dbUser.Status == database.UserStatusSuspended { + //nolint:gocritic + _, err = api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{ + ID: dbUser.ID, + // The user will get transitioned to Active after logging in. + Status: database.UserStatusDormant, + UpdatedAt: database.Now(), + }) + if err != nil { + _ = handlerutil.WriteError(rw, err) + return + } + } httpapi.Write(ctx, rw, http.StatusOK, sUser) return @@ -201,7 +215,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { } //nolint:gocritic // needed for SCIM - user, _, err = api.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, agpl.CreateUserRequest{ + dbUser, _, err = api.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, agpl.CreateUserRequest{ CreateUserRequest: codersdk.CreateUserRequest{ Username: sUser.UserName, Email: email, @@ -214,8 +228,8 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { return } - sUser.ID = user.ID.String() - sUser.UserName = user.Username + sUser.ID = dbUser.ID.String() + sUser.UserName = dbUser.Username httpapi.Write(ctx, rw, http.StatusOK, sUser) } @@ -263,7 +277,8 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) { var status database.UserStatus if sUser.Active { - status = database.UserStatusActive + // The user will get transitioned to Active after logging in. + status = database.UserStatusDormant } else { status = database.UserStatusSuspended } diff --git a/enterprise/coderd/scim_test.go b/enterprise/coderd/scim_test.go index f0778c26b51d0..a74dc9bf3452b 100644 --- a/enterprise/coderd/scim_test.go +++ b/enterprise/coderd/scim_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "testing" @@ -164,6 +165,54 @@ func TestScim(t *testing.T) { assert.Equal(t, sUser.UserName, userRes.Users[0].Username) }) + t.Run("Unsuspend", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + scimAPIKey := []byte("hi") + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + SCIMAPIKey: scimAPIKey, + LicenseOptions: &coderdenttest.LicenseOptions{ + AccountID: "coolin", + Features: license.Features{ + codersdk.FeatureSCIM: 1, + }, + }, + }) + + sUser := makeScimUser(t) + res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey)) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) + err = json.NewDecoder(res.Body).Decode(&sUser) + require.NoError(t, err) + + sUser.Active = false + res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey)) + require.NoError(t, err) + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) + + sUser.Active = true + res, err = client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey)) + require.NoError(t, err) + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) + + userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value}) + require.NoError(t, err) + require.Len(t, userRes.Users, 1) + + assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email) + assert.Equal(t, sUser.UserName, userRes.Users[0].Username) + assert.Equal(t, codersdk.UserStatusDormant, userRes.Users[0].Status) + }) + t.Run("DomainStrips", func(t *testing.T) { t.Parallel() @@ -185,7 +234,8 @@ func TestScim(t *testing.T) { sUser.UserName = sUser.UserName + "@coder.com" res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey)) require.NoError(t, err) - defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() assert.Equal(t, http.StatusOK, res.StatusCode) userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value}) @@ -220,7 +270,8 @@ func TestScim(t *testing.T) { res, err := client.Request(ctx, "PATCH", "/scim/v2/Users/bob", struct{}{}) require.NoError(t, err) - defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() assert.Equal(t, http.StatusNotFound, res.StatusCode) }) @@ -242,7 +293,8 @@ func TestScim(t *testing.T) { res, err := client.Request(ctx, "PATCH", "/scim/v2/Users/bob", struct{}{}) require.NoError(t, err) - defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() assert.Equal(t, http.StatusInternalServerError, res.StatusCode) }) @@ -276,7 +328,8 @@ func TestScim(t *testing.T) { res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey)) require.NoError(t, err) - defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() assert.Equal(t, http.StatusOK, res.StatusCode) userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value}) diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 68e91a3ff0975..254d444eb18b2 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -373,8 +373,7 @@ func TestTemplateACL(t *testing.T) { require.NoError(t, err) require.Len(t, acl.Groups, 1) - // We don't return members for the 'Everyone' group. - require.Len(t, acl.Groups[0].Members, 0) + require.Len(t, acl.Groups[0].Members, 2) require.Len(t, acl.Users, 0) }) diff --git a/enterprise/coderd/userauth.go b/enterprise/coderd/userauth.go index 86aa9f0ddf88b..98833263355e3 100644 --- a/enterprise/coderd/userauth.go +++ b/enterprise/coderd/userauth.go @@ -9,10 +9,12 @@ import ( "cdr.dev/slog" "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/codersdk" ) -func (api *API) setUserGroups(ctx context.Context, db database.Store, userID uuid.UUID, groupNames []string) error { +// nolint: revive +func (api *API) setUserGroups(ctx context.Context, logger slog.Logger, db database.Store, userID uuid.UUID, groupNames []string, createMissingGroups bool) error { api.entitlementsMu.RLock() enabled := api.entitlements.Features[codersdk.FeatureTemplateRBAC].Enabled api.entitlementsMu.RUnlock() @@ -39,6 +41,25 @@ func (api *API) setUserGroups(ctx context.Context, db database.Store, userID uui return xerrors.Errorf("delete user groups: %w", err) } + if createMissingGroups { + // This is the system creating these additional groups, so we use the system restricted context. + // nolint:gocritic + created, err := tx.InsertMissingGroups(dbauthz.AsSystemRestricted(ctx), database.InsertMissingGroupsParams{ + OrganizationID: orgs[0].ID, + GroupNames: groupNames, + Source: database.GroupSourceOidc, + }) + if err != nil { + return xerrors.Errorf("insert missing groups: %w", err) + } + if len(created) > 0 { + logger.Debug(ctx, "auto created missing groups", + slog.F("org_id", orgs[0].ID), + slog.F("created", created), + ) + } + } + // Re-add the user to all groups returned by the auth provider. err = tx.InsertUserGroupsByName(ctx, database.InsertUserGroupsByNameParams{ UserID: userID, @@ -53,13 +74,13 @@ func (api *API) setUserGroups(ctx context.Context, db database.Store, userID uui }, nil) } -func (api *API) setUserSiteRoles(ctx context.Context, db database.Store, userID uuid.UUID, roles []string) error { +func (api *API) setUserSiteRoles(ctx context.Context, logger slog.Logger, db database.Store, userID uuid.UUID, roles []string) error { api.entitlementsMu.RLock() enabled := api.entitlements.Features[codersdk.FeatureUserRoleManagement].Enabled api.entitlementsMu.RUnlock() if !enabled { - api.Logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise entitlement, roles left unchanged", + logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise entitlement, roles left unchanged", slog.F("user_id", userID), slog.F("roles", roles), ) return nil diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index 428cf91a6fef2..0b773871b98f2 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -5,10 +5,9 @@ import ( "fmt" "io" "net/http" + "regexp" "testing" - "github.com/coder/coder/enterprise/coderd/license" - "github.com/golang-jwt/jwt" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -16,9 +15,13 @@ import ( "github.com/coder/coder/coderd" "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/coderd/util/slice" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/testutil" ) @@ -99,6 +102,7 @@ func TestUserOIDC(t *testing.T) { "roles": []string{"random", oidcRoleName, rbac.RoleOwner()}, })) require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + _ = resp.Body.Close() user, err := client.User(ctx, "alice") require.NoError(t, err) @@ -112,6 +116,7 @@ func TestUserOIDC(t *testing.T) { "roles": []string{"random"}, })) require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + _ = resp.Body.Close() user, err = client.User(ctx, "alice") require.NoError(t, err) @@ -352,6 +357,216 @@ func TestUserOIDC(t *testing.T) { }) } +func TestGroupSync(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + modCfg func(cfg *coderd.OIDCConfig) + // initialOrgGroups is initial groups in the org + initialOrgGroups []string + // initialUserGroups is initial groups for the user + initialUserGroups []string + // expectedUserGroups is expected groups for the user + expectedUserGroups []string + // expectedOrgGroups is expected all groups on the system + expectedOrgGroups []string + claims jwt.MapClaims + }{ + { + name: "NoGroups", + modCfg: func(cfg *coderd.OIDCConfig) { + }, + initialOrgGroups: []string{}, + expectedUserGroups: []string{}, + expectedOrgGroups: []string{}, + claims: jwt.MapClaims{}, + }, + { + name: "GroupSyncDisabled", + modCfg: func(cfg *coderd.OIDCConfig) { + // Disable group sync + cfg.GroupField = "" + cfg.GroupFilter = regexp.MustCompile(".*") + }, + initialOrgGroups: []string{"a", "b", "c", "d"}, + initialUserGroups: []string{"b", "c", "d"}, + expectedUserGroups: []string{"b", "c", "d"}, + expectedOrgGroups: []string{"a", "b", "c", "d"}, + claims: jwt.MapClaims{}, + }, + { + // From a,c,b -> b,c,d + name: "ChangeUserGroups", + modCfg: func(cfg *coderd.OIDCConfig) { + cfg.GroupMapping = map[string]string{ + "D": "d", + } + }, + initialOrgGroups: []string{"a", "b", "c", "d"}, + initialUserGroups: []string{"a", "b", "c"}, + expectedUserGroups: []string{"b", "c", "d"}, + expectedOrgGroups: []string{"a", "b", "c", "d"}, + claims: jwt.MapClaims{ + // D -> d mapped + "groups": []string{"b", "c", "D"}, + }, + }, + { + // From a,c,b -> [] + name: "RemoveAllGroups", + modCfg: func(cfg *coderd.OIDCConfig) { + cfg.GroupFilter = regexp.MustCompile(".*") + }, + initialOrgGroups: []string{"a", "b", "c", "d"}, + initialUserGroups: []string{"a", "b", "c"}, + expectedUserGroups: []string{}, + expectedOrgGroups: []string{"a", "b", "c", "d"}, + claims: jwt.MapClaims{ + // No claim == no groups + }, + }, + { + // From a,c,b -> b,c,d,e,f + name: "CreateMissingGroups", + modCfg: func(cfg *coderd.OIDCConfig) { + cfg.CreateMissingGroups = true + }, + initialOrgGroups: []string{"a", "b", "c", "d"}, + initialUserGroups: []string{"a", "b", "c"}, + expectedUserGroups: []string{"b", "c", "d", "e", "f"}, + expectedOrgGroups: []string{"a", "b", "c", "d", "e", "f"}, + claims: jwt.MapClaims{ + "groups": []string{"b", "c", "d", "e", "f"}, + }, + }, + { + // From a,c,b -> b,c,d,e,f + name: "CreateMissingGroupsFilter", + modCfg: func(cfg *coderd.OIDCConfig) { + cfg.CreateMissingGroups = true + // Only single letter groups + cfg.GroupFilter = regexp.MustCompile("^[a-z]$") + cfg.GroupMapping = map[string]string{ + // Does not match the filter, but does after being mapped! + "zebra": "z", + } + }, + initialOrgGroups: []string{"a", "b", "c", "d"}, + initialUserGroups: []string{"a", "b", "c"}, + expectedUserGroups: []string{"b", "c", "d", "e", "f", "z"}, + expectedOrgGroups: []string{"a", "b", "c", "d", "e", "f", "z"}, + claims: jwt.MapClaims{ + "groups": []string{ + "b", "c", "d", "e", "f", + // These groups are ignored + "excess", "ignore", "dumb", "foobar", "zebra", + }, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + conf := coderdtest.NewOIDCConfig(t, "") + + config := conf.OIDCConfig(t, jwt.MapClaims{}, tc.modCfg) + + client, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + OIDCConfig: config, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureTemplateRBAC: 1}, + }, + }) + + admin, err := client.User(ctx, "me") + require.NoError(t, err) + require.Len(t, admin.OrganizationIDs, 1) + + // Setup + initialGroups := make(map[string]codersdk.Group) + for _, group := range tc.initialOrgGroups { + newGroup, err := client.CreateGroup(ctx, admin.OrganizationIDs[0], codersdk.CreateGroupRequest{ + Name: group, + }) + require.NoError(t, err) + require.Len(t, newGroup.Members, 0) + initialGroups[group] = newGroup + } + + // Create the user and add them to their initial groups + _, user := coderdtest.CreateAnotherUser(t, client, admin.OrganizationIDs[0]) + for _, group := range tc.initialUserGroups { + _, err := client.PatchGroup(ctx, initialGroups[group].ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user.ID.String()}, + }) + require.NoError(t, err) + } + + // nolint:gocritic + _, err = api.Database.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{ + NewLoginType: database.LoginTypeOIDC, + UserID: user.ID, + }) + require.NoError(t, err, "user must be oidc type") + + // Log in the new user + tc.claims["email"] = user.Email + resp := oidcCallback(t, client, conf.EncodeClaims(t, tc.claims)) + assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + _ = resp.Body.Close() + + orgGroups, err := client.GroupsByOrganization(ctx, admin.OrganizationIDs[0]) + require.NoError(t, err) + + for _, group := range orgGroups { + if slice.Contains(tc.initialOrgGroups, group.Name) || group.IsEveryone() { + require.Equal(t, group.Source, codersdk.GroupSourceUser) + } else { + require.Equal(t, group.Source, codersdk.GroupSourceOIDC) + } + } + + orgGroupsMap := make(map[string]struct{}) + for _, group := range orgGroups { + orgGroupsMap[group.Name] = struct{}{} + } + + for _, expected := range tc.expectedOrgGroups { + if _, ok := orgGroupsMap[expected]; !ok { + t.Errorf("expected group %s not found", expected) + } + delete(orgGroupsMap, expected) + } + delete(orgGroupsMap, database.EveryoneGroup) + require.Empty(t, orgGroupsMap, "unexpected groups found") + + expectedUserGroups := make(map[string]struct{}) + for _, group := range tc.expectedUserGroups { + expectedUserGroups[group] = struct{}{} + } + + for _, group := range orgGroups { + userInGroup := slice.ContainsCompare(group.Members, codersdk.User{Email: user.Email}, func(a, b codersdk.User) bool { + return a.Email == b.Email + }) + if group.IsEveryone() { + require.True(t, userInGroup, "user cannot be removed from 'Everyone' group") + } else if _, ok := expectedUserGroups[group.Name]; ok { + require.Truef(t, userInGroup, "user should be in group %s", group.Name) + } else { + require.Falsef(t, userInGroup, "user should not be in group %s", group.Name) + } + } + }) + } +} + func oidcCallback(t *testing.T, client *codersdk.Client, code string) *http.Response { t.Helper() client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go index 591f2fa33374f..87a4f8898872c 100644 --- a/enterprise/coderd/workspaceproxy.go +++ b/enterprise/coderd/workspaceproxy.go @@ -23,6 +23,7 @@ import ( "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/coderd/workspaceapps" "github.com/coder/coder/codersdk" "github.com/coder/coder/cryptorand" @@ -369,6 +370,10 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) { return } + api.Telemetry.Report(&telemetry.Snapshot{ + WorkspaceProxies: []telemetry.WorkspaceProxy{telemetry.ConvertWorkspaceProxy(proxy)}, + }) + aReq.New = proxy httpapi.Write(ctx, rw, http.StatusCreated, codersdk.UpdateWorkspaceProxyResponse{ Proxy: convertProxy(proxy, proxyhealth.ProxyStatus{ @@ -492,6 +497,36 @@ func (api *API) workspaceProxyIssueSignedAppToken(rw http.ResponseWriter, r *htt }) } +// @Summary Report workspace app stats +// @ID report-workspace-app-stats +// @Security CoderSessionToken +// @Accept json +// @Tags Enterprise +// @Param request body wsproxysdk.ReportAppStatsRequest true "Report app stats request" +// @Success 204 +// @Router /workspaceproxies/me/app-stats [post] +// @x-apidocgen {"skip": true} +func (api *API) workspaceProxyReportAppStats(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + _ = httpmw.WorkspaceProxy(r) // Ensure the proxy is authenticated. + + var req wsproxysdk.ReportAppStatsRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + api.Logger.Debug(ctx, "report app stats", slog.F("stats", req.Stats)) + + reporter := api.WorkspaceAppsStatsCollectorOptions.Reporter + if err := reporter.Report(ctx, req.Stats); err != nil { + api.Logger.Error(ctx, "report app stats failed", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} + // workspaceProxyRegister is used to register a new workspace proxy. When a proxy // comes online, it will announce itself to this endpoint. This updates its values // in the database and returns a signed token that can be used to authenticate diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go index a142c86535c4d..112db37e75b57 100644 --- a/enterprise/coderd/workspacequota_test.go +++ b/enterprise/coderd/workspacequota_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/coderd/coderdenttest" "github.com/coder/coder/enterprise/coderd/license" @@ -53,7 +54,14 @@ func TestWorkspaceQuota(t *testing.T) { verifyQuota(ctx, t, client, 0, 0) - // Add user to two groups, granting them a total budget of 3. + // Patch the 'Everyone' group to verify its quota allowance is being accounted for. + _, err := client.PatchGroup(ctx, user.OrganizationID, codersdk.PatchGroupRequest{ + QuotaAllowance: ptr.Ref(1), + }) + require.NoError(t, err) + verifyQuota(ctx, t, client, 0, 1) + + // Add user to two groups, granting them a total budget of 4. group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ Name: "test-1", QuotaAllowance: 1, @@ -76,7 +84,7 @@ func TestWorkspaceQuota(t *testing.T) { }) require.NoError(t, err) - verifyQuota(ctx, t, client, 0, 3) + verifyQuota(ctx, t, client, 0, 4) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ @@ -105,7 +113,7 @@ func TestWorkspaceQuota(t *testing.T) { // Spin up three workspaces fine var wg sync.WaitGroup - for i := 0; i < 3; i++ { + for i := 0; i < 4; i++ { wg.Add(1) go func() { defer wg.Done() @@ -115,14 +123,14 @@ func TestWorkspaceQuota(t *testing.T) { }() } wg.Wait() - verifyQuota(ctx, t, client, 3, 3) + verifyQuota(ctx, t, client, 4, 4) // Next one must fail workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) // Consumed shouldn't bump - verifyQuota(ctx, t, client, 3, 3) + verifyQuota(ctx, t, client, 4, 4) require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status) require.Contains(t, build.Job.Error, "quota") @@ -138,7 +146,7 @@ func TestWorkspaceQuota(t *testing.T) { }) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID) - verifyQuota(ctx, t, client, 2, 3) + verifyQuota(ctx, t, client, 3, 4) break } @@ -146,7 +154,7 @@ func TestWorkspaceQuota(t *testing.T) { workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) build = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - verifyQuota(ctx, t, client, 3, 3) + verifyQuota(ctx, t, client, 4, 4) require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) }) } diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 07e501032f9e0..a5acba7618b64 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "sync/atomic" "testing" "time" @@ -24,6 +25,16 @@ import ( "github.com/coder/coder/testutil" ) +// agplUserQuietHoursScheduleStore is passed to +// NewEnterpriseTemplateScheduleStore as we don't care about updating the +// schedule and having it recalculate the build deadline in these tests. +func agplUserQuietHoursScheduleStore() *atomic.Pointer[agplschedule.UserQuietHoursScheduleStore] { + store := agplschedule.NewAGPLUserQuietHoursScheduleStore() + p := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} + p.Store(&store) + return p +} + func TestCreateWorkspace(t *testing.T) { t.Parallel() @@ -100,7 +111,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -147,7 +158,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -193,7 +204,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -235,7 +246,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -292,7 +303,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -334,7 +345,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -376,7 +387,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -427,7 +438,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -497,7 +508,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -558,7 +569,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -677,6 +688,61 @@ func TestWorkspacesFiltering(t *testing.T) { }) } +// TestWorkspacesWithoutTemplatePerms creates a workspace for a user, then drops +// the user's perms to the underlying template. +func TestWorkspacesWithoutTemplatePerms(t *testing.T) { + t.Parallel() + + client, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + + user, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + workspace := coderdtest.CreateWorkspace(t, user, first.OrganizationID, template.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Remove everyone access + err := client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + GroupPerms: map[string]codersdk.TemplateRole{ + first.OrganizationID.String(): codersdk.TemplateRoleDeleted, + }, + }) + require.NoError(t, err, "remove everyone access") + + // This should fail as the user cannot read the template + _, err = user.Workspace(ctx, workspace.ID) + require.Error(t, err, "fetch workspace") + var sdkError *codersdk.Error + require.ErrorAs(t, err, &sdkError) + require.Equal(t, http.StatusForbidden, sdkError.StatusCode()) + + _, err = user.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err, "fetch workspaces should not fail") + + // Now create another workspace the user can read. + version2 := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version2.ID) + template2 := coderdtest.CreateTemplate(t, client, first.OrganizationID, version2.ID) + _ = coderdtest.CreateWorkspace(t, user, first.OrganizationID, template2.ID) + + workspaces, err := user.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err, "fetch workspaces should not fail") + require.Len(t, workspaces.Workspaces, 1) +} + func TestWorkspaceLock(t *testing.T) { t.Parallel() diff --git a/enterprise/replicasync/replicasync.go b/enterprise/replicasync/replicasync.go index 42bf402a6682e..d0ca629d25ae8 100644 --- a/enterprise/replicasync/replicasync.go +++ b/enterprise/replicasync/replicasync.go @@ -375,7 +375,13 @@ func (m *Manager) InRegion(regionID int32) []database.Replica { // Regional returns all replicas in the same region excluding itself. func (m *Manager) Regional() []database.Replica { - return m.InRegion(m.self.RegionID) + return m.InRegion(m.regionID()) +} + +func (m *Manager) regionID() int32 { + m.mutex.Lock() + defer m.mutex.Unlock() + return m.self.RegionID } // SetCallback sets a function to execute whenever new peers diff --git a/enterprise/tailnet/pgcoord.go b/enterprise/tailnet/pgcoord.go index 8693d8e9a5bdd..5d1e09d441243 100644 --- a/enterprise/tailnet/pgcoord.go +++ b/enterprise/tailnet/pgcoord.go @@ -22,6 +22,7 @@ import ( "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/database/pubsub" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/util/slice" agpl "github.com/coder/coder/tailnet" ) @@ -585,10 +586,12 @@ type querier struct { workQ *workQ[mKey] heartbeats *heartbeats - updates <-chan struct{} + updates <-chan hbUpdate mu sync.Mutex mappers map[mKey]*countedMapper + conns map[*connIO]struct{} + healthy bool } type countedMapper struct { @@ -603,7 +606,7 @@ func newQuerier( self uuid.UUID, newConnections chan *connIO, numWorkers int, firstHeartbeat chan<- struct{}, ) *querier { - updates := make(chan struct{}) + updates := make(chan hbUpdate) q := &querier{ ctx: ctx, logger: logger.Named("querier"), @@ -613,7 +616,9 @@ func newQuerier( workQ: newWorkQ[mKey](ctx), heartbeats: newHeartbeats(ctx, logger, ps, store, self, updates, firstHeartbeat), mappers: make(map[mKey]*countedMapper), + conns: make(map[*connIO]struct{}), updates: updates, + healthy: true, // assume we start healthy } go q.subscribe() go q.handleConnIO() @@ -638,6 +643,15 @@ func (q *querier) handleConnIO() { func (q *querier) newConn(c *connIO) { q.mu.Lock() defer q.mu.Unlock() + if !q.healthy { + err := c.updates.Close() + q.logger.Info(q.ctx, "closed incoming connection while unhealthy", + slog.Error(err), + slog.F("agent_id", c.agent), + slog.F("client_id", c.client), + ) + return + } mk := mKey{ agent: c.agent, // if client is Nil, this is an agent connection, and it wants the mappings for all the clients of itself @@ -660,6 +674,7 @@ func (q *querier) newConn(c *connIO) { return } cm.count++ + q.conns[c] = struct{}{} go q.cleanupConn(c) } @@ -667,6 +682,7 @@ func (q *querier) cleanupConn(c *connIO) { <-c.ctx.Done() q.mu.Lock() defer q.mu.Unlock() + delete(q.conns, c) mk := mKey{ agent: c.agent, // if client is Nil, this is an agent connection, and it wants the mappings for all the clients of itself @@ -910,8 +926,18 @@ func (q *querier) handleUpdates() { select { case <-q.ctx.Done(): return - case <-q.updates: - q.updateAll() + case u := <-q.updates: + if u.filter == filterUpdateUpdated { + q.updateAll() + } + if u.health == healthUpdateUnhealthy { + q.unhealthyCloseAll() + continue + } + if u.health == healthUpdateHealthy { + q.setHealthy() + continue + } } } } @@ -931,6 +957,30 @@ func (q *querier) updateAll() { } } +// unhealthyCloseAll marks the coordinator unhealthy and closes all connections. We do this so that clients and agents +// are forced to reconnect to the coordinator, and will hopefully land on a healthy coordinator. +func (q *querier) unhealthyCloseAll() { + q.mu.Lock() + defer q.mu.Unlock() + q.healthy = false + for c := range q.conns { + // close connections async so that we don't block the querier routine that responds to updates + go func(c *connIO) { + err := c.updates.Close() + if err != nil { + q.logger.Debug(q.ctx, "error closing conn while unhealthy", slog.Error(err)) + } + }(c) + // NOTE: we don't need to remove the connection from the map, as that will happen async in q.cleanupConn() + } +} + +func (q *querier) setHealthy() { + q.mu.Lock() + defer q.mu.Unlock() + q.healthy = true +} + func (q *querier) getAll(ctx context.Context) (map[uuid.UUID]database.TailnetAgent, map[uuid.UUID][]database.TailnetClient, error) { agents, err := q.store.GetAllTailnetAgents(ctx) if err != nil { @@ -1077,6 +1127,28 @@ func (q *workQ[K]) done(key K) { q.cond.Signal() } +type filterUpdate int + +const ( + filterUpdateNone filterUpdate = iota + filterUpdateUpdated +) + +type healthUpdate int + +const ( + healthUpdateNone healthUpdate = iota + healthUpdateHealthy + healthUpdateUnhealthy +) + +// hbUpdate is an update sent from the heartbeats to the querier. Zero values of the fields mean no update of that +// kind. +type hbUpdate struct { + filter filterUpdate + health healthUpdate +} + // heartbeats sends heartbeats for this coordinator on a timer, and monitors heartbeats from other coordinators. If a // coordinator misses their heartbeat, we remove it from our map of "valid" coordinators, such that we will filter out // any mappings for it when filter() is called, and we send a signal on the update channel, which triggers all mappers @@ -1088,8 +1160,9 @@ type heartbeats struct { store database.Store self uuid.UUID - update chan<- struct{} - firstHeartbeat chan<- struct{} + update chan<- hbUpdate + firstHeartbeat chan<- struct{} + failedHeartbeats int lock sync.RWMutex coordinators map[uuid.UUID]time.Time @@ -1102,7 +1175,7 @@ type heartbeats struct { func newHeartbeats( ctx context.Context, logger slog.Logger, ps pubsub.Pubsub, store database.Store, - self uuid.UUID, update chan<- struct{}, + self uuid.UUID, update chan<- hbUpdate, firstHeartbeat chan<- struct{}, ) *heartbeats { h := &heartbeats{ @@ -1193,7 +1266,7 @@ func (h *heartbeats) recvBeat(id uuid.UUID) { h.logger.Info(h.ctx, "heartbeats (re)started", slog.F("other_coordinator_id", id)) // send on a separate goroutine to avoid holding lock. Triggering update can be async go func() { - _ = sendCtx(h.ctx, h.update, struct{}{}) + _ = sendCtx(h.ctx, h.update, hbUpdate{filter: filterUpdateUpdated}) }() } h.coordinators[id] = time.Now() @@ -1240,7 +1313,7 @@ func (h *heartbeats) checkExpiry() { if expired { // send on a separate goroutine to avoid holding lock. Triggering update can be async go func() { - _ = sendCtx(h.ctx, h.update, struct{}{}) + _ = sendCtx(h.ctx, h.update, hbUpdate{filter: filterUpdateUpdated}) }() } // we need to reset the timer for when the next oldest coordinator will expire, if any. @@ -1268,11 +1341,20 @@ func (h *heartbeats) sendBeats() { func (h *heartbeats) sendBeat() { _, err := h.store.UpsertTailnetCoordinator(h.ctx, h.self) if err != nil { - // just log errors, heartbeats are rescheduled on a timer h.logger.Error(h.ctx, "failed to send heartbeat", slog.Error(err)) + h.failedHeartbeats++ + if h.failedHeartbeats == 3 { + h.logger.Error(h.ctx, "coordinator failed 3 heartbeats and is unhealthy") + _ = sendCtx(h.ctx, h.update, hbUpdate{health: healthUpdateUnhealthy}) + } return } h.logger.Debug(h.ctx, "sent heartbeat") + if h.failedHeartbeats >= 3 { + h.logger.Info(h.ctx, "coordinator sent heartbeat and is healthy") + _ = sendCtx(h.ctx, h.update, hbUpdate{health: healthUpdateHealthy}) + } + h.failedHeartbeats = 0 } func (h *heartbeats) sendDelete() { @@ -1351,8 +1433,8 @@ func (c *pgCoord) htmlDebug(ctx context.Context) (agpl.HTMLDebug, error) { Node: conn.Node, }) } - slices.SortFunc(htmlAgent.Connections, func(a, b *agpl.HTMLClient) bool { - return a.Name < b.Name + slices.SortFunc(htmlAgent.Connections, func(a, b *agpl.HTMLClient) int { + return slice.Ascending(a.Name, b.Name) }) data.Agents = append(data.Agents, htmlAgent) @@ -1362,8 +1444,8 @@ func (c *pgCoord) htmlDebug(ctx context.Context) (agpl.HTMLDebug, error) { Node: agent.Node, }) } - slices.SortFunc(data.Agents, func(a, b *agpl.HTMLAgent) bool { - return a.Name < b.Name + slices.SortFunc(data.Agents, func(a, b *agpl.HTMLAgent) int { + return slice.Ascending(a.Name, b.Name) }) for agentID, conns := range clients { @@ -1389,14 +1471,14 @@ func (c *pgCoord) htmlDebug(ctx context.Context) (agpl.HTMLDebug, error) { Node: conn.Node, }) } - slices.SortFunc(agent.Connections, func(a, b *agpl.HTMLClient) bool { - return a.Name < b.Name + slices.SortFunc(agent.Connections, func(a, b *agpl.HTMLClient) int { + return slice.Ascending(a.Name, b.Name) }) data.MissingAgents = append(data.MissingAgents, agent) } - slices.SortFunc(data.MissingAgents, func(a, b *agpl.HTMLAgent) bool { - return a.Name < b.Name + slices.SortFunc(data.MissingAgents, func(a, b *agpl.HTMLAgent) int { + return slice.Ascending(a.Name, b.Name) }) return data, nil diff --git a/enterprise/tailnet/pgcoord_test.go b/enterprise/tailnet/pgcoord_test.go index 25d80ca854566..200945371099e 100644 --- a/enterprise/tailnet/pgcoord_test.go +++ b/enterprise/tailnet/pgcoord_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/golang/mock/gomock" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,7 +22,9 @@ 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/dbtestutil" + "github.com/coder/coder/coderd/database/pubsub" "github.com/coder/coder/enterprise/tailnet" agpl "github.com/coder/coder/tailnet" "github.com/coder/coder/testutil" @@ -36,11 +39,11 @@ func TestPGCoordinatorSingle_ClientWithoutAgent(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("test only with postgres") } - store, pubsub := dbtestutil.NewDB(t) + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - coordinator, err := tailnet.NewPGCoord(ctx, logger, pubsub, store) + coordinator, err := tailnet.NewPGCoord(ctx, logger, ps, store) require.NoError(t, err) defer coordinator.Close() @@ -75,11 +78,11 @@ func TestPGCoordinatorSingle_AgentWithoutClients(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("test only with postgres") } - store, pubsub := dbtestutil.NewDB(t) + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - coordinator, err := tailnet.NewPGCoord(ctx, logger, pubsub, store) + coordinator, err := tailnet.NewPGCoord(ctx, logger, ps, store) require.NoError(t, err) defer coordinator.Close() @@ -112,11 +115,11 @@ func TestPGCoordinatorSingle_AgentWithClient(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("test only with postgres") } - store, pubsub := dbtestutil.NewDB(t) + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - coordinator, err := tailnet.NewPGCoord(ctx, logger, pubsub, store) + coordinator, err := tailnet.NewPGCoord(ctx, logger, ps, store) require.NoError(t, err) defer coordinator.Close() @@ -189,11 +192,11 @@ func TestPGCoordinatorSingle_MissedHeartbeats(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("test only with postgres") } - store, pubsub := dbtestutil.NewDB(t) + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - coordinator, err := tailnet.NewPGCoord(ctx, logger, pubsub, store) + coordinator, err := tailnet.NewPGCoord(ctx, logger, ps, store) require.NoError(t, err) defer coordinator.Close() @@ -276,14 +279,14 @@ func TestPGCoordinatorSingle_SendsHeartbeats(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("test only with postgres") } - store, pubsub := dbtestutil.NewDB(t) + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) mu := sync.Mutex{} heartbeats := []time.Time{} - unsub, err := pubsub.SubscribeWithErr(tailnet.EventHeartbeats, func(_ context.Context, msg []byte, err error) { + unsub, err := ps.SubscribeWithErr(tailnet.EventHeartbeats, func(_ context.Context, msg []byte, err error) { assert.NoError(t, err) mu.Lock() defer mu.Unlock() @@ -293,7 +296,7 @@ func TestPGCoordinatorSingle_SendsHeartbeats(t *testing.T) { defer unsub() start := time.Now() - coordinator, err := tailnet.NewPGCoord(ctx, logger, pubsub, store) + coordinator, err := tailnet.NewPGCoord(ctx, logger, ps, store) require.NoError(t, err) defer coordinator.Close() @@ -326,14 +329,14 @@ func TestPGCoordinatorDual_Mainline(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("test only with postgres") } - store, pubsub := dbtestutil.NewDB(t) + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - coord1, err := tailnet.NewPGCoord(ctx, logger, pubsub, store) + coord1, err := tailnet.NewPGCoord(ctx, logger, ps, store) require.NoError(t, err) defer coord1.Close() - coord2, err := tailnet.NewPGCoord(ctx, logger, pubsub, store) + coord2, err := tailnet.NewPGCoord(ctx, logger, ps, store) require.NoError(t, err) defer coord2.Close() @@ -453,17 +456,17 @@ func TestPGCoordinator_MultiAgent(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("test only with postgres") } - store, pubsub := dbtestutil.NewDB(t) + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - coord1, err := tailnet.NewPGCoord(ctx, logger, pubsub, store) + coord1, err := tailnet.NewPGCoord(ctx, logger, ps, store) require.NoError(t, err) defer coord1.Close() - coord2, err := tailnet.NewPGCoord(ctx, logger, pubsub, store) + coord2, err := tailnet.NewPGCoord(ctx, logger, ps, store) require.NoError(t, err) defer coord2.Close() - coord3, err := tailnet.NewPGCoord(ctx, logger, pubsub, store) + coord3, err := tailnet.NewPGCoord(ctx, logger, ps, store) require.NoError(t, err) defer coord3.Close() @@ -516,6 +519,76 @@ func TestPGCoordinator_MultiAgent(t *testing.T) { assertEventuallyNoAgents(ctx, t, store, agent1.id) } +func TestPGCoordinator_Unhealthy(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) + defer cancel() + ctrl := gomock.NewController(t) + mStore := dbmock.NewMockStore(ctrl) + ps := pubsub.NewInMemory() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + + calls := make(chan struct{}) + threeMissed := mStore.EXPECT().UpsertTailnetCoordinator(gomock.Any(), gomock.Any()). + Times(3). + Do(func(_ context.Context, _ uuid.UUID) { <-calls }). + Return(database.TailnetCoordinator{}, xerrors.New("test disconnect")) + mStore.EXPECT().UpsertTailnetCoordinator(gomock.Any(), gomock.Any()). + MinTimes(1). + After(threeMissed). + Do(func(_ context.Context, _ uuid.UUID) { <-calls }). + Return(database.TailnetCoordinator{}, nil) + // extra calls we don't particularly care about for this test + mStore.EXPECT().CleanTailnetCoordinators(gomock.Any()).AnyTimes().Return(nil) + mStore.EXPECT().GetTailnetClientsForAgent(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil) + mStore.EXPECT().DeleteTailnetAgent(gomock.Any(), gomock.Any()). + AnyTimes().Return(database.DeleteTailnetAgentRow{}, nil) + mStore.EXPECT().DeleteCoordinator(gomock.Any(), gomock.Any()).AnyTimes().Return(nil) + + uut, err := tailnet.NewPGCoord(ctx, logger, ps, mStore) + require.NoError(t, err) + defer func() { + err := uut.Close() + require.NoError(t, err) + }() + agent1 := newTestAgent(t, uut) + defer agent1.close() + for i := 0; i < 3; i++ { + select { + case <-ctx.Done(): + t.Fatal("timeout") + case calls <- struct{}{}: + // OK + } + } + // connected agent should be disconnected + agent1.waitForClose(ctx, t) + + // new agent should immediately disconnect + agent2 := newTestAgent(t, uut) + defer agent2.close() + agent2.waitForClose(ctx, t) + + // next heartbeats succeed, so we are healthy + for i := 0; i < 2; i++ { + select { + case <-ctx.Done(): + t.Fatal("timeout") + case calls <- struct{}{}: + // OK + } + } + agent3 := newTestAgent(t, uut) + defer agent3.close() + select { + case <-agent3.closeChan: + t.Fatal("agent conn closed after we are healthy") + case <-time.After(time.Second): + // OK + } +} + type testConn struct { ws, serverWS net.Conn nodeChan chan []*agpl.Node diff --git a/enterprise/wsproxy/appstatsreporter.go b/enterprise/wsproxy/appstatsreporter.go new file mode 100644 index 0000000000000..ba3cb92df93cc --- /dev/null +++ b/enterprise/wsproxy/appstatsreporter.go @@ -0,0 +1,21 @@ +package wsproxy + +import ( + "context" + + "github.com/coder/coder/coderd/workspaceapps" + "github.com/coder/coder/enterprise/wsproxy/wsproxysdk" +) + +var _ workspaceapps.StatsReporter = (*appStatsReporter)(nil) + +type appStatsReporter struct { + Client *wsproxysdk.Client +} + +func (r *appStatsReporter) Report(ctx context.Context, stats []workspaceapps.StatsReport) error { + err := r.Client.ReportAppStats(ctx, wsproxysdk.ReportAppStatsRequest{ + Stats: stats, + }) + return err +} diff --git a/enterprise/wsproxy/wsproxy.go b/enterprise/wsproxy/wsproxy.go index 843b79ee394bf..80414e40ceae6 100644 --- a/enterprise/wsproxy/wsproxy.go +++ b/enterprise/wsproxy/wsproxy.go @@ -79,6 +79,8 @@ type Options struct { // By default, CORs is set to accept external requests // from the dashboardURL. This should only be used in development. AllowAllCors bool + + StatsCollectorOptions workspaceapps.StatsCollectorOptions } func (o *Options) Validate() error { @@ -250,6 +252,7 @@ func New(ctx context.Context, opts *Options) (*Server, error) { connInfo.DERPMap, s.DialCoordinator, wsconncache.New(s.DialWorkspaceAgent, 0), + s.TracerProvider, ) if err != nil { return nil, xerrors.Errorf("create server tailnet: %w", err) @@ -261,8 +264,17 @@ func New(ctx context.Context, opts *Options) (*Server, error) { } } + workspaceAppsLogger := opts.Logger.Named("workspaceapps") + if opts.StatsCollectorOptions.Logger == nil { + named := workspaceAppsLogger.Named("stats_collector") + opts.StatsCollectorOptions.Logger = &named + } + if opts.StatsCollectorOptions.Reporter == nil { + opts.StatsCollectorOptions.Reporter = &appStatsReporter{Client: client} + } + s.AppServer = &workspaceapps.Server{ - Logger: opts.Logger.Named("workspaceapps"), + Logger: workspaceAppsLogger, DashboardURL: opts.DashboardURL, AccessURL: opts.AccessURL, Hostname: opts.AppHostname, @@ -278,9 +290,11 @@ func New(ctx context.Context, opts *Options) (*Server, error) { }, AppSecurityKey: secKey, - AgentProvider: agentProvider, DisablePathApps: opts.DisablePathApps, SecureAuthCookie: opts.SecureAuthCookie, + + AgentProvider: agentProvider, + StatsCollector: workspaceapps.NewStatsCollector(opts.StatsCollectorOptions), } derpHandler := derphttp.Handler(derpServer) diff --git a/enterprise/wsproxy/wsproxy_test.go b/enterprise/wsproxy/wsproxy_test.go index afcb3d1f16143..64fe414fe5fac 100644 --- a/enterprise/wsproxy/wsproxy_test.go +++ b/enterprise/wsproxy/wsproxy_test.go @@ -27,7 +27,6 @@ import ( "github.com/coder/coder/enterprise/coderd/coderdenttest" "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/tailnet" "github.com/coder/coder/testutil" ) @@ -203,10 +202,10 @@ resourceLoop: connInfo, err := client.WorkspaceAgentConnectionInfo(ctx, agentID) require.NoError(t, err) - // There should be three DERP servers in the map: the primary, and each - // of the two running proxies. + // There should be three DERP regions in the map: the primary, and each + // of the two running proxies. Also the STUN-only regions. require.NotNil(t, connInfo.DERPMap) - require.Len(t, connInfo.DERPMap.Regions, 3) + require.Len(t, connInfo.DERPMap.Regions, 3+len(api.DeploymentValues.DERP.Server.STUNAddresses.Value())) var ( primaryRegion *tailcfg.DERPRegion @@ -230,6 +229,11 @@ resourceLoop: // The last region is never started, which means it's never healthy, // which means it's never added to the DERP map. + if len(r.Nodes) == 1 && r.Nodes[0].STUNOnly { + // Skip STUN-only regions. + continue + } + t.Fatalf("unexpected region: %+v", r) } @@ -244,24 +248,24 @@ resourceLoop: require.Equal(t, "coder_best-proxy", proxy1Region.RegionCode) require.Equal(t, 10001, proxy1Region.RegionID) require.False(t, proxy1Region.EmbeddedRelay) - require.Len(t, proxy1Region.Nodes, 2) // proxy + stun - require.Equal(t, "10001a", proxy1Region.Nodes[1].Name) - require.Equal(t, 10001, proxy1Region.Nodes[1].RegionID) - require.Equal(t, proxyAPI1.Options.AccessURL.Hostname(), proxy1Region.Nodes[1].HostName) - require.Equal(t, proxyAPI1.Options.AccessURL.Port(), fmt.Sprint(proxy1Region.Nodes[1].DERPPort)) - require.Equal(t, proxyAPI1.Options.AccessURL.Scheme == "http", proxy1Region.Nodes[1].ForceHTTP) + require.Len(t, proxy1Region.Nodes, 1) + require.Equal(t, "10001a", proxy1Region.Nodes[0].Name) + require.Equal(t, 10001, proxy1Region.Nodes[0].RegionID) + require.Equal(t, proxyAPI1.Options.AccessURL.Hostname(), proxy1Region.Nodes[0].HostName) + require.Equal(t, proxyAPI1.Options.AccessURL.Port(), fmt.Sprint(proxy1Region.Nodes[0].DERPPort)) + require.Equal(t, proxyAPI1.Options.AccessURL.Scheme == "http", proxy1Region.Nodes[0].ForceHTTP) // The second proxy region: require.Equal(t, "worst-proxy", proxy2Region.RegionName) require.Equal(t, "coder_worst-proxy", proxy2Region.RegionCode) require.Equal(t, 10002, proxy2Region.RegionID) require.False(t, proxy2Region.EmbeddedRelay) - require.Len(t, proxy2Region.Nodes, 2) // proxy + stun - require.Equal(t, "10002a", proxy2Region.Nodes[1].Name) - require.Equal(t, 10002, proxy2Region.Nodes[1].RegionID) - require.Equal(t, proxyAPI2.Options.AccessURL.Hostname(), proxy2Region.Nodes[1].HostName) - require.Equal(t, proxyAPI2.Options.AccessURL.Port(), fmt.Sprint(proxy2Region.Nodes[1].DERPPort)) - require.Equal(t, proxyAPI2.Options.AccessURL.Scheme == "http", proxy2Region.Nodes[1].ForceHTTP) + require.Len(t, proxy2Region.Nodes, 1) + require.Equal(t, "10002a", proxy2Region.Nodes[0].Name) + require.Equal(t, 10002, proxy2Region.Nodes[0].RegionID) + require.Equal(t, proxyAPI2.Options.AccessURL.Hostname(), proxy2Region.Nodes[0].HostName) + require.Equal(t, proxyAPI2.Options.AccessURL.Port(), fmt.Sprint(proxy2Region.Nodes[0].DERPPort)) + require.Equal(t, proxyAPI2.Options.AccessURL.Scheme == "http", proxy2Region.Nodes[0].ForceHTTP) }) t.Run("ConnectDERP", func(t *testing.T) { @@ -270,11 +274,15 @@ resourceLoop: connInfo, err := client.WorkspaceAgentConnectionInfo(testutil.Context(t, testutil.WaitLong), agentID) require.NoError(t, err) require.NotNil(t, connInfo.DERPMap) - require.Len(t, connInfo.DERPMap.Regions, 3) + require.Len(t, connInfo.DERPMap.Regions, 3+len(api.DeploymentValues.DERP.Server.STUNAddresses.Value())) // Connect to each region. for _, r := range connInfo.DERPMap.Regions { r := r + if len(r.Nodes) == 1 && r.Nodes[0].STUNOnly { + // Skip STUN-only regions. + continue + } t.Run(r.RegionName, func(t *testing.T) { t.Parallel() @@ -311,132 +319,6 @@ resourceLoop: }) } -func TestDERPMapStunNodes(t *testing.T) { - t.Parallel() - - deploymentValues := coderdtest.DeploymentValues(t) - deploymentValues.Experiments = []string{ - string(codersdk.ExperimentMoons), - "*", - } - stunAddresses := []string{ - "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", - } - deploymentValues.DERP.Server.STUNAddresses = stunAddresses - - client, closer, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - DeploymentValues: deploymentValues, - AppHostname: "*.primary.test.coder.com", - IncludeProvisionerDaemon: true, - RealIPConfig: &httpmw.RealIPConfig{ - TrustedOrigins: []*net.IPNet{{ - IP: net.ParseIP("127.0.0.1"), - Mask: net.CIDRMask(8, 32), - }}, - TrustedHeaders: []string{ - "CF-Connecting-IP", - }, - }, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureWorkspaceProxy: 1, - }, - }, - }) - t.Cleanup(func() { - _ = closer.Close() - }) - - // Create a running external proxy. - _ = coderdenttest.NewWorkspaceProxy(t, api, client, &coderdenttest.ProxyOptions{ - Name: "cool-proxy", - }) - - // Wait for both running proxies to become healthy. - require.Eventually(t, func() bool { - healthCtx := testutil.Context(t, testutil.WaitLong) - err := api.ProxyHealth.ForceUpdate(healthCtx) - if !assert.NoError(t, err) { - return false - } - - regions, err := client.Regions(healthCtx) - if !assert.NoError(t, err) { - return false - } - if !assert.Len(t, regions, 2) { - return false - } - - // All regions should be healthy. - for _, r := range regions { - if !r.Healthy { - return false - } - } - return true - }, testutil.WaitLong, testutil.IntervalMedium) - - // Get the DERP map and ensure that the built-in region and the proxy region - // both have the STUN nodes. - ctx := testutil.Context(t, testutil.WaitLong) - connInfo, err := client.WorkspaceAgentConnectionInfoGeneric(ctx) - require.NoError(t, err) - - // There should be two DERP servers in the map: the primary and the - // proxy. - require.NotNil(t, connInfo.DERPMap) - require.Len(t, connInfo.DERPMap.Regions, 2) - - var ( - primaryRegion *tailcfg.DERPRegion - proxyRegion *tailcfg.DERPRegion - ) - for _, r := range connInfo.DERPMap.Regions { - if r.EmbeddedRelay { - primaryRegion = r - continue - } - if r.RegionName == "cool-proxy" { - proxyRegion = r - continue - } - - t.Fatalf("unexpected region: %+v", r) - } - - // The primary region: - require.Equal(t, "Coder Embedded Relay", primaryRegion.RegionName) - require.Equal(t, "coder", primaryRegion.RegionCode) - require.Equal(t, 999, primaryRegion.RegionID) - require.True(t, primaryRegion.EmbeddedRelay) - require.Len(t, primaryRegion.Nodes, len(stunAddresses)+1) - - // The proxy region: - require.Equal(t, "cool-proxy", proxyRegion.RegionName) - require.Equal(t, "coder_cool-proxy", proxyRegion.RegionCode) - require.Equal(t, 10001, proxyRegion.RegionID) - require.False(t, proxyRegion.EmbeddedRelay) - require.Len(t, proxyRegion.Nodes, len(stunAddresses)+1) - - for _, region := range []*tailcfg.DERPRegion{primaryRegion, proxyRegion} { - stunNodes, err := tailnet.STUNNodes(region.RegionID, stunAddresses) - require.NoError(t, err) - require.Len(t, stunNodes, len(stunAddresses)) - - require.Equal(t, stunNodes, region.Nodes[:len(stunNodes)]) - - // The last node should be the Coder server. - require.NotZero(t, region.Nodes[len(region.Nodes)-1].DERPPort) - } -} - func TestDERPEndToEnd(t *testing.T) { t.Parallel() @@ -596,6 +478,7 @@ func TestWorkspaceProxyWorkspaceApps_Wsconncache(t *testing.T) { "CF-Connecting-IP", }, }, + WorkspaceAppsStatsCollectorOptions: opts.StatsCollectorOptions, }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ @@ -654,6 +537,7 @@ func TestWorkspaceProxyWorkspaceApps_SingleTailnet(t *testing.T) { "CF-Connecting-IP", }, }, + WorkspaceAppsStatsCollectorOptions: opts.StatsCollectorOptions, }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ diff --git a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go index 6d1a4b1227b5d..d9b60d311eb0b 100644 --- a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go +++ b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go @@ -152,6 +152,25 @@ func (c *Client) IssueSignedAppTokenHTML(ctx context.Context, rw http.ResponseWr return res, true } +type ReportAppStatsRequest struct { + Stats []workspaceapps.StatsReport `json:"stats"` +} + +// ReportAppStats reports the given app stats to the primary coder server. +func (c *Client) ReportAppStats(ctx context.Context, req ReportAppStatsRequest) error { + resp, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceproxies/me/app-stats", req) + if err != nil { + return xerrors.Errorf("make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return codersdk.ReadBodyAsError(resp) + } + + return nil +} + type RegisterWorkspaceProxyRequest struct { // AccessURL that hits the workspace proxy api. AccessURL string `json:"access_url"` diff --git a/examples/parameters/README.md b/examples/parameters/README.md new file mode 100644 index 0000000000000..8ebd3ee3c8b50 --- /dev/null +++ b/examples/parameters/README.md @@ -0,0 +1,19 @@ +--- +name: Sample Template with Parameters +description: Review the sample template and introduce parameters to your template +tags: [local, docker, parameters] +icon: /icon/docker.png +--- + +# Overview + +This Coder template presents various features of [rich parameters](https://coder.com/docs/v2/latest/templates/parameters), including types, validation constraints, +mutability, ephemeral (one-time) parameters, etc. + +## Development + +Update the template and push it using the following command: + +```bash +./scripts/coder-dev.sh templates push examples-parameters -d examples/parameters --create +``` diff --git a/examples/templates/jfrog-docker/build/Dockerfile b/examples/parameters/build/Dockerfile similarity index 82% rename from examples/templates/jfrog-docker/build/Dockerfile rename to examples/parameters/build/Dockerfile index 1dfaa77015f32..a443b5d07100e 100644 --- a/examples/templates/jfrog-docker/build/Dockerfile +++ b/examples/parameters/build/Dockerfile @@ -8,14 +8,11 @@ RUN apt-get update \ sudo \ vim \ wget \ - npm \ && rm -rf /var/lib/apt/lists/* ARG USER=coder RUN useradd --groups sudo --no-create-home --shell /bin/bash ${USER} \ && echo "${USER} ALL=(ALL) NOPASSWD:ALL" >/etc/sudoers.d/${USER} \ && chmod 0440 /etc/sudoers.d/${USER} -RUN curl -fL https://install-cli.jfrog.io | sh -RUN chmod 755 $(which jf) USER ${USER} WORKDIR /home/${USER} diff --git a/examples/parameters/main.tf b/examples/parameters/main.tf new file mode 100644 index 0000000000000..0903a2d2e6475 --- /dev/null +++ b/examples/parameters/main.tf @@ -0,0 +1,281 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 0.11.1" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0.1" + } + } +} + +locals { + username = data.coder_workspace.me.owner +} + +data "coder_provisioner" "me" { +} + +provider "docker" { +} + +data "coder_workspace" "me" { +} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + 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 --version 4.11.0 + /tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 & + EOT +} + +resource "coder_app" "code-server" { + agent_id = coder_agent.main.id + slug = "code-server" + display_name = "code-server" + url = "http://localhost:13337/?folder=/home/${local.username}" + icon = "/icon/code.svg" + subdomain = false + share = "owner" + + healthcheck { + url = "http://localhost:13337/healthz" + interval = 5 + threshold = 6 + } +} + +resource "docker_volume" "home_volume" { + name = "coder-${data.coder_workspace.me.id}-home" + # Protect the volume from being deleted due to changes in attributes. + lifecycle { + ignore_changes = all + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace.me.owner + } + labels { + label = "coder.owner_id" + value = data.coder_workspace.me.owner_id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + # This field becomes outdated if the workspace is renamed but can + # be useful for debugging or cleaning out dangling volumes. + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} + +resource "docker_image" "main" { + name = "coder-${data.coder_workspace.me.id}" + build { + context = "./build" + build_args = { + USER = local.username + } + } + triggers = { + dir_sha1 = sha1(join("", [for f in fileset(path.module, "build/*") : filesha1(f)])) + } +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = docker_image.main.name + # Uses lower() to avoid Docker restriction on container names. + name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" + # Hostname makes the shell more user friendly: coder@my-workspace:~$ + hostname = data.coder_workspace.me.name + # Use the docker gateway if the access URL is 127.0.0.1 + entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] + env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] + host { + host = "host.docker.internal" + ip = "host-gateway" + } + volumes { + container_path = "/home/${local.username}" + volume_name = docker_volume.home_volume.name + read_only = false + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace.me.owner + } + labels { + label = "coder.owner_id" + value = data.coder_workspace.me.owner_id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name + } +} + +// Rich parameters +// See: https://coder.com/docs/v2/latest/templates/parameters + +data "coder_parameter" "project_id" { + name = "project_id" + display_name = "My Project ID" + icon = "/emojis/1fab5.png" + description = "Specify the project ID to deploy in workspace." + default = "A1B2C3" + mutable = true + validation { + regex = "^[A-Z0-9]+$" + error = "Project ID is incorrect" + } + + order = 1 +} + +data "coder_parameter" "region" { + name = "region" + display_name = "Region" + icon = "/emojis/1f30e.png" + description = "Select the region in which you would like to deploy your workspace." + default = "eu-helsinki" + option { + icon = "/emojis/1f1fa-1f1f8.png" + name = "Pittsburgh" + description = "Pittsburgh is a city in the Commonwealth of Pennsylvania and the county seat of Allegheny County." + value = "us-pittsburgh" + } + option { + icon = "/emojis/1f1eb-1f1ee.png" + name = "Helsinki" + description = "Helsinki, the capital city of Finland, is renowned for its vibrant cultural scene, stunning waterfront architecture, and a harmonious blend of modernity and natural beauty." + value = "eu-helsinki" + } + option { + icon = "/emojis/1f1e6-1f1fa.png" + name = "Sydney" + description = "Sydney, the largest city in Australia, captivates with its iconic Sydney Opera House, picturesque harbor, and diverse neighborhoods, making it a captivating blend of urban sophistication and coastal charm." + value = "ap-sydney" + } + + order = 1 +} + +data "coder_parameter" "apps_dir" { + name = "apps_dir" + display_name = "Apps Directory" + icon = "/emojis/1f9ba.png" + type = "string" + description = "Specify the directory to install project applications and tools." + default = "/var/apps" + + order = 2 +} + +data "coder_parameter" "worker_instances" { + name = "worker_instances" + display_name = "Worker Instances" + icon = "/emojis/2697.png" + type = "number" + description = "Specify the number of worker instances to spawn." + default = "3" + mutable = true + validation { + min = 3 + max = 12 + monotonic = "increasing" + } + order = 2 +} + +data "coder_parameter" "security_groups" { + name = "security_groups" + display_name = "Security Groups" + icon = "/emojis/26f4.png" + type = "list(string)" + description = "Select relevant security groups." + mutable = true + default = jsonencode([ + "Web Server Security Group", + "Database Security Group", + "Backend Security Group" + ]) + order = 2 +} + +data "coder_parameter" "docker_image" { + name = "docker_image" + display_name = "Docker Image" + mutable = true + type = "string" + description = "Docker image for the development container" + default = "ghcr.io/coder/coder-preview:main" + + order = 3 +} + +data "coder_parameter" "command_line_args" { + name = "command_line_args" + display_name = "Extra command line args" + type = "string" + default = "" + description = "Provide extra command line args for the startup script." + mutable = true + order = 80 +} + +data "coder_parameter" "enable_monitoring" { + name = "enable_monitoring" + display_name = "Enable Workspace Monitoring" + type = "bool" + description = "This monitoring functionality empowers you to closely track the health and resource utilization of your instance in real-time." + mutable = true + order = 90 +} + +// Build options (ephemeral parameters) +// See: https://coder.com/docs/v2/latest/templates/parameters#ephemeral-parameters + +data "coder_parameter" "pause-startup" { + name = "pause-startup" + display_name = "Pause startup script" + type = "number" + description = "Pause the startup script (seconds)" + default = "1" + mutable = true + ephemeral = true + validation { + min = 0 + max = 300 + } + + order = 4 +} + +data "coder_parameter" "force-rebuild" { + name = "force-rebuild" + display_name = "Force rebuild project" + type = "bool" + description = "Rebuild the workspace project" + default = "false" + mutable = true + ephemeral = true + + order = 4 +} diff --git a/examples/templates/community-templates.md b/examples/templates/community-templates.md index 53b10dddcb0a0..5554738611487 100644 --- a/examples/templates/community-templates.md +++ b/examples/templates/community-templates.md @@ -14,6 +14,7 @@ See [Getting Started](./README.md#getting-started) for how to use these template - [m.lan/coder-templates](https://gitlab.com/m.lan/coder-templates) - Kubernetes template with DinD. - [jsjoeio/coder-templates](https://github.com/jsjoeio/coder-templates) - Docker templates that prompt for dotfiles and base Docker image. - [sharkymark/v2-templates](https://github.com/sharkymark/v2-templates) - Kubernetes, Docker, AWS, Google Cloud, Azure templates, videos, emoji links, and API examples. +- [bpmct/coder-templates](https://github.com/bpmct/coder-templates) - Kubernetes, OpenStack, podman, Docker, VM, AWS, Google Cloud, Azure templates. - [kozmiknano/vscode-server-template](https://github.com/KozmikNano/vscode-server-template) - Run the full VS Code server within docker! (Built-in settings sync and Microsoft Marketplace enabled) - [atnomoverflow/coder-template](https://github.com/atnomoverflow/coder-template) - Kubernetes template that install VS code server Rstudio jupyter and also set ssh access to gitlab (Works also on self managed gitlab). diff --git a/examples/templates/devcontainer-docker/main.tf b/examples/templates/devcontainer-docker/main.tf index 8769ff1f07078..5941b0c6b9450 100644 --- a/examples/templates/devcontainer-docker/main.tf +++ b/examples/templates/devcontainer-docker/main.tf @@ -206,7 +206,9 @@ data "coder_parameter" "custom_repo_url" { resource "docker_container" "workspace" { count = data.coder_workspace.me.start_count - image = "ghcr.io/coder/envbuilder:0.1.3" + # Find the latest version here: + # https://github.com/coder/envbuilder/tags + image = "ghcr.io/coder/envbuilder:0.2.1" # Uses lower() to avoid Docker restriction on container names. name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" # Hostname makes the shell more user friendly: coder@my-workspace:~$ diff --git a/examples/templates/devcontainer-kubernetes/main.tf b/examples/templates/devcontainer-kubernetes/main.tf index 65694c25989a9..58c183e5be173 100644 --- a/examples/templates/devcontainer-kubernetes/main.tf +++ b/examples/templates/devcontainer-kubernetes/main.tf @@ -187,8 +187,10 @@ resource "kubernetes_deployment" "workspace" { } spec { container { - name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" - image = "ghcr.io/coder/envbuilder:0.1.3" + name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" + # Find the latest version here: + # https://github.com/coder/envbuilder/tags + image = "ghcr.io/coder/envbuilder:0.2.1" env { name = "CODER_AGENT_TOKEN" value = coder_agent.main.token diff --git a/examples/templates/jfrog-docker/README.md b/examples/templates/jfrog/docker/README.md similarity index 97% rename from examples/templates/jfrog-docker/README.md rename to examples/templates/jfrog/docker/README.md index ac1a3a128643f..4db4676e8a43d 100644 --- a/examples/templates/jfrog-docker/README.md +++ b/examples/templates/jfrog/docker/README.md @@ -5,7 +5,7 @@ tags: [local, docker, jfrog] icon: /icon/docker.png --- -# jfrog-docker +# docker To get started, run `coder templates init`. When prompted, select this template. Follow the on-screen instructions to proceed. diff --git a/examples/templates/jfrog/docker/build/Dockerfile b/examples/templates/jfrog/docker/build/Dockerfile new file mode 100644 index 0000000000000..2d966c10cffa2 --- /dev/null +++ b/examples/templates/jfrog/docker/build/Dockerfile @@ -0,0 +1,28 @@ +FROM ubuntu + +RUN apt-get update \ + && apt-get install -y \ + curl \ + git \ + python3-pip \ + sudo \ + vim \ + wget \ + npm \ + && rm -rf /var/lib/apt/lists/* + +ARG GO_VERSION=1.20.7 +RUN mkdir --parents /usr/local/go && curl --silent --show-error --location \ + "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" -o /usr/local/go.tar.gz && \ + tar --extract --gzip --directory=/usr/local/go --file=/usr/local/go.tar.gz --strip-components=1 + +ENV PATH=$PATH:/usr/local/go/bin + +ARG USER=coder +RUN useradd --groups sudo --no-create-home --shell /bin/bash ${USER} \ + && echo "${USER} ALL=(ALL) NOPASSWD:ALL" >/etc/sudoers.d/${USER} \ + && chmod 0440 /etc/sudoers.d/${USER} +RUN curl -fL https://install-cli.jfrog.io | sh +RUN chmod 755 $(which jf) +USER ${USER} +WORKDIR /home/${USER} diff --git a/examples/templates/jfrog-docker/main.tf b/examples/templates/jfrog/docker/main.tf similarity index 51% rename from examples/templates/jfrog-docker/main.tf rename to examples/templates/jfrog/docker/main.tf index 0c409d5ebe54b..f5bcb6728cf59 100644 --- a/examples/templates/jfrog-docker/main.tf +++ b/examples/templates/jfrog/docker/main.tf @@ -10,13 +10,21 @@ terraform { } artifactory = { source = "registry.terraform.io/jfrog/artifactory" - version = "6.22.3" + version = "~> 8.4.0" } } } locals { - username = data.coder_workspace.me.owner + # take care to use owner_email instead of owner because users can change + # their username. + artifactory_username = data.coder_workspace.me.owner_email + artifactory_repository_keys = { + "npm" = "npm" + "python" = "python" + "go" = "go" + } + workspace_user = data.coder_workspace.me.owner } data "coder_provisioner" "me" { @@ -28,9 +36,9 @@ provider "docker" { data "coder_workspace" "me" { } -variable "jfrog_url" { +variable "jfrog_host" { type = string - description = "The URL of the JFrog instance." + description = "JFrog instance hostname. For example, 'YYY.jfrog.io'." } variable "artifactory_access_token" { @@ -38,17 +46,16 @@ variable "artifactory_access_token" { description = "The admin-level access token to use for JFrog." } - # Configure the Artifactory provider provider "artifactory" { - url = "${var.jfrog_url}/artifactory" + url = "https://${var.jfrog_host}/artifactory" access_token = var.artifactory_access_token } -resource "artifactory_access_token" "me" { - username = data.coder_workspace.me.owner_email - # The token should live for the duration of the workspace. - end_date_relative = "0s" +resource "artifactory_scoped_token" "me" { + # This is hacky, but on terraform plan the data source gives empty strings, + # which fails validation. + username = length(local.artifactory_username) > 0 ? local.artifactory_username : "plan" } resource "coder_agent" "main" { @@ -62,28 +69,53 @@ resource "coder_agent" "main" { curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/tmp/code-server --version 4.11.0 /tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 & + # Install the JFrog VS Code extension. + # Find the latest version number at + # https://open-vsx.org/extension/JFrog/jfrog-vscode-extension. + JFROG_EXT_VERSION=2.4.1 + curl -o /tmp/jfrog.vsix -L "https://open-vsx.org/api/JFrog/jfrog-vscode-extension/$JFROG_EXT_VERSION/file/JFrog.jfrog-vscode-extension-$JFROG_EXT_VERSION.vsix" + /tmp/code-server/bin/code-server --install-extension /tmp/jfrog.vsix + # The jf CLI checks $CI when determining whether to use interactive # flows. export CI=true jf c rm 0 || true - echo ${artifactory_access_token.me.access_token} | \ - jf c add --access-token-stdin --url ${var.jfrog_url} 0 + echo ${artifactory_scoped_token.me.access_token} | \ + jf c add --access-token-stdin --url https://${var.jfrog_host} 0 - # Configure the `npm` CLI to use the Artifactory "npm" registry. + # Configure the `npm` CLI to use the Artifactory "npm" repository. cat << EOF > ~/.npmrc email = ${data.coder_workspace.me.owner_email} - registry=${var.jfrog_url}/artifactory/api/npm/npm/ + registry = https://${var.jfrog_host}/artifactory/api/npm/${local.artifactory_repository_keys["npm"]} EOF jf rt curl /api/npm/auth >> .npmrc + + # Configure the `pip` to use the Artifactory "python" repository. + mkdir -p ~/.pip + cat << EOF > ~/.pip/pip.conf + [global] + index-url = https://${local.artifactory_username}:${artifactory_scoped_token.me.access_token}@${var.jfrog_host}/artifactory/api/pypi/${local.artifactory_repository_keys["python"]}/simple + EOF + EOT + # Set GOPROXY to use the Artifactory "go" repository. + env = { + GOPROXY : "https://${local.artifactory_username}:${artifactory_scoped_token.me.access_token}@${var.jfrog_host}/artifactory/api/go/${local.artifactory_repository_keys["go"]}" + # Authenticate with JFrog extension. + JFROG_IDE_URL : "https://${var.jfrog_host}" + JFROG_IDE_USERNAME : "${local.artifactory_username}" + JFROG_IDE_PASSWORD : "${artifactory_scoped_token.me.access_token}" + JFROG_IDE_ACCESS_TOKEN : "${artifactory_scoped_token.me.access_token}" + JFROG_IDE_STORE_CONNECTION : "true" + } } resource "coder_app" "code-server" { agent_id = coder_agent.main.id slug = "code-server" display_name = "code-server" - url = "http://localhost:13337/?folder=/home/${local.username}" + url = "http://localhost:13337/?folder=/home/${local.workspace_user}" icon = "/icon/code.svg" subdomain = false share = "owner" @@ -106,13 +138,13 @@ resource "docker_volume" "home_volume" { resource "docker_image" "main" { name = "coder-${data.coder_workspace.me.id}" build { - context = "./build" + context = "${path.module}/build" build_args = { - USER = local.username + USER = local.workspace_user } } triggers = { - dir_sha1 = sha1(join("", [for f in fileset(path.module, "build/*") : filesha1(f)])) + dir_sha1 = sha1(join("", [for f in fileset(path.module, "build/*") : filesha1("${path.module}/${f}")])) } } @@ -130,7 +162,7 @@ resource "docker_container" "workspace" { ip = "host-gateway" } volumes { - container_path = "/home/${local.username}" + container_path = "/home/${local.workspace_user}" volume_name = docker_volume.home_volume.name read_only = false } diff --git a/examples/templates/jfrog/remote/main.tf b/examples/templates/jfrog/remote/main.tf new file mode 100644 index 0000000000000..77fd75ed55b56 --- /dev/null +++ b/examples/templates/jfrog/remote/main.tf @@ -0,0 +1,16 @@ +module "docker" { + source = "cdr.jfrog.io/tf__main/docker/docker" + jfrog_host = var.jfrog_host + artifactory_access_token = var.artifactory_access_token +} + +variable "jfrog_host" { + type = string + description = "JFrog instance hostname. For example, 'YYY.jfrog.io'." +} + +variable "artifactory_access_token" { + type = string + description = "The admin-level access token to use for JFrog." +} + diff --git a/flake.nix b/flake.nix index d2e7cd492dd7a..18d06adc4a98f 100644 --- a/flake.nix +++ b/flake.nix @@ -44,6 +44,7 @@ postgresql protoc-gen-go ripgrep + screen shellcheck shfmt sqlc diff --git a/go.mod b/go.mod index fa411a3263e27..481649a867911 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,14 @@ replace github.com/dlclark/regexp2 => github.com/dlclark/regexp2 v1.7.0 // There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here: // https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main -replace tailscale.com => github.com/coder/tailscale v0.0.0-20230731105344-d1b7f8087191 +replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20230815060514-ebed8c967bd2 + +// This is replaced to include a fix that causes a deadlock when closing the +// wireguard network. +// The branch used is from https://github.com/coder/wireguard-go/tree/colin/tailscale +// It is based on https://github.com/tailscale/wireguard-go/tree/tailscale, but +// includes the upstream fix https://github.com/WireGuard/wireguard-go/commit/b7cd547315bed421a648d0a0f1ee5a0fc1b1151e +replace github.com/tailscale/wireguard-go => github.com/coder/wireguard-go v0.0.0-20230807234434-d825b45ccbf5 // Use our tempfork of gvisor that includes a fix for TCP connection stalls: // https://github.com/coder/coder/issues/7388 @@ -71,7 +78,7 @@ require ( github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/bep/debounce v1.2.1 - github.com/bgentry/speakeasy v0.1.0 + github.com/bgentry/speakeasy v0.1.1-0.20220910012023-760eaf8b6816 github.com/bramvdbogaerde/go-scp v1.2.1-0.20221219230748-977ee74ac37b github.com/briandowns/spinner v1.18.1 github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 @@ -100,7 +107,6 @@ require ( github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa github.com/gen2brain/beeep v0.0.0-20220402123239-6a3042f4b71a github.com/gliderlabs/ssh v0.3.4 - github.com/go-chi/chi v1.5.4 github.com/go-chi/chi/v5 v5.0.8 github.com/go-chi/cors v1.2.1 github.com/go-chi/httprate v0.7.1 @@ -108,15 +114,16 @@ require ( github.com/go-jose/go-jose/v3 v3.0.0 github.com/go-logr/logr v1.2.4 github.com/go-ping/ping v1.1.0 - github.com/go-playground/validator/v10 v10.14.0 + github.com/go-playground/validator/v10 v10.15.0 github.com/gofrs/flock v0.8.1 - github.com/gohugoio/hugo v0.116.0 + github.com/gohugoio/hugo v0.117.0 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-migrate/migrate/v4 v4.16.0 github.com/golang/mock v1.6.0 github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405 github.com/google/uuid v1.3.0 + github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-reap v0.0.0-20170704170343-bf58d8a43e7b github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/golang-lru/v2 v2.0.1 @@ -130,8 +137,8 @@ require ( github.com/jmoiron/sqlx v1.3.5 github.com/justinas/nosurf v1.1.1 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f - github.com/klauspost/compress v1.16.3 - github.com/lib/pq v1.10.6 + github.com/klauspost/compress v1.16.5 + github.com/lib/pq v1.10.9 github.com/mattn/go-isatty v0.0.19 github.com/mitchellh/go-wordwrap v1.0.1 github.com/mitchellh/mapstructure v1.5.0 @@ -144,7 +151,7 @@ require ( github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e github.com/pkg/sftp v1.13.6-0.20221018182125-7da137aa03f0 github.com/prometheus/client_golang v1.16.0 - github.com/prometheus/client_model v0.3.0 + github.com/prometheus/client_model v0.4.0 github.com/prometheus/common v0.42.0 github.com/quasilyte/go-ruleguard/dsl v0.3.21 github.com/robfig/cron/v3 v3.0.1 @@ -168,40 +175,41 @@ require ( go.opentelemetry.io/otel/trace v1.16.0 go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.2.1 - go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf - golang.org/x/crypto v0.11.0 - golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 + go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 + golang.org/x/crypto v0.12.0 + golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b golang.org/x/mod v0.12.0 - golang.org/x/net v0.12.0 - golang.org/x/oauth2 v0.10.0 + golang.org/x/net v0.14.0 + golang.org/x/oauth2 v0.11.0 golang.org/x/sync v0.3.0 - golang.org/x/sys v0.10.0 - golang.org/x/term v0.10.0 - golang.org/x/tools v0.11.0 + golang.org/x/sys v0.11.0 + golang.org/x/term v0.11.0 + golang.org/x/text v0.12.0 + golang.org/x/tools v0.12.0 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b - google.golang.org/api v0.134.0 + google.golang.org/api v0.137.0 google.golang.org/grpc v1.57.0 google.golang.org/protobuf v1.31.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 - gvisor.dev/gvisor v0.0.0-20221203005347-703fd9b7fbc0 + gvisor.dev/gvisor v0.0.0-20230504175454-7b0a1988a28f nhooyr.io/websocket v1.8.7 storj.io/drpc v0.0.33-0.20230420154621-9716137f6037 - tailscale.com v1.32.3 + tailscale.com v1.46.1 ) require ( cloud.google.com/go/compute v1.23.0 // indirect cloud.google.com/go/logging v1.7.0 // indirect cloud.google.com/go/longrunning v0.5.1 // indirect - filippo.io/edwards25519 v1.0.0-rc.1 // indirect + filippo.io/edwards25519 v1.0.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect - github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/agnivade/levenshtein v1.1.1 // indirect github.com/akutz/memconn v0.1.0 // indirect @@ -210,6 +218,19 @@ require ( github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.20.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.18.32 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.13.31 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.38 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.31 // indirect + github.com/aws/aws-sdk-go-v2/service/ssm v1.37.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.21.1 // indirect + github.com/aws/smithy-go v1.14.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -219,14 +240,14 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/charmbracelet/bubbles v0.15.0 // indirect github.com/charmbracelet/bubbletea v0.23.2 // indirect - github.com/clbanning/mxj/v2 v2.5.7 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.3.3 // indirect github.com/containerd/console v1.0.3 // indirect github.com/containerd/continuity v0.4.1 // indirect github.com/coreos/go-iptables v0.6.0 // indirect github.com/dlclark/regexp2 v1.10.0 // indirect - github.com/docker/cli v20.10.17+incompatible // indirect - github.com/docker/docker v23.0.3+incompatible // indirect + github.com/docker/cli v23.0.5+incompatible // indirect + github.com/docker/docker v23.0.5+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/elastic/go-windows v1.0.0 // indirect @@ -234,14 +255,17 @@ require ( github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/gin-gonic/gin v1.9.1 // indirect + github.com/go-chi/chi v1.5.4 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/spec v0.20.6 // indirect - github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-openapi/swag v0.22.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-sql-driver/mysql v1.7.1 // indirect github.com/go-test/deep v1.0.8 // indirect github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect github.com/gobwas/glob v0.2.3 // indirect @@ -255,17 +279,18 @@ require ( github.com/google/flatbuffers v23.1.21+incompatible // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/s2a-go v0.1.4 // indirect + github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c // indirect + github.com/google/s2a-go v0.1.5 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect github.com/gorilla/css v1.0.0 // indirect + github.com/gorilla/mux v1.8.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.15.1 // indirect github.com/h2non/filetype v1.1.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 // indirect github.com/hashicorp/go-hclog v1.2.1 // indirect - github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.17.0 // indirect @@ -273,14 +298,15 @@ require ( github.com/hashicorp/terraform-plugin-go v0.12.0 // indirect github.com/hashicorp/terraform-plugin-log v0.7.0 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.20.0 // indirect - github.com/hdevalence/ed25519consensus v0.0.0-20220222234857-c00d1f31bab3 // indirect + github.com/hdevalence/ed25519consensus v0.1.0 // indirect github.com/illarion/gonotify v1.0.1 // indirect - github.com/imdario/mergo v0.3.13 // indirect - github.com/insomniacslk/dhcp v0.0.0-20221215072855-de60144f33f8 // indirect + github.com/imdario/mergo v0.3.15 // indirect + github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect - github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b // indirect + github.com/jsimonetti/rtnetlink v1.3.2 // indirect github.com/juju/errors v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect @@ -292,13 +318,13 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect - github.com/mdlayher/genetlink v1.2.0 // indirect - github.com/mdlayher/netlink v1.6.2 // indirect + github.com/mdlayher/genetlink v1.3.2 // indirect + github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/sdnotify v1.0.0 // indirect - github.com/mdlayher/socket v0.2.3 // indirect + github.com/mdlayher/socket v0.4.1 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/microcosm-cc/bluemonday v1.0.23 // indirect - github.com/miekg/dns v1.1.45 // indirect + github.com/miekg/dns v1.1.55 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect @@ -313,7 +339,8 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc4 // indirect github.com/opencontainers/runc v1.1.5 // indirect - github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pelletier/go-toml/v2 v2.0.9 // indirect + github.com/pierrec/lz4/v4 v4.1.17 // indirect github.com/pion/transport v0.14.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -326,18 +353,18 @@ require ( github.com/swaggo/files/v2 v2.0.0 // indirect github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d // indirect - github.com/tailscale/golang-x-crypto v0.0.0-20221102133106-bc99ab8c2d17 // indirect + github.com/tailscale/golang-x-crypto v0.0.0-20230713185742-f0b76a10a08e // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect - github.com/tailscale/wireguard-go v0.0.0-20221219190806-4fa124729667 // indirect + github.com/tailscale/wireguard-go v0.0.0-20230710185534-bb2c8f22eccf // indirect github.com/tchap/go-patricia/v2 v2.3.1 // indirect github.com/tcnksm/go-httpstat v0.2.0 // indirect github.com/tdewolff/parse/v2 v2.6.6 // indirect github.com/tdewolff/test v1.0.9 // indirect - github.com/u-root/uio v0.0.0-20221213070652-c3537552635f // indirect + github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect github.com/ulikunitz/xz v0.5.11 // indirect github.com/vishvananda/netlink v1.2.1-beta.2 // indirect - github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect + github.com/vishvananda/netns v0.0.4 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v4 v4.3.12 // indirect github.com/vmihailenco/tagparser v0.1.1 // indirect @@ -355,22 +382,16 @@ require ( go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.16.0 // indirect go.opentelemetry.io/otel/metric v1.16.0 // indirect go.opentelemetry.io/proto/otlp v0.19.0 // indirect - go4.org/mem v0.0.0-20210711025021-927187094b94 // indirect - golang.org/x/text v0.11.0 + go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect golang.org/x/time v0.3.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230215201556-9c5414ab4bde // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20230720185612-659f7aaaa771 // indirect + google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20230803162519-f966b187b2e5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230807174057-1744710a1577 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect howett.net/plist v1.0.0 // indirect inet.af/peercred v0.0.0-20210906144145-0893ea02156a // indirect ) - -require ( - github.com/go-ini/ini v1.67.0 // indirect - github.com/gorilla/mux v1.8.0 // indirect -) diff --git a/go.sum b/go.sum index 7e721762b664b..6951d73fcdccf 100644 --- a/go.sum +++ b/go.sum @@ -46,15 +46,14 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU= -filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= -filippo.io/mkcert v1.4.3 h1:axpnmtrZMM8u5Hf4N3UXxboGemMOV+Tn+e+pkHM6E3o= +filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek= +filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= github.com/AlecAivazis/survey/v2 v2.3.5 h1:A8cYupsAZkjaUmhtTYv3sSqc7LO5mp1XDfqe5E/9wRQ= github.com/AlecAivazis/survey/v2 v2.3.5/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -68,8 +67,8 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= -github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756 h1:L6S7kR7SlhQKplIBpkra3s6yhcZV51lhRnXmYc4HohI= +github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= @@ -109,6 +108,32 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= +github.com/aws/aws-sdk-go-v2 v1.20.0 h1:INUDpYLt4oiPOJl0XwZDK2OVAVf0Rzo+MGVTv9f+gy8= +github.com/aws/aws-sdk-go-v2 v1.20.0/go.mod h1:uWOr0m0jDsiWw8nnXiqZ+YG6LdvAlGYDLLf2NmHZoy4= +github.com/aws/aws-sdk-go-v2/config v1.18.32 h1:tqEOvkbTxwEV7hToRcJ1xZRjcATqwDVsWbAscgRKyNI= +github.com/aws/aws-sdk-go-v2/config v1.18.32/go.mod h1:U3ZF0fQRRA4gnbn9GGvOWLoT2EzzZfAWeKwnVrm1rDc= +github.com/aws/aws-sdk-go-v2/credentials v1.13.31 h1:vJyON3lG7R8VOErpJJBclBADiWTwzcwdkQpTKx8D2sk= +github.com/aws/aws-sdk-go-v2/credentials v1.13.31/go.mod h1:T4sESjBtY2lNxLgkIASmeP57b5j7hTQqCbqG0tWnxC4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.7 h1:X3H6+SU21x+76LRglk21dFRgMTJMa5QcpW+SqUf5BBg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.7/go.mod h1:3we0V09SwcJBzNlnyovrR2wWJhWmVdqAsmVs4uronv8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.37 h1:zr/gxAZkMcvP71ZhQOcvdm8ReLjFgIXnIn0fw5AM7mo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.37/go.mod h1:Pdn4j43v49Kk6+82spO3Tu5gSeQXRsxo56ePPQAvFiA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.31 h1:0HCMIkAkVY9KMgueD8tf4bRTUanzEYvhw7KkPXIMpO0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.31/go.mod h1:fTJDMe8LOFYtqiFFFeHA+SVMAwqLhoq0kcInYoLa9Js= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.38 h1:+i1DOFrW3YZ3apE45tCal9+aDKK6kNEbW6Ib7e1nFxE= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.38/go.mod h1:1/jLp0OgOaWIetycOmycW+vYTYgTZFPttJQRgsI1PoU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.31 h1:auGDJ0aLZahF5SPvkJ6WcUuX7iQ7kyl2MamV7Tm8QBk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.31/go.mod h1:3+lloe3sZuBQw1aBc5MyndvodzQlyqCZ7x1QPDHaWP4= +github.com/aws/aws-sdk-go-v2/service/ssm v1.37.1 h1:8wSXZ0h+Oqwe44nBX8kW5A98pgoKaI3BpolnnpuBcOA= +github.com/aws/aws-sdk-go-v2/service/ssm v1.37.1/go.mod h1:Z4GG8XYwKzRKKtexaeWeVmPVdwRDgh+LaR5ildi4mYQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.13.1 h1:DSNpSbfEgFXRV+IfEcKE5kTbqxm+MeF5WgyeRlsLnHY= +github.com/aws/aws-sdk-go-v2/service/sso v1.13.1/go.mod h1:TC9BubuFMVScIU+TLKamO6VZiYTkYoEHqlSQwAe2omw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.1 h1:hd0SKLMdOL/Sl6Z0np1PX9LeH2gqNtBe0MhTedA8MGI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.1/go.mod h1:XO/VcyoQ8nKyKfFW/3DMsRQXsfh/052tHTWmg3xBXRg= +github.com/aws/aws-sdk-go-v2/service/sts v1.21.1 h1:pAOJj+80tC8sPVgSDHzMYD6KLWsaLQ1kZw31PTeORbs= +github.com/aws/aws-sdk-go-v2/service/sts v1.21.1/go.mod h1:G8SbvL0rFk4WOJroU8tKBczhsbhj2p/YY7qeJezJ3CI= +github.com/aws/smithy-go v1.14.0 h1:+X90sB94fizKjDmwb4vyl2cTTPXTE5E2G/1mjByb0io= +github.com/aws/smithy-go v1.14.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -125,8 +150,8 @@ github.com/bep/godartsass/v2 v2.0.0 h1:Ruht+BpBWkpmW+yAM2dkp7RSSeN0VLaTobyW0CiSP github.com/bep/godartsass/v2 v2.0.0/go.mod h1:AcP8QgC+OwOXEq6im0WgDRYK7scDsmZCEW62o1prQLo= github.com/bep/golibsass v1.1.1 h1:xkaet75ygImMYjM+FnHIT3xJn7H0xBA9UxSOJjk8Khw= github.com/bep/golibsass v1.1.1/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA= -github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bgentry/speakeasy v0.1.1-0.20220910012023-760eaf8b6816 h1:41iFGWnSlI2gVpmOtVTJZNodLdLQLn/KsJqFvXwnd/s= +github.com/bgentry/speakeasy v0.1.1-0.20220910012023-760eaf8b6816/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= github.com/bramvdbogaerde/go-scp v1.2.1-0.20221219230748-977ee74ac37b h1:UJeNthMS3NHVtMFKMhzZNxdaXpYqQlbLrDRtVXorT7w= github.com/bramvdbogaerde/go-scp v1.2.1-0.20221219230748-977ee74ac37b/go.mod h1:s4ZldBoRAOgUg8IrRP2Urmq5qqd2yPXQTPshACY8vQ0= @@ -161,10 +186,9 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhD github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= -github.com/cilium/ebpf v0.8.1/go.mod h1:f5zLIM0FSNuAkSyLAN7X+Hy6yznlF1mNiWUMfxMtrgk= -github.com/cilium/ebpf v0.9.3 h1:5KtxXZU+scyERvkJMEm16TbScVvuuMrlhPly78ZMbSc= -github.com/clbanning/mxj/v2 v2.5.7 h1:7q5lvUpaPF/WOkqgIDiwjBJaznaLCCBd78pi8ZyAnE0= -github.com/clbanning/mxj/v2 v2.5.7/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/cilium/ebpf v0.10.0 h1:nk5HPMeoBXtOzbkZBWym+ZWq1GIiHUsBFXxwewXAHLQ= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= @@ -196,12 +220,14 @@ github.com/coder/retry v1.4.0 h1:g0fojHFxcdgM3sBULqgjFDxw1UIvaCqk4ngUDu0EWag= github.com/coder/retry v1.4.0/go.mod h1:blHMk9vs6LkoRT9ZHyuZo360cufXEhrxqvEzeMtRGoY= github.com/coder/ssh v0.0.0-20230621095435-9a7e23486f1c h1:TI7TzdFI0UvQmwgyQhtI1HeyYNRxAQpr8Tw/rjT8VSA= github.com/coder/ssh v0.0.0-20230621095435-9a7e23486f1c/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ= -github.com/coder/tailscale v0.0.0-20230731105344-d1b7f8087191 h1:FweUPlasdC67APP0jS8LTUdVlWcl49cX4NqTkr0ZL/4= -github.com/coder/tailscale v0.0.0-20230731105344-d1b7f8087191/go.mod h1:jpg+77g19FpXL43U1VoIqoSg1K/Vh5CVxycGldQ8KhA= +github.com/coder/tailscale v1.1.1-0.20230815060514-ebed8c967bd2 h1:kHuTT70/yda7hdB8vi87gmgp5SgFf+oFT9d9aQ8aeXw= +github.com/coder/tailscale v1.1.1-0.20230815060514-ebed8c967bd2/go.mod h1:L8tPrwSi31RAMEMV8rjb0vYTGs7rXt8rAHbqY/p41j4= github.com/coder/terraform-provider-coder v0.11.1 h1:1sXcHfQrX8XhmLbtKxBED2lZ5jk3/ezBtaw6uVhpJZ4= github.com/coder/terraform-provider-coder v0.11.1/go.mod h1:UIfU3bYNeSzJJvHyJ30tEKjD6Z9utloI+HUM/7n94CY= github.com/coder/wgtunnel v0.1.5 h1:WP3sCj/3iJ34eKvpMQEp1oJHvm24RYh0NHbj1kfUKfs= github.com/coder/wgtunnel v0.1.5/go.mod h1:bokoUrHnUFY4lu9KOeSYiIcHTI2MO1KwqumU4DPDyJI= +github.com/coder/wireguard-go v0.0.0-20230807234434-d825b45ccbf5 h1:eDk/42Kj4xN4yfE504LsvcFEo3dWUiCOaBiWJ2uIH2A= +github.com/coder/wireguard-go v0.0.0-20230807234434-d825b45ccbf5/go.mod h1:QRIcq2+DbdIC5sKh/gcAZhuqu6WT6L6G8/ALPN5wqYw= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/continuity v0.4.1 h1:wQnVrjIyQ8vhU2sgOiL5T07jo+ouqc2bnKsv5/EqGhU= @@ -221,7 +247,7 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/dave/dst v0.27.2 h1:4Y5VFTkhGLC1oddtNwuxxe36pnyLxMFXT51FOzH8Ekc= github.com/dave/dst v0.27.2/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= -github.com/dave/jennifer v1.5.0 h1:HmgPN93bVDpkQyYbqhCHj5QlgvUkvEOzMyEvKLgCRrg= +github.com/dave/jennifer v1.6.1 h1:T4T/67t6RAA5AIV6+NP8Uk/BIsXgDoqEowgycdQQLuk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -232,11 +258,11 @@ github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8 github.com/dhui/dktest v0.3.16 h1:i6gq2YQEtcrjKbeJpBkWjE8MmLZPYllcjOFbTZuPDnw= github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v20.10.17+incompatible h1:eO2KS7ZFeov5UJeaDmIs1NFEDRf32PaqRpvoEkKBy5M= -github.com/docker/cli v20.10.17+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v23.0.5+incompatible h1:ufWmAOuD3Vmr7JP2G5K3cyuNC4YZWiAsuDEvFVVDafE= +github.com/docker/cli v23.0.5+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= -github.com/docker/docker v23.0.3+incompatible h1:9GhVsShNWz1hO//9BNg/dpMnZW25KydO4wtVxWAIbho= -github.com/docker/docker v23.0.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v23.0.5+incompatible h1:DaxtlTJjFSnLOXVNUBU1+6kXGz2lpDoEAH6QoxaSg8k= +github.com/docker/docker v23.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= @@ -255,7 +281,6 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= @@ -269,9 +294,8 @@ github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8 github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI= github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= -github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og= github.com/frankban/quicktest v1.14.2/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= -github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa h1:RDBNVkRviHZtvDvId8XSGPu3rmpmSe+wKRcEWNgsfWU= github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88= @@ -314,15 +338,18 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-ping/ping v1.1.0 h1:3MCGhVX4fyEUuhsfwPrsEdQw6xspHkv5zHsiSoDFZYw= github.com/go-ping/ping v1.1.0/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -334,10 +361,11 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= -github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= -github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-playground/validator/v10 v10.15.0 h1:nDU5XeOKtB3GEa+uB7GNYwhVKsgjAR7VgKoNB6ryXfw= +github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -363,8 +391,8 @@ github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/gohugoio/hugo v0.116.0 h1:hUxu+nTFBi+fkQef5HBxmpxunzqnB9e5e7g6kdj+3Oc= -github.com/gohugoio/hugo v0.116.0/go.mod h1:Xg+aNZZj+wHiGiSVbaPAv3Y+FnpL5GXPvviQy/JrrXs= +github.com/gohugoio/hugo v0.117.0 h1:VP7MVke7R36zjUkWezg7mEtfnwHm2L5u0JwvMHvcsZQ= +github.com/gohugoio/hugo v0.117.0/go.mod h1:b6mjGjvIqRD36x+ePNDhn1ZCXdWBkvu95+dZNTCuoDM= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= @@ -423,6 +451,7 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405 h1:DdHws/YnnPrSywrjNYu2lEHqYHWp/LnEx56w59esd54= @@ -433,6 +462,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c h1:06RMfw+TMMHtRuUOroMeatRCCgSMWXCJQeABvHU69YQ= +github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c/go.mod h1:BVIYo3cdnT4qSylnYqcd5YtmXhr51cJPGtnLBe/uLBU= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= @@ -444,8 +475,8 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc= -github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= +github.com/google/s2a-go v0.1.5 h1:8IYp3w9nysqv3JH+NJgXJzGbDHzLOTj43BmSkp+O7qg= +github.com/google/s2a-go v0.1.5/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -458,7 +489,6 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= @@ -516,45 +546,42 @@ github.com/hashicorp/terraform-registry-address v0.0.0-20220623143253-7d51757b57 github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 h1:HKLsbzeOsfXmKNpr3GiT18XAblV0BjCbzL8KQAMZGa0= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= -github.com/hdevalence/ed25519consensus v0.0.0-20220222234857-c00d1f31bab3 h1:aSVUgRRRtOrZOC1fYmY9gV0e9z/Iu+xNVSASWjsuyGU= -github.com/hdevalence/ed25519consensus v0.0.0-20220222234857-c00d1f31bab3/go.mod h1:5PC6ZNPde8bBqU/ewGZig35+UIZtw9Ytxez8/q5ZyFE= +github.com/hdevalence/ed25519consensus v0.1.0 h1:jtBwzzcHuTmFrQN6xQZn6CQEO/V9f7HsjsjeEZ6auqU= +github.com/hdevalence/ed25519consensus v0.1.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= -github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= github.com/iancoleman/orderedmap v0.2.0 h1:sq1N/TFpYH++aViPcaKjys3bDClUEU7s5B+z6jq8pNA= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= -github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= -github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= -github.com/insomniacslk/dhcp v0.0.0-20221215072855-de60144f33f8 h1:Z72DOke2yOK0Ms4Z2LK1E1OrRJXOxSj5DllTz2FYTRg= -github.com/insomniacslk/dhcp v0.0.0-20221215072855-de60144f33f8/go.mod h1:m5WMe03WCvWcXjRnhvaAbAAXdCnu20J5P+mmH44ZzpE= +github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= +github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16 h1:+aAGyK41KRn8jbF2Q7PLL0Sxwg6dShGcQSeCC7nZQ8E= +github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16/go.mod h1:IKrnDWs3/Mqq5n0lI+RxA2sB7MvN/vbMBP3ehXg65UI= github.com/jedib0t/go-pretty/v6 v6.4.0 h1:YlI/2zYDrweA4MThiYMKtGRfT+2qZOO65ulej8GTcVI= github.com/jedib0t/go-pretty/v6 v6.4.0/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= -github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= -github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= -github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok= -github.com/jsimonetti/rtnetlink v0.0.0-20201110080708-d2c240429e6c/go.mod h1:huN4d1phzjhlOsNIjFsw2SVRbwIHj3fJDMEU2SDPTmg= -github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b h1:Yws7RV6kZr2O7PPdT+RkbSmmOponA8i/1DuGHe8BRsM= -github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b/go.mod h1:TzDCVOZKUa79z6iXbbXqhtAflVgUKaFkZ21M5tK5tzY= +github.com/jsimonetti/rtnetlink v1.3.2 h1:dcn0uWkfxycEEyNy0IGfx3GrhQ38LH7odjxAghimsVI= +github.com/jsimonetti/rtnetlink v1.3.2/go.mod h1:BBu4jZCpTjP6Gk0/wfrO8qcqymnN3g0hoFqObRmUo6U= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/juju/errors v1.0.0 h1:yiq7kjCLll1BiaRuNY53MGI0+EQ3rF6GB+wvboZDefM= github.com/juju/errors v1.0.0/go.mod h1:B5x9thDqx0wIMH3+aLIMP9HjItInYWObRovoCFM5Qe8= github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= @@ -566,8 +593,8 @@ github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDS github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= -github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= +github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= @@ -598,8 +625,8 @@ github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs= -github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -632,30 +659,21 @@ github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y= -github.com/mdlayher/genetlink v1.2.0 h1:4yrIkRV5Wfk1WfpWTcoOlGmsWgQj3OtQN9ZsbrE+XtU= -github.com/mdlayher/genetlink v1.2.0/go.mod h1:ra5LDov2KrUCZJiAtEvXXZBxGMInICMXIwshlJ+qRxQ= -github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= -github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= -github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY= -github.com/mdlayher/netlink v1.1.1/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o= -github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA= -github.com/mdlayher/netlink v1.6.2 h1:D2zGSkvYsJ6NreeED3JiVTu1lj2sIYATqSaZlhPzUgQ= -github.com/mdlayher/netlink v1.6.2/go.mod h1:O1HXX2sIWSMJ3Qn1BYZk1yZM+7iMki/uYGGiwGyq/iU= -github.com/mdlayher/raw v0.0.0-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= -github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= -github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs= -github.com/mdlayher/socket v0.2.3 h1:XZA2X2TjdOwNoNPVPclRCURoX/hokBY8nkTmRZFEheM= -github.com/mdlayher/socket v0.2.3/go.mod h1:bz12/FozYNH/VbvC3q7TRIK/Y6dH1kCKsXaUeXi/FmY= +github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY= github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= -github.com/miekg/dns v1.1.45 h1:g5fRIhm9nx7g8osrAvgb16QJfmyMsyOCb+J7LSv+Qzk= -github.com/miekg/dns v1.1.45/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= +github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= +github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= @@ -715,8 +733,11 @@ github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.m github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4= github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= -github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= -github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= +github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= +github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40= github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI= @@ -738,8 +759,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= @@ -769,8 +790,6 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= @@ -796,8 +815,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/swaggest/assertjson v1.8.1 h1:Be2EHY9S2qwKWV+xWZB747Cd7Y79YK6JLdeyrgFvyMo= @@ -812,14 +831,12 @@ github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG0 github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d h1:K3j02b5j2Iw1xoggN9B2DIEkhWGheqFOeDkdJdBrJI8= github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d/go.mod h1:2P+hpOwd53e7JMX/L4f3VXkv1G+33ES6IWZSrkIeWNs= -github.com/tailscale/golang-x-crypto v0.0.0-20221102133106-bc99ab8c2d17 h1:cSm67hIDABvL13S0n9TNoVhzYwjb24M46znbABLll18= -github.com/tailscale/golang-x-crypto v0.0.0-20221102133106-bc99ab8c2d17/go.mod h1:95n9fbUCixVSI4QXLEvdKJjnYK2eUlkTx9+QwLPXFKU= +github.com/tailscale/golang-x-crypto v0.0.0-20230713185742-f0b76a10a08e h1:JyeJF/HuSwvxWtsR1c0oKX1lzaSH5Wh4aX+MgiStaGQ= +github.com/tailscale/golang-x-crypto v0.0.0-20230713185742-f0b76a10a08e/go.mod h1:DjoeCULdP6vTJ/xY+nzzR9LaUHprkbZEpNidX0aqEEk= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk= github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= -github.com/tailscale/wireguard-go v0.0.0-20221219190806-4fa124729667 h1:etWp6uUwKu8NEj37K2OuMBnZ7EnVMKA7gJg5AqPFy/o= -github.com/tailscale/wireguard-go v0.0.0-20221219190806-4fa124729667/go.mod h1:iiClgxBTruKI+nmzlQxbFw6c3nB/wb4Td/WCyX2berY= github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes= github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/tdewolff/parse/v2 v2.6.6 h1:Yld+0CrKUJaCV78DL1G2nk3C9lKrxyRTux5aaK/AkDo= @@ -831,8 +848,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90 h1:zTk5683I9K62wtZ6eUa6vu6IWwVHXPnoKK5n2unAwv0= github.com/u-root/u-root v0.11.0 h1:6gCZLOeRyevw7gbTwMj3fKxnr9+yHFlgF3N7udUVNO8= github.com/u-root/u-root v0.11.0/go.mod h1:DBkDtiZyONk9hzVEdB/PWI9B4TxDkElWlVTHseglrZY= -github.com/u-root/uio v0.0.0-20221213070652-c3537552635f h1:dpx1PHxYqAnXzbryJrWP1NQLzEjwcVgFLhkknuFQ7ww= -github.com/u-root/uio v0.0.0-20221213070652-c3537552635f/go.mod h1:IogEAUBXDEwX7oR/BMmCctShYs80ql4hF0ySdzGxf7E= +github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 h1:YcojQL98T/OO+rybuzn2+5KrD5dBwXIvYBvQ2cD3Avg= +github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= @@ -850,8 +867,8 @@ github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0m github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= -github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg= -github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -925,12 +942,10 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0 go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= -go4.org/intern v0.0.0-20211027215823-ae77deb06f29 h1:UXLjNohABv4S58tHmeuIZDO6e3mHpW2Dx33gaNt03LE= -go4.org/mem v0.0.0-20210711025021-927187094b94 h1:OAAkygi2Js191AJP1Ds42MhJRgeofeKGjuoUqNp1QC4= -go4.org/mem v0.0.0-20210711025021-927187094b94/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= -go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf h1:IdwJUzqoIo5lkr2EOyKoe5qipUaEjbOKKY5+fzPBZ3A= -go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf/go.mod h1:+QXzaoURFd0rGDIjDNpyIkv+F9R7EmeKorvlKRnhqgA= -go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 h1:FyBZqvoA/jbNzuAWLQE2kG820zMAkcilx6BMjGbL/E4= +go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8= +go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 h1:X66ZEoMN2SuaoI/dfZVYobB6E5zjZyyHUMWlCA7MgGE= +go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516/go.mod h1:TQvodOM+hJTioNQJilmLXu08JNb8i+ccq418+KWu1/Y= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -947,8 +962,9 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -959,9 +975,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= -golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a h1:Jw5wfR+h9mnIYH+OtGT2im5wV1YGGDora5vTv/aa5bE= +golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b h1:r+vk0EmXNmekl0S0BascoeeoHk/L7wmaW2QF90K+kYI= +golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -987,6 +1002,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -996,14 +1012,11 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190419010253-1f3472d942ba/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -1018,7 +1031,6 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -1027,16 +1039,14 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1047,8 +1057,8 @@ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= -golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= +golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= +golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1061,7 +1071,6 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= @@ -1069,20 +1078,15 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606122018-79a91cf218c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1105,8 +1109,6 @@ golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1131,7 +1133,6 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1145,15 +1146,18 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= -golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1165,8 +1169,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1178,7 +1184,6 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -1224,10 +1229,10 @@ golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8= -golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= +golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1261,8 +1266,8 @@ google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz513 google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.134.0 h1:ktL4Goua+UBgoP1eL1/60LwZJqa1sIzkLmvoR3hR6Gw= -google.golang.org/api v0.134.0/go.mod h1:sjRL3UnjTx5UqNQS9EWr9N8p7xbHpy1k0XGRLCf3Spk= +google.golang.org/api v0.137.0 h1:QrKX6uNvzJLr0Fd3vWVqcyrcmFoYi036VUAsZbiF4+s= +google.golang.org/api v0.137.0/go.mod h1:4xyob8CxC+0GChNBvEUAk8VBKNvYOTWM9T3v3UfRxuY= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1308,12 +1313,12 @@ google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e h1:xIXmWJ303kJCuogpj0bHq+dcjcZHU+XFyc1I0Yl9cRg= -google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:0ggbjUrZYpy1q+ANUS30SEoGZ53cdfwtbuG7Ptgy108= -google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 h1:XVeBY8d/FaK4848myy41HBqnDwvxeV3zMZhwN1TvAMU= -google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:mPBs5jNgx2GuQGvFwUvVKqtn6HsUw9nP64BedgvqEsQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230720185612-659f7aaaa771 h1:Z8qdAF9GFsmcUuWQ5KVYIpP3PCKydn/YKORnghIalu4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230720185612-659f7aaaa771/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= +google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 h1:L6iMMGrtzgHsWofoFcihmDEMYeDR9KN/ThbPWGrh++g= +google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5/go.mod h1:oH/ZOT02u4kWEp7oYBGYFFkCdKS/uYR9Z7+0/xuuFp8= +google.golang.org/genproto/googleapis/api v0.0.0-20230803162519-f966b187b2e5 h1:nIgk/EEq3/YlnmVVXVnm14rC2oxgs1o0ong4sD/rd44= +google.golang.org/genproto/googleapis/api v0.0.0-20230803162519-f966b187b2e5/go.mod h1:5DZzOUPCLYL3mNkQ0ms0F3EuUNZ7py1Bqeq6sxzI7/Q= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230807174057-1744710a1577 h1:wukfNtZmZUurLN/atp2hiIeTKn7QJWIQdHzqmsOnAOk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230807174057-1744710a1577/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1356,6 +1361,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= @@ -1367,7 +1373,6 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= @@ -1378,7 +1383,6 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.4.2 h1:6qXr+R5w+ktL5UkwEbPp+fEvfyoMPche6GkOpGHZcLc= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= inet.af/peercred v0.0.0-20210906144145-0893ea02156a h1:qdkS8Q5/i10xU2ArJMKYhVa1DORzBfYS/qA2UK2jheg= diff --git a/helm/Makefile b/helm/Makefile index a3f689b1637af..4010cf42d64fb 100644 --- a/helm/Makefile +++ b/helm/Makefile @@ -13,6 +13,13 @@ all: lint lint: lint/helm .PHONY: lint -lint/helm: - helm lint --strict --set coder.image.tag=v0.0.1 . +lint/helm: lint/helm/coder lint/helm/provisioner .PHONY: lint/helm + +lint/helm/coder: + helm lint --strict --set coder.image.tag=v0.0.1 coder/ +.PHONY: lint/helm/coder + +lint/helm/provisioner: + helm lint --strict --set coder.image.tag=v0.0.1 provisioner/ +.PHONY: lint/helm/provisioner diff --git a/helm/.helmignore b/helm/coder/.helmignore similarity index 100% rename from helm/.helmignore rename to helm/coder/.helmignore diff --git a/helm/coder/Chart.lock b/helm/coder/Chart.lock new file mode 100644 index 0000000000000..9692722e192f1 --- /dev/null +++ b/helm/coder/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: libcoder + repository: file://../libcoder + version: 0.1.0 +digest: sha256:5c9a99109258073b590a9f98268490ef387fde24c0c7c7ade9c1a8c7ef5e6e10 +generated: "2023-08-08T07:27:19.677972411Z" diff --git a/helm/Chart.yaml b/helm/coder/Chart.yaml similarity index 85% rename from helm/Chart.yaml rename to helm/coder/Chart.yaml index a68aa330d8d49..99f6b710474c3 100644 --- a/helm/Chart.yaml +++ b/helm/coder/Chart.yaml @@ -21,9 +21,14 @@ keywords: - coder - terraform sources: - - https://github.com/coder/coder/tree/main/helm + - https://github.com/coder/coder/tree/main/helm/coder icon: https://helm.coder.com/coder_logo_black.png maintainers: - name: Coder Technologies, Inc. email: support@coder.com url: https://coder.com/contact + +dependencies: + - name: libcoder + version: 0.1.0 + repository: file://../libcoder diff --git a/helm/README.md b/helm/coder/README.md similarity index 100% rename from helm/README.md rename to helm/coder/README.md diff --git a/helm/coder/charts/libcoder-0.1.0.tgz b/helm/coder/charts/libcoder-0.1.0.tgz new file mode 100644 index 0000000000000..baae560bb8310 Binary files /dev/null and b/helm/coder/charts/libcoder-0.1.0.tgz differ diff --git a/helm/templates/NOTES.txt b/helm/coder/templates/NOTES.txt similarity index 100% rename from helm/templates/NOTES.txt rename to helm/coder/templates/NOTES.txt diff --git a/helm/coder/templates/_coder.tpl b/helm/coder/templates/_coder.tpl new file mode 100644 index 0000000000000..98a89ff5d419a --- /dev/null +++ b/helm/coder/templates/_coder.tpl @@ -0,0 +1,102 @@ +{{/* +Service account to merge into the libcoder template +*/}} +{{- define "coder.serviceaccount" -}} +{{- end -}} + +{{/* +Deployment to merge into the libcoder template +*/}} +{{- define "coder.deployment" -}} +spec: + template: + spec: + containers: + - +{{ include "libcoder.containerspec" (list . "coder.containerspec") | indent 8}} + +{{- end -}} + +{{/* +ContainerSpec for the Coder container of the Coder deployment +*/}} +{{- define "coder.containerspec" -}} +args: +{{- if .Values.coder.commandArgs }} + {{- toYaml .Values.coder.commandArgs | nindent 12 }} +{{- else }} + {{- if .Values.coder.workspaceProxy }} +- wsproxy + {{- end }} +- server +{{- end }} +env: +- name: CODER_HTTP_ADDRESS + value: "0.0.0.0:8080" +- name: CODER_PROMETHEUS_ADDRESS + value: "0.0.0.0:2112" +{{- if .Values.provisionerDaemon.pskSecretName }} +- name: CODER_PROVISIONER_DAEMON_PSK + valueFrom: + secretKeyRef: + name: {{ .Values.provisionerDaemon.pskSecretName | quote }} + key: psk +{{- end }} + # Set the default access URL so a `helm apply` works by default. + # See: https://github.com/coder/coder/issues/5024 +{{- $hasAccessURL := false }} +{{- range .Values.coder.env }} +{{- if eq .name "CODER_ACCESS_URL" }} +{{- $hasAccessURL = true }} +{{- end }} +{{- end }} +{{- if not $hasAccessURL }} +- name: CODER_ACCESS_URL + value: {{ include "coder.defaultAccessURL" . | quote }} +{{- end }} +# Used for inter-pod communication with high-availability. +- name: KUBE_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP +- name: CODER_DERP_SERVER_RELAY_URL + value: "http://$(KUBE_POD_IP):8080" +{{- include "coder.tlsEnv" . }} +{{- with .Values.coder.env }} +{{ toYaml . }} +{{- end }} +ports: +- name: "http" + containerPort: 8080 + protocol: TCP + {{- if eq (include "coder.tlsEnabled" .) "true" }} +- name: "https" + containerPort: 8443 + protocol: TCP + {{- end }} + {{- range .Values.coder.env }} + {{- if eq .name "CODER_PROMETHEUS_ENABLE" }} + {{/* + This sadly has to be nested to avoid evaluating the second part + of the condition too early and potentially getting type errors if + the value is not a string (like a `valueFrom`). We do not support + `valueFrom` for this env var specifically. + */}} + {{- if eq .value "true" }} +- name: "prometheus-http" + containerPort: 2112 + protocol: TCP + {{- end }} + {{- end }} + {{- end }} +readinessProbe: + httpGet: + path: /healthz + port: "http" + scheme: "HTTP" +livenessProbe: + httpGet: + path: /healthz + port: "http" + scheme: "HTTP" +{{- end }} diff --git a/helm/coder/templates/coder.yaml b/helm/coder/templates/coder.yaml new file mode 100644 index 0000000000000..65eaac00ac001 --- /dev/null +++ b/helm/coder/templates/coder.yaml @@ -0,0 +1,5 @@ +--- +{{ include "libcoder.serviceaccount" (list . "coder.serviceaccount") }} + +--- +{{ include "libcoder.deployment" (list . "coder.deployment") }} diff --git a/helm/templates/extra-templates.yaml b/helm/coder/templates/extra-templates.yaml similarity index 100% rename from helm/templates/extra-templates.yaml rename to helm/coder/templates/extra-templates.yaml diff --git a/helm/templates/ingress.yaml b/helm/coder/templates/ingress.yaml similarity index 100% rename from helm/templates/ingress.yaml rename to helm/coder/templates/ingress.yaml diff --git a/helm/coder/templates/rbac.yaml b/helm/coder/templates/rbac.yaml new file mode 100644 index 0000000000000..07fb36d876824 --- /dev/null +++ b/helm/coder/templates/rbac.yaml @@ -0,0 +1 @@ +{{ include "libcoder.rbac.tpl" . }} diff --git a/helm/templates/service.yaml b/helm/coder/templates/service.yaml similarity index 78% rename from helm/templates/service.yaml rename to helm/coder/templates/service.yaml index 60dd5fe931dfa..1881f992a695e 100644 --- a/helm/templates/service.yaml +++ b/helm/coder/templates/service.yaml @@ -16,11 +16,17 @@ spec: port: 80 targetPort: "http" protocol: TCP + {{ if eq .Values.coder.service.type "NodePort" }} + nodePort: {{ .Values.coder.service.httpNodePort }} + {{ end }} {{- if eq (include "coder.tlsEnabled" .) "true" }} - name: "https" port: 443 targetPort: "https" protocol: TCP + {{ if eq .Values.coder.service.type "NodePort" }} + nodePort: {{ .Values.coder.service.httpsNodePort }} + {{ end }} {{- end }} {{- if eq "LoadBalancer" .Values.coder.service.type }} {{- with .Values.coder.service.loadBalancerIP }} diff --git a/helm/tests/chart_test.go b/helm/coder/tests/chart_test.go similarity index 91% rename from helm/tests/chart_test.go rename to helm/coder/tests/chart_test.go index 7442be08fc2e3..451e4407f9abe 100644 --- a/helm/tests/chart_test.go +++ b/helm/coder/tests/chart_test.go @@ -20,10 +20,10 @@ import ( // All values and golden files are located in the `testdata` directory. // To update golden files, run `go test . -update`. -// UpdateGoldenFiles is a flag that can be set to update golden files. -var UpdateGoldenFiles = flag.Bool("update", false, "Update golden files") +// updateGoldenFiles is a flag that can be set to update golden files. +var updateGoldenFiles = flag.Bool("update", false, "Update golden files") -var TestCases = []TestCase{ +var testCases = []testCase{ { name: "default_values", expectedError: "", @@ -56,24 +56,28 @@ var TestCases = []TestCase{ name: "command_args", expectedError: "", }, + { + name: "provisionerd_psk", + expectedError: "", + }, } -type TestCase struct { +type testCase struct { name string // Name of the test case. This is used to control which values and golden file are used. expectedError string // Expected error from running `helm template`. } -func (tc TestCase) valuesFilePath() string { +func (tc testCase) valuesFilePath() string { return filepath.Join("./testdata", tc.name+".yaml") } -func (tc TestCase) goldenFilePath() string { +func (tc testCase) goldenFilePath() string { return filepath.Join("./testdata", tc.name+".golden") } func TestRenderChart(t *testing.T) { t.Parallel() - if *UpdateGoldenFiles { + if *updateGoldenFiles { t.Skip("Golden files are being updated. Skipping test.") } if testutil.InCI() { @@ -85,7 +89,7 @@ func TestRenderChart(t *testing.T) { // Ensure that Helm is available in $PATH helmPath := lookupHelm(t) - for _, tc := range TestCases { + for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -121,12 +125,12 @@ func TestRenderChart(t *testing.T) { func TestUpdateGoldenFiles(t *testing.T) { t.Parallel() - if !*UpdateGoldenFiles { + if !*updateGoldenFiles { t.Skip("Run with -update to update golden files") } helmPath := lookupHelm(t) - for _, tc := range TestCases { + for _, tc := range testCases { if tc.expectedError != "" { t.Logf("skipping test case %q with render error", tc.name) continue diff --git a/helm/tests/testdata/command.golden b/helm/coder/tests/testdata/command.golden similarity index 61% rename from helm/tests/testdata/command.golden rename to helm/coder/tests/testdata/command.golden index 616971e98d458..4e88c36d4641d 100644 --- a/helm/tests/testdata/command.golden +++ b/helm/coder/tests/testdata/command.golden @@ -3,16 +3,15 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: "coder" - annotations: - {} + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder --- # Source: coder/templates/rbac.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -91,6 +90,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder @@ -100,37 +100,32 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: - name: coder + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm - annotations: - {} + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder spec: replicas: 1 selector: matchLabels: - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder template: metadata: + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm - annotations: - {} + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 spec: - serviceAccountName: "coder" - restartPolicy: Always - terminationGracePeriodSeconds: 60 affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: @@ -144,55 +139,52 @@ spec: topologyKey: kubernetes.io/hostname weight: 1 containers: - - name: coder - image: "ghcr.io/coder/coder:latest" - imagePullPolicy: IfNotPresent - command: - - /opt/colin - args: - - server - resources: - {} - lifecycle: - {} - env: - - name: CODER_HTTP_ADDRESS - value: "0.0.0.0:8080" - - name: CODER_PROMETHEUS_ADDRESS - value: "0.0.0.0:2112" - # Set the default access URL so a `helm apply` works by default. - # See: https://github.com/coder/coder/issues/5024 - - name: CODER_ACCESS_URL - value: "http://coder.default.svc.cluster.local" - # Used for inter-pod communication with high-availability. - - name: KUBE_POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: CODER_DERP_SERVER_RELAY_URL - value: "http://$(KUBE_POD_IP):8080" - - ports: - - name: "http" - containerPort: 8080 - protocol: TCP - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: null - runAsGroup: 1000 - runAsNonRoot: true - runAsUser: 1000 - seccompProfile: - type: RuntimeDefault - readinessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - livenessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - volumeMounts: [] + - args: + - server + command: + - /opt/colin + env: + - name: CODER_HTTP_ADDRESS + value: 0.0.0.0:8080 + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_ACCESS_URL + value: http://coder.default.svc.cluster.local + - name: KUBE_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: http://$(KUBE_POD_IP):8080 + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + livenessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + name: coder + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder + terminationGracePeriodSeconds: 60 volumes: [] diff --git a/helm/tests/testdata/command.yaml b/helm/coder/tests/testdata/command.yaml similarity index 100% rename from helm/tests/testdata/command.yaml rename to helm/coder/tests/testdata/command.yaml diff --git a/helm/tests/testdata/command_args.golden b/helm/coder/tests/testdata/command_args.golden similarity index 61% rename from helm/tests/testdata/command_args.golden rename to helm/coder/tests/testdata/command_args.golden index 92e87fd58097c..9e7a9a01ee27a 100644 --- a/helm/tests/testdata/command_args.golden +++ b/helm/coder/tests/testdata/command_args.golden @@ -3,16 +3,15 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: "coder" - annotations: - {} + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder --- # Source: coder/templates/rbac.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -91,6 +90,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder @@ -100,37 +100,32 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: - name: coder + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm - annotations: - {} + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder spec: replicas: 1 selector: matchLabels: - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder template: metadata: + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm - annotations: - {} + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 spec: - serviceAccountName: "coder" - restartPolicy: Always - terminationGracePeriodSeconds: 60 affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: @@ -144,56 +139,53 @@ spec: topologyKey: kubernetes.io/hostname weight: 1 containers: - - name: coder - image: "ghcr.io/coder/coder:latest" - imagePullPolicy: IfNotPresent - command: - - /opt/coder - args: - - arg1 - - arg2 - resources: - {} - lifecycle: - {} - env: - - name: CODER_HTTP_ADDRESS - value: "0.0.0.0:8080" - - name: CODER_PROMETHEUS_ADDRESS - value: "0.0.0.0:2112" - # Set the default access URL so a `helm apply` works by default. - # See: https://github.com/coder/coder/issues/5024 - - name: CODER_ACCESS_URL - value: "http://coder.default.svc.cluster.local" - # Used for inter-pod communication with high-availability. - - name: KUBE_POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: CODER_DERP_SERVER_RELAY_URL - value: "http://$(KUBE_POD_IP):8080" - - ports: - - name: "http" - containerPort: 8080 - protocol: TCP - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: null - runAsGroup: 1000 - runAsNonRoot: true - runAsUser: 1000 - seccompProfile: - type: RuntimeDefault - readinessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - livenessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - volumeMounts: [] + - args: + - arg1 + - arg2 + command: + - /opt/coder + env: + - name: CODER_HTTP_ADDRESS + value: 0.0.0.0:8080 + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_ACCESS_URL + value: http://coder.default.svc.cluster.local + - name: KUBE_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: http://$(KUBE_POD_IP):8080 + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + livenessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + name: coder + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder + terminationGracePeriodSeconds: 60 volumes: [] diff --git a/helm/tests/testdata/command_args.yaml b/helm/coder/tests/testdata/command_args.yaml similarity index 100% rename from helm/tests/testdata/command_args.yaml rename to helm/coder/tests/testdata/command_args.yaml diff --git a/helm/tests/testdata/default_values.golden b/helm/coder/tests/testdata/default_values.golden similarity index 61% rename from helm/tests/testdata/default_values.golden rename to helm/coder/tests/testdata/default_values.golden index cb1988e1ab3e9..ed02773c6f7bb 100644 --- a/helm/tests/testdata/default_values.golden +++ b/helm/coder/tests/testdata/default_values.golden @@ -3,16 +3,15 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: "coder" - annotations: - {} + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder --- # Source: coder/templates/rbac.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -91,6 +90,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder @@ -100,37 +100,32 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: - name: coder + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm - annotations: - {} + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder spec: replicas: 1 selector: matchLabels: - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder template: metadata: + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm - annotations: - {} + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 spec: - serviceAccountName: "coder" - restartPolicy: Always - terminationGracePeriodSeconds: 60 affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: @@ -144,55 +139,52 @@ spec: topologyKey: kubernetes.io/hostname weight: 1 containers: - - name: coder - image: "ghcr.io/coder/coder:latest" - imagePullPolicy: IfNotPresent - command: - - /opt/coder - args: - - server - resources: - {} - lifecycle: - {} - env: - - name: CODER_HTTP_ADDRESS - value: "0.0.0.0:8080" - - name: CODER_PROMETHEUS_ADDRESS - value: "0.0.0.0:2112" - # Set the default access URL so a `helm apply` works by default. - # See: https://github.com/coder/coder/issues/5024 - - name: CODER_ACCESS_URL - value: "http://coder.default.svc.cluster.local" - # Used for inter-pod communication with high-availability. - - name: KUBE_POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: CODER_DERP_SERVER_RELAY_URL - value: "http://$(KUBE_POD_IP):8080" - - ports: - - name: "http" - containerPort: 8080 - protocol: TCP - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: null - runAsGroup: 1000 - runAsNonRoot: true - runAsUser: 1000 - seccompProfile: - type: RuntimeDefault - readinessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - livenessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - volumeMounts: [] + - args: + - server + command: + - /opt/coder + env: + - name: CODER_HTTP_ADDRESS + value: 0.0.0.0:8080 + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_ACCESS_URL + value: http://coder.default.svc.cluster.local + - name: KUBE_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: http://$(KUBE_POD_IP):8080 + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + livenessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + name: coder + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder + terminationGracePeriodSeconds: 60 volumes: [] diff --git a/helm/tests/testdata/default_values.yaml b/helm/coder/tests/testdata/default_values.yaml similarity index 100% rename from helm/tests/testdata/default_values.yaml rename to helm/coder/tests/testdata/default_values.yaml diff --git a/helm/tests/testdata/labels_annotations.golden b/helm/coder/tests/testdata/labels_annotations.golden similarity index 64% rename from helm/tests/testdata/labels_annotations.golden rename to helm/coder/tests/testdata/labels_annotations.golden index e6f85d0dfa476..38812ffeab832 100644 --- a/helm/tests/testdata/labels_annotations.golden +++ b/helm/coder/tests/testdata/labels_annotations.golden @@ -3,16 +3,15 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: "coder" - annotations: - {} + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder --- # Source: coder/templates/rbac.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -91,6 +90,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder @@ -100,43 +100,40 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: - name: coder + annotations: + com.coder/annotation/baz: qux + com.coder/annotation/foo: bar labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 com.coder/label/baz: qux com.coder/label/foo: bar - annotations: - com.coder/annotation/baz: qux - com.coder/annotation/foo: bar + helm.sh/chart: coder-0.1.0 + name: coder spec: replicas: 1 selector: matchLabels: - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder template: metadata: + annotations: + com.coder/podAnnotation/baz: qux + com.coder/podAnnotation/foo: bar labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 com.coder/podLabel/baz: qux com.coder/podLabel/foo: bar - annotations: - com.coder/podAnnotation/baz: qux - com.coder/podAnnotation/foo: bar + helm.sh/chart: coder-0.1.0 spec: - serviceAccountName: "coder" - restartPolicy: Always - terminationGracePeriodSeconds: 60 affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: @@ -150,55 +147,52 @@ spec: topologyKey: kubernetes.io/hostname weight: 1 containers: - - name: coder - image: "ghcr.io/coder/coder:latest" - imagePullPolicy: IfNotPresent - command: - - /opt/coder - args: - - server - resources: - {} - lifecycle: - {} - env: - - name: CODER_HTTP_ADDRESS - value: "0.0.0.0:8080" - - name: CODER_PROMETHEUS_ADDRESS - value: "0.0.0.0:2112" - # Set the default access URL so a `helm apply` works by default. - # See: https://github.com/coder/coder/issues/5024 - - name: CODER_ACCESS_URL - value: "http://coder.default.svc.cluster.local" - # Used for inter-pod communication with high-availability. - - name: KUBE_POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: CODER_DERP_SERVER_RELAY_URL - value: "http://$(KUBE_POD_IP):8080" - - ports: - - name: "http" - containerPort: 8080 - protocol: TCP - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: null - runAsGroup: 1000 - runAsNonRoot: true - runAsUser: 1000 - seccompProfile: - type: RuntimeDefault - readinessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - livenessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - volumeMounts: [] + - args: + - server + command: + - /opt/coder + env: + - name: CODER_HTTP_ADDRESS + value: 0.0.0.0:8080 + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_ACCESS_URL + value: http://coder.default.svc.cluster.local + - name: KUBE_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: http://$(KUBE_POD_IP):8080 + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + livenessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + name: coder + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder + terminationGracePeriodSeconds: 60 volumes: [] diff --git a/helm/tests/testdata/labels_annotations.yaml b/helm/coder/tests/testdata/labels_annotations.yaml similarity index 100% rename from helm/tests/testdata/labels_annotations.yaml rename to helm/coder/tests/testdata/labels_annotations.yaml diff --git a/helm/tests/testdata/missing_values.yaml b/helm/coder/tests/testdata/missing_values.yaml similarity index 100% rename from helm/tests/testdata/missing_values.yaml rename to helm/coder/tests/testdata/missing_values.yaml diff --git a/helm/coder/tests/testdata/provisionerd_psk.golden b/helm/coder/tests/testdata/provisionerd_psk.golden new file mode 100644 index 0000000000000..4dcde1eabe0fc --- /dev/null +++ b/helm/coder/tests/testdata/provisionerd_psk.golden @@ -0,0 +1,195 @@ +--- +# Source: coder/templates/coder.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder +--- +# Source: coder/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-workspace-perms +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +--- +# Source: coder/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: "coder" +subjects: + - kind: ServiceAccount + name: "coder" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-workspace-perms +--- +# Source: coder/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: coder + labels: + helm.sh/chart: coder-0.1.0 + app.kubernetes.io/name: coder + app.kubernetes.io/instance: release-name + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm + annotations: + {} +spec: + type: LoadBalancer + sessionAffinity: ClientIP + ports: + - name: "http" + port: 80 + targetPort: "http" + protocol: TCP + + externalTrafficPolicy: "Cluster" + selector: + app.kubernetes.io/name: coder + app.kubernetes.io/instance: release-name +--- +# Source: coder/templates/coder.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder + template: + metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/instance + operator: In + values: + - coder + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - args: + - server + command: + - /opt/coder + env: + - name: CODER_HTTP_ADDRESS + value: 0.0.0.0:8080 + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_PROVISIONER_DAEMON_PSK + valueFrom: + secretKeyRef: + key: psk + name: coder-provisionerd-psk + - name: CODER_ACCESS_URL + value: http://coder.default.svc.cluster.local + - name: KUBE_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: http://$(KUBE_POD_IP):8080 + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + livenessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + name: coder + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder + terminationGracePeriodSeconds: 60 + volumes: [] diff --git a/helm/coder/tests/testdata/provisionerd_psk.yaml b/helm/coder/tests/testdata/provisionerd_psk.yaml new file mode 100644 index 0000000000000..915b7aeb66f0f --- /dev/null +++ b/helm/coder/tests/testdata/provisionerd_psk.yaml @@ -0,0 +1,5 @@ +coder: + image: + tag: latest +provisionerDaemon: + pskSecretName: "coder-provisionerd-psk" diff --git a/helm/tests/testdata/sa.golden b/helm/coder/tests/testdata/sa.golden similarity index 60% rename from helm/tests/testdata/sa.golden rename to helm/coder/tests/testdata/sa.golden index 5e94a67818c62..cf3b2df693835 100644 --- a/helm/tests/testdata/sa.golden +++ b/helm/coder/tests/testdata/sa.golden @@ -3,22 +3,22 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: "coder-service-account" - annotations: + annotations: eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/coder-service-account labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder-service-account --- # Source: coder/templates/rbac.yaml apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - name: coder-workspace-perms + name: coder-service-account-workspace-perms rules: - apiGroups: [""] resources: ["pods"] @@ -67,7 +67,7 @@ subjects: roleRef: apiGroup: rbac.authorization.k8s.io kind: Role - name: coder-workspace-perms + name: coder-service-account-workspace-perms --- # Source: coder/templates/service.yaml apiVersion: v1 @@ -91,6 +91,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder @@ -100,37 +101,32 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: - name: coder + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm - annotations: - {} + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder spec: replicas: 1 selector: matchLabels: - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder template: metadata: + annotations: {} labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" app.kubernetes.io/managed-by: Helm - annotations: - {} + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 spec: - serviceAccountName: "coder-service-account" - restartPolicy: Always - terminationGracePeriodSeconds: 60 affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: @@ -144,55 +140,52 @@ spec: topologyKey: kubernetes.io/hostname weight: 1 containers: - - name: coder - image: "ghcr.io/coder/coder:latest" - imagePullPolicy: IfNotPresent - command: - - /opt/coder - args: - - server - resources: - {} - lifecycle: - {} - env: - - name: CODER_HTTP_ADDRESS - value: "0.0.0.0:8080" - - name: CODER_PROMETHEUS_ADDRESS - value: "0.0.0.0:2112" - # Set the default access URL so a `helm apply` works by default. - # See: https://github.com/coder/coder/issues/5024 - - name: CODER_ACCESS_URL - value: "http://coder.default.svc.cluster.local" - # Used for inter-pod communication with high-availability. - - name: KUBE_POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: CODER_DERP_SERVER_RELAY_URL - value: "http://$(KUBE_POD_IP):8080" - - ports: - - name: "http" - containerPort: 8080 - protocol: TCP - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: null - runAsGroup: 1000 - runAsNonRoot: true - runAsUser: 1000 - seccompProfile: - type: RuntimeDefault - readinessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - livenessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - volumeMounts: [] + - args: + - server + command: + - /opt/coder + env: + - name: CODER_HTTP_ADDRESS + value: 0.0.0.0:8080 + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_ACCESS_URL + value: http://coder.default.svc.cluster.local + - name: KUBE_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: http://$(KUBE_POD_IP):8080 + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + livenessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + name: coder + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder-service-account + terminationGracePeriodSeconds: 60 volumes: [] diff --git a/helm/tests/testdata/sa.yaml b/helm/coder/tests/testdata/sa.yaml similarity index 100% rename from helm/tests/testdata/sa.yaml rename to helm/coder/tests/testdata/sa.yaml diff --git a/helm/coder/tests/testdata/tls.golden b/helm/coder/tests/testdata/tls.golden new file mode 100644 index 0000000000000..fccbbec0a2aa2 --- /dev/null +++ b/helm/coder/tests/testdata/tls.golden @@ -0,0 +1,212 @@ +--- +# Source: coder/templates/coder.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder +--- +# Source: coder/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-workspace-perms +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +--- +# Source: coder/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: "coder" +subjects: + - kind: ServiceAccount + name: "coder" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-workspace-perms +--- +# Source: coder/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: coder + labels: + helm.sh/chart: coder-0.1.0 + app.kubernetes.io/name: coder + app.kubernetes.io/instance: release-name + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm + annotations: + {} +spec: + type: LoadBalancer + sessionAffinity: ClientIP + ports: + - name: "http" + port: 80 + targetPort: "http" + protocol: TCP + + - name: "https" + port: 443 + targetPort: "https" + protocol: TCP + + externalTrafficPolicy: "Cluster" + selector: + app.kubernetes.io/name: coder + app.kubernetes.io/instance: release-name +--- +# Source: coder/templates/coder.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder + template: + metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/instance + operator: In + values: + - coder + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - args: + - server + command: + - /opt/coder + env: + - name: CODER_HTTP_ADDRESS + value: 0.0.0.0:8080 + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_ACCESS_URL + value: https://coder.default.svc.cluster.local + - name: KUBE_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: http://$(KUBE_POD_IP):8080 + - name: CODER_TLS_ENABLE + value: "true" + - name: CODER_TLS_ADDRESS + value: 0.0.0.0:8443 + - name: CODER_TLS_CERT_FILE + value: /etc/ssl/certs/coder/coder-tls/tls.crt + - name: CODER_TLS_KEY_FILE + value: /etc/ssl/certs/coder/coder-tls/tls.key + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + livenessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + name: coder + ports: + - containerPort: 8080 + name: http + protocol: TCP + - containerPort: 8443 + name: https + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: + - mountPath: /etc/ssl/certs/coder/coder-tls + name: tls-coder-tls + readOnly: true + restartPolicy: Always + serviceAccountName: coder + terminationGracePeriodSeconds: 60 + volumes: + - name: tls-coder-tls + secret: + secretName: coder-tls diff --git a/helm/tests/testdata/tls.yaml b/helm/coder/tests/testdata/tls.yaml similarity index 100% rename from helm/tests/testdata/tls.yaml rename to helm/coder/tests/testdata/tls.yaml diff --git a/helm/coder/tests/testdata/workspace_proxy.golden b/helm/coder/tests/testdata/workspace_proxy.golden new file mode 100644 index 0000000000000..096b40978aac0 --- /dev/null +++ b/helm/coder/tests/testdata/workspace_proxy.golden @@ -0,0 +1,198 @@ +--- +# Source: coder/templates/coder.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder +--- +# Source: coder/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-workspace-perms +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +--- +# Source: coder/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: "coder" +subjects: + - kind: ServiceAccount + name: "coder" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-workspace-perms +--- +# Source: coder/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: coder + labels: + helm.sh/chart: coder-0.1.0 + app.kubernetes.io/name: coder + app.kubernetes.io/instance: release-name + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm + annotations: + {} +spec: + type: LoadBalancer + sessionAffinity: ClientIP + ports: + - name: "http" + port: 80 + targetPort: "http" + protocol: TCP + + externalTrafficPolicy: "Cluster" + selector: + app.kubernetes.io/name: coder + app.kubernetes.io/instance: release-name +--- +# Source: coder/templates/coder.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + name: coder +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder + template: + metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder + app.kubernetes.io/part-of: coder + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-0.1.0 + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/instance + operator: In + values: + - coder + topologyKey: kubernetes.io/hostname + weight: 1 + containers: + - args: + - wsproxy + - server + command: + - /opt/coder + env: + - name: CODER_HTTP_ADDRESS + value: 0.0.0.0:8080 + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_ACCESS_URL + value: http://coder.default.svc.cluster.local + - name: KUBE_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: http://$(KUBE_POD_IP):8080 + - name: CODER_PRIMARY_ACCESS_URL + value: https://dev.coder.com + - name: CODER_PROXY_SESSION_TOKEN + valueFrom: + secretKeyRef: + key: token + name: coder-workspace-proxy-session-token + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + livenessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + name: coder + ports: + - containerPort: 8080 + name: http + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + scheme: HTTP + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder + terminationGracePeriodSeconds: 60 + volumes: [] diff --git a/helm/tests/testdata/workspace_proxy.yaml b/helm/coder/tests/testdata/workspace_proxy.yaml similarity index 100% rename from helm/tests/testdata/workspace_proxy.yaml rename to helm/coder/tests/testdata/workspace_proxy.yaml diff --git a/helm/values.yaml b/helm/coder/values.yaml similarity index 92% rename from helm/values.yaml rename to helm/coder/values.yaml index e3a1298628d5b..2b85b54e67127 100644 --- a/helm/values.yaml +++ b/helm/coder/values.yaml @@ -10,7 +10,7 @@ coder: # - CODER_TLS_ENABLE: set if tls.secretName is not empty. # - CODER_TLS_CERT_FILE: set if tls.secretName is not empty. # - CODER_TLS_KEY_FILE: set if tls.secretName is not empty. - # - CODER_PROMETHEUS_ADDRESS: set to 0.0.0.0:6060 and cannot be changed. + # - CODER_PROMETHEUS_ADDRESS: set to 0.0.0.0:2112 and cannot be changed. # Prometheus must still be enabled by setting CODER_PROMETHEUS_ENABLE. # - KUBE_POD_IP # - CODER_DERP_SERVER_RELAY_URL @@ -241,6 +241,12 @@ coder: # coder.service.annotations -- The service annotations. See: # https://kubernetes.io/docs/concepts/services-networking/service/#internal-load-balancer annotations: {} + # coder.service.httpNodePort -- Enabled if coder.service.type is set to NodePort. + # If not set, Kubernetes will allocate a port from the default range, 30000-32767. + httpNodePort: "" + # coder.service.httpsNodePort -- Enabled if coder.service.type is set to NodePort. + # If not set, Kubernetes will allocate a port from the default range, 30000-32767. + httpsNodePort: "" # coder.ingress -- The Ingress object to expose for Coder. ingress: @@ -280,6 +286,16 @@ coder: # coder.commandArgs -- Set arguments for the entrypoint command of the Coder pod. commandArgs: [] +# provisionerDaemon -- Configuration for external provisioner daemons. +# +# This is an Enterprise feature. Contact sales@coder.com. +provisionerDaemon: + # provisionerDaemon.pskSecretName -- The name of the Kubernetes secret that contains the + # Pre-Shared Key (PSK) to use to authenticate external provisioner daemons with Coder. The + # secret must be in the same namespace as the Helm deployment, and contain an item called "psk" + # which contains the pre-shared key. + pskSecretName: "" + # extraTemplates -- Array of extra objects to deploy with the release. Strings # are evaluated as a template and can use template expansions and functions. All # other objects are used as yaml. diff --git a/helm/libcoder/Chart.yaml b/helm/libcoder/Chart.yaml new file mode 100644 index 0000000000000..90c881af5d62d --- /dev/null +++ b/helm/libcoder/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: libcoder +description: Coder library chart +home: https://github.com/coder/coder + +type: library +version: "0.1.0" +appVersion: "0.1.0" + +maintainers: + - name: Coder Technologies, Inc. + email: support@coder.com + url: https://coder.com/contact diff --git a/helm/libcoder/templates/_coder.yaml b/helm/libcoder/templates/_coder.yaml new file mode 100644 index 0000000000000..77cdbb2a3dfe5 --- /dev/null +++ b/helm/libcoder/templates/_coder.yaml @@ -0,0 +1,85 @@ +{{- define "libcoder.deployment.tpl" -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "coder.name" .}} + labels: + {{- include "coder.labels" . | nindent 4 }} + {{- with .Values.coder.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + annotations: {{ toYaml .Values.coder.annotations | nindent 4}} +spec: + replicas: {{ .Values.coder.replicaCount }} + selector: + matchLabels: + {{- include "coder.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "coder.labels" . | nindent 8 }} + {{- with .Values.coder.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + {{- toYaml .Values.coder.podAnnotations | nindent 8 }} + spec: + serviceAccountName: {{ .Values.coder.serviceAccount.name | quote }} + restartPolicy: Always + {{- with .Values.coder.image.pullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + terminationGracePeriodSeconds: 60 + {{- with .Values.coder.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.coder.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.coder.nodeSelector }} + nodeSelector: + {{ toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.coder.initContainers }} + initContainers: + {{ toYaml . | nindent 8 }} + {{- end }} + containers: [] + {{- include "coder.volumes" . | nindent 6 }} +{{- end -}} +{{- define "libcoder.deployment" -}} +{{- include "libcoder.util.merge" (append . "libcoder.deployment.tpl") -}} +{{- end -}} + +{{- define "libcoder.containerspec.tpl" -}} +name: coder +image: {{ include "coder.image" . | quote }} +imagePullPolicy: {{ .Values.coder.image.pullPolicy }} +command: + {{- toYaml .Values.coder.command | nindent 2 }} +resources: + {{- toYaml .Values.coder.resources | nindent 2 }} +lifecycle: + {{- toYaml .Values.coder.lifecycle | nindent 2 }} +securityContext: {{ toYaml .Values.coder.securityContext | nindent 2 }} +{{ include "coder.volumeMounts" . }} +{{- end -}} +{{- define "libcoder.containerspec" -}} +{{- include "libcoder.util.merge" (append . "libcoder.containerspec.tpl") -}} +{{- end -}} + +{{- define "libcoder.serviceaccount.tpl" -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.coder.serviceAccount.name | quote }} + annotations: {{ toYaml .Values.coder.serviceAccount.annotations | nindent 4 }} + labels: + {{- include "coder.labels" . | nindent 4 }} +{{- end -}} +{{- define "libcoder.serviceaccount" -}} +{{- include "libcoder.util.merge" (append . "libcoder.serviceaccount.tpl") -}} +{{- end -}} diff --git a/helm/templates/_helpers.tpl b/helm/libcoder/templates/_helpers.tpl similarity index 93% rename from helm/templates/_helpers.tpl rename to helm/libcoder/templates/_helpers.tpl index d884b28402000..9a6c5dfcfb82d 100644 --- a/helm/templates/_helpers.tpl +++ b/helm/libcoder/templates/_helpers.tpl @@ -49,11 +49,15 @@ Coder Docker image URI Coder TLS enabled. */}} {{- define "coder.tlsEnabled" -}} -{{- if .Values.coder.tls.secretNames -}} -true -{{- else -}} -false -{{- end -}} + {{- if hasKey .Values.coder "tls" -}} + {{- if .Values.coder.tls.secretNames -}} + true + {{- else -}} + false + {{- end -}} + {{- else -}} + false + {{- end -}} {{- end }} {{/* @@ -88,11 +92,13 @@ http Coder volume definitions. */}} {{- define "coder.volumeList" }} -{{ range $secretName := .Values.coder.tls.secretNames -}} +{{- if hasKey .Values.coder "tls" -}} +{{- range $secretName := .Values.coder.tls.secretNames }} - name: "tls-{{ $secretName }}" secret: secretName: {{ $secretName | quote }} {{ end -}} +{{- end }} {{ range $secret := .Values.coder.certs.secrets -}} - name: "ca-cert-{{ $secret.name }}" secret: @@ -119,11 +125,13 @@ volumes: [] Coder volume mounts. */}} {{- define "coder.volumeMountList" }} +{{- if hasKey .Values.coder "tls" }} {{ range $secretName := .Values.coder.tls.secretNames -}} - name: "tls-{{ $secretName }}" mountPath: "/etc/ssl/certs/coder/{{ $secretName }}" readOnly: true {{ end -}} +{{- end }} {{ range $secret := .Values.coder.certs.secrets -}} - name: "ca-cert-{{ $secret.name }}" mountPath: "/etc/ssl/certs/{{ $secret.name }}.crt" diff --git a/helm/templates/rbac.yaml b/helm/libcoder/templates/_rbac.yaml similarity index 85% rename from helm/templates/rbac.yaml rename to helm/libcoder/templates/_rbac.yaml index 3105e1a604b63..c60357ad2a796 100644 --- a/helm/templates/rbac.yaml +++ b/helm/libcoder/templates/_rbac.yaml @@ -1,9 +1,10 @@ +{{- define "libcoder.rbac.tpl" -}} {{- if .Values.coder.serviceAccount.workspacePerms }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - name: coder-workspace-perms + name: {{ .Values.coder.serviceAccount.name }}-workspace-perms rules: - apiGroups: [""] resources: ["pods"] @@ -53,5 +54,6 @@ subjects: roleRef: apiGroup: rbac.authorization.k8s.io kind: Role - name: coder-workspace-perms + name: {{ .Values.coder.serviceAccount.name }}-workspace-perms {{- end }} +{{- end -}} diff --git a/helm/libcoder/templates/_util.yaml b/helm/libcoder/templates/_util.yaml new file mode 100644 index 0000000000000..ebdc13e3631ec --- /dev/null +++ b/helm/libcoder/templates/_util.yaml @@ -0,0 +1,13 @@ +{{- /* + libcoder.util.merge will merge two YAML templates and output the result. + This takes an array of three values: + - the top context + - the template name of the overrides (destination) + - the template name of the base (source) +*/}} +{{- define "libcoder.util.merge" -}} +{{- $top := first . -}} +{{- $overrides := fromYaml (include (index . 1) $top) | default (dict ) -}} +{{- $tpl := fromYaml (include (index . 2) $top) | default (dict ) -}} +{{- toYaml (merge $overrides $tpl) -}} +{{- end -}} diff --git a/helm/provisioner/Chart.lock b/helm/provisioner/Chart.lock new file mode 100644 index 0000000000000..b51a533086d42 --- /dev/null +++ b/helm/provisioner/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: libcoder + repository: file://../libcoder + version: 0.1.0 +digest: sha256:5c9a99109258073b590a9f98268490ef387fde24c0c7c7ade9c1a8c7ef5e6e10 +generated: "2023-08-07T12:43:45.49343898Z" diff --git a/helm/provisioner/Chart.yaml b/helm/provisioner/Chart.yaml new file mode 100644 index 0000000000000..e27e85ec12bd4 --- /dev/null +++ b/helm/provisioner/Chart.yaml @@ -0,0 +1,34 @@ +apiVersion: v2 +name: coder-provisioner +description: "External provisioner daemon for Coder. This is an Enterprise feature; contact sales@coder.com." +home: https://github.com/coder/coder + +# version and appVersion are injected at release and will always be shown as +# 0.1.0 in the repository. +# +# If you're installing the Helm chart directly from git it will have this +# version, which means the auto-generated image URI will be invalid. You can set +# "coder.image.tag" to the desired tag manually. +type: application +version: "0.1.0" +appVersion: "0.1.0" + +# Coder has a hard requirement on Kubernetes 1.19, as this version introduced +# the networking.k8s.io/v1 API. +kubeVersion: ">= 1.19.0-0" + +keywords: + - coder + - terraform +sources: + - https://github.com/coder/coder/tree/main/helm/provisioner +icon: https://helm.coder.com/coder_logo_black.png +maintainers: + - name: Coder Technologies, Inc. + email: support@coder.com + url: https://coder.com/contact + +dependencies: + - name: libcoder + version: 0.1.0 + repository: file://../libcoder diff --git a/helm/provisioner/README.md b/helm/provisioner/README.md new file mode 100644 index 0000000000000..d1f8b6727fa11 --- /dev/null +++ b/helm/provisioner/README.md @@ -0,0 +1,36 @@ +# Coder Helm Chart + +This directory contains the Helm chart used to deploy Coder provisioner daemons onto a Kubernetes +cluster. + +External provisioner daemons are an Enterprise feature. Contact sales@coder.com. + +## Getting Started + +> **Warning**: The main branch in this repository does not represent the +> latest release of Coder. Please reference our installation docs for +> instructions on a tagged release. + +View +[our docs](https://coder.com/docs/v2/latest/admin/provisioners) +for detailed installation instructions. + +## Values + +Please refer to [values.yaml](values.yaml) for available Helm values and their +defaults. + +A good starting point for your values file is: + +```yaml +coder: + env: + - name: CODER_URL + value: "https://coder.example.com" + # This env enables the Prometheus metrics endpoint. + - name: CODER_PROMETHEUS_ADDRESS + value: "0.0.0.0:2112" + replicaCount: 10 +provisionerDaemon: + pskSecretName: "coder-provisioner-psk" +``` diff --git a/helm/provisioner/charts/libcoder-0.1.0.tgz b/helm/provisioner/charts/libcoder-0.1.0.tgz new file mode 100644 index 0000000000000..094e3f64207ad Binary files /dev/null and b/helm/provisioner/charts/libcoder-0.1.0.tgz differ diff --git a/helm/provisioner/templates/_coder.tpl b/helm/provisioner/templates/_coder.tpl new file mode 100644 index 0000000000000..b84b7d8c4e48c --- /dev/null +++ b/helm/provisioner/templates/_coder.tpl @@ -0,0 +1,86 @@ +{{/* +Service account to merge into the libcoder template +*/}} +{{- define "coder.serviceaccount" -}} +{{- end }} + +{{/* +Deployment to merge into the libcoder template +*/}} +{{- define "coder.deployment" -}} +spec: + template: + spec: + terminationGracePeriodSeconds: {{ .Values.provisionerDaemon.terminationGracePeriodSeconds }} + containers: + - +{{ include "libcoder.containerspec" (list . "coder.containerspec") | indent 8}} + +{{- end }} + +{{/* +ContainerSpec for the Coder container of the Coder deployment +*/}} +{{- define "coder.containerspec" -}} +args: +{{- if .Values.coder.commandArgs }} + {{- toYaml .Values.coder.commandArgs | nindent 12 }} +{{- else }} +- provisionerd +- start +{{- end }} +env: +- name: CODER_PROMETHEUS_ADDRESS + value: "0.0.0.0:2112" +- name: CODER_PROVISIONER_DAEMON_PSK + valueFrom: + secretKeyRef: + name: {{ .Values.provisionerDaemon.pskSecretName | quote }} + key: psk +{{- if include "provisioner.tags" . }} +- name: CODER_PROVISIONERD_TAGS + value: {{ include "provisioner.tags" . }} +{{- end }} + # Set the default access URL so a `helm apply` works by default. + # See: https://github.com/coder/coder/issues/5024 +{{- $hasAccessURL := false }} +{{- range .Values.coder.env }} +{{- if eq .name "CODER_URL" }} +{{- $hasAccessURL = true }} +{{- end }} +{{- end }} +{{- if not $hasAccessURL }} +- name: CODER_URL + value: {{ include "coder.defaultAccessURL" . | quote }} +{{- end }} +{{- with .Values.coder.env }} +{{ toYaml . }} +{{- end }} +ports: + {{- range .Values.coder.env }} + {{- if eq .name "CODER_PROMETHEUS_ENABLE" }} + {{/* + This sadly has to be nested to avoid evaluating the second part + of the condition too early and potentially getting type errors if + the value is not a string (like a `valueFrom`). We do not support + `valueFrom` for this env var specifically. + */}} + {{- if eq .value "true" }} +- name: "prometheus-http" + containerPort: 2112 + protocol: TCP + {{- end }} + {{- end }} + {{- end }} +{{- end }} + +{{/* +Convert provisioner tags to the environment variable format +*/}} +{{- define "provisioner.tags" -}} + {{- $keys := keys .Values.provisionerDaemon.tags | sortAlpha -}} + {{- range $i, $key := $keys -}} + {{- $val := get $.Values.provisionerDaemon.tags $key -}} + {{- if ne $i 0 -}},{{- end -}}{{ $key }}={{ $val }} + {{- end -}} +{{- end -}} diff --git a/helm/provisioner/templates/coder.yaml b/helm/provisioner/templates/coder.yaml new file mode 100644 index 0000000000000..65eaac00ac001 --- /dev/null +++ b/helm/provisioner/templates/coder.yaml @@ -0,0 +1,5 @@ +--- +{{ include "libcoder.serviceaccount" (list . "coder.serviceaccount") }} + +--- +{{ include "libcoder.deployment" (list . "coder.deployment") }} diff --git a/helm/provisioner/templates/rbac.yaml b/helm/provisioner/templates/rbac.yaml new file mode 100644 index 0000000000000..07fb36d876824 --- /dev/null +++ b/helm/provisioner/templates/rbac.yaml @@ -0,0 +1 @@ +{{ include "libcoder.rbac.tpl" . }} diff --git a/helm/provisioner/tests/chart_test.go b/helm/provisioner/tests/chart_test.go new file mode 100644 index 0000000000000..95d516b3b04bf --- /dev/null +++ b/helm/provisioner/tests/chart_test.go @@ -0,0 +1,172 @@ +package tests // nolint: testpackage + +import ( + "bytes" + "flag" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/testutil" +) + +// These tests run `helm template` with the values file specified in each test +// and compare the output to the contents of the corresponding golden file. +// All values and golden files are located in the `testdata` directory. +// To update golden files, run `go test . -update`. + +// updateGoldenFiles is a flag that can be set to update golden files. +var updateGoldenFiles = flag.Bool("update", false, "Update golden files") + +var testCases = []testCase{ + { + name: "default_values", + expectedError: "", + }, + { + name: "missing_values", + expectedError: `You must specify the coder.image.tag value if you're installing the Helm chart directly from Git.`, + }, + { + name: "sa", + expectedError: "", + }, + { + name: "labels_annotations", + expectedError: "", + }, + { + name: "command", + expectedError: "", + }, + { + name: "command_args", + expectedError: "", + }, + { + name: "provisionerd_psk", + expectedError: "", + }, +} + +type testCase struct { + name string // Name of the test case. This is used to control which values and golden file are used. + expectedError string // Expected error from running `helm template`. +} + +func (tc testCase) valuesFilePath() string { + return filepath.Join("./testdata", tc.name+".yaml") +} + +func (tc testCase) goldenFilePath() string { + return filepath.Join("./testdata", tc.name+".golden") +} + +func TestRenderChart(t *testing.T) { + t.Parallel() + if *updateGoldenFiles { + t.Skip("Golden files are being updated. Skipping test.") + } + if testutil.InCI() { + switch runtime.GOOS { + case "windows", "darwin": + t.Skip("Skipping tests on Windows and macOS in CI") + } + } + + // Ensure that Helm is available in $PATH + helmPath := lookupHelm(t) + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Ensure that the values file exists. + valuesFilePath := tc.valuesFilePath() + if _, err := os.Stat(valuesFilePath); os.IsNotExist(err) { + t.Fatalf("values file %q does not exist", valuesFilePath) + } + + // Run helm template with the values file. + templateOutput, err := runHelmTemplate(t, helmPath, "..", valuesFilePath) + if tc.expectedError != "" { + require.Error(t, err, "helm template should have failed") + require.Contains(t, templateOutput, tc.expectedError, "helm template output should contain expected error") + } else { + require.NoError(t, err, "helm template should not have failed") + require.NotEmpty(t, templateOutput, "helm template output should not be empty") + goldenFilePath := tc.goldenFilePath() + goldenBytes, err := os.ReadFile(goldenFilePath) + require.NoError(t, err, "failed to read golden file %q", goldenFilePath) + + // Remove carriage returns to make tests pass on Windows. + goldenBytes = bytes.Replace(goldenBytes, []byte("\r"), []byte(""), -1) + expected := string(goldenBytes) + + require.NoError(t, err, "failed to load golden file %q") + require.Equal(t, expected, templateOutput) + } + }) + } +} + +func TestUpdateGoldenFiles(t *testing.T) { + t.Parallel() + if !*updateGoldenFiles { + t.Skip("Run with -update to update golden files") + } + + helmPath := lookupHelm(t) + for _, tc := range testCases { + if tc.expectedError != "" { + t.Logf("skipping test case %q with render error", tc.name) + continue + } + + valuesPath := tc.valuesFilePath() + templateOutput, err := runHelmTemplate(t, helmPath, "..", valuesPath) + + require.NoError(t, err, "failed to run `helm template -f %q`", valuesPath) + + goldenFilePath := tc.goldenFilePath() + err = os.WriteFile(goldenFilePath, []byte(templateOutput), 0o644) // nolint:gosec + require.NoError(t, err, "failed to write golden file %q", goldenFilePath) + } + t.Log("Golden files updated. Please review the changes and commit them.") +} + +// runHelmTemplate runs helm template on the given chart with the given values and +// returns the raw output. +func runHelmTemplate(t testing.TB, helmPath, chartDir, valuesFilePath string) (string, error) { + // Ensure that valuesFilePath exists + if _, err := os.Stat(valuesFilePath); err != nil { + return "", xerrors.Errorf("values file %q does not exist: %w", valuesFilePath, err) + } + + cmd := exec.Command(helmPath, "template", chartDir, "-f", valuesFilePath, "--namespace", "default") + t.Logf("exec command: %v", cmd.Args) + out, err := cmd.CombinedOutput() + return string(out), err +} + +// lookupHelm ensures that Helm is available in $PATH and returns the path to the +// Helm executable. +func lookupHelm(t testing.TB) string { + helmPath, err := exec.LookPath("helm") + if err != nil { + t.Fatalf("helm not found in $PATH: %v", err) + return "" + } + t.Logf("Using helm at %q", helmPath) + return helmPath +} + +func TestMain(m *testing.M) { + flag.Parse() + os.Exit(m.Run()) +} diff --git a/helm/provisioner/tests/testdata/command.golden b/helm/provisioner/tests/testdata/command.golden new file mode 100644 index 0000000000000..39760332be082 --- /dev/null +++ b/helm/provisioner/tests/testdata/command.golden @@ -0,0 +1,135 @@ +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-provisioner +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-provisioner-workspace-perms +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: "coder-provisioner" +subjects: + - kind: ServiceAccount + name: "coder-provisioner" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-provisioner-workspace-perms +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-provisioner +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder-provisioner + template: + metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + spec: + containers: + - args: + - provisionerd + - start + command: + - /opt/colin + env: + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_PROVISIONER_DAEMON_PSK + valueFrom: + secretKeyRef: + key: psk + name: coder-provisioner-psk + - name: CODER_URL + value: http://coder.default.svc.cluster.local + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + name: coder + ports: null + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder-provisioner + terminationGracePeriodSeconds: 600 + volumes: [] diff --git a/helm/provisioner/tests/testdata/command.yaml b/helm/provisioner/tests/testdata/command.yaml new file mode 100644 index 0000000000000..ef4c8de967f1a --- /dev/null +++ b/helm/provisioner/tests/testdata/command.yaml @@ -0,0 +1,5 @@ +coder: + image: + tag: latest + command: + - /opt/colin diff --git a/helm/provisioner/tests/testdata/command_args.golden b/helm/provisioner/tests/testdata/command_args.golden new file mode 100644 index 0000000000000..48162991f61eb --- /dev/null +++ b/helm/provisioner/tests/testdata/command_args.golden @@ -0,0 +1,135 @@ +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-provisioner +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-provisioner-workspace-perms +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: "coder-provisioner" +subjects: + - kind: ServiceAccount + name: "coder-provisioner" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-provisioner-workspace-perms +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-provisioner +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder-provisioner + template: + metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + spec: + containers: + - args: + - arg1 + - arg2 + command: + - /opt/coder + env: + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_PROVISIONER_DAEMON_PSK + valueFrom: + secretKeyRef: + key: psk + name: coder-provisioner-psk + - name: CODER_URL + value: http://coder.default.svc.cluster.local + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + name: coder + ports: null + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder-provisioner + terminationGracePeriodSeconds: 600 + volumes: [] diff --git a/helm/provisioner/tests/testdata/command_args.yaml b/helm/provisioner/tests/testdata/command_args.yaml new file mode 100644 index 0000000000000..59d012aabbefb --- /dev/null +++ b/helm/provisioner/tests/testdata/command_args.yaml @@ -0,0 +1,6 @@ +coder: + image: + tag: latest + commandArgs: + - arg1 + - arg2 diff --git a/helm/provisioner/tests/testdata/default_values.golden b/helm/provisioner/tests/testdata/default_values.golden new file mode 100644 index 0000000000000..04197fca37468 --- /dev/null +++ b/helm/provisioner/tests/testdata/default_values.golden @@ -0,0 +1,135 @@ +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-provisioner +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-provisioner-workspace-perms +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: "coder-provisioner" +subjects: + - kind: ServiceAccount + name: "coder-provisioner" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-provisioner-workspace-perms +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-provisioner +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder-provisioner + template: + metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + spec: + containers: + - args: + - provisionerd + - start + command: + - /opt/coder + env: + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_PROVISIONER_DAEMON_PSK + valueFrom: + secretKeyRef: + key: psk + name: coder-provisioner-psk + - name: CODER_URL + value: http://coder.default.svc.cluster.local + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + name: coder + ports: null + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder-provisioner + terminationGracePeriodSeconds: 600 + volumes: [] diff --git a/helm/provisioner/tests/testdata/default_values.yaml b/helm/provisioner/tests/testdata/default_values.yaml new file mode 100644 index 0000000000000..70cdc8b472215 --- /dev/null +++ b/helm/provisioner/tests/testdata/default_values.yaml @@ -0,0 +1,3 @@ +coder: + image: + tag: latest diff --git a/helm/provisioner/tests/testdata/labels_annotations.golden b/helm/provisioner/tests/testdata/labels_annotations.golden new file mode 100644 index 0000000000000..1c2d49d8c424c --- /dev/null +++ b/helm/provisioner/tests/testdata/labels_annotations.golden @@ -0,0 +1,143 @@ +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-provisioner +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-provisioner-workspace-perms +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: "coder-provisioner" +subjects: + - kind: ServiceAccount + name: "coder-provisioner" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-provisioner-workspace-perms +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + com.coder/annotation/baz: qux + com.coder/annotation/foo: bar + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + com.coder/label/baz: qux + com.coder/label/foo: bar + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-provisioner +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder-provisioner + template: + metadata: + annotations: + com.coder/podAnnotation/baz: qux + com.coder/podAnnotation/foo: bar + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + com.coder/podLabel/baz: qux + com.coder/podLabel/foo: bar + helm.sh/chart: coder-provisioner-0.1.0 + spec: + containers: + - args: + - provisionerd + - start + command: + - /opt/coder + env: + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_PROVISIONER_DAEMON_PSK + valueFrom: + secretKeyRef: + key: psk + name: coder-provisioner-psk + - name: CODER_URL + value: http://coder.default.svc.cluster.local + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + name: coder + ports: null + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder-provisioner + terminationGracePeriodSeconds: 600 + volumes: [] diff --git a/helm/provisioner/tests/testdata/labels_annotations.yaml b/helm/provisioner/tests/testdata/labels_annotations.yaml new file mode 100644 index 0000000000000..a7ddda708be79 --- /dev/null +++ b/helm/provisioner/tests/testdata/labels_annotations.yaml @@ -0,0 +1,15 @@ +coder: + image: + tag: latest + annotations: + com.coder/annotation/foo: bar + com.coder/annotation/baz: qux + labels: + com.coder/label/foo: bar + com.coder/label/baz: qux + podAnnotations: + com.coder/podAnnotation/foo: bar + com.coder/podAnnotation/baz: qux + podLabels: + com.coder/podLabel/foo: bar + com.coder/podLabel/baz: qux diff --git a/helm/provisioner/tests/testdata/missing_values.yaml b/helm/provisioner/tests/testdata/missing_values.yaml new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/helm/provisioner/tests/testdata/provisionerd_psk.golden b/helm/provisioner/tests/testdata/provisionerd_psk.golden new file mode 100644 index 0000000000000..b641ee0db37cb --- /dev/null +++ b/helm/provisioner/tests/testdata/provisionerd_psk.golden @@ -0,0 +1,137 @@ +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-provisioner +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-provisioner-workspace-perms +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: "coder-provisioner" +subjects: + - kind: ServiceAccount + name: "coder-provisioner" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-provisioner-workspace-perms +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-provisioner +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder-provisioner + template: + metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + spec: + containers: + - args: + - provisionerd + - start + command: + - /opt/coder + env: + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_PROVISIONER_DAEMON_PSK + valueFrom: + secretKeyRef: + key: psk + name: coder-provisionerd-psk + - name: CODER_PROVISIONERD_TAGS + value: clusterType=k8s,location=auh + - name: CODER_URL + value: http://coder.default.svc.cluster.local + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + name: coder + ports: null + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder-provisioner + terminationGracePeriodSeconds: 600 + volumes: [] diff --git a/helm/provisioner/tests/testdata/provisionerd_psk.yaml b/helm/provisioner/tests/testdata/provisionerd_psk.yaml new file mode 100644 index 0000000000000..f891b007db539 --- /dev/null +++ b/helm/provisioner/tests/testdata/provisionerd_psk.yaml @@ -0,0 +1,8 @@ +coder: + image: + tag: latest +provisionerDaemon: + pskSecretName: "coder-provisionerd-psk" + tags: + location: auh + clusterType: k8s diff --git a/helm/provisioner/tests/testdata/sa.golden b/helm/provisioner/tests/testdata/sa.golden new file mode 100644 index 0000000000000..e8f6ee3bd45dd --- /dev/null +++ b/helm/provisioner/tests/testdata/sa.golden @@ -0,0 +1,136 @@ +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/coder-service-account + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-service-account +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-service-account-workspace-perms +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - deletecollection + - get + - list + - patch + - update + - watch +--- +# Source: coder-provisioner/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: "coder-service-account" +subjects: + - kind: ServiceAccount + name: "coder-service-account" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-service-account-workspace-perms +--- +# Source: coder-provisioner/templates/coder.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + name: coder-provisioner +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/name: coder-provisioner + template: + metadata: + annotations: {} + labels: + app.kubernetes.io/instance: release-name + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: coder-provisioner + app.kubernetes.io/part-of: coder-provisioner + app.kubernetes.io/version: 0.1.0 + helm.sh/chart: coder-provisioner-0.1.0 + spec: + containers: + - args: + - provisionerd + - start + command: + - /opt/coder + env: + - name: CODER_PROMETHEUS_ADDRESS + value: 0.0.0.0:2112 + - name: CODER_PROVISIONER_DAEMON_PSK + valueFrom: + secretKeyRef: + key: psk + name: coder-provisioner-psk + - name: CODER_URL + value: http://coder.default.svc.cluster.local + image: ghcr.io/coder/coder:latest + imagePullPolicy: IfNotPresent + lifecycle: {} + name: coder + ports: null + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: null + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + volumeMounts: [] + restartPolicy: Always + serviceAccountName: coder-service-account + terminationGracePeriodSeconds: 600 + volumes: [] diff --git a/helm/provisioner/tests/testdata/sa.yaml b/helm/provisioner/tests/testdata/sa.yaml new file mode 100644 index 0000000000000..4e0c98c223ae1 --- /dev/null +++ b/helm/provisioner/tests/testdata/sa.yaml @@ -0,0 +1,8 @@ +coder: + image: + tag: latest + serviceAccount: + name: coder-service-account + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/coder-service-account + workspacePerms: true diff --git a/helm/provisioner/values.yaml b/helm/provisioner/values.yaml new file mode 100644 index 0000000000000..ff628dd883929 --- /dev/null +++ b/helm/provisioner/values.yaml @@ -0,0 +1,209 @@ +# coder -- Common configuration options. +coder: + # coder.env -- The environment variables to set. These can be used to + # configure all aspects of Coder provisioner daemon. Please see + # `coder provisionerd start --help for information about what environment + # variables can be set. + # Note: The following environment variables are set by default and cannot be + # overridden: + # - CODER_PROMETHEUS_ADDRESS: set to 0.0.0.0:2112 and cannot be changed. + # Prometheus must still be enabled by setting CODER_PROMETHEUS_ENABLE. + # + # We will additionally set CODER_URL, if unset, to the cluster service + # URL. + env: [] + # - name: "CODER_URL" + # value: "https://coder.example.com" + + # coder.image -- The image to use for Coder provisioner daemon. + image: + # coder.image.repo -- The repository of the image. + repo: "ghcr.io/coder/coder" + # coder.image.tag -- The tag of the image, defaults to {{.Chart.AppVersion}} + # if not set. If you're using the chart directly from git, the default + # app version will not work and you'll need to set this value. The helm + # chart helpfully fails quickly in this case. + tag: "" + # coder.image.pullPolicy -- The pull policy to use for the image. See: + # https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy + pullPolicy: IfNotPresent + # coder.image.pullSecrets -- The secrets used for pulling the Coder image from + # a private registry. + pullSecrets: [] + # - name: "pull-secret" + + # coder.initContainers -- Init containers for the deployment. See: + # https://kubernetes.io/docs/concepts/workloads/pods/init-containers/ + initContainers: + [] + # - name: init-container + # image: busybox:1.28 + # command: ['sh', '-c', "sleep 2"] + + # coder.annotations -- The Deployment annotations. See: + # https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + annotations: {} + + # coder.labels -- The Deployment labels. See: + # https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + labels: {} + + # coder.podAnnotations -- The Coder pod annotations. See: + # https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + podAnnotations: {} + + # coder.podLabels -- The Coder pod labels. See: + # https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + podLabels: {} + + # coder.serviceAccount -- Configuration for the automatically created service + # account. Creation of the service account cannot be disabled. + serviceAccount: + # coder.serviceAccount.workspacePerms -- Whether or not to grant the + # service account permissions to manage workspaces. This includes + # permission to manage pods and persistent volume claims in the deployment + # namespace. + # + # It is recommended to keep this on if you are using Kubernetes templates + # within Coder. + workspacePerms: true + # coder.serviceAccount.enableDeployments -- Provides the service account permission + # to manage Kubernetes deployments. + enableDeployments: true + # coder.serviceAccount.annotations -- The Coder service account annotations. + annotations: {} + # coder.serviceAccount.name -- The service account name + name: coder-provisioner + + # coder.securityContext -- Fields related to the container's security + # context (as opposed to the pod). Some fields are also present in the pod + # security context, in which case these values will take precedence. + securityContext: + # coder.securityContext.runAsNonRoot -- Requires that the coder container + # runs as an unprivileged user. If setting runAsUser to 0 (root), this + # will need to be set to false. + runAsNonRoot: true + # coder.securityContext.runAsUser -- Sets the user id of the container. + # For security reasons, we recommend using a non-root user. + runAsUser: 1000 + # coder.securityContext.runAsGroup -- Sets the group id of the container. + # For security reasons, we recommend using a non-root group. + runAsGroup: 1000 + # coder.securityContext.readOnlyRootFilesystem -- Mounts the container's + # root filesystem as read-only. + readOnlyRootFilesystem: null + # coder.securityContext.seccompProfile -- Sets the seccomp profile for + # the coder container. + seccompProfile: + type: RuntimeDefault + # coder.securityContext.allowPrivilegeEscalation -- Controls whether + # the container can gain additional privileges, such as escalating to + # root. It is recommended to leave this setting disabled in production. + allowPrivilegeEscalation: false + + # coder.volumes -- A list of extra volumes to add to the Coder provisioner daemon pod. + volumes: [] + # - name: "my-volume" + # emptyDir: {} + + # coder.volumeMounts -- A list of extra volume mounts to add to the Coder provisioner daemon pod. + volumeMounts: [] + # - name: "my-volume" + # mountPath: "/mnt/my-volume" + + # coder.replicaCount -- The number of Kubernetes deployment replicas. This + # should only be increased if High Availability is enabled. + # + # This is an Enterprise feature. Contact sales@coder.com. + replicaCount: 1 + + # coder.lifecycle -- container lifecycle handlers for the Coder container, allowing + # for lifecycle events such as postStart and preStop events + # See: https://kubernetes.io/docs/tasks/configure-pod-container/attach-handler-lifecycle-event/ + lifecycle: + {} + # postStart: + # exec: + # command: ["/bin/sh", "-c", "echo postStart"] + # preStop: + # exec: + # command: ["/bin/sh","-c","echo preStart"] + + # coder.resources -- The resources to request for Coder. These are optional + # and are not set by default. + resources: + {} + # limits: + # cpu: 2000m + # memory: 4096Mi + # requests: + # cpu: 2000m + # memory: 4096Mi + + # coder.certs -- CA bundles to mount inside the Coder pod. + certs: + # coder.certs.secrets -- A list of CA bundle secrets to mount into the + # pod. The secrets should exist in the same namespace as the Helm + # deployment. + # + # The given key in each secret is mounted at + # `/etc/ssl/certs/{secret_name}.crt`. + secrets: + [] + # - name: "my-ca-bundle" + # key: "ca-bundle.crt" + + # coder.affinity -- Allows specifying an affinity rule for the deployment. + affinity: + {} + # podAntiAffinity: + # preferredDuringSchedulingIgnoredDuringExecution: + # - podAffinityTerm: + # labelSelector: + # matchExpressions: + # - key: app.kubernetes.io/instance + # operator: In + # values: + # - "coder" + # topologyKey: kubernetes.io/hostname + # weight: 1 + + # coder.tolerations -- Tolerations for tainted nodes. + # See: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ + tolerations: + {} + # - key: "key" + # operator: "Equal" + # value: "value" + # effect: "NoSchedule" + + # coder.nodeSelector -- Node labels for constraining coder pods to nodes. + # See: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#nodeselector + nodeSelector: {} + # kubernetes.io/os: linux + + # coder.command -- The command to use when running the container. Used + # for customizing the location of the `coder` binary in your image. + command: + - /opt/coder + + # coder.commandArgs -- Set arguments for the entrypoint command of the Coder pod. + commandArgs: [] + +# provisionerDaemon -- Provisioner Daemon configuration options +provisionerDaemon: + # provisionerDaemon.pskSecretName -- The name of the Kubernetes secret that contains the + # Pre-Shared Key (PSK) to use to authenticate with Coder. The secret must be in the same namespace + # as the Helm deployment, and contain an item called "psk" which contains the pre-shared key. + pskSecretName: "coder-provisioner-psk" + + # provisionerDaemon.tags -- Tags to filter provisioner jobs by + tags: + {} + # location: usa + # provider: kubernetes + + # provisionerDaemon.terminationGracePeriodSeconds -- Time in seconds that Kubernetes should wait before forcibly + # terminating the provisioner daemon. You should set this to be longer than your longest expected build time so that + # redeployments do not interrupt builds in progress. + terminationGracePeriodSeconds: 600 diff --git a/helm/templates/coder.yaml b/helm/templates/coder.yaml deleted file mode 100644 index 09b284e676bc8..0000000000000 --- a/helm/templates/coder.yaml +++ /dev/null @@ -1,143 +0,0 @@ -{{- include "coder.verifyDeprecated" . -}} ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ .Values.coder.serviceAccount.name | quote }} - annotations: {{ toYaml .Values.coder.serviceAccount.annotations | nindent 4 }} - labels: - {{- include "coder.labels" . | nindent 4 }} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: coder - labels: - {{- include "coder.labels" . | nindent 4 }} - {{- with .Values.coder.labels }} - {{- toYaml . | nindent 4 }} - {{- end }} - annotations: {{ toYaml .Values.coder.annotations | nindent 4}} -spec: - replicas: {{ .Values.coder.replicaCount }} - selector: - matchLabels: - {{- include "coder.selectorLabels" . | nindent 6 }} - template: - metadata: - labels: - {{- include "coder.labels" . | nindent 8 }} - {{- with .Values.coder.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - annotations: - {{- toYaml .Values.coder.podAnnotations | nindent 8 }} - spec: - serviceAccountName: {{ .Values.coder.serviceAccount.name | quote }} - restartPolicy: Always - {{- with .Values.coder.image.pullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - terminationGracePeriodSeconds: 60 - {{- with .Values.coder.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.coder.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.coder.nodeSelector }} - nodeSelector: - {{ toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.coder.initContainers }} - initContainers: - {{ toYaml . | nindent 8 }} - {{- end }} - containers: - - name: coder - image: {{ include "coder.image" . | quote }} - imagePullPolicy: {{ .Values.coder.image.pullPolicy }} - command: - {{- toYaml .Values.coder.command | nindent 12 }} - args: - {{- if .Values.coder.commandArgs }} - {{- toYaml .Values.coder.commandArgs | nindent 12 }} - {{- else }} - {{- if .Values.coder.workspaceProxy }} - - wsproxy - {{- end }} - - server - {{- end }} - resources: - {{- toYaml .Values.coder.resources | nindent 12 }} - lifecycle: - {{- toYaml .Values.coder.lifecycle | nindent 12 }} - env: - - name: CODER_HTTP_ADDRESS - value: "0.0.0.0:8080" - - name: CODER_PROMETHEUS_ADDRESS - value: "0.0.0.0:2112" - # Set the default access URL so a `helm apply` works by default. - # See: https://github.com/coder/coder/issues/5024 - {{- $hasAccessURL := false }} - {{- range .Values.coder.env }} - {{- if eq .name "CODER_ACCESS_URL" }} - {{- $hasAccessURL = true }} - {{- end }} - {{- end }} - {{- if not $hasAccessURL }} - - name: CODER_ACCESS_URL - value: {{ include "coder.defaultAccessURL" . | quote }} - {{- end }} - # Used for inter-pod communication with high-availability. - - name: KUBE_POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: CODER_DERP_SERVER_RELAY_URL - value: "http://$(KUBE_POD_IP):8080" - {{- include "coder.tlsEnv" . | nindent 12 }} - {{- with .Values.coder.env -}} - {{ toYaml . | nindent 12 }} - {{- end }} - ports: - - name: "http" - containerPort: 8080 - protocol: TCP - {{- if eq (include "coder.tlsEnabled" .) "true" }} - - name: "https" - containerPort: 8443 - protocol: TCP - {{- end }} - {{- range .Values.coder.env }} - {{- if eq .name "CODER_PROMETHEUS_ENABLE" }} - {{/* - This sadly has to be nested to avoid evaluating the second part - of the condition too early and potentially getting type errors if - the value is not a string (like a `valueFrom`). We do not support - `valueFrom` for this env var specifically. - */}} - {{- if eq .value "true" }} - - name: "prometheus-http" - containerPort: 2112 - protocol: TCP - {{- end }} - {{- end }} - {{- end }} - securityContext: {{ toYaml .Values.coder.securityContext | nindent 12 }} - readinessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - livenessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - {{- include "coder.volumeMounts" . | nindent 10 }} - - {{- include "coder.volumes" . | nindent 6 }} diff --git a/helm/tests/testdata/tls.golden b/helm/tests/testdata/tls.golden deleted file mode 100644 index 8ef85d138f722..0000000000000 --- a/helm/tests/testdata/tls.golden +++ /dev/null @@ -1,220 +0,0 @@ ---- -# Source: coder/templates/coder.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: "coder" - annotations: - {} - labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" - app.kubernetes.io/managed-by: Helm ---- -# Source: coder/templates/rbac.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: coder-workspace-perms -rules: - - apiGroups: [""] - resources: ["pods"] - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: [""] - resources: ["persistentvolumeclaims"] - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - apps - resources: - - deployments - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch ---- -# Source: coder/templates/rbac.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: "coder" -subjects: - - kind: ServiceAccount - name: "coder" -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: coder-workspace-perms ---- -# Source: coder/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: coder - labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" - app.kubernetes.io/managed-by: Helm - annotations: - {} -spec: - type: LoadBalancer - sessionAffinity: ClientIP - ports: - - name: "http" - port: 80 - targetPort: "http" - protocol: TCP - - name: "https" - port: 443 - targetPort: "https" - protocol: TCP - externalTrafficPolicy: "Cluster" - selector: - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name ---- -# Source: coder/templates/coder.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: coder - labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" - app.kubernetes.io/managed-by: Helm - annotations: - {} -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name - template: - metadata: - labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" - app.kubernetes.io/managed-by: Helm - annotations: - {} - spec: - serviceAccountName: "coder" - restartPolicy: Always - terminationGracePeriodSeconds: 60 - affinity: - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - podAffinityTerm: - labelSelector: - matchExpressions: - - key: app.kubernetes.io/instance - operator: In - values: - - coder - topologyKey: kubernetes.io/hostname - weight: 1 - containers: - - name: coder - image: "ghcr.io/coder/coder:latest" - imagePullPolicy: IfNotPresent - command: - - /opt/coder - args: - - server - resources: - {} - lifecycle: - {} - env: - - name: CODER_HTTP_ADDRESS - value: "0.0.0.0:8080" - - name: CODER_PROMETHEUS_ADDRESS - value: "0.0.0.0:2112" - # Set the default access URL so a `helm apply` works by default. - # See: https://github.com/coder/coder/issues/5024 - - name: CODER_ACCESS_URL - value: "https://coder.default.svc.cluster.local" - # Used for inter-pod communication with high-availability. - - name: KUBE_POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: CODER_DERP_SERVER_RELAY_URL - value: "http://$(KUBE_POD_IP):8080" - - - name: CODER_TLS_ENABLE - value: "true" - - name: CODER_TLS_ADDRESS - value: "0.0.0.0:8443" - - name: CODER_TLS_CERT_FILE - value: "/etc/ssl/certs/coder/coder-tls/tls.crt" - - name: CODER_TLS_KEY_FILE - value: "/etc/ssl/certs/coder/coder-tls/tls.key" - ports: - - name: "http" - containerPort: 8080 - protocol: TCP - - name: "https" - containerPort: 8443 - protocol: TCP - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: null - runAsGroup: 1000 - runAsNonRoot: true - runAsUser: 1000 - seccompProfile: - type: RuntimeDefault - readinessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - livenessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - volumeMounts: - - name: "tls-coder-tls" - mountPath: "/etc/ssl/certs/coder/coder-tls" - readOnly: true - - volumes: - - name: "tls-coder-tls" - secret: - secretName: "coder-tls" diff --git a/helm/tests/testdata/workspace_proxy.golden b/helm/tests/testdata/workspace_proxy.golden deleted file mode 100644 index 88e0213be559d..0000000000000 --- a/helm/tests/testdata/workspace_proxy.golden +++ /dev/null @@ -1,206 +0,0 @@ ---- -# Source: coder/templates/coder.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: "coder" - annotations: - {} - labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" - app.kubernetes.io/managed-by: Helm ---- -# Source: coder/templates/rbac.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: coder-workspace-perms -rules: - - apiGroups: [""] - resources: ["pods"] - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: [""] - resources: ["persistentvolumeclaims"] - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - apps - resources: - - deployments - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch ---- -# Source: coder/templates/rbac.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: "coder" -subjects: - - kind: ServiceAccount - name: "coder" -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: coder-workspace-perms ---- -# Source: coder/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: coder - labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" - app.kubernetes.io/managed-by: Helm - annotations: - {} -spec: - type: LoadBalancer - sessionAffinity: ClientIP - ports: - - name: "http" - port: 80 - targetPort: "http" - protocol: TCP - externalTrafficPolicy: "Cluster" - selector: - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name ---- -# Source: coder/templates/coder.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: coder - labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" - app.kubernetes.io/managed-by: Helm - annotations: - {} -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name - template: - metadata: - labels: - helm.sh/chart: coder-0.1.0 - app.kubernetes.io/name: coder - app.kubernetes.io/instance: release-name - app.kubernetes.io/part-of: coder - app.kubernetes.io/version: "0.1.0" - app.kubernetes.io/managed-by: Helm - annotations: - {} - spec: - serviceAccountName: "coder" - restartPolicy: Always - terminationGracePeriodSeconds: 60 - affinity: - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - podAffinityTerm: - labelSelector: - matchExpressions: - - key: app.kubernetes.io/instance - operator: In - values: - - coder - topologyKey: kubernetes.io/hostname - weight: 1 - containers: - - name: coder - image: "ghcr.io/coder/coder:latest" - imagePullPolicy: IfNotPresent - command: - - /opt/coder - args: - - wsproxy - - server - resources: - {} - lifecycle: - {} - env: - - name: CODER_HTTP_ADDRESS - value: "0.0.0.0:8080" - - name: CODER_PROMETHEUS_ADDRESS - value: "0.0.0.0:2112" - # Set the default access URL so a `helm apply` works by default. - # See: https://github.com/coder/coder/issues/5024 - - name: CODER_ACCESS_URL - value: "http://coder.default.svc.cluster.local" - # Used for inter-pod communication with high-availability. - - name: KUBE_POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: CODER_DERP_SERVER_RELAY_URL - value: "http://$(KUBE_POD_IP):8080" - - - name: CODER_PRIMARY_ACCESS_URL - value: https://dev.coder.com - - name: CODER_PROXY_SESSION_TOKEN - valueFrom: - secretKeyRef: - key: token - name: coder-workspace-proxy-session-token - ports: - - name: "http" - containerPort: 8080 - protocol: TCP - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: null - runAsGroup: 1000 - runAsNonRoot: true - runAsUser: 1000 - seccompProfile: - type: RuntimeDefault - readinessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - livenessProbe: - httpGet: - path: /healthz - port: "http" - scheme: "HTTP" - volumeMounts: [] - volumes: [] diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index c882807a8d434..28d88f568c123 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -351,7 +351,7 @@ func TestProvision(t *testing.T) { Files: map[string]string{ "main.tf": `a`, }, - ErrorContains: "plan terraform", + ErrorContains: "initialize terraform", ExpectLogContains: "Argument or block definition required", }, { @@ -359,7 +359,7 @@ func TestProvision(t *testing.T) { Files: map[string]string{ "main.tf": `;asdf;`, }, - ErrorContains: "plan terraform", + ErrorContains: "initialize terraform", ExpectLogContains: `The ";" character is not valid.`, }, { diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index d7e50f96bfab1..79de066596198 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -204,7 +204,7 @@ func (p *Server) connect(ctx context.Context) { p.clientValue.Store(ptr.Ref(client)) p.mutex.Unlock() - p.opts.Logger.Debug(context.Background(), "connected") + p.opts.Logger.Debug(ctx, "successfully connected to coderd") break } select { diff --git a/pty/start_test.go b/pty/start_test.go index 5f273428d2ea6..a3141e82689d8 100644 --- a/pty/start_test.go +++ b/pty/start_test.go @@ -5,11 +5,9 @@ import ( "context" "fmt" "io" - "strings" "testing" "time" - "github.com/hinshun/vt10x" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -73,7 +71,7 @@ func Test_Start_truncation(t *testing.T) { n := 1 for n <= countEnd { want := fmt.Sprintf("%d", n) - err := readUntil(ctx, t, want, pc.OutputReader()) + err := testutil.ReadUntilString(ctx, t, want, pc.OutputReader()) assert.NoError(t, err, "want: %s", want) if err != nil { return @@ -141,36 +139,3 @@ func Test_Start_cancel_context(t *testing.T) { t.Error("cmd.Wait() timed out") } } - -// readUntil reads one byte at a time until we either see the string we want, or the context expires -func readUntil(ctx context.Context, t *testing.T, want string, r io.Reader) error { - // output can contain virtual terminal sequences, so we need to parse these - // to correctly interpret getting what we want. - term := vt10x.New(vt10x.WithSize(80, 80)) - readErrs := make(chan error, 1) - for { - b := make([]byte, 1) - go func() { - _, err := r.Read(b) - readErrs <- err - }() - select { - case err := <-readErrs: - if err != nil { - t.Logf("err: %v\ngot: %v", err, term) - return err - } - term.Write(b) - case <-ctx.Done(): - return ctx.Err() - } - got := term.String() - lines := strings.Split(got, "\n") - for _, line := range lines { - if strings.TrimSpace(line) == want { - t.Logf("want: %v\n got:%v", want, line) - return nil - } - } - } -} diff --git a/scaletest/createworkspaces/run_test.go b/scaletest/createworkspaces/run_test.go index ac9812dccf3ce..ca71a6c041629 100644 --- a/scaletest/createworkspaces/run_test.go +++ b/scaletest/createworkspaces/run_test.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" "github.com/coder/coder/provisioner/echo" @@ -156,6 +157,117 @@ func Test_Runner(t *testing.T) { require.Len(t, workspaces.Workspaces, 0) }) + t.Run("CleanupPendingBuild", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + 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, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: []*proto.Provision_Response{ + { + Type: &proto.Provision_Response_Log{Log: &proto.Log{}}, + }, + }, + }) + + version = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(request *codersdk.CreateTemplateRequest) { + request.AllowUserCancelWorkspaceJobs = ptr.Ref(true) + }) + + const ( + username = "scaletest-user" + email = "scaletest@test.coder.com" + ) + runner := createworkspaces.NewRunner(client, createworkspaces.Config{ + User: createworkspaces.UserConfig{ + OrganizationID: user.OrganizationID, + Username: username, + Email: email, + }, + Workspace: workspacebuild.Config{ + OrganizationID: user.OrganizationID, + Request: codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + }, + }, + }) + + cancelCtx, cancelFunc := context.WithCancel(ctx) + done := make(chan struct{}) + logs := bytes.NewBuffer(nil) + go func() { + err := runner.Run(cancelCtx, "1", logs) + logsStr := logs.String() + t.Log("Runner logs:\n\n" + logsStr) + require.ErrorIs(t, err, context.Canceled) + close(done) + }() + + require.Eventually(t, func() bool { + workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + if err != nil { + return false + } + + return len(workspaces.Workspaces) > 0 + }, testutil.WaitShort, testutil.IntervalFast) + + cancelFunc() + <-done + + // When we run the cleanup, it should be canceled + cancelCtx, cancelFunc = context.WithCancel(ctx) + done = make(chan struct{}) + go func() { + // This will return an error as the "delete" operation will never complete. + _ = runner.Cleanup(cancelCtx, "1") + close(done) + }() + + // Ensure the job has been marked as deleted + require.Eventually(t, func() bool { + workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + if err != nil { + return false + } + + if len(workspaces.Workspaces) == 0 { + return false + } + + // There should be two builds + builds, err := client.WorkspaceBuilds(ctx, codersdk.WorkspaceBuildsRequest{ + WorkspaceID: workspaces.Workspaces[0].ID, + }) + if err != nil { + return false + } + for _, build := range builds { + // One of the builds should be for creating the workspace, + if build.Transition != codersdk.WorkspaceTransitionStart { + continue + } + + // And it should be either canceled or cancelling + if build.Job.Status == codersdk.ProvisionerJobCanceled || build.Job.Status == codersdk.ProvisionerJobCanceling { + return true + } + } + return false + }, testutil.WaitShort, testutil.IntervalFast) + cancelFunc() + <-done + }) + t.Run("NoCleanup", func(t *testing.T) { t.Parallel() diff --git a/scaletest/terraform/gcp_vpc.tf b/scaletest/terraform/gcp_vpc.tf index 7ed76a00235e9..eb965354c3917 100644 --- a/scaletest/terraform/gcp_vpc.tf +++ b/scaletest/terraform/gcp_vpc.tf @@ -12,7 +12,7 @@ resource "google_compute_subnetwork" "subnet" { project = var.project_id region = var.region network = google_compute_network.vpc.name - ip_cidr_range = "10.10.0.0/24" + ip_cidr_range = "10.200.0.0/24" } resource "google_compute_global_address" "sql_peering" { diff --git a/scaletest/workspacebuild/run.go b/scaletest/workspacebuild/run.go index 166aaf1f1aa79..997e58ff43819 100644 --- a/scaletest/workspacebuild/run.go +++ b/scaletest/workspacebuild/run.go @@ -112,7 +112,30 @@ func (r *CleanupRunner) Run(ctx context.Context, _ string, logs io.Writer) error ctx, span := tracing.StartSpan(ctx) defer span.End() - build, err := r.client.CreateWorkspaceBuild(ctx, r.workspaceID, codersdk.CreateWorkspaceBuildRequest{ + logs = loadtestutil.NewSyncWriter(logs) + logger := slog.Make(sloghuman.Sink(logs)).Leveled(slog.LevelDebug) + r.client.SetLogger(logger) + r.client.SetLogBodies(true) + + ws, err := r.client.Workspace(ctx, r.workspaceID) + if err != nil { + return err + } + + build, err := r.client.WorkspaceBuild(ctx, ws.LatestBuild.ID) + if err == nil && build.Job.Status.Active() { + // mark the build as canceled + if err = r.client.CancelWorkspaceBuild(ctx, build.ID); err == nil { + // Wait for the job to cancel before we delete it + _ = waitForBuild(ctx, logs, r.client, build.ID) // it will return a "build canceled" error + } else { + logger.Warn(ctx, "failed to cancel workspace build, attempting to delete anyway", slog.Error(err)) + } + } else { + logger.Warn(ctx, "unable to lookup latest workspace build, attempting to delete anyway", slog.Error(err)) + } + + build, err = r.client.CreateWorkspaceBuild(ctx, r.workspaceID, codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionDelete, }) if err != nil { diff --git a/scaletest/workspacetraffic/conn.go b/scaletest/workspacetraffic/conn.go index 167164c5ef33f..4be38a02c6abf 100644 --- a/scaletest/workspacetraffic/conn.go +++ b/scaletest/workspacetraffic/conn.go @@ -19,7 +19,7 @@ func connectPTY(ctx context.Context, client *codersdk.Client, agentID, reconnect Reconnect: reconnect, Height: 25, Width: 80, - Command: "/bin/sh", + Command: "sh", }) if err != nil { return nil, xerrors.Errorf("connect pty: %w", err) diff --git a/scripts/Dockerfile.base b/scripts/Dockerfile.base index 738b66f01090e..2681ef9cafec2 100644 --- a/scripts/Dockerfile.base +++ b/scripts/Dockerfile.base @@ -1,7 +1,7 @@ # This is the base image used for Coder images. It's a multi-arch image that is # built in depot.dev for all supported architectures. Since it's built on real # hardware and not cross-compiled, it can have "RUN" commands. -FROM alpine:3.18.2 +FROM alpine:3.18.3 # We use a single RUN command to reduce the number of layers in the image. # NOTE: Keep the Terraform version in sync with minTerraformVersion and @@ -10,10 +10,7 @@ RUN apk add --no-cache \ curl \ wget \ bash \ - jq \ git \ - # Fixes CVE-2023-3446 and CVE-2023-2975. Only necessary until Alpine 3.18.3. - openssl \ openssh-client && \ # Use the edge repo, since Terraform doesn't seem to be backported to 3.18. apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community \ diff --git a/scripts/chocolatey/coder.nuspec b/scripts/chocolatey/coder.nuspec new file mode 100644 index 0000000000000..65751f257e436 --- /dev/null +++ b/scripts/chocolatey/coder.nuspec @@ -0,0 +1,39 @@ + + + + + + + + + + + + + coder + $version$ + https://github.com/coder/coder/blob/main/scripts/chocolatey + + Coder Technologies\, Inc. + + + + + Coder (Install) + Coder Technologies\, Inc. + https://coder.com + https://github.com/coder/presskit/raw/main/logos/coder%20logo%20black%20square.png?raw=true + Coder Technologies, Inc. + https://coder.com/legal/terms-of-service + true + https://github.com/coder/coder.git + https://coder.com/docs/v2/latest + https://github.com/coder/coder/issues + coder remote-dev terraform development + Remote development environments on your infrastructure provisioned with Terraform + Remote development environments on your infrastructure provisioned with Terraform + + + + + diff --git a/scripts/ci-report/main.go b/scripts/ci-report/main.go index 9e3ae7e39d6bb..e6a2cf736b524 100644 --- a/scripts/ci-report/main.go +++ b/scripts/ci-report/main.go @@ -12,6 +12,8 @@ import ( "golang.org/x/exp/slices" "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/util/slice" ) func main() { @@ -161,9 +163,8 @@ func parseCIReport(report GotestsumReport) (CIReport, error) { } timeouts = timeoutsNorm - sortAZ := func(a, b string) bool { return a < b } - slices.SortFunc(packagesSortedByName, sortAZ) - slices.SortFunc(testSortedByName, sortAZ) + slices.SortFunc(packagesSortedByName, slice.Ascending[string]) + slices.SortFunc(testSortedByName, slice.Ascending[string]) var rep CIReport diff --git a/scripts/deploy-pr.sh b/scripts/deploy-pr.sh index 84ee6ed6266f4..e852368fd8bbe 100755 --- a/scripts/deploy-pr.sh +++ b/scripts/deploy-pr.sh @@ -1,21 +1,26 @@ #!/usr/bin/env bash -# Usage: ./deploy-pr.sh [--skip-build -s] [--dry-run -n] [--yes -y] +# Usage: ./deploy-pr.sh [--dry-run -n] [--yes -y] [--experiments -e ] [--build -b] [--deploy -d] # deploys the current branch to a PR environment and posts login credentials to # [#pr-deployments](https://codercom.slack.com/archives/C05DNE982E8) Slack channel set -euo pipefail # default settings -skipBuild=false dryRun=false confirm=true +build=false +deploy=false experiments="" # parse arguments while (("$#")); do case "$1" in - -s | --skip-build) - skipBuild=true + -b | --build) + build=true + shift + ;; + -d | --deploy) + deploy=true shift ;; -n | --dry-run) @@ -63,30 +68,20 @@ fi branchName=$(gh pr view --json headRefName | jq -r .headRefName) prNumber=$(gh pr view --json number | jq -r .number) -if $skipBuild; then - #check if the image exists - foundTag=$(curl -fsSL https://github.com/coder/coder/pkgs/container/coder-preview | grep -o "$prNumber" | head -n 1) || true - echo "foundTag is: '${foundTag}'" - if [[ -z "${foundTag}" ]]; then - echo "Image not found" - echo "${prNumber} tag not found in ghcr.io/coder/coder-preview" - echo "Please remove --skip-build and try again" - exit 1 - fi -fi - -if $dryRun; then +if [[ "$dryRun" = true ]]; then echo "dry run" echo "branchName: ${branchName}" echo "prNumber: ${prNumber}" - echo "skipBuild: ${skipBuild}" echo "experiments: ${experiments}" + echo "build: ${build}" + echo "deploy: ${deploy}" exit 0 fi echo "branchName: ${branchName}" echo "prNumber: ${prNumber}" -echo "skipBuild: ${skipBuild}" echo "experiments: ${experiments}" +echo "build: ${build}" +echo "deploy: ${deploy}" -gh workflow run pr-deploy.yaml --ref "${branchName}" -f "pr_number=${prNumber}" -f "skip_build=${skipBuild}" -f "experiments=${experiments}" +gh workflow run pr-deploy.yaml --ref "${branchName}" -f "pr_number=${prNumber}" -f "experiments=${experiments}" -f "build=${build}" -f "deploy=${deploy}" diff --git a/scripts/develop.sh b/scripts/develop.sh index 671c46a0bd5cc..fc21ae8b647cf 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -131,7 +131,7 @@ fatal() { trap 'fatal "Script encountered an error"' ERR cdroot - start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "http://127.0.0.1:3000" --dangerous-allow-cors-requests=true --experiments "*,moons" "$@" + start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --dangerous-allow-cors-requests=true "$@" echo '== Waiting for Coder to become ready' # Start the timeout in the background so interrupting this script diff --git a/scripts/helm.sh b/scripts/helm.sh index 33b556f0100a1..1397281c7e3c4 100755 --- a/scripts/helm.sh +++ b/scripts/helm.sh @@ -4,15 +4,14 @@ # .tgz file at the specified path, and may optionally push it to the Coder OSS # repo. # -# ./helm.sh [--version 1.2.3] [--output path/to/coder.tgz] [--push] +# ./helm.sh [--version 1.2.3] [--chart coder|provisioner] [--output path/to/coder.tgz] # # If no version is specified, defaults to the version from ./version.sh. # -# If no output path is specified, defaults to -# "$repo_root/build/coder_helm_$version.tgz". +# If no chart is specified, defaults to 'coder' # -# If the --push parameter is specified, the resulting artifact will be published -# to the Coder OSS repo. This requires `gsutil` to be installed and configured. +# If no output path is specified, defaults to +# "$repo_root/build/$chart_helm_$version.tgz". set -euo pipefail # shellcheck source=scripts/lib.sh @@ -20,9 +19,9 @@ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" version="" output_path="" -push=0 +chart="" -args="$(getopt -o "" -l version:,output:,push -- "$@")" +args="$(getopt -o "" -l version:,chart:,output:,push -- "$@")" eval set -- "$args" while true; do case "$1" in @@ -30,14 +29,14 @@ while true; do version="$2" shift 2 ;; + --chart) + chart="$2" + shift 2 + ;; --output) output_path="$(realpath "$2")" shift 2 ;; - --push) - push="1" - shift - ;; --) shift break @@ -54,10 +53,17 @@ if [[ "$version" == "" ]]; then version="$(execrelative ./version.sh)" fi +if [[ "$chart" == "" ]]; then + chart="coder" +fi +if ! [[ "$chart" =~ ^(coder|provisioner)$ ]]; then + error "--chart value must be one of (coder, provisioner)" +fi + if [[ "$output_path" == "" ]]; then cdroot mkdir -p build - output_path="$(realpath "build/coder_helm_$version.tgz")" + output_path="$(realpath "build/${chart}_helm_${version}.tgz")" fi # Check dependencies @@ -69,8 +75,10 @@ cdroot temp_dir="$(mktemp -d)" cdroot -cd ./helm -log "--- Packaging helm chart for version $version ($output_path)" +cd ./helm/$chart +log "--- Updating dependencies" +helm dependency update . +log "--- Packaging helm chart $chart for version $version ($output_path)" helm package \ --version "$version" \ --app-version "$version" \ @@ -80,8 +88,3 @@ helm package \ log "Moving helm chart to $output_path" cp "$temp_dir"/*.tgz "$output_path" rm -rf "$temp_dir" - -if [[ "$push" == 1 ]]; then - log "--- Publishing helm chart..." - # TODO: figure out how/where we want to publish the helm chart -fi diff --git a/scripts/metricsdocgen/main.go b/scripts/metricsdocgen/main.go index fbeb148715c54..8589653172005 100644 --- a/scripts/metricsdocgen/main.go +++ b/scripts/metricsdocgen/main.go @@ -56,13 +56,13 @@ func main() { } } -func readMetrics() ([]dto.MetricFamily, error) { +func readMetrics() ([]*dto.MetricFamily, error) { f, err := os.Open(metricsFile) if err != nil { return nil, xerrors.New("can't open metrics file") } - var metrics []dto.MetricFamily + var metrics []*dto.MetricFamily decoder := expfmt.NewDecoder(f, expfmt.FmtProtoText) for { @@ -73,7 +73,7 @@ func readMetrics() ([]dto.MetricFamily, error) { } else if err != nil { return nil, err } - metrics = append(metrics, m) + metrics = append(metrics, &m) } sort.Slice(metrics, func(i, j int) bool { @@ -90,7 +90,7 @@ func readPrometheusDoc() ([]byte, error) { return doc, nil } -func updatePrometheusDoc(doc []byte, metricFamilies []dto.MetricFamily) ([]byte, error) { +func updatePrometheusDoc(doc []byte, metricFamilies []*dto.MetricFamily) ([]byte, error) { i := bytes.Index(doc, generatorPrefix) if i < 0 { return nil, xerrors.New("generator prefix tag not found") diff --git a/scripts/release/check_commit_metadata.sh b/scripts/release/check_commit_metadata.sh index 7ad78992d0e0c..02a39525365d4 100755 --- a/scripts/release/check_commit_metadata.sh +++ b/scripts/release/check_commit_metadata.sh @@ -82,16 +82,20 @@ main() { --json mergeCommit,labels,author \ --jq '.[] | "\( .mergeCommit.oid ) author:\( .author.login ) labels:\(["label:\( .labels[].name )"] | join(" "))"' )" - mapfile -t pr_metadata_raw <<<"$pr_list_out" + declare -A authors labels - for entry in "${pr_metadata_raw[@]}"; do - commit_sha_long=${entry%% *} - commit_author=${entry#* author:} - commit_author=${commit_author%% *} - authors[$commit_sha_long]=$commit_author - all_labels=${entry#* labels:} - labels[$commit_sha_long]=$all_labels - done + if [[ -n $pr_list_out ]]; then + mapfile -t pr_metadata_raw <<<"$pr_list_out" + + for entry in "${pr_metadata_raw[@]}"; do + commit_sha_long=${entry%% *} + commit_author=${entry#* author:} + commit_author=${commit_author%% *} + authors[$commit_sha_long]=$commit_author + all_labels=${entry#* labels:} + labels[$commit_sha_long]=$all_labels + done + fi for commit in "${commits[@]}"; do mapfile -d ' ' -t parts <<<"$commit" diff --git a/scripts/rules.go b/scripts/rules.go index 20d0c43f7b883..1c26b80704b62 100644 --- a/scripts/rules.go +++ b/scripts/rules.go @@ -51,8 +51,12 @@ func xerrors(m dsl.Matcher) { m.Import("fmt") m.Import("golang.org/x/xerrors") - m.Match("fmt.Errorf($*args)"). - Suggest("xerrors.New($args)"). + m.Match("fmt.Errorf($arg)"). + Suggest("xerrors.New($arg)"). + Report("Use xerrors to provide additional stacktrace information!") + + m.Match("fmt.Errorf($arg1, $*args)"). + Suggest("xerrors.Errorf($arg1, $args)"). Report("Use xerrors to provide additional stacktrace information!") m.Match("errors.$_($msg)"). diff --git a/site/.eslintignore b/site/.eslintignore index 46023d091348a..9bed2be372b11 100644 --- a/site/.eslintignore +++ b/site/.eslintignore @@ -67,7 +67,7 @@ stats/ # .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/site/.prettierignore b/site/.prettierignore index 46023d091348a..9bed2be372b11 100644 --- a/site/.prettierignore +++ b/site/.prettierignore @@ -67,7 +67,7 @@ stats/ # .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/site/e2e/helpers.ts b/site/e2e/helpers.ts index dfbd5f99896a2..bdfc8b015352d 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -65,20 +65,21 @@ export const createTemplate = async ( export const sshIntoWorkspace = async ( page: Page, workspace: string, + binaryPath = "go", + binaryArgs: string[] = [], ): Promise => { + if (binaryPath === "go") { + binaryArgs = ["run", coderMainPath()] + } const sessionToken = await findSessionToken(page) return new Promise((resolve, reject) => { - const cp = spawn( - "go", - ["run", coderMainPath(), "ssh", "--stdio", workspace], - { - env: { - ...process.env, - CODER_SESSION_TOKEN: sessionToken, - CODER_URL: "http://localhost:3000", - }, + const cp = spawn(binaryPath, [...binaryArgs, "ssh", "--stdio", workspace], { + env: { + ...process.env, + CODER_SESSION_TOKEN: sessionToken, + CODER_URL: "http://localhost:3000", }, - ) + }) cp.on("error", (err) => reject(err)) const proxyStream = new Duplex({ read: (size) => { @@ -122,7 +123,7 @@ export const downloadCoderVersion = async ( } const binaryName = "coder-e2e-" + version - const tempDir = "/tmp" + const tempDir = "/tmp/coder-e2e-cache" // The install script adds `./bin` automatically to the path :shrug: const binaryPath = path.join(tempDir, "bin", binaryName) @@ -140,26 +141,35 @@ export const downloadCoderVersion = async ( // Runs our public install script using our options to // install the binary! await new Promise((resolve, reject) => { - const cp = spawn("sh", [ - "-c", + const cp = spawn( + "sh", [ - "curl", - "-L", - "https://coder.com/install.sh", - "|", - "sh", - "-s", - "--", - "--version", - version, - "--method", - "standalone", - "--prefix", - tempDir, - "--binary-name", - binaryName, - ].join(" "), - ]) + "-c", + [ + "curl", + "-L", + "https://coder.com/install.sh", + "|", + "sh", + "-s", + "--", + "--version", + version, + "--method", + "standalone", + "--prefix", + tempDir, + "--binary-name", + binaryName, + ].join(" "), + ], + { + env: { + ...process.env, + XDG_CACHE_HOME: "/tmp/coder-e2e-cache", + }, + }, + ) // eslint-disable-next-line no-console -- Needed for debugging cp.stderr.on("data", (data) => console.log(data.toString())) cp.on("close", (code) => { @@ -191,7 +201,7 @@ export const startAgentWithCommand = async ( buffer = Buffer.concat([buffer, data]) }) try { - await page.getByTestId("agent-status-ready").isVisible() + await page.getByTestId("agent-status-ready").waitFor({ state: "visible" }) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The error is a string } catch (ex: any) { throw new Error(ex.toString() + "\n" + buffer.toString()) diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index 0ff9a6fe74622..78eb4495ce371 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ use: { storageState: STORAGE_STATE, }, + timeout: 60000, }, ], use: { diff --git a/site/e2e/tests/outdatedCLI.spec.ts b/site/e2e/tests/outdatedCLI.spec.ts new file mode 100644 index 0000000000000..ab143bad27c34 --- /dev/null +++ b/site/e2e/tests/outdatedCLI.spec.ts @@ -0,0 +1,52 @@ +import { test } from "@playwright/test" +import { randomUUID } from "crypto" +import { + createTemplate, + createWorkspace, + downloadCoderVersion, + sshIntoWorkspace, + startAgent, +} from "../helpers" + +const clientVersion = "v0.14.0" + +test("ssh with client " + clientVersion, async ({ page }) => { + const token = randomUUID() + const template = await createTemplate(page, { + apply: [ + { + complete: { + resources: [ + { + agents: [ + { + token, + }, + ], + }, + ], + }, + }, + ], + }) + const workspace = await createWorkspace(page, template) + await startAgent(page, token) + const binaryPath = await downloadCoderVersion(clientVersion) + + const client = await sshIntoWorkspace(page, workspace, binaryPath) + await new Promise((resolve, reject) => { + // We just exec a command to be certain the agent is running! + client.exec("exit 0", (err, stream) => { + if (err) { + return reject(err) + } + stream.on("exit", (code) => { + if (code !== 0) { + return reject(new Error(`Command exited with code ${code}`)) + } + client.end() + resolve() + }) + }) + }) +}) diff --git a/site/index.html b/site/index.html index 92801202a5d88..29cafbb453a46 100644 --- a/site/index.html +++ b/site/index.html @@ -10,6 +10,7 @@ + Coder @@ -35,6 +36,12 @@ href="/favicons/favicon.svg" data-react-helmet="true" /> + diff --git a/site/package.json b/site/package.json index 538e158218292..43ec541d149c0 100644 --- a/site/package.json +++ b/site/package.json @@ -21,7 +21,7 @@ "playwright:test": "playwright test --config=e2e/playwright.config.ts", "gen:provisioner": "protoc --plugin=./node_modules/.bin/protoc-gen-ts-proto --ts_proto_out=./e2e/ --ts_proto_opt=outputJsonMethods=false,outputEncodeMethods=encode-no-creation,outputClientImpl=false,nestJs=false,outputPartialMethods=false,fileSuffix=Generated,suffix=hey -I ../provisionersdk/proto ../provisionersdk/proto/provisioner.proto && prettier --cache --write './e2e/provisionerGenerated.ts'", "storybook": "STORYBOOK=true storybook dev -p 6006", - "storybook:build": "storybook build", + "storybook:build": "storybook build --webpack-stats-json", "test": "jest --selectProjects test", "test:ci": "jest --selectProjects test --silent", "test:coverage": "jest --selectProjects test --collectCoverage", @@ -48,6 +48,7 @@ "@types/color-convert": "2.0.0", "@types/lodash": "4.14.196", "@types/react-color": "3.0.6", + "@types/react-date-range": "1.4.4", "@types/semver": "7.5.0", "@vitejs/plugin-react": "4.0.1", "@xstate/inspect": "0.8.0", @@ -55,7 +56,7 @@ "ansi-to-html": "0.7.2", "axios": "1.3.4", "canvas": "2.11.0", - "chart.js": "3.9.1", + "chart.js": "4.3.3", "chartjs-adapter-date-fns": "3.0.0", "chroma-js": "2.4.2", "color-convert": "2.0.1", @@ -77,6 +78,7 @@ "react-chartjs-2": "5.2.0", "react-color": "2.19.3", "react-confetti": "6.1.0", + "react-date-range": "1.4.0", "react-dom": "18.2.0", "react-headless-tabs": "6.0.3", "react-helmet-async": "1.3.0", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index abb876c024c61..00d76bb9c3efc 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -60,6 +60,9 @@ dependencies: '@types/react-color': specifier: 3.0.6 version: 3.0.6 + '@types/react-date-range': + specifier: 1.4.4 + version: 1.4.4 '@types/semver': specifier: 7.5.0 version: 7.5.0 @@ -82,11 +85,11 @@ dependencies: specifier: 2.11.0 version: 2.11.0 chart.js: - specifier: 3.9.1 - version: 3.9.1 + specifier: 4.3.3 + version: 4.3.3 chartjs-adapter-date-fns: specifier: 3.0.0 - version: 3.0.0(chart.js@3.9.1)(date-fns@2.30.0) + version: 3.0.0(chart.js@4.3.3)(date-fns@2.30.0) chroma-js: specifier: 2.4.2 version: 2.4.2 @@ -140,13 +143,16 @@ dependencies: version: 18.2.0 react-chartjs-2: specifier: 5.2.0 - version: 5.2.0(chart.js@3.9.1)(react@18.2.0) + version: 5.2.0(chart.js@4.3.3)(react@18.2.0) react-color: specifier: 2.19.3 version: 2.19.3(react@18.2.0) react-confetti: specifier: 6.1.0 version: 6.1.0(react@18.2.0) + react-date-range: + specifier: 1.4.0 + version: 1.4.0(date-fns@2.30.0)(react@18.2.0) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) @@ -2510,6 +2516,10 @@ packages: resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} dev: true + /@kurkle/color@0.3.2: + resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} + dev: false + /@mapbox/node-pre-gyp@1.0.11: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true @@ -4952,6 +4962,13 @@ packages: '@types/reactcss': 1.2.6 dev: false + /@types/react-date-range@1.4.4: + resolution: {integrity: sha512-9Y9NyNgaCsEVN/+O4HKuxzPbVjRVBGdOKRxMDcsTRWVG62lpYgnxefNckTXDWup8FvczoqPW0+ESZR6R1yymDg==} + dependencies: + '@types/react': 18.2.6 + date-fns: 2.30.0 + dev: false + /@types/react-dom@18.2.4: resolution: {integrity: sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==} dependencies: @@ -6172,17 +6189,20 @@ packages: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} dev: true - /chart.js@3.9.1: - resolution: {integrity: sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==} + /chart.js@4.3.3: + resolution: {integrity: sha512-aTk7pBw+x6sQYhon/NR3ikfUJuym/LdgpTlgZRe2PaEhjUMKBKyNaFCMVRAyTEWYFNO7qRu7iQVqOw/OqzxZxQ==} + engines: {pnpm: '>=7'} + dependencies: + '@kurkle/color': 0.3.2 dev: false - /chartjs-adapter-date-fns@3.0.0(chart.js@3.9.1)(date-fns@2.30.0): + /chartjs-adapter-date-fns@3.0.0(chart.js@4.3.3)(date-fns@2.30.0): resolution: {integrity: sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==} peerDependencies: chart.js: '>=2.8.0' date-fns: '>=2.0.0' dependencies: - chart.js: 3.9.1 + chart.js: 4.3.3 date-fns: 2.30.0 dev: false @@ -6226,6 +6246,10 @@ packages: resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} dev: true + /classnames@2.3.2: + resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} + dev: false + /clean-regexp@1.0.0: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} @@ -11134,13 +11158,13 @@ packages: unpipe: 1.0.0 dev: true - /react-chartjs-2@5.2.0(chart.js@3.9.1)(react@18.2.0): + /react-chartjs-2@5.2.0(chart.js@4.3.3)(react@18.2.0): resolution: {integrity: sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==} peerDependencies: chart.js: ^4.1.1 react: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: - chart.js: 3.9.1 + chart.js: 4.3.3 react: 18.2.0 dev: false @@ -11179,6 +11203,20 @@ packages: tween-functions: 1.2.0 dev: false + /react-date-range@1.4.0(date-fns@2.30.0)(react@18.2.0): + resolution: {integrity: sha512-+9t0HyClbCqw1IhYbpWecjsiaftCeRN5cdhsi9v06YdimwyMR2yYHWcgVn3URwtN/txhqKpEZB6UX1fHpvK76w==} + peerDependencies: + date-fns: 2.0.0-alpha.7 || >=2.0.0 + react: ^0.14 || ^15.0.0-rc || >=15.0 + dependencies: + classnames: 2.3.2 + date-fns: 2.30.0 + prop-types: 15.8.1 + react: 18.2.0 + react-list: 0.8.17(react@18.2.0) + shallow-equal: 1.2.1 + dev: false + /react-docgen-typescript@2.2.2(typescript@5.1.6): resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==} peerDependencies: @@ -11313,6 +11351,15 @@ packages: /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + /react-list@0.8.17(react@18.2.0): + resolution: {integrity: sha512-pgmzGi0G5uGrdHzMhgO7KR1wx5ZXVvI3SsJUmkblSAKtewIhMwbQiMuQiTE83ozo04BQJbe0r3WIWzSO0dR1xg==} + peerDependencies: + react: 0.14 || 15 - 18 + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /react-markdown@8.0.3(@types/react@18.2.6)(react@18.2.0): resolution: {integrity: sha512-We36SfqaKoVNpN1QqsZwWSv/OZt5J15LNgTLWynwAN5b265hrQrsjMtlRNwUvS+YyR3yDM8HpTNc4pK9H/Gc0A==} peerDependencies: @@ -12001,6 +12048,10 @@ packages: kind-of: 6.0.3 dev: true + /shallow-equal@1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + dev: false + /shallowequal@1.1.0: resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} dev: false diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 449d4925febb3..c1f222d90c334 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -272,10 +272,7 @@ export const AppRouter: FC = () => { } /> - } - > + }> } /> } /> } /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index bd2f36967f74c..4d5c88fe74b16 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -488,7 +488,7 @@ export function waitForBuild(build: TypesGen.WorkspaceBuild) { const { job } = await getWorkspaceBuildByNumber( build.workspace_owner_name, build.workspace_name, - String(build.build_number), + build.build_number, ) latestJobInfo = job @@ -554,6 +554,21 @@ export const cancelWorkspaceBuild = async ( return response.data } +export const updateWorkspaceLock = async ( + workspaceId: string, + lock: boolean, +): Promise => { + const data: TypesGen.UpdateWorkspaceLock = { + lock: lock, + } + + const response = await axios.put( + `/api/v2/workspaces/${workspaceId}/lock`, + data, + ) + return response.data +} + export const restartWorkspace = async ({ workspace, buildParameters, @@ -757,7 +772,7 @@ export const getWorkspaceBuilds = async ( export const getWorkspaceBuildByNumber = async ( username = "me", workspaceName: string, - buildNumber: string, + buildNumber: number, ): Promise => { const response = await axios.get( `/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fdfa3542dbf6a..ccb37bc923004 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -247,6 +247,7 @@ export interface CreateUserRequest { readonly email: string readonly username: string readonly password: string + readonly login_type: LoginType readonly disable_login: boolean readonly organization_id: string } @@ -513,6 +514,7 @@ export interface Group { readonly members: User[] readonly avatar_url: string readonly quota_allowance: number + readonly source: GroupSource } // From codersdk/workspaceapps.go @@ -614,6 +616,8 @@ export interface OIDCConfig { readonly allow_signups: boolean readonly client_id: string readonly client_secret: string + readonly client_key_file: string + readonly client_cert_file: string // This is likely an enum in an external package ("github.com/coder/coder/cli/clibase.StringArray") readonly email_domain: string[] readonly issuer_url: string @@ -626,6 +630,10 @@ export interface OIDCConfig { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type readonly auth_url_params: any readonly ignore_user_info: boolean + readonly group_auto_create: boolean + // Named type "github.com/coder/coder/cli/clibase.Regexp" unknown, using "any" + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type + readonly group_regex_filter: any readonly groups_field: string // Named type "github.com/coder/coder/cli/clibase.Struct[map[string]string]" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type @@ -714,6 +722,7 @@ export interface ProvisionerConfig { readonly daemon_poll_interval: number readonly daemon_poll_jitter: number readonly force_cancel_interval: number + readonly daemon_psk: string } // From codersdk/provisionerdaemons.go @@ -986,6 +995,8 @@ export interface TemplateParameterUsage { readonly template_ids: string[] readonly display_name: string readonly name: string + readonly type: string + readonly description: string readonly options?: TemplateVersionParameterOption[] readonly values: TemplateParameterValue[] } @@ -1332,7 +1343,7 @@ export interface WorkspaceAgent { readonly login_before_ready: boolean readonly shutdown_script?: string readonly shutdown_script_timeout_seconds: number - readonly subsystem: AgentSubsystem + readonly subsystems: AgentSubsystem[] readonly health: WorkspaceAgentHealth } @@ -1537,8 +1548,12 @@ export type APIKeyScope = "all" | "application_connect" export const APIKeyScopes: APIKeyScope[] = ["all", "application_connect"] // From codersdk/workspaceagents.go -export type AgentSubsystem = "envbox" -export const AgentSubsystems: AgentSubsystem[] = ["envbox"] +export type AgentSubsystem = "envbox" | "envbuilder" | "exectrace" +export const AgentSubsystems: AgentSubsystem[] = [ + "envbox", + "envbuilder", + "exectrace", +] // From codersdk/audit.go export type AuditAction = @@ -1585,6 +1600,7 @@ export type Experiment = | "tailnet_pg_coordinator" | "template_restart_requirement" | "workspace_actions" + | "workspaces_batch_actions" export const Experiments: Experiment[] = [ "deployment_health_page", "moons", @@ -1592,6 +1608,7 @@ export const Experiments: Experiment[] = [ "tailnet_pg_coordinator", "template_restart_requirement", "workspace_actions", + "workspaces_batch_actions", ] // From codersdk/deployment.go @@ -1634,6 +1651,10 @@ export const GitProviders: GitProvider[] = [ "gitlab", ] +// From codersdk/groups.go +export type GroupSource = "oidc" | "user" +export const GroupSources: GroupSource[] = ["oidc", "user"] + // From codersdk/insights.go export type InsightsReportInterval = "day" export const InsightsReportIntervals: InsightsReportInterval[] = ["day"] @@ -1656,8 +1677,9 @@ export type LogSource = "provisioner" | "provisioner_daemon" export const LogSources: LogSource[] = ["provisioner", "provisioner_daemon"] // From codersdk/apikey.go -export type LoginType = "github" | "none" | "oidc" | "password" | "token" +export type LoginType = "" | "github" | "none" | "oidc" | "password" | "token" export const LoginTypes: LoginType[] = [ + "", "github", "none", "oidc", diff --git a/site/src/components/BuildIcon/BuildIcon.tsx b/site/src/components/BuildIcon/BuildIcon.tsx new file mode 100644 index 0000000000000..ccdc68da1cb3e --- /dev/null +++ b/site/src/components/BuildIcon/BuildIcon.tsx @@ -0,0 +1,22 @@ +import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined" +import StopOutlined from "@mui/icons-material/StopOutlined" +import DeleteOutlined from "@mui/icons-material/DeleteOutlined" +import { WorkspaceTransition } from "api/typesGenerated" +import { ComponentProps } from "react" + +type SVGIcon = typeof PlayArrowOutlined + +type SVGIconProps = ComponentProps + +const iconByTransition: Record = { + start: PlayArrowOutlined, + stop: StopOutlined, + delete: DeleteOutlined, +} + +export const BuildIcon = ( + props: SVGIconProps & { transition: WorkspaceTransition }, +) => { + const Icon = iconByTransition[props.transition] + return +} diff --git a/site/src/components/BuildsTable/BuildAvatar.tsx b/site/src/components/BuildsTable/BuildAvatar.tsx index e095bda76a32e..3733db4650ffc 100644 --- a/site/src/components/BuildsTable/BuildAvatar.tsx +++ b/site/src/components/BuildsTable/BuildAvatar.tsx @@ -1,14 +1,12 @@ import Badge from "@mui/material/Badge" import { useTheme, withStyles } from "@mui/styles" import { FC } from "react" -import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined" -import PauseOutlined from "@mui/icons-material/PauseOutlined" -import DeleteOutlined from "@mui/icons-material/DeleteOutlined" -import { WorkspaceBuild, WorkspaceTransition } from "api/typesGenerated" +import { WorkspaceBuild } from "api/typesGenerated" import { getDisplayWorkspaceBuildStatus } from "utils/workspace" import { Avatar, AvatarProps } from "components/Avatar/Avatar" import { PaletteIndex } from "theme/theme" import { Theme } from "@mui/material/styles" +import { BuildIcon } from "components/BuildIcon/BuildIcon" interface StylesBadgeProps { type: PaletteIndex @@ -31,12 +29,6 @@ export interface BuildAvatarProps { size?: AvatarProps["size"] } -const iconByTransition: Record = { - start: , - stop: , - delete: , -} - export const BuildAvatar: FC = ({ build, size }) => { const theme = useTheme() const displayBuildStatus = getDisplayWorkspaceBuildStatus(theme, build) @@ -55,7 +47,7 @@ export const BuildAvatar: FC = ({ build, size }) => { badgeContent={
} > - {iconByTransition[build.transition]} + ) diff --git a/site/src/components/CreateUserForm/CreateUserForm.tsx b/site/src/components/CreateUserForm/CreateUserForm.tsx index c2f03155e7c62..6270f0ca88799 100644 --- a/site/src/components/CreateUserForm/CreateUserForm.tsx +++ b/site/src/components/CreateUserForm/CreateUserForm.tsx @@ -13,6 +13,7 @@ import { FullPageForm } from "../FullPageForm/FullPageForm" import { Stack } from "../Stack/Stack" import { ErrorAlert } from "components/Alert/ErrorAlert" import { hasApiFieldErrors, isApiError } from "api/errors" +import MenuItem from "@mui/material/MenuItem" export const Language = { emailLabel: "Email", @@ -31,6 +32,7 @@ export interface CreateUserFormProps { error?: unknown isLoading: boolean myOrgId: string + authMethods?: TypesGen.AuthMethods } const validationSchema = Yup.object({ @@ -38,13 +40,31 @@ const validationSchema = Yup.object({ .trim() .email(Language.emailInvalid) .required(Language.emailRequired), - password: Yup.string().required(Language.passwordRequired), + password: Yup.string().when("login_type", { + is: "password", + then: (schema) => schema.required(Language.passwordRequired), + otherwise: (schema) => schema, + }), username: nameValidator(Language.usernameLabel), }) +const authMethodSelect = ( + title: string, + value: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- future will use this + description: string, +) => { + return ( + + {title} + {/* TODO: Add description */} + + ) +} + export const CreateUserForm: FC< React.PropsWithChildren -> = ({ onSubmit, onCancel, error, isLoading, myOrgId }) => { +> = ({ onSubmit, onCancel, error, isLoading, myOrgId, authMethods }) => { const form: FormikContextType = useFormik({ initialValues: { @@ -53,6 +73,7 @@ export const CreateUserForm: FC< username: "", organization_id: myOrgId, disable_login: false, + login_type: "password", }, validationSchema, onSubmit, @@ -62,6 +83,42 @@ export const CreateUserForm: FC< error, ) + const methods = [] + if (authMethods?.password.enabled) { + methods.push( + authMethodSelect( + "Password", + "password", + "User can provide their email and password to login.", + ), + ) + } + if (authMethods?.oidc.enabled) { + methods.push( + authMethodSelect( + "OpenID Connect", + "oidc", + "Uses an OpenID connect provider to authenticate the user.", + ), + ) + } + if (authMethods?.github.enabled) { + methods.push( + authMethodSelect( + "Github", + "github", + "Uses github oauth to authenticate the user.", + ), + ) + } + methods.push( + authMethodSelect( + "None", + "none", + "User authentication is disabled. This user an only be used if an api token is created for them.", + ), + ) + return ( {isApiError(error) && !hasApiFieldErrors(error) && ( @@ -85,13 +142,39 @@ export const CreateUserForm: FC< label={Language.emailLabel} /> + { + if (e.target.value !== "password") { + await form.setFieldValue("password", "") + } + await form.setFieldValue("login_type", e.target.value) + }} + > + {methods} + diff --git a/site/src/components/DAUChart/DAUChart.tsx b/site/src/components/DAUChart/DAUChart.tsx index add0b6aa17db0..ae93b0e086dff 100644 --- a/site/src/components/DAUChart/DAUChart.tsx +++ b/site/src/components/DAUChart/DAUChart.tsx @@ -9,8 +9,7 @@ import { defaults, Legend, LinearScale, - LineElement, - PointElement, + BarElement, TimeScale, Title, Tooltip, @@ -23,14 +22,13 @@ import { } from "components/Tooltips/HelpTooltip" import dayjs from "dayjs" import { FC } from "react" -import { Line } from "react-chartjs-2" +import { Bar } from "react-chartjs-2" ChartJS.register( CategoryScale, LinearScale, TimeScale, - PointElement, - LineElement, + BarElement, Title, Tooltip, Legend, @@ -54,12 +52,21 @@ export const DAUChart: FC = ({ daus }) => { defaults.font.family = theme.typography.fontFamily as string defaults.color = theme.palette.text.secondary - const options: ChartOptions<"line"> = { + const options: ChartOptions<"bar"> = { responsive: true, plugins: { legend: { display: false, }, + tooltip: { + displayColors: false, + callbacks: { + title: (context) => { + const date = new Date(context[0].parsed.x) + return date.toLocaleDateString() + }, + }, + }, }, scales: { y: { @@ -69,11 +76,12 @@ export const DAUChart: FC = ({ daus }) => { }, }, x: { - ticks: {}, + ticks: { + stepSize: daus.entries.length > 10 ? 2 : undefined, + }, type: "time", time: { unit: "day", - stepSize: 2, }, }, }, @@ -81,7 +89,7 @@ export const DAUChart: FC = ({ daus }) => { } return ( - = ({ daus }) => { { label: "Daily Active Users", data: data, - tension: 1 / 4, backgroundColor: theme.palette.secondary.dark, borderColor: theme.palette.secondary.dark, + barThickness: 8, + borderWidth: 2, + borderRadius: Number.MAX_VALUE, + borderSkipped: false, }, ], }} diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index fd9065abdb27a..ed26b64ffc481 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -87,3 +87,13 @@ export const useDashboard = (): DashboardProviderValue => { return context } + +export const useIsWorkspaceActionsEnabled = (): boolean => { + const { entitlements, experiments } = useDashboard() + const allowAdvancedScheduling = + entitlements.features["advanced_template_scheduling"].enabled + // This check can be removed when https://github.com/coder/coder/milestone/19 + // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions") + return allowWorkspaceActions && allowAdvancedScheduling +} diff --git a/site/src/components/DeploySettingsLayout/Badges.tsx b/site/src/components/DeploySettingsLayout/Badges.tsx index f9d34bca918b6..465422ef03a3f 100644 --- a/site/src/components/DeploySettingsLayout/Badges.tsx +++ b/site/src/components/DeploySettingsLayout/Badges.tsx @@ -23,11 +23,15 @@ export const EntitledBadge: FC = () => { ) } -export const HealthyBadge: FC = () => { +export const HealthyBadge: FC<{ derpOnly: boolean }> = ({ derpOnly }) => { const styles = useStyles() + let text = "Healthy" + if (derpOnly) { + text = "Healthy (DERP Only)" + } return ( - Healthy + {text} ) } diff --git a/site/src/components/DeploymentBanner/DeploymentBannerView.tsx b/site/src/components/DeploymentBanner/DeploymentBannerView.tsx index edee6dc836491..d27511911a74f 100644 --- a/site/src/components/DeploymentBanner/DeploymentBannerView.tsx +++ b/site/src/components/DeploymentBanner/DeploymentBannerView.tsx @@ -268,12 +268,8 @@ const useStyles = makeStyles((theme) => ({ fontSize: 12, gap: theme.spacing(4), borderTop: `1px solid ${theme.palette.divider}`, - - [theme.breakpoints.down("lg")]: { - flexDirection: "column", - gap: theme.spacing(1), - alignItems: "left", - }, + overflowX: "auto", + whiteSpace: "nowrap", }, group: { display: "flex", diff --git a/site/src/components/Navbar/NavbarView.test.tsx b/site/src/components/Navbar/NavbarView.test.tsx index 307670708cc72..2ba0abc8e0ab4 100644 --- a/site/src/components/Navbar/NavbarView.test.tsx +++ b/site/src/components/Navbar/NavbarView.test.tsx @@ -154,7 +154,7 @@ describe("NavbarView", () => { ) const auditLink = await screen.findByText(navLanguage.deployment) expect((auditLink as HTMLAnchorElement).href).toContain( - "/settings/deployment/general", + "/deployment/general", ) }) diff --git a/site/src/components/Navbar/NavbarView.tsx b/site/src/components/Navbar/NavbarView.tsx index 6bc5777b95064..9cf2257b7fbd8 100644 --- a/site/src/components/Navbar/NavbarView.tsx +++ b/site/src/components/Navbar/NavbarView.tsx @@ -11,7 +11,7 @@ import { colors } from "theme/colors" import * as TypesGen from "../../api/typesGenerated" import { navHeight } from "../../theme/constants" import { combineClasses } from "../../utils/combineClasses" -import { UserDropdown } from "../UserDropdown/UsersDropdown" +import { UserDropdown } from "../UserDropdown/UserDropdown" import Box from "@mui/material/Box" import Menu from "@mui/material/Menu" import Button from "@mui/material/Button" @@ -93,7 +93,7 @@ const NavItems: React.FC< )} {canViewDeployment && ( - + {Language.deployment} @@ -307,7 +307,9 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({ }} > Workspace proxies improve terminal and web app connections to - workspaces. This does not apply to SSH connections. + workspaces. This does not apply to CLI connections. A region must be + manually selected, otherwise the default primary region will be + used. theme.palette.divider }} /> diff --git a/site/src/components/PaginationStatus/PaginationStatus.tsx b/site/src/components/PaginationStatus/PaginationStatus.tsx deleted file mode 100644 index cd870f54d0b69..0000000000000 --- a/site/src/components/PaginationStatus/PaginationStatus.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import Box from "@mui/material/Box" -import Skeleton from "@mui/material/Skeleton" - -type BasePaginationStatusProps = { - label: string - isLoading: boolean - showing?: number - total?: number -} - -type LoadedPaginationStatusProps = BasePaginationStatusProps & { - isLoading: false - showing: number - total: number -} - -export const PaginationStatus = ({ - isLoading, - showing, - total, - label, -}: BasePaginationStatusProps | LoadedPaginationStatusProps) => { - return ( - theme.palette.text.secondary, - "& strong": { color: (theme) => theme.palette.text.primary }, - }} - > - {!isLoading ? ( - <> - Showing {showing} of{" "} - {total?.toLocaleString()} {label} - - ) : ( - - - - )} - - ) -} diff --git a/site/src/components/PortForwardButton/PortForwardButton.tsx b/site/src/components/PortForwardButton/PortForwardButton.tsx deleted file mode 100644 index 5c8b4943f8b60..0000000000000 --- a/site/src/components/PortForwardButton/PortForwardButton.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import Button from "@mui/material/Button" -import Link from "@mui/material/Link" -import Popover from "@mui/material/Popover" -import { makeStyles } from "@mui/styles" -import TextField from "@mui/material/TextField" -import { Stack } from "components/Stack/Stack" -import { useRef, useState, Fragment } from "react" -import { colors } from "theme/colors" -import { CodeExample } from "../CodeExample/CodeExample" -import { - HelpTooltipLink, - HelpTooltipLinksGroup, - HelpTooltipText, - HelpTooltipTitle, -} from "../Tooltips/HelpTooltip" -import { Maybe } from "components/Conditionals/Maybe" -import { useMachine } from "@xstate/react" -import { portForwardMachine } from "xServices/portForward/portForwardXService" -import { SecondaryAgentButton } from "components/Resources/AgentButton" -import { docs } from "utils/docs" - -export interface PortForwardButtonProps { - host: string - username: string - workspaceName: string - agentName: string - agentId: string -} - -export const portForwardURL = ( - host: string, - port: number, - agentName: string, - workspaceName: string, - username: string, -): string => { - const { location } = window - - const subdomain = `${ - isNaN(port) ? 3000 : port - }--${agentName}--${workspaceName}--${username}` - return `${location.protocol}//${host}`.replace("*", subdomain) -} - -const TooltipView: React.FC = (props) => { - const { host, workspaceName, agentName, agentId, username } = props - - const styles = useStyles() - const [port, setPort] = useState("3000") - const urlExample = portForwardURL( - host, - parseInt(port), - agentName, - workspaceName, - username, - ) - - const [state] = useMachine(portForwardMachine, { - context: { agentId: agentId }, - }) - const ports = state.context.listeningPorts?.ports - - return ( - <> - - Access ports running on the agent with the{" "} - port, agent name, workspace name and{" "} - your username URL schema, as shown below. Port URLs are - only accessible by you. - - - - - - Use the form to open applications in a new tab. - - - - { - setPort(e.currentTarget.value) - }} - /> - - - - - - 0)}> - - {ports && - ports.map((p, i) => { - const url = portForwardURL( - host, - p.port, - agentName, - workspaceName, - username, - ) - let label = `${p.port}` - if (p.process_name) { - label = `${p.process_name} - ${p.port}` - } - - return ( - - {i > 0 && ·} - - {label} - - - ) - })} - - - - - - Learn more about web port forwarding - - - - ) -} - -export const PortForwardButton: React.FC = (props) => { - const anchorRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - const id = isOpen ? "schedule-popover" : undefined - const styles = useStyles() - - const onClose = () => { - setIsOpen(false) - } - - return ( - <> - { - setIsOpen(true) - }} - > - Port forward - - - Port forward - - - - ) -} - -const useStyles = makeStyles((theme) => ({ - popoverPaper: { - padding: `${theme.spacing(2.5)} ${theme.spacing(3.5)} ${theme.spacing( - 3.5, - )}`, - width: theme.spacing(52), - color: theme.palette.text.secondary, - marginTop: theme.spacing(0.25), - }, - - openUrlButton: { - flexShrink: 0, - }, - - portField: { - // The default border don't contrast well with the popover - "& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": { - borderColor: colors.gray[10], - }, - }, - - code: { - margin: theme.spacing(2, 0), - }, - - form: { - margin: theme.spacing(2, 0), - }, -})) diff --git a/site/src/components/RequireAuth/RequireAuth.tsx b/site/src/components/RequireAuth/RequireAuth.tsx index dd7bcddf25662..4ece776e282a3 100644 --- a/site/src/components/RequireAuth/RequireAuth.tsx +++ b/site/src/components/RequireAuth/RequireAuth.tsx @@ -1,16 +1,44 @@ +import axios from "axios" import { useAuth } from "components/AuthProvider/AuthProvider" -import { FC } from "react" +import { FC, useEffect } from "react" import { Outlet, Navigate, useLocation } from "react-router-dom" import { embedRedirect } from "../../utils/redirect" import { FullScreenLoader } from "../Loader/FullScreenLoader" import { DashboardProvider } from "components/Dashboard/DashboardProvider" import { ProxyProvider } from "contexts/ProxyContext" +import { getErrorDetail } from "api/errors" export const RequireAuth: FC = () => { - const [authState] = useAuth() + const [authState, authSend] = useAuth() const location = useLocation() const isHomePage = location.pathname === "/" - const navigateTo = isHomePage ? "/login" : embedRedirect(location.pathname) + const navigateTo = isHomePage + ? "/login" + : embedRedirect(`${location.pathname}${location.search}`) + + useEffect(() => { + const interceptorHandle = axios.interceptors.response.use( + (okResponse) => okResponse, + (error) => { + // 401 Unauthorized + // If we encountered an authentication error, then our token is probably + // invalid and we should update the auth state to reflect that. + if ( + error.response.status === 401 && + getErrorDetail(error)?.startsWith("API key expired") + ) { + authSend("SIGN_OUT") + } + + // Otherwise, pass the response through so that it can be displayed in the UI + return Promise.reject(error) + }, + ) + + return () => { + axios.interceptors.response.eject(interceptorHandle) + } + }, [authSend]) if (authState.matches("signedOut")) { return diff --git a/site/src/components/Resources/AgentLatency.tsx b/site/src/components/Resources/AgentLatency.tsx index 04f790980c977..6f3ef29ad729b 100644 --- a/site/src/components/Resources/AgentLatency.tsx +++ b/site/src/components/Resources/AgentLatency.tsx @@ -66,17 +66,11 @@ export const AgentLatency: FC<{ agent: WorkspaceAgent }> = ({ agent }) => { This is the latency overhead on non peer to peer connections. The first row is the preferred relay. - - {Object.keys(agent.latency).map((regionName) => { - if (!agent.latency) { - throw new Error("No latency found on agent") - } - - const region = agent.latency[regionName] - - return ( + {Object.entries(agent.latency) + .sort(([, a], [, b]) => a.latency_ms - b.latency_ms) + .map(([regionName, region]) => ( = ({ agent }) => { {regionName} {Math.round(region.latency_ms)}ms - ) - })} + ))} diff --git a/site/src/components/Resources/AgentMetadata.tsx b/site/src/components/Resources/AgentMetadata.tsx index 53d6ad4feeb92..0c309492b86f4 100644 --- a/site/src/components/Resources/AgentMetadata.tsx +++ b/site/src/components/Resources/AgentMetadata.tsx @@ -201,7 +201,8 @@ const StaticWidth = (props: BoxProps) => { const ref = useRef(null) useEffect(() => { - if (!ref.current) { + // Ignore this in storybook + if (!ref.current || process.env.STORYBOOK === "true") { return } diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index f26c8b8fbdd5c..320f6cc11007d 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -8,7 +8,7 @@ import { OpenDropdown, } from "components/DropdownArrows/DropdownArrows" import { LogLine, logLineHeight } from "components/Logs/Logs" -import { PortForwardButton } from "components/PortForwardButton/PortForwardButton" +import { PortForwardButton } from "./PortForwardButton" import { VSCodeDesktopButton } from "components/VSCodeDesktopButton/VSCodeDesktopButton" import { FC, diff --git a/site/src/components/Resources/PortForwardButton.stories.tsx b/site/src/components/Resources/PortForwardButton.stories.tsx new file mode 100644 index 0000000000000..46014bcca71cb --- /dev/null +++ b/site/src/components/Resources/PortForwardButton.stories.tsx @@ -0,0 +1,38 @@ +import Box from "@mui/material/Box" +import { PortForwardPopoverView } from "./PortForwardButton" +import type { Meta, StoryObj } from "@storybook/react" +import { MockListeningPortsResponse } from "testHelpers/entities" + +const meta: Meta = { + title: "components/PortForwardPopoverView", + component: PortForwardPopoverView, + decorators: [ + (Story) => ( + theme.spacing(38), + border: (theme) => `1px solid ${theme.palette.divider}`, + borderRadius: 1, + backgroundColor: (theme) => theme.palette.background.paper, + }} + > + + + ), + ], +} + +export default meta +type Story = StoryObj + +export const WithPorts: Story = { + args: { + ports: MockListeningPortsResponse.ports, + }, +} + +export const Empty: Story = { + args: { + ports: [], + }, +} diff --git a/site/src/components/Resources/PortForwardButton.tsx b/site/src/components/Resources/PortForwardButton.tsx new file mode 100644 index 0000000000000..0209f4bd9a9c8 --- /dev/null +++ b/site/src/components/Resources/PortForwardButton.tsx @@ -0,0 +1,273 @@ +import Link from "@mui/material/Link" +import Popover from "@mui/material/Popover" +import { makeStyles } from "@mui/styles" +import { useRef, useState } from "react" +import { colors } from "theme/colors" +import { + HelpTooltipLink, + HelpTooltipLinksGroup, + HelpTooltipText, + HelpTooltipTitle, +} from "../Tooltips/HelpTooltip" +import { SecondaryAgentButton } from "components/Resources/AgentButton" +import { docs } from "utils/docs" +import Box from "@mui/material/Box" +import { useQuery } from "@tanstack/react-query" +import { getAgentListeningPorts } from "api/api" +import { WorkspaceAgentListeningPort } from "api/typesGenerated" +import CircularProgress from "@mui/material/CircularProgress" +import { portForwardURL } from "utils/portForward" +import { MockListeningPortsResponse } from "testHelpers/entities" +import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined" + +export interface PortForwardButtonProps { + host: string + username: string + workspaceName: string + agentName: string + agentId: string +} + +export const PortForwardButton: React.FC = (props) => { + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const id = isOpen ? "schedule-popover" : undefined + const styles = useStyles() + const { data: listeningPorts } = useQuery({ + queryKey: ["portForward", props.agentId], + queryFn: () => getAgentListeningPorts(props.agentId), + initialData: MockListeningPortsResponse, + }) + + const onClose = () => { + setIsOpen(false) + } + + return ( + <> + { + setIsOpen(true) + }} + > + Ports + {listeningPorts ? ( + theme.spacing(0, 0.5), + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.gray[11], + ml: 1, + }} + > + {listeningPorts.ports.length} + + ) : ( + + )} + + + + + + ) +} + +export const PortForwardPopoverView: React.FC< + PortForwardButtonProps & { ports?: WorkspaceAgentListeningPort[] } +> = (props) => { + const { host, workspaceName, agentName, username, ports } = props + + return ( + <> + theme.spacing(2.5), + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + Forwarded ports + theme.palette.text.secondary }} + > + {ports?.length === 0 + ? "No open ports were detected." + : "The forwarded ports are exclusively accessible to you."} + + theme.spacing(1.5) }}> + {ports?.map((p) => { + const url = portForwardURL( + host, + p.port, + agentName, + workspaceName, + username, + ) + const label = p.process_name !== "" ? p.process_name : p.port + return ( + theme.palette.text.primary, + fontSize: 14, + display: "flex", + alignItems: "center", + gap: 1, + py: 0.5, + fontWeight: 500, + }} + key={p.port} + href={url} + target="_blank" + rel="noreferrer" + > + + {label} + theme.palette.text.secondary, + fontSize: 13, + fontWeight: 400, + }} + > + {p.port} + + + ) + })} + + + + theme.spacing(2.5), + }} + > + Forward port + theme.palette.text.secondary }} + > + Access ports running on the agent: + + + `1px solid ${theme.palette.divider}`, + borderRadius: "4px", + mt: 2, + display: "flex", + alignItems: "center", + "&:focus-within": { + borderColor: (theme) => theme.palette.primary.main, + }, + }} + onSubmit={(e) => { + e.preventDefault() + const formData = new FormData(e.currentTarget) + const port = Number(formData.get("portNumber")) + const url = portForwardURL( + host, + port, + agentName, + workspaceName, + username, + ) + window.open(url, "_blank") + }} + > + theme.spacing(0, 1.5), + background: "none", + border: 0, + outline: "none", + color: (theme) => theme.palette.text.primary, + appearance: "textfield", + display: "block", + width: "100%", + }} + /> + theme.spacing(1.5), + color: (theme) => theme.palette.text.primary, + }} + /> + + + + + Learn more + + + + + ) +} + +const useStyles = makeStyles((theme) => ({ + popoverPaper: { + padding: 0, + width: theme.spacing(38), + color: theme.palette.text.secondary, + marginTop: theme.spacing(0.5), + }, + + openUrlButton: { + flexShrink: 0, + }, + + portField: { + // The default border don't contrast well with the popover + "& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": { + borderColor: colors.gray[10], + }, + }, + + code: { + margin: theme.spacing(2, 0), + }, + + form: { + margin: theme.spacing(2, 0), + }, +})) diff --git a/site/src/components/Sidebar/Sidebar.tsx b/site/src/components/Sidebar/Sidebar.tsx new file mode 100644 index 0000000000000..bbcf743f48da9 --- /dev/null +++ b/site/src/components/Sidebar/Sidebar.tsx @@ -0,0 +1,44 @@ +import Box, { BoxProps } from "@mui/material/Box" +import { styled } from "@mui/styles" +import { colors } from "theme/colors" + +export const Sidebar = styled((props: BoxProps) => ( + +))(({ theme }) => ({ + width: theme.spacing(32), + flexShrink: 0, + borderRight: `1px solid ${theme.palette.divider}`, + height: "100%", + overflowY: "auto", +})) + +export const SidebarItem = styled( + ({ active, ...props }: BoxProps & { active?: boolean }) => ( + + ), +)(({ theme, active }) => ({ + background: active ? colors.gray[13] : "none", + border: "none", + fontSize: 14, + width: "100%", + textAlign: "left", + padding: theme.spacing(0, 3), + cursor: "pointer", + pointerEvents: active ? "none" : "auto", + color: active ? theme.palette.text.primary : theme.palette.text.secondary, + "&:hover": { + background: theme.palette.action.hover, + color: theme.palette.text.primary, + }, + paddingTop: theme.spacing(1.25), + paddingBottom: theme.spacing(1.25), +})) + +export const SidebarCaption = styled(Box)(({ theme }) => ({ + fontSize: 10, + textTransform: "uppercase", + fontWeight: 500, + color: theme.palette.text.secondary, + padding: theme.spacing(1.5, 3), + letterSpacing: "0.5px", +})) diff --git a/site/src/components/TableToolbar/TableToolbar.tsx b/site/src/components/TableToolbar/TableToolbar.tsx new file mode 100644 index 0000000000000..0b38b66640696 --- /dev/null +++ b/site/src/components/TableToolbar/TableToolbar.tsx @@ -0,0 +1,48 @@ +import { styled } from "@mui/material/styles" +import Box from "@mui/material/Box" +import Skeleton from "@mui/material/Skeleton" + +export const TableToolbar = styled(Box)(({ theme }) => ({ + fontSize: 13, + marginBottom: theme.spacing(1), + marginTop: theme.spacing(0), + height: 36, // The size of a small button + color: theme.palette.text.secondary, + "& strong": { color: theme.palette.text.primary }, + display: "flex", + alignItems: "center", +})) + +type BasePaginationStatusProps = { + label: string + isLoading: boolean + showing?: number + total?: number +} + +type LoadedPaginationStatusProps = BasePaginationStatusProps & { + isLoading: false + showing: number + total: number +} + +export const PaginationStatus = ({ + isLoading, + showing, + total, + label, +}: BasePaginationStatusProps | LoadedPaginationStatusProps) => { + if (isLoading) { + return ( + + + + ) + } + return ( + + Showing {showing} of{" "} + {total?.toLocaleString()} {label} + + ) +} diff --git a/site/src/components/TemplateFiles/TemplateFiles.tsx b/site/src/components/TemplateFiles/TemplateFiles.tsx index 11bc49397f4bb..73aaad4b1f38b 100644 --- a/site/src/components/TemplateFiles/TemplateFiles.tsx +++ b/site/src/components/TemplateFiles/TemplateFiles.tsx @@ -87,6 +87,7 @@ const useStyles = makeStyles((theme) => ({ alignItems: "baseline", borderBottom: `1px solid ${theme.palette.divider}`, gap: 1, + overflowX: "auto", }, tab: { @@ -101,6 +102,7 @@ const useStyles = makeStyles((theme) => ({ gap: theme.spacing(0.5), position: "relative", color: theme.palette.text.secondary, + whiteSpace: "nowrap", "& svg": { width: 22, diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx index d4a81a3363b7a..64b986dac79d0 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -1,5 +1,6 @@ import Button from "@mui/material/Button" import IconButton from "@mui/material/IconButton" +import Link from "@mui/material/Link" import { makeStyles } from "@mui/styles" import Tooltip from "@mui/material/Tooltip" import CreateIcon from "@mui/icons-material/AddOutlined" @@ -13,6 +14,7 @@ import { VariableValue, WorkspaceResource, } from "api/typesGenerated" +import { Link as RouterLink } from "react-router-dom" import { Alert, AlertDetail } from "components/Alert/Alert" import { Avatar } from "components/Avatar/Avatar" import { AvatarData } from "components/AvatarData/AvatarData" @@ -186,15 +188,21 @@ export const TemplateVersionEditor: FC = ({
- - ) - } - /> + + + ) + } + /> +
@@ -217,13 +225,6 @@ export const TemplateVersionEditor: FC = ({