diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index 03acfa4335995..0000000000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json - -# CodeRabbit Configuration -# This configuration disables automatic reviews entirely - -language: "en-US" -early_access: false - -reviews: - # Disable automatic reviews for new PRs, but allow incremental reviews - auto_review: - enabled: false # Disable automatic review of new/updated PRs - drafts: false # Don't review draft PRs automatically - - # Other review settings (only apply if manually requested) - profile: "chill" - request_changes_workflow: false - high_level_summary: false - poem: false - review_status: false - collapse_walkthrough: true - high_level_summary_in_walkthrough: true - -chat: - auto_reply: true # Allow automatic chat replies - -# Note: With auto_review.enabled: false, CodeRabbit will only perform initial -# reviews when manually requested, but incremental reviews and chat replies remain enabled diff --git a/.github/actions/embedded-pg-cache/download/action.yml b/.github/actions/embedded-pg-cache/download/action.yml index c2c3c0c0b299c..854e5045c2dda 100644 --- a/.github/actions/embedded-pg-cache/download/action.yml +++ b/.github/actions/embedded-pg-cache/download/action.yml @@ -25,9 +25,11 @@ runs: export YEAR_MONTH=$(date +'%Y-%m') export PREV_YEAR_MONTH=$(date -d 'last month' +'%Y-%m') export DAY=$(date +'%d') - echo "year-month=$YEAR_MONTH" >> $GITHUB_OUTPUT - echo "prev-year-month=$PREV_YEAR_MONTH" >> $GITHUB_OUTPUT - echo "cache-key=${{ inputs.key-prefix }}-${YEAR_MONTH}-${DAY}" >> $GITHUB_OUTPUT + echo "year-month=$YEAR_MONTH" >> "$GITHUB_OUTPUT" + echo "prev-year-month=$PREV_YEAR_MONTH" >> "$GITHUB_OUTPUT" + echo "cache-key=${INPUTS_KEY_PREFIX}-${YEAR_MONTH}-${DAY}" >> "$GITHUB_OUTPUT" + env: + INPUTS_KEY_PREFIX: ${{ inputs.key-prefix }} # By default, depot keeps caches for 14 days. This is plenty for embedded # postgres, which changes infrequently. diff --git a/.github/actions/test-cache/download/action.yml b/.github/actions/test-cache/download/action.yml index 06a87fee06d4b..623bb61e11c52 100644 --- a/.github/actions/test-cache/download/action.yml +++ b/.github/actions/test-cache/download/action.yml @@ -27,9 +27,11 @@ runs: export YEAR_MONTH=$(date +'%Y-%m') export PREV_YEAR_MONTH=$(date -d 'last month' +'%Y-%m') export DAY=$(date +'%d') - echo "year-month=$YEAR_MONTH" >> $GITHUB_OUTPUT - echo "prev-year-month=$PREV_YEAR_MONTH" >> $GITHUB_OUTPUT - echo "cache-key=${{ inputs.key-prefix }}-${YEAR_MONTH}-${DAY}" >> $GITHUB_OUTPUT + echo "year-month=$YEAR_MONTH" >> "$GITHUB_OUTPUT" + echo "prev-year-month=$PREV_YEAR_MONTH" >> "$GITHUB_OUTPUT" + echo "cache-key=${INPUTS_KEY_PREFIX}-${YEAR_MONTH}-${DAY}" >> "$GITHUB_OUTPUT" + env: + INPUTS_KEY_PREFIX: ${{ inputs.key-prefix }} # TODO: As a cost optimization, we could remove caches that are older than # a day or two. By default, depot keeps caches for 14 days, which isn't diff --git a/.github/actions/upload-datadog/action.yaml b/.github/actions/upload-datadog/action.yaml index a2df93ab14b28..274ff3df6493a 100644 --- a/.github/actions/upload-datadog/action.yaml +++ b/.github/actions/upload-datadog/action.yaml @@ -12,13 +12,12 @@ runs: run: | set -e - owner=${{ github.repository_owner }} - echo "owner: $owner" - if [[ $owner != "coder" ]]; then + echo "owner: $REPO_OWNER" + if [[ "$REPO_OWNER" != "coder" ]]; then echo "Not a pull request from the main repo, skipping..." exit 0 fi - if [[ -z "${{ inputs.api-key }}" ]]; then + if [[ -z "${DATADOG_API_KEY}" ]]; then # This can happen for dependabot. echo "No API key provided, skipping..." exit 0 @@ -31,37 +30,38 @@ runs: TMP_DIR=$(mktemp -d) - if [[ "${{ runner.os }}" == "Windows" ]]; then + if [[ "${RUNNER_OS}" == "Windows" ]]; then BINARY_PATH="${TMP_DIR}/datadog-ci.exe" BINARY_URL="https://github.com/DataDog/datadog-ci/releases/download/${BINARY_VERSION}/datadog-ci_win-x64" - elif [[ "${{ runner.os }}" == "macOS" ]]; then + elif [[ "${RUNNER_OS}" == "macOS" ]]; then BINARY_PATH="${TMP_DIR}/datadog-ci" BINARY_URL="https://github.com/DataDog/datadog-ci/releases/download/${BINARY_VERSION}/datadog-ci_darwin-arm64" - elif [[ "${{ runner.os }}" == "Linux" ]]; then + elif [[ "${RUNNER_OS}" == "Linux" ]]; then BINARY_PATH="${TMP_DIR}/datadog-ci" BINARY_URL="https://github.com/DataDog/datadog-ci/releases/download/${BINARY_VERSION}/datadog-ci_linux-x64" else - echo "Unsupported OS: ${{ runner.os }}" + echo "Unsupported OS: $RUNNER_OS" exit 1 fi - echo "Downloading DataDog CI binary version ${BINARY_VERSION} for ${{ runner.os }}..." + echo "Downloading DataDog CI binary version ${BINARY_VERSION} for $RUNNER_OS..." curl -sSL "$BINARY_URL" -o "$BINARY_PATH" - if [[ "${{ runner.os }}" == "Windows" ]]; then + if [[ "${RUNNER_OS}" == "Windows" ]]; then echo "$BINARY_HASH_WINDOWS $BINARY_PATH" | sha256sum --check - elif [[ "${{ runner.os }}" == "macOS" ]]; then + elif [[ "${RUNNER_OS}" == "macOS" ]]; then echo "$BINARY_HASH_MACOS $BINARY_PATH" | shasum -a 256 --check - elif [[ "${{ runner.os }}" == "Linux" ]]; then + elif [[ "${RUNNER_OS}" == "Linux" ]]; then echo "$BINARY_HASH_LINUX $BINARY_PATH" | sha256sum --check fi # Make binary executable (not needed for Windows) - if [[ "${{ runner.os }}" != "Windows" ]]; then + if [[ "${RUNNER_OS}" != "Windows" ]]; then chmod +x "$BINARY_PATH" fi "$BINARY_PATH" junit upload --service coder ./gotests.xml \ - --tags os:${{runner.os}} --tags runner_name:${{runner.name}} + --tags "os:${RUNNER_OS}" --tags "runner_name:${RUNNER_NAME}" env: + REPO_OWNER: ${{ github.repository_owner }} DATADOG_API_KEY: ${{ inputs.api-key }} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000000..66deeefbc1d47 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1 @@ +If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 17aae8fe47f0f..747f158e28a9e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,7 +42,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 - # For pull requests it's not necessary to checkout the code + persist-credentials: false - name: check changed files uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter @@ -111,7 +111,9 @@ jobs: - id: debug run: | - echo "${{ toJSON(steps.filter )}}" + echo "$FILTER_JSON" + env: + FILTER_JSON: ${{ toJSON(steps.filter.outputs) }} # Disabled due to instability. See: https://github.com/coder/coder/issues/14553 # Re-enable once the flake hash calculation is stable. @@ -162,6 +164,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -171,10 +174,10 @@ jobs: - name: Get golangci-lint cache dir run: | - linter_ver=$(egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2) - go install github.com/golangci/golangci-lint/cmd/golangci-lint@v$linter_ver + linter_ver=$(grep -Eo 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2) + go install "github.com/golangci/golangci-lint/cmd/golangci-lint@v$linter_ver" dir=$(golangci-lint cache status | awk '/Dir/ { print $2 }') - echo "LINT_CACHE_DIR=$dir" >> $GITHUB_ENV + echo "LINT_CACHE_DIR=$dir" >> "$GITHUB_ENV" - name: golangci-lint cache uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 @@ -206,7 +209,12 @@ jobs: - name: make lint run: | - make --output-sync=line -j lint + # zizmor isn't included in the lint target because it takes a while, + # but we explicitly want to run it in CI. + make --output-sync=line -j lint lint/actions/zizmor + env: + # Used by zizmor to lint third-party GitHub actions. + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Check workflow files run: | @@ -234,6 +242,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -289,6 +298,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -305,8 +315,8 @@ jobs: - name: make fmt run: | - export PATH=${PATH}:$(go env GOPATH)/bin - make --output-sync -j -B fmt + PATH="${PATH}:$(go env GOPATH)/bin" \ + make --output-sync -j -B fmt - name: Check for unstaged files run: ./scripts/check_unstaged.sh @@ -340,8 +350,8 @@ jobs: - name: Disable Spotlight Indexing if: runner.os == 'macOS' run: | - enabled=$(sudo mdutil -a -s | grep "Indexing enabled" | wc -l) - if [ $enabled -eq 0 ]; then + enabled=$(sudo mdutil -a -s | { grep -Fc "Indexing enabled" || true; }) + if [ "$enabled" -eq 0 ]; then echo "Spotlight indexing is already disabled" exit 0 fi @@ -353,12 +363,13 @@ jobs: # a separate repository to allow its use before actions/checkout. - name: Setup RAM Disks if: runner.os == 'Windows' - uses: coder/setup-ramdisk-action@e1100847ab2d7bcd9d14bcda8f2d1b0f07b36f1b + uses: coder/setup-ramdisk-action@e1100847ab2d7bcd9d14bcda8f2d1b0f07b36f1b # v0.1.0 - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Go Paths id: go-paths @@ -421,63 +432,61 @@ jobs: set -o errexit set -o pipefail - if [ "${{ runner.os }}" == "Windows" ]; then + if [ "$RUNNER_OS" == "Windows" ]; then # Create a temp dir on the R: ramdisk drive for Windows. The default # C: drive is extremely slow: https://github.com/actions/runner-images/issues/8755 mkdir -p "R:/temp/embedded-pg" go run scripts/embedded-pg/main.go -path "R:/temp/embedded-pg" -cache "${EMBEDDED_PG_CACHE_DIR}" - elif [ "${{ runner.os }}" == "macOS" ]; then + elif [ "$RUNNER_OS" == "macOS" ]; then # Postgres runs faster on a ramdisk on macOS too mkdir -p /tmp/tmpfs sudo mount_tmpfs -o noowners -s 8g /tmp/tmpfs go run scripts/embedded-pg/main.go -path /tmp/tmpfs/embedded-pg -cache "${EMBEDDED_PG_CACHE_DIR}" - elif [ "${{ runner.os }}" == "Linux" ]; then + elif [ "$RUNNER_OS" == "Linux" ]; then make test-postgres-docker fi # if macOS, install google-chrome for scaletests # As another concern, should we really have this kind of external dependency # requirement on standard CI? - if [ "${{ matrix.os }}" == "macos-latest" ]; then + if [ "${RUNNER_OS}" == "macOS" ]; then brew install google-chrome fi # macOS will output "The default interactive shell is now zsh" # intermittently in CI... - if [ "${{ matrix.os }}" == "macos-latest" ]; then + if [ "${RUNNER_OS}" == "macOS" ]; then touch ~/.bash_profile && echo "export BASH_SILENCE_DEPRECATION_WARNING=1" >> ~/.bash_profile fi - if [ "${{ runner.os }}" == "Windows" ]; then + if [ "${RUNNER_OS}" == "Windows" ]; then # Our Windows runners have 16 cores. # On Windows Postgres chokes up when we have 16x16=256 tests # running in parallel, and dbtestutil.NewDB starts to take more than # 10s to complete sometimes causing test timeouts. With 16x8=128 tests # Postgres tends not to choke. - NUM_PARALLEL_PACKAGES=8 - NUM_PARALLEL_TESTS=16 + export TEST_NUM_PARALLEL_PACKAGES=8 + export TEST_NUM_PARALLEL_TESTS=16 # Only the CLI and Agent are officially supported on Windows and the rest are too flaky - PACKAGES="./cli/... ./enterprise/cli/... ./agent/..." - elif [ "${{ runner.os }}" == "macOS" ]; then + export TEST_PACKAGES="./cli/... ./enterprise/cli/... ./agent/..." + elif [ "${RUNNER_OS}" == "macOS" ]; then # Our macOS runners have 8 cores. We set NUM_PARALLEL_TESTS to 16 # because the tests complete faster and Postgres doesn't choke. It seems # that macOS's tmpfs is faster than the one on Windows. - NUM_PARALLEL_PACKAGES=8 - NUM_PARALLEL_TESTS=16 + export TEST_NUM_PARALLEL_PACKAGES=8 + export TEST_NUM_PARALLEL_TESTS=16 # Only the CLI and Agent are officially supported on macOS and the rest are too flaky - PACKAGES="./cli/... ./enterprise/cli/... ./agent/..." - elif [ "${{ runner.os }}" == "Linux" ]; then + export TEST_PACKAGES="./cli/... ./enterprise/cli/... ./agent/..." + elif [ "${RUNNER_OS}" == "Linux" ]; then # Our Linux runners have 8 cores. - NUM_PARALLEL_PACKAGES=8 - NUM_PARALLEL_TESTS=8 - PACKAGES="./..." + export TEST_NUM_PARALLEL_PACKAGES=8 + export TEST_NUM_PARALLEL_TESTS=8 fi # by default, run tests with cache - TESTCOUNT="" - if [ "${{ github.ref }}" == "refs/heads/main" ]; then + if [ "${GITHUB_REF}" == "refs/heads/main" ]; then # on main, run tests without cache - TESTCOUNT="-count=1" + export TEST_COUNT="1" fi mkdir -p "$RUNNER_TEMP/sym" @@ -485,10 +494,9 @@ jobs: # terraform gets installed in a random directory, so we need to normalize # the path to the terraform binary or a bunch of cached tests will be # invalidated. See scripts/normalize_path.sh for more details. - normalize_path_with_symlinks "$RUNNER_TEMP/sym" "$(dirname $(which terraform))" + normalize_path_with_symlinks "$RUNNER_TEMP/sym" "$(dirname "$(which terraform)")" - gotestsum --format standard-quiet --packages "$PACKAGES" \ - -- -timeout=20m -v -p $NUM_PARALLEL_PACKAGES -parallel=$NUM_PARALLEL_TESTS $TESTCOUNT + make test - name: Upload failed test db dumps uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -546,6 +554,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go @@ -594,6 +603,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go @@ -653,6 +663,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go @@ -679,11 +690,12 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node - - run: pnpm test:ci --max-workers $(nproc) + - run: pnpm test:ci --max-workers "$(nproc)" working-directory: site test-e2e: @@ -711,6 +723,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -785,6 +798,7 @@ jobs: fetch-depth: 0 # 👇 Tells the checkout which commit hash to reference ref: ${{ github.event.pull_request.head.ref }} + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -863,6 +877,7 @@ jobs: with: # 0 is required here for version.sh to work. fetch-depth: 0 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -916,6 +931,7 @@ jobs: - test-e2e - offlinedocs - sqlc-vet + - check-build # Allow this job to run even if the needed jobs fail, are skipped or # cancelled. if: always() @@ -926,7 +942,7 @@ jobs: egress-policy: audit - name: Ensure required checks - run: | + run: | # zizmor: ignore[template-injection] We're just reading needs.x.result here, no risk of injection echo "Checking required checks" echo "- fmt: ${{ needs.fmt.result }}" echo "- lint: ${{ needs.lint.result }}" @@ -936,6 +952,7 @@ jobs: echo "- test-js: ${{ needs.test-js.result }}" echo "- test-e2e: ${{ needs.test-e2e.result }}" echo "- offlinedocs: ${{ needs.offlinedocs.result }}" + echo "- check-build: ${{ needs.check-build.result }}" echo # We allow skipped jobs to pass, but not failed or cancelled jobs. @@ -959,13 +976,16 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: Setup build tools run: | brew install bash gnu-getopt make - echo "$(brew --prefix bash)/bin" >> $GITHUB_PATH - echo "$(brew --prefix gnu-getopt)/bin" >> $GITHUB_PATH - echo "$(brew --prefix make)/libexec/gnubin" >> $GITHUB_PATH + { + echo "$(brew --prefix bash)/bin" + echo "$(brew --prefix gnu-getopt)/bin" + echo "$(brew --prefix make)/libexec/gnubin" + } >> "$GITHUB_PATH" - name: Switch XCode Version uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 @@ -1026,6 +1046,47 @@ jobs: if: ${{ github.repository_owner == 'coder' && github.ref == 'refs/heads/main' }} run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8} + check-build: + # This job runs make build to verify compilation on PRs. + # The build doesn't get signed, and is not suitable for usage, unlike the + # `build` job that runs on main. + needs: changes + if: needs.changes.outputs.go == 'true' && github.ref != 'refs/heads/main' + runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: ./.github/actions/setup-node + + - name: Setup Go + uses: ./.github/actions/setup-go + + - name: Install go-winres + run: go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3 + + - name: Install nfpm + run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.35.1 + + - name: Install zstd + run: sudo apt-get install -y zstd + + - name: Build + run: | + set -euxo pipefail + go mod download + make gen/mark-fresh + make build + build: # This builds and publishes ghcr.io/coder/coder-preview:main for each commit # to main branch. @@ -1057,6 +1118,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: GHCR Login uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 @@ -1154,8 +1216,8 @@ jobs: go mod download version="$(./scripts/version.sh)" - tag="main-$(echo "$version" | sed 's/+/-/g')" - echo "tag=$tag" >> $GITHUB_OUTPUT + tag="main-${version//+/-}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" make gen/mark-fresh make -j \ @@ -1191,15 +1253,15 @@ jobs: # build Docker images for each architecture version="$(./scripts/version.sh)" - tag="main-$(echo "$version" | sed 's/+/-/g')" - echo "tag=$tag" >> $GITHUB_OUTPUT + tag="main-${version//+/-}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" # build images for each architecture # note: omitting the -j argument to avoid race conditions when pushing make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag # only push if we are on main branch - if [ "${{ github.ref }}" == "refs/heads/main" ]; then + if [ "${GITHUB_REF}" == "refs/heads/main" ]; then # build and push multi-arch manifest, this depends on the other images # being pushed so will automatically push them # note: omitting the -j argument to avoid race conditions when pushing @@ -1212,10 +1274,11 @@ jobs: # we are adding `latest` tag and keeping `main` for backward # compatibality for t in "${tags[@]}"; do + # shellcheck disable=SC2046 ./scripts/build_docker_multiarch.sh \ --push \ --target "ghcr.io/coder/coder-preview:$t" \ - --version $version \ + --version "$version" \ $(cat build/coder_"$version"_linux_{amd64,arm64,armv7}.tag) done fi @@ -1225,12 +1288,13 @@ jobs: continue-on-error: true env: COSIGN_EXPERIMENTAL: 1 + BUILD_TAG: ${{ steps.build-docker.outputs.tag }} run: | set -euxo pipefail # Define image base and tags IMAGE_BASE="ghcr.io/coder/coder-preview" - TAGS=("${{ steps.build-docker.outputs.tag }}" "main" "latest") + TAGS=("${BUILD_TAG}" "main" "latest") # Generate and attest SBOM for each tag for tag in "${TAGS[@]}"; do @@ -1369,7 +1433,7 @@ jobs: # Report attestation failures but don't fail the workflow - name: Check attestation status if: github.ref == 'refs/heads/main' - run: | + run: | # zizmor: ignore[template-injection] We're just reading steps.attest_x.outcome here, no risk of injection if [[ "${{ steps.attest_main.outcome }}" == "failure" ]]; then echo "::warning::GitHub attestation for main tag failed" fi @@ -1429,6 +1493,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: Authenticate to Google Cloud uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5 # v2.1.12 @@ -1493,6 +1558,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: Setup flyctl uses: superfly/flyctl-actions/setup-flyctl@fc53c09e1bc3be6f54706524e3b82c4f462f77be # v1.5 @@ -1528,7 +1594,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 - # We need golang to run the migration main.go + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go @@ -1564,15 +1630,15 @@ jobs: "fields": [ { "type": "mrkdwn", - "text": "*Workflow:*\n${{ github.workflow }}" + "text": "*Workflow:*\n'"${GITHUB_WORKFLOW}"'" }, { "type": "mrkdwn", - "text": "*Committer:*\n${{ github.actor }}" + "text": "*Committer:*\n'"${GITHUB_ACTOR}"'" }, { "type": "mrkdwn", - "text": "*Commit:*\n${{ github.sha }}" + "text": "*Commit:*\n'"${GITHUB_SHA}"'" } ] }, @@ -1580,8 +1646,18 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": "*View failure:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Click here>" + "text": "*View failure:* <'"${RUN_URL}"'|Click here>" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "<@U08TJ4YNCA3> investigate this CI failure. Check logs, search for existing issues, use git blame to find who last modified failing tests, create issue in coder/internal (not public repo), use title format \"flake: TestName\" for flaky tests, and assign to the person from git blame." } } ] - }' ${{ secrets.CI_FAILURE_SLACK_WEBHOOK }} + }' "${SLACK_WEBHOOK}" + env: + SLACK_WEBHOOK: ${{ secrets.CI_FAILURE_SLACK_WEBHOOK }} + RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/contrib.yaml b/.github/workflows/contrib.yaml index 27dffe94f4000..e9c5c9ec2afd8 100644 --- a/.github/workflows/contrib.yaml +++ b/.github/workflows/contrib.yaml @@ -3,6 +3,7 @@ name: contrib on: issue_comment: types: [created, edited] + # zizmor: ignore[dangerous-triggers] We explicitly want to run on pull_request_target. pull_request_target: types: - opened diff --git a/.github/workflows/dependabot.yaml b/.github/workflows/dependabot.yaml index f86601096ae96..f95ae3fa810e6 100644 --- a/.github/workflows/dependabot.yaml +++ b/.github/workflows/dependabot.yaml @@ -15,7 +15,7 @@ jobs: github.event_name == 'pull_request' && github.event.action == 'opened' && github.event.pull_request.user.login == 'dependabot[bot]' && - github.actor_id == 49699333 && + github.event.pull_request.user.id == 49699333 && github.repository == 'coder/coder' permissions: pull-requests: write @@ -44,10 +44,6 @@ jobs: GH_TOKEN: ${{secrets.GITHUB_TOKEN}} - name: Send Slack notification - env: - PR_URL: ${{github.event.pull_request.html_url}} - PR_TITLE: ${{github.event.pull_request.title}} - PR_NUMBER: ${{github.event.pull_request.number}} run: | curl -X POST -H 'Content-type: application/json' \ --data '{ @@ -58,7 +54,7 @@ jobs: "type": "header", "text": { "type": "plain_text", - "text": ":pr-merged: Auto merge enabled for Dependabot PR #${{ env.PR_NUMBER }}", + "text": ":pr-merged: Auto merge enabled for Dependabot PR #'"${PR_NUMBER}"'", "emoji": true } }, @@ -67,7 +63,7 @@ jobs: "fields": [ { "type": "mrkdwn", - "text": "${{ env.PR_TITLE }}" + "text": "'"${PR_TITLE}"'" } ] }, @@ -80,9 +76,14 @@ jobs: "type": "plain_text", "text": "View PR" }, - "url": "${{ env.PR_URL }}" + "url": "'"${PR_URL}"'" } ] } ] - }' ${{ secrets.DEPENDABOT_PRS_SLACK_WEBHOOK }} + }' "${{ secrets.DEPENDABOT_PRS_SLACK_WEBHOOK }}" + env: + SLACK_WEBHOOK: ${{ secrets.DEPENDABOT_PRS_SLACK_WEBHOOK }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} diff --git a/.github/workflows/docker-base.yaml b/.github/workflows/docker-base.yaml index dd36ab5a45ea0..5c8fa142450bb 100644 --- a/.github/workflows/docker-base.yaml +++ b/.github/workflows/docker-base.yaml @@ -44,6 +44,8 @@ jobs: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Docker login uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 diff --git a/.github/workflows/docs-ci.yaml b/.github/workflows/docs-ci.yaml index cba5bcbcd2b42..887db40660caf 100644 --- a/.github/workflows/docs-ci.yaml +++ b/.github/workflows/docs-ci.yaml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -39,10 +41,16 @@ jobs: - name: lint if: steps.changed-files.outputs.any_changed == 'true' run: | - pnpm exec markdownlint-cli2 ${{ steps.changed-files.outputs.all_changed_files }} + # shellcheck disable=SC2086 + pnpm exec markdownlint-cli2 $ALL_CHANGED_FILES + env: + ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} - name: fmt if: steps.changed-files.outputs.any_changed == 'true' run: | # markdown-table-formatter requires a space separated list of files - echo ${{ steps.changed-files.outputs.all_changed_files }} | tr ',' '\n' | pnpm exec markdown-table-formatter --check + # shellcheck disable=SC2086 + echo $ALL_CHANGED_FILES | tr ',' '\n' | pnpm exec markdown-table-formatter --check + env: + ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml index 6735f7d2ce8ae..119cd4fe85244 100644 --- a/.github/workflows/dogfood.yaml +++ b/.github/workflows/dogfood.yaml @@ -18,8 +18,7 @@ on: workflow_dispatch: permissions: - # Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage) - id-token: write + contents: read jobs: build_image: @@ -33,6 +32,8 @@ jobs: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Setup Nix uses: nixbuild/nix-quick-install-action@63ca48f939ee3b8d835f4126562537df0fee5b91 # v32 @@ -67,10 +68,11 @@ jobs: - name: "Branch name to Docker tag name" id: docker-tag-name run: | - tag=${{ steps.branch-name.outputs.current_branch }} # Replace / with --, e.g. user/feature => user--feature. - tag=${tag//\//--} - echo "tag=${tag}" >> $GITHUB_OUTPUT + tag=${BRANCH_NAME//\//--} + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + env: + BRANCH_NAME: ${{ steps.branch-name.outputs.current_branch }} - name: Set up Depot CLI uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0 @@ -107,15 +109,20 @@ jobs: CURRENT_SYSTEM=$(nix eval --impure --raw --expr 'builtins.currentSystem') - docker image tag codercom/oss-dogfood-nix:latest-$CURRENT_SYSTEM codercom/oss-dogfood-nix:${{ steps.docker-tag-name.outputs.tag }} - docker image push codercom/oss-dogfood-nix:${{ steps.docker-tag-name.outputs.tag }} + docker image tag "codercom/oss-dogfood-nix:latest-$CURRENT_SYSTEM" "codercom/oss-dogfood-nix:${DOCKER_TAG}" + docker image push "codercom/oss-dogfood-nix:${DOCKER_TAG}" - docker image tag codercom/oss-dogfood-nix:latest-$CURRENT_SYSTEM codercom/oss-dogfood-nix:latest - docker image push codercom/oss-dogfood-nix:latest + docker image tag "codercom/oss-dogfood-nix:latest-$CURRENT_SYSTEM" "codercom/oss-dogfood-nix:latest" + docker image push "codercom/oss-dogfood-nix:latest" + env: + DOCKER_TAG: ${{ steps.docker-tag-name.outputs.tag }} deploy_template: needs: build_image runs-on: ubuntu-latest + permissions: + # Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage) + id-token: write steps: - name: Harden Runner uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 @@ -124,6 +131,8 @@ jobs: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Setup Terraform uses: ./.github/actions/setup-tf @@ -152,12 +161,12 @@ jobs: - name: Get short commit SHA if: github.ref == 'refs/heads/main' id: vars - run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + run: echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - name: Get latest commit title if: github.ref == 'refs/heads/main' id: message - run: echo "pr_title=$(git log --format=%s -n 1 ${{ github.sha }})" >> $GITHUB_OUTPUT + run: echo "pr_title=$(git log --format=%s -n 1 ${{ github.sha }})" >> "$GITHUB_OUTPUT" - name: "Push template" if: github.ref == 'refs/heads/main' diff --git a/.github/workflows/nightly-gauntlet.yaml b/.github/workflows/nightly-gauntlet.yaml index 7b20ee92554b2..5769b3b652c44 100644 --- a/.github/workflows/nightly-gauntlet.yaml +++ b/.github/workflows/nightly-gauntlet.yaml @@ -37,8 +37,8 @@ jobs: - name: Disable Spotlight Indexing if: runner.os == 'macOS' run: | - enabled=$(sudo mdutil -a -s | grep "Indexing enabled" | wc -l) - if [ $enabled -eq 0 ]; then + enabled=$(sudo mdutil -a -s | { grep -Fc "Indexing enabled" || true; }) + if [ "$enabled" -eq 0 ]; then echo "Spotlight indexing is already disabled" exit 0 fi @@ -50,12 +50,13 @@ jobs: # a separate repository to allow its use before actions/checkout. - name: Setup RAM Disks if: runner.os == 'Windows' - uses: coder/setup-ramdisk-action@e1100847ab2d7bcd9d14bcda8f2d1b0f07b36f1b + uses: coder/setup-ramdisk-action@e1100847ab2d7bcd9d14bcda8f2d1b0f07b36f1b # v0.1.0 - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go @@ -185,15 +186,15 @@ jobs: "fields": [ { "type": "mrkdwn", - "text": "*Workflow:*\n${{ github.workflow }}" + "text": "*Workflow:*\n'"${GITHUB_WORKFLOW}"'" }, { "type": "mrkdwn", - "text": "*Committer:*\n${{ github.actor }}" + "text": "*Committer:*\n'"${GITHUB_ACTOR}"'" }, { "type": "mrkdwn", - "text": "*Commit:*\n${{ github.sha }}" + "text": "*Commit:*\n'"${GITHUB_SHA}"'" } ] }, @@ -201,8 +202,18 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": "*View failure:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Click here>" + "text": "*View failure:* <'"${RUN_URL}"'|Click here>" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "<@U08TJ4YNCA3> investigate this CI failure. Check logs, search for existing issues, use git blame to find who last modified failing tests, create issue in coder/internal (not public repo), use title format \"flake: TestName\" for flaky tests, and assign to the person from git blame." } } ] - }' ${{ secrets.CI_FAILURE_SLACK_WEBHOOK }} + }' "${SLACK_WEBHOOK}" + env: + SLACK_WEBHOOK: ${{ secrets.CI_FAILURE_SLACK_WEBHOOK }} + RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/pr-auto-assign.yaml b/.github/workflows/pr-auto-assign.yaml index 746b471f57b39..7e2f6441de383 100644 --- a/.github/workflows/pr-auto-assign.yaml +++ b/.github/workflows/pr-auto-assign.yaml @@ -3,6 +3,7 @@ name: PR Auto Assign on: + # zizmor: ignore[dangerous-triggers] We explicitly want to run on pull_request_target. pull_request_target: types: [opened] diff --git a/.github/workflows/pr-cleanup.yaml b/.github/workflows/pr-cleanup.yaml index 4c3023990efe5..32e260b112dea 100644 --- a/.github/workflows/pr-cleanup.yaml +++ b/.github/workflows/pr-cleanup.yaml @@ -27,10 +27,12 @@ jobs: id: pr_number run: | if [ -n "${{ github.event.pull_request.number }}" ]; then - echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" else - echo "PR_NUMBER=${{ github.event.inputs.pr_number }}" >> $GITHUB_OUTPUT + echo "PR_NUMBER=${PR_NUMBER}" >> "$GITHUB_OUTPUT" fi + env: + PR_NUMBER: ${{ github.event.inputs.pr_number }} - name: Delete image continue-on-error: true @@ -51,17 +53,21 @@ jobs: - name: Delete helm release run: | set -euo pipefail - helm delete --namespace "pr${{ steps.pr_number.outputs.PR_NUMBER }}" "pr${{ steps.pr_number.outputs.PR_NUMBER }}" || echo "helm release not found" + helm delete --namespace "pr${PR_NUMBER}" "pr${PR_NUMBER}" || echo "helm release not found" + env: + PR_NUMBER: ${{ steps.pr_number.outputs.PR_NUMBER }} - name: "Remove PR namespace" run: | - kubectl delete namespace "pr${{ steps.pr_number.outputs.PR_NUMBER }}" || echo "namespace not found" + kubectl delete namespace "pr${PR_NUMBER}" || echo "namespace not found" + env: + PR_NUMBER: ${{ steps.pr_number.outputs.PR_NUMBER }} - name: "Remove DNS records" run: | set -euo pipefail # Get identifier for the record - record_id=$(curl -X GET "https://api.cloudflare.com/client/v4/zones/${{ secrets.PR_DEPLOYMENTS_ZONE_ID }}/dns_records?name=%2A.pr${{ steps.pr_number.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}" \ + record_id=$(curl -X GET "https://api.cloudflare.com/client/v4/zones/${{ secrets.PR_DEPLOYMENTS_ZONE_ID }}/dns_records?name=%2A.pr${PR_NUMBER}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}" \ -H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \ -H "Content-Type:application/json" | jq -r '.result[0].id') || echo "DNS record not found" @@ -73,9 +79,13 @@ jobs: -H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \ -H "Content-Type:application/json" | jq -r '.success' ) || echo "DNS record not found" + env: + PR_NUMBER: ${{ steps.pr_number.outputs.PR_NUMBER }} - name: "Delete certificate" if: ${{ github.event.pull_request.merged == true }} run: | set -euxo pipefail - kubectl delete certificate "pr${{ steps.pr_number.outputs.PR_NUMBER }}-tls" -n pr-deployment-certs || echo "certificate not found" + kubectl delete certificate "pr${PR_NUMBER}-tls" -n pr-deployment-certs || echo "certificate not found" + env: + PR_NUMBER: ${{ steps.pr_number.outputs.PR_NUMBER }} diff --git a/.github/workflows/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml index e31cc26e7927c..ccf7511eafc78 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -45,6 +45,8 @@ jobs: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Check if PR is open id: check_pr @@ -55,7 +57,7 @@ jobs: echo "PR doesn't exist or is closed." pr_open=false fi - echo "pr_open=$pr_open" >> $GITHUB_OUTPUT + echo "pr_open=$pr_open" >> "$GITHUB_OUTPUT" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -82,6 +84,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: Get PR number, title, and branch name id: pr_info @@ -90,9 +93,11 @@ jobs: 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_URL=$PR_URL" + echo "PR_NUMBER=$PR_NUMBER" + echo "PR_TITLE=$PR_TITLE" + } >> "$GITHUB_OUTPUT" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -100,8 +105,8 @@ jobs: id: set_tags run: | set -euo pipefail - echo "CODER_BASE_IMAGE_TAG=$CODER_BASE_IMAGE_TAG" >> $GITHUB_OUTPUT - echo "CODER_IMAGE_TAG=$CODER_IMAGE_TAG" >> $GITHUB_OUTPUT + echo "CODER_BASE_IMAGE_TAG=$CODER_BASE_IMAGE_TAG" >> "$GITHUB_OUTPUT" + echo "CODER_IMAGE_TAG=$CODER_IMAGE_TAG" >> "$GITHUB_OUTPUT" env: CODER_BASE_IMAGE_TAG: ghcr.io/coder/coder-preview-base:pr${{ steps.pr_info.outputs.PR_NUMBER }} CODER_IMAGE_TAG: ghcr.io/coder/coder-preview:pr${{ steps.pr_info.outputs.PR_NUMBER }} @@ -118,14 +123,16 @@ jobs: id: check_deployment run: | 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 + if helm status "pr${PR_NUMBER}" --namespace "pr${PR_NUMBER}" > /dev/null 2>&1; then echo "Deployment already exists. Skipping deployment." NEW=false else echo "Deployment doesn't exist." NEW=true fi - echo "NEW=$NEW" >> $GITHUB_OUTPUT + echo "NEW=$NEW" >> "$GITHUB_OUTPUT" + env: + PR_NUMBER: ${{ steps.pr_info.outputs.PR_NUMBER }} - name: Check changed files uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 @@ -154,17 +161,20 @@ jobs: - name: Print number of changed files run: | set -euo pipefail - echo "Total number of changed files: ${{ steps.filter.outputs.all_count }}" - echo "Number of ignored files: ${{ steps.filter.outputs.ignored_count }}" + echo "Total number of changed files: ${ALL_COUNT}" + echo "Number of ignored files: ${IGNORED_COUNT}" + env: + ALL_COUNT: ${{ steps.filter.outputs.all_count }} + IGNORED_COUNT: ${{ 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 + 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 already 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 + echo "automatic_rebuild=${{ steps.check_deployment.outputs.NEW == 'false' && steps.filter.outputs.all_count > steps.filter.outputs.ignored_count }}" >> "$GITHUB_OUTPUT" comment-pr: needs: get_info @@ -226,6 +236,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -250,12 +261,13 @@ jobs: make gen/mark-fresh export DOCKER_IMAGE_NO_PREREQUISITES=true version="$(./scripts/version.sh)" - export CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")" + CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")" + export CODER_IMAGE_BUILD_BASE_TAG make -j build/coder_linux_amd64 ./scripts/build_docker.sh \ --arch amd64 \ - --target ${{ env.CODER_IMAGE_TAG }} \ - --version $version \ + --target "${CODER_IMAGE_TAG}" \ + --version "$version" \ --push \ build/coder_linux_amd64 @@ -293,13 +305,13 @@ jobs: set -euo pipefail foundTag=$( gh api /orgs/coder/packages/container/coder-preview/versions | - jq -r --arg tag "pr${{ env.PR_NUMBER }}" '.[] | + jq -r --arg tag "pr${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 "${CODER_IMAGE_TAG} not found in ghcr.io/coder/coder-preview" exit 1 else echo "Image found" @@ -314,40 +326,42 @@ 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_HOSTNAME }}","content":"${{ env.PR_HOSTNAME }}","ttl":1,"proxied":false}' + --data '{"type":"CNAME","name":"*.'"${PR_HOSTNAME}"'","content":"'"${PR_HOSTNAME}"'","ttl":1,"proxied":false}' - name: Create PR namespace if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | 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 }}" + kubectl delete namespace "pr${PR_NUMBER}" || true + kubectl create namespace "pr${PR_NUMBER}" - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Check and Create Certificate 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 + if ! kubectl get certificate "pr${PR_NUMBER}-tls" -n pr-deployment-certs > /dev/null 2>&1; then echo "Certificate doesn't exist. Creating a new one." envsubst < ./.github/pr-deployments/certificate.yaml | kubectl apply -f - else echo "Certificate exists. Skipping certificate creation." fi - echo "Copy certificate from pr-deployment-certs to pr${{ env.PR_NUMBER }} namespace" - until kubectl get secret pr${{ env.PR_NUMBER }}-tls -n pr-deployment-certs &> /dev/null + echo "Copy certificate from pr-deployment-certs to pr${PR_NUMBER} namespace" + until kubectl get secret "pr${PR_NUMBER}-tls" -n pr-deployment-certs &> /dev/null do - echo "Waiting for secret pr${{ env.PR_NUMBER }}-tls to be created..." + echo "Waiting for secret pr${PR_NUMBER}-tls to be created..." sleep 5 done ( - kubectl get secret pr${{ env.PR_NUMBER }}-tls -n pr-deployment-certs -o json | + kubectl get secret "pr${PR_NUMBER}-tls" -n pr-deployment-certs -o json | jq 'del(.metadata.namespace,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.selfLink,.metadata.uid,.metadata.managedFields)' | - kubectl -n pr${{ env.PR_NUMBER }} apply -f - + kubectl -n "pr${PR_NUMBER}" apply -f - ) - name: Set up PostgreSQL database @@ -355,13 +369,13 @@ jobs: run: | helm repo add bitnami https://charts.bitnami.com/bitnami helm install coder-db bitnami/postgresql \ - --namespace pr${{ env.PR_NUMBER }} \ + --namespace "pr${PR_NUMBER}" \ --set auth.username=coder \ --set auth.password=coder \ --set auth.database=coder \ --set persistence.size=10Gi - kubectl create secret generic coder-db-url -n pr${{ env.PR_NUMBER }} \ - --from-literal=url="postgres://coder:coder@coder-db-postgresql.pr${{ env.PR_NUMBER }}.svc.cluster.local:5432/coder?sslmode=disable" + kubectl create secret generic coder-db-url -n "pr${PR_NUMBER}" \ + --from-literal=url="postgres://coder:coder@coder-db-postgresql.pr${PR_NUMBER}.svc.cluster.local:5432/coder?sslmode=disable" - name: Create a service account, role, and rolebinding for the PR namespace if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' @@ -383,8 +397,8 @@ jobs: run: | set -euo pipefail helm dependency update --skip-refresh ./helm/coder - helm upgrade --install "pr${{ env.PR_NUMBER }}" ./helm/coder \ - --namespace "pr${{ env.PR_NUMBER }}" \ + helm upgrade --install "pr${PR_NUMBER}" ./helm/coder \ + --namespace "pr${PR_NUMBER}" \ --values ./pr-deploy-values.yaml \ --force @@ -393,8 +407,8 @@ jobs: 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://${{ env.PR_HOSTNAME }}" + --namespace "pr${PR_NUMBER}" \ + --set url="https://${PR_HOSTNAME}" - name: Get Coder binary if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' @@ -402,16 +416,16 @@ jobs: set -euo pipefail DEST="${HOME}/coder" - URL="https://${{ env.PR_HOSTNAME }}/bin/coder-linux-amd64" + URL="https://${PR_HOSTNAME}/bin/coder-linux-amd64" - mkdir -p "$(dirname ${DEST})" + mkdir -p "$(dirname "$DEST")" COUNT=0 - until $(curl --output /dev/null --silent --head --fail "$URL"); do + until curl --output /dev/null --silent --head --fail "$URL"; do printf '.' sleep 5 COUNT=$((COUNT+1)) - if [ $COUNT -ge 60 ]; then + if [ "$COUNT" -ge 60 ]; then echo "Timed out waiting for URL to be available" exit 1 fi @@ -435,24 +449,24 @@ jobs: # add mask so that the password is not printed to the logs echo "::add-mask::$password" - echo "password=$password" >> $GITHUB_OUTPUT + echo "password=$password" >> "$GITHUB_OUTPUT" coder login \ - --first-user-username pr${{ env.PR_NUMBER }}-admin \ - --first-user-email pr${{ env.PR_NUMBER }}@coder.com \ - --first-user-password $password \ + --first-user-username "pr${PR_NUMBER}-admin" \ + --first-user-email "pr${PR_NUMBER}@coder.com" \ + --first-user-password "$password" \ --first-user-trial=false \ --use-token-as-session \ - https://${{ env.PR_HOSTNAME }} + "https://${PR_HOSTNAME}" # Create a user for the github.actor # TODO: update once https://github.com/coder/coder/issues/15466 is resolved # coder users create \ - # --username ${{ github.actor }} \ + # --username ${GITHUB_ACTOR} \ # --login-type github # promote the user to admin role - # coder org members edit-role ${{ github.actor }} organization-admin + # coder org members edit-role ${GITHUB_ACTOR} organization-admin # TODO: update once https://github.com/coder/internal/issues/207 is resolved - name: Send Slack notification @@ -461,17 +475,19 @@ jobs: curl -s -o /dev/null -X POST -H 'Content-type: application/json' \ -d \ '{ - "pr_number": "'"${{ env.PR_NUMBER }}"'", - "pr_url": "'"${{ env.PR_URL }}"'", - "pr_title": "'"${{ env.PR_TITLE }}"'", - "pr_access_url": "'"https://${{ env.PR_HOSTNAME }}"'", - "pr_username": "'"pr${{ env.PR_NUMBER }}-admin"'", - "pr_email": "'"pr${{ env.PR_NUMBER }}@coder.com"'", - "pr_password": "'"${{ steps.setup_deployment.outputs.password }}"'", - "pr_actor": "'"${{ github.actor }}"'" + "pr_number": "'"${PR_NUMBER}"'", + "pr_url": "'"${PR_URL}"'", + "pr_title": "'"${PR_TITLE}"'", + "pr_access_url": "'"https://${PR_HOSTNAME}"'", + "pr_username": "'"pr${PR_NUMBER}-admin"'", + "pr_email": "'"pr${PR_NUMBER}@coder.com"'", + "pr_password": "'"${PASSWORD}"'", + "pr_actor": "'"${GITHUB_ACTOR}"'" }' \ ${{ secrets.PR_DEPLOYMENTS_SLACK_WEBHOOK }} echo "Slack notification sent" + env: + PASSWORD: ${{ steps.setup_deployment.outputs.password }} - name: Find Comment uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0 @@ -504,7 +520,7 @@ jobs: run: | set -euo pipefail cd .github/pr-deployments/template - coder templates push -y --variable namespace=pr${{ env.PR_NUMBER }} kubernetes + coder templates push -y --variable "namespace=pr${PR_NUMBER}" kubernetes # Create workspace coder create --template="kubernetes" kube --parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 06041e1865d3a..f4f9c8f317664 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -68,6 +68,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false # If the event that triggered the build was an annotated tag (which our # tags are supposed to be), actions/checkout has a bug where the tag in @@ -80,9 +81,11 @@ jobs: - name: Setup build tools run: | brew install bash gnu-getopt make - echo "$(brew --prefix bash)/bin" >> $GITHUB_PATH - echo "$(brew --prefix gnu-getopt)/bin" >> $GITHUB_PATH - echo "$(brew --prefix make)/libexec/gnubin" >> $GITHUB_PATH + { + echo "$(brew --prefix bash)/bin" + echo "$(brew --prefix gnu-getopt)/bin" + echo "$(brew --prefix make)/libexec/gnubin" + } >> "$GITHUB_PATH" - name: Switch XCode Version uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 @@ -169,6 +172,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false # If the event that triggered the build was an annotated tag (which our # tags are supposed to be), actions/checkout has a bug where the tag in @@ -183,9 +187,9 @@ jobs: run: | set -euo pipefail version="$(./scripts/version.sh)" - echo "version=$version" >> $GITHUB_OUTPUT + echo "version=$version" >> "$GITHUB_OUTPUT" # Speed up future version.sh calls. - echo "CODER_FORCE_VERSION=$version" >> $GITHUB_ENV + echo "CODER_FORCE_VERSION=$version" >> "$GITHUB_ENV" echo "$version" # Verify that all expectations for a release are met. @@ -227,7 +231,7 @@ jobs: release_notes_file="$(mktemp -t release_notes.XXXXXX)" echo "$CODER_RELEASE_NOTES" > "$release_notes_file" - echo CODER_RELEASE_NOTES_FILE="$release_notes_file" >> $GITHUB_ENV + echo CODER_RELEASE_NOTES_FILE="$release_notes_file" >> "$GITHUB_ENV" - name: Show release notes run: | @@ -377,9 +381,9 @@ jobs: set -euo pipefail if [[ "${CODER_RELEASE:-}" != *t* ]] || [[ "${CODER_DRY_RUN:-}" == *t* ]]; then # Empty value means use the default and avoid building a fresh one. - echo "tag=" >> $GITHUB_OUTPUT + echo "tag=" >> "$GITHUB_OUTPUT" else - echo "tag=$(CODER_IMAGE_BASE=ghcr.io/coder/coder-base ./scripts/image_tag.sh)" >> $GITHUB_OUTPUT + echo "tag=$(CODER_IMAGE_BASE=ghcr.io/coder/coder-base ./scripts/image_tag.sh)" >> "$GITHUB_OUTPUT" fi - name: Create empty base-build-context directory @@ -414,7 +418,7 @@ jobs: # available immediately for i in {1..10}; do rc=0 - raw_manifests=$(docker buildx imagetools inspect --raw "${{ steps.image-base-tag.outputs.tag }}") || rc=$? + raw_manifests=$(docker buildx imagetools inspect --raw "${IMAGE_TAG}") || rc=$? if [[ "$rc" -eq 0 ]]; then break fi @@ -436,6 +440,8 @@ jobs: echo "$manifests" | grep -q linux/amd64 echo "$manifests" | grep -q linux/arm64 echo "$manifests" | grep -q linux/arm/v7 + env: + IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }} # GitHub attestation provides SLSA provenance for Docker images, establishing a verifiable # record that these images were built in GitHub Actions with specific inputs and environment. @@ -503,7 +509,7 @@ jobs: # Save multiarch image tag for attestation multiarch_image="$(./scripts/image_tag.sh)" - echo "multiarch_image=${multiarch_image}" >> $GITHUB_OUTPUT + echo "multiarch_image=${multiarch_image}" >> "$GITHUB_OUTPUT" # For debugging, print all docker image tags docker images @@ -511,16 +517,15 @@ jobs: # if the current version is equal to the highest (according to semver) # version in the repo, also create a multi-arch image as ":latest" and # push it - created_latest_tag=false if [[ "$(git tag | grep '^v' | grep -vE '(rc|dev|-|\+|\/)' | sort -r --version-sort | head -n1)" == "v$(./scripts/version.sh)" ]]; then + # shellcheck disable=SC2046 ./scripts/build_docker_multiarch.sh \ --push \ --target "$(./scripts/image_tag.sh --version latest)" \ $(cat build/coder_"$version"_linux_{amd64,arm64,armv7}.tag) - created_latest_tag=true - echo "created_latest_tag=true" >> $GITHUB_OUTPUT + echo "created_latest_tag=true" >> "$GITHUB_OUTPUT" else - echo "created_latest_tag=false" >> $GITHUB_OUTPUT + echo "created_latest_tag=false" >> "$GITHUB_OUTPUT" fi env: CODER_BASE_IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }} @@ -528,24 +533,27 @@ jobs: - name: SBOM Generation and Attestation if: ${{ !inputs.dry_run }} env: - COSIGN_EXPERIMENTAL: "1" + COSIGN_EXPERIMENTAL: '1' + MULTIARCH_IMAGE: ${{ steps.build_docker.outputs.multiarch_image }} + VERSION: ${{ steps.version.outputs.version }} + CREATED_LATEST_TAG: ${{ steps.build_docker.outputs.created_latest_tag }} run: | set -euxo pipefail # Generate SBOM for multi-arch image with version in filename - echo "Generating SBOM for multi-arch image: ${{ steps.build_docker.outputs.multiarch_image }}" - syft "${{ steps.build_docker.outputs.multiarch_image }}" -o spdx-json > coder_${{ steps.version.outputs.version }}_sbom.spdx.json + echo "Generating SBOM for multi-arch image: ${MULTIARCH_IMAGE}" + syft "${MULTIARCH_IMAGE}" -o spdx-json > "coder_${VERSION}_sbom.spdx.json" # Attest SBOM to multi-arch image - echo "Attesting SBOM to multi-arch image: ${{ steps.build_docker.outputs.multiarch_image }}" - cosign clean --force=true "${{ steps.build_docker.outputs.multiarch_image }}" + echo "Attesting SBOM to multi-arch image: ${MULTIARCH_IMAGE}" + cosign clean --force=true "${MULTIARCH_IMAGE}" cosign attest --type spdxjson \ - --predicate coder_${{ steps.version.outputs.version }}_sbom.spdx.json \ + --predicate "coder_${VERSION}_sbom.spdx.json" \ --yes \ - "${{ steps.build_docker.outputs.multiarch_image }}" + "${MULTIARCH_IMAGE}" # If latest tag was created, also attest it - if [[ "${{ steps.build_docker.outputs.created_latest_tag }}" == "true" ]]; then + if [[ "${CREATED_LATEST_TAG}" == "true" ]]; then latest_tag="$(./scripts/image_tag.sh --version latest)" echo "Generating SBOM for latest image: ${latest_tag}" syft "${latest_tag}" -o spdx-json > coder_latest_sbom.spdx.json @@ -599,7 +607,7 @@ jobs: - name: Get latest tag name id: latest_tag if: ${{ !inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' }} - run: echo "tag=$(./scripts/image_tag.sh --version latest)" >> $GITHUB_OUTPUT + run: echo "tag=$(./scripts/image_tag.sh --version latest)" >> "$GITHUB_OUTPUT" # If this is the highest version according to semver, also attest the "latest" tag - name: GitHub Attestation for "latest" Docker image @@ -642,7 +650,7 @@ jobs: # Report attestation failures but don't fail the workflow - name: Check attestation status if: ${{ !inputs.dry_run }} - run: | + run: | # zizmor: ignore[template-injection] We're just reading steps.attest_x.outcome here, no risk of injection if [[ "${{ steps.attest_base.outcome }}" == "failure" && "${{ steps.attest_base.conclusion }}" != "skipped" ]]; then echo "::warning::GitHub attestation for base image failed" fi @@ -707,11 +715,11 @@ jobs: ./build/*.apk ./build/*.deb ./build/*.rpm - ./coder_${{ steps.version.outputs.version }}_sbom.spdx.json + "./coder_${VERSION}_sbom.spdx.json" ) # Only include the latest SBOM file if it was created - if [[ "${{ steps.build_docker.outputs.created_latest_tag }}" == "true" ]]; then + if [[ "${CREATED_LATEST_TAG}" == "true" ]]; then files+=(./coder_latest_sbom.spdx.json) fi @@ -722,6 +730,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }} + VERSION: ${{ steps.version.outputs.version }} + CREATED_LATEST_TAG: ${{ steps.build_docker.outputs.created_latest_tag }} - name: Authenticate to Google Cloud uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5 # v2.1.12 @@ -742,12 +752,12 @@ jobs: 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 - helm push build/coder_helm_${version}.tgz oci://ghcr.io/coder/chart - helm push build/provisioner_helm_${version}.tgz oci://ghcr.io/coder/chart + 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 + helm push "build/coder_helm_${version}.tgz" oci://ghcr.io/coder/chart + helm push "build/provisioner_helm_${version}.tgz" oci://ghcr.io/coder/chart - name: Upload artifacts to actions (if dry-run) if: ${{ inputs.dry_run }} @@ -798,12 +808,12 @@ jobs: - name: Update homebrew env: - # Variables used by the `gh` command GH_REPO: coder/homebrew-coder GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + VERSION: ${{ needs.release.outputs.version }} run: | # Keep version number around for reference, removing any potential leading v - coder_version="$(echo "${{ needs.release.outputs.version }}" | tr -d v)" + coder_version="$(echo "${VERSION}" | tr -d v)" set -euxo pipefail @@ -822,9 +832,9 @@ jobs: wget "$checksums_url" -O checksums.txt # Get the SHAs - darwin_arm_sha="$(cat checksums.txt | grep "darwin_arm64.zip" | awk '{ print $1 }')" - darwin_intel_sha="$(cat checksums.txt | grep "darwin_amd64.zip" | awk '{ print $1 }')" - linux_sha="$(cat checksums.txt | grep "linux_amd64.tar.gz" | awk '{ print $1 }')" + darwin_arm_sha="$(grep "darwin_arm64.zip" checksums.txt | awk '{ print $1 }')" + darwin_intel_sha="$(grep "darwin_amd64.zip" checksums.txt | awk '{ print $1 }')" + linux_sha="$(grep "linux_amd64.tar.gz" checksums.txt | awk '{ print $1 }')" echo "macOS arm64: $darwin_arm_sha" echo "macOS amd64: $darwin_intel_sha" @@ -837,7 +847,7 @@ jobs: # Check if a PR already exists. pr_count="$(gh pr list --search "head:$brew_branch" --json id,closed | jq -r ".[] | select(.closed == false) | .id" | wc -l)" - if [[ "$pr_count" > 0 ]]; then + if [ "$pr_count" -gt 0 ]; then echo "Bailing out as PR already exists" 2>&1 exit 0 fi @@ -856,8 +866,8 @@ jobs: -B master -H "$brew_branch" \ -t "coder $coder_version" \ -b "" \ - -r "${{ github.actor }}" \ - -a "${{ github.actor }}" \ + -r "${GITHUB_ACTOR}" \ + -a "${GITHUB_ACTOR}" \ -b "This automatic PR was triggered by the release of Coder v$coder_version" publish-winget: @@ -881,6 +891,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false # If the event that triggered the build was an annotated tag (which our # tags are supposed to be), actions/checkout has a bug where the tag in @@ -899,7 +910,7 @@ jobs: # 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') + $version = $env:VERSION.Trim('v') $release_assets = gh release view --repo coder/coder "v${version}" --json assets | ` ConvertFrom-Json @@ -931,13 +942,14 @@ jobs: # For wingetcreate. We need a real token since we're pushing a commit # to GitHub and then making a PR in a different repo. WINGET_GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + VERSION: ${{ needs.release.outputs.version }} - name: Comment on PR run: | # wait 30 seconds Start-Sleep -Seconds 30.0 # Find the PR that wingetcreate just made. - $version = "${{ needs.release.outputs.version }}".Trim('v') + $version = $env:VERSION.Trim('v') $pr_list = gh pr list --repo microsoft/winget-pkgs --search "author:cdrci Coder.Coder version ${version}" --limit 1 --json number | ` ConvertFrom-Json $pr_number = $pr_list[0].number @@ -948,6 +960,7 @@ 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 }} + VERSION: ${{ needs.release.outputs.version }} # publish-sqlc pushes the latest schema to sqlc cloud. # At present these pushes cannot be tagged, so the last push is always the latest. @@ -966,6 +979,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false # We need golang to run the migration main.go - name: Setup Go diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index 27b5137738098..e7fde82bf1dce 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -33,6 +33,8 @@ jobs: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go @@ -75,6 +77,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go @@ -134,12 +137,13 @@ jobs: # This environment variables forces scripts/build_docker.sh to build # the base image tag locally instead of using the cached version from # the registry. - export CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")" + CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")" + export CODER_IMAGE_BUILD_BASE_TAG # We would like to use make -j here, but it doesn't work with the some recent additions # to our code generation. make "$image_job" - echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT + echo "image=$(cat "$image_job")" >> "$GITHUB_OUTPUT" - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index c0c2494db6fbf..27ec157fa0f3f 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -102,6 +102,8 @@ jobs: - name: Checkout repository uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Run delete-old-branches-action uses: beatlabs/delete-old-branches-action@4eeeb8740ff8b3cb310296ddd6b43c3387734588 # v0.0.11 with: diff --git a/.github/workflows/weekly-docs.yaml b/.github/workflows/weekly-docs.yaml index 8d152f73981f5..56f5e799305e8 100644 --- a/.github/workflows/weekly-docs.yaml +++ b/.github/workflows/weekly-docs.yaml @@ -27,6 +27,8 @@ jobs: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Check Markdown links uses: umbrelladocs/action-linkspector@874d01cae9fd488e3077b08952093235bd626977 # v1.3.7 @@ -41,7 +43,10 @@ jobs: - name: Send Slack notification if: failure() && github.event_name == 'schedule' run: | - curl -X POST -H 'Content-type: application/json' -d '{"msg":"Broken links found in the documentation. Please check the logs at ${{ env.LOGS_URL }}"}' ${{ secrets.DOCS_LINK_SLACK_WEBHOOK }} + curl \ + -X POST \ + -H 'Content-type: application/json' \ + -d '{"msg":"Broken links found in the documentation. Please check the logs at '"${LOGS_URL}"'"}' "${{ secrets.DOCS_LINK_SLACK_WEBHOOK }}" echo "Sent Slack notification" env: LOGS_URL: https://github.com/coder/coder/actions/runs/${{ github.run_id }} diff --git a/.vscode/settings.json b/.vscode/settings.json index eaea72e7501b5..7fef4af975bc2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,7 +60,5 @@ "typos.config": ".github/workflows/typos.toml", "[markdown]": { "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" - }, - "biome.configurationPath": "./site/biome.jsonc", - "biome.lsp.bin": "./site/node_modules/.bin/biome" + } } diff --git a/CODEOWNERS b/CODEOWNERS index 451b34835eea0..fde24a9d874ed 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -18,7 +18,7 @@ coderd/rbac/ @Emyrk scripts/apitypings/ @Emyrk scripts/gensite/ @aslilac -site/ @aslilac +site/ @aslilac @Parkreiner site/src/hooks/ @Parkreiner # These rules intentionally do not specify any owners. More specific rules # override less specific rules, so these files are "ignored" by the site/ rule. @@ -27,6 +27,7 @@ site/e2e/provisionerGenerated.ts site/src/api/countriesGenerated.ts site/src/api/rbacresourcesGenerated.ts site/src/api/typesGenerated.ts +site/src/testHelpers/entities.ts site/CLAUDE.md # The blood and guts of the autostop algorithm, which is quite complex and diff --git a/Makefile b/Makefile index 9040a891700e1..3974966836881 100644 --- a/Makefile +++ b/Makefile @@ -559,7 +559,9 @@ else endif .PHONY: fmt/markdown -lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown +# Note: we don't run zizmor in the lint target because it takes a while. CI +# runs it explicitly. +lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint .PHONY: lint lint/site-icons: @@ -598,6 +600,20 @@ lint/markdown: node_modules/.installed pnpm lint-docs .PHONY: lint/markdown +lint/actions: lint/actions/actionlint lint/actions/zizmor +.PHONY: lint/actions + +lint/actions/actionlint: + go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 +.PHONY: lint/actions/actionlint + +lint/actions/zizmor: + ./scripts/zizmor.sh \ + --strict-collection \ + --persona=regular \ + . +.PHONY: lint/actions/zizmor + # All files generated by the database should be added here, and this can be used # as a target for jobs that need to run after the database is generated. DB_GEN_FILES := \ @@ -636,7 +652,8 @@ GEN_FILES := \ coderd/database/pubsub/psmock/psmock.go \ agent/agentcontainers/acmock/acmock.go \ agent/agentcontainers/dcspec/dcspec_gen.go \ - coderd/httpmw/loggermw/loggermock/loggermock.go + coderd/httpmw/loggermw/loggermock/loggermock.go \ + codersdk/workspacesdk/agentconnmock/agentconnmock.go # all gen targets should be added here and to gen/mark-fresh gen: gen/db gen/golden-files $(GEN_FILES) @@ -686,6 +703,7 @@ gen/mark-fresh: agent/agentcontainers/acmock/acmock.go \ agent/agentcontainers/dcspec/dcspec_gen.go \ coderd/httpmw/loggermw/loggermock/loggermock.go \ + codersdk/workspacesdk/agentconnmock/agentconnmock.go \ " for file in $$files; do @@ -729,6 +747,10 @@ coderd/httpmw/loggermw/loggermock/loggermock.go: coderd/httpmw/loggermw/logger.g go generate ./coderd/httpmw/loggermw/loggermock/ touch "$@" +codersdk/workspacesdk/agentconnmock/agentconnmock.go: codersdk/workspacesdk/agentconn.go + go generate ./codersdk/workspacesdk/agentconnmock/ + touch "$@" + agent/agentcontainers/dcspec/dcspec_gen.go: \ node_modules/.installed \ agent/agentcontainers/dcspec/devContainer.base.schema.json \ @@ -936,12 +958,31 @@ else GOTESTSUM_RETRY_FLAGS := endif +# default to 8x8 parallelism to avoid overwhelming our workspaces. Hopefully we can remove these defaults +# when we get our test suite's resource utilization under control. +GOTEST_FLAGS := -v -p $(or $(TEST_NUM_PARALLEL_PACKAGES),"8") -parallel=$(or $(TEST_NUM_PARALLEL_TESTS),"8") + +# The most common use is to set TEST_COUNT=1 to avoid Go's test cache. +ifdef TEST_COUNT +GOTEST_FLAGS += -count=$(TEST_COUNT) +endif + +ifdef TEST_SHORT +GOTEST_FLAGS += -short +endif + +ifdef RUN +GOTEST_FLAGS += -run $(RUN) +endif + +TEST_PACKAGES ?= ./... + test: - $(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="./..." -- -v -short -count=1 $(if $(RUN),-run $(RUN)) + $(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="$(TEST_PACKAGES)" -- $(GOTEST_FLAGS) .PHONY: test test-cli: - $(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="./cli/..." -- -v -short -count=1 + $(MAKE) test TEST_PACKAGES="./cli..." .PHONY: test-cli # sqlc-cloud-is-setup will fail if no SQLc auth token is set. Use this as a diff --git a/agent/agent_test.go b/agent/agent_test.go index 52d8cfc09d573..d80f5d1982b74 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -2750,9 +2750,9 @@ func TestAgent_Dial(t *testing.T) { switch l.Addr().Network() { case "tcp": - conn, err = agentConn.Conn.DialContextTCP(ctx, ipp) + conn, err = agentConn.TailnetConn().DialContextTCP(ctx, ipp) case "udp": - conn, err = agentConn.Conn.DialContextUDP(ctx, ipp) + conn, err = agentConn.TailnetConn().DialContextUDP(ctx, ipp) default: t.Fatalf("unknown network: %s", l.Addr().Network()) } @@ -2811,7 +2811,7 @@ func TestAgent_UpdatedDERP(t *testing.T) { }) // Setup a client connection. - newClientConn := func(derpMap *tailcfg.DERPMap, name string) *workspacesdk.AgentConn { + newClientConn := func(derpMap *tailcfg.DERPMap, name string) workspacesdk.AgentConn { conn, err := tailnet.NewConn(&tailnet.Options{ Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.RandomPrefix()}, DERPMap: derpMap, @@ -2891,13 +2891,13 @@ func TestAgent_UpdatedDERP(t *testing.T) { // Connect from a second client and make sure it uses the new DERP map. conn2 := newClientConn(newDerpMap, "client2") - require.Equal(t, []int{2}, conn2.DERPMap().RegionIDs()) + require.Equal(t, []int{2}, conn2.TailnetConn().DERPMap().RegionIDs()) t.Log("conn2 got the new DERPMap") // If the first client gets a DERP map update, it should be able to // reconnect just fine. - conn1.SetDERPMap(newDerpMap) - require.Equal(t, []int{2}, conn1.DERPMap().RegionIDs()) + conn1.TailnetConn().SetDERPMap(newDerpMap) + require.Equal(t, []int{2}, conn1.TailnetConn().DERPMap().RegionIDs()) t.Log("set the new DERPMap on conn1") ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -3264,7 +3264,7 @@ func setupSSHSessionOnPort( } func setupAgent(t testing.TB, metadata agentsdk.Manifest, ptyTimeout time.Duration, opts ...func(*agenttest.Client, *agent.Options)) ( - *workspacesdk.AgentConn, + workspacesdk.AgentConn, *agenttest.Client, <-chan *proto.Stats, afero.Fs, @@ -3470,7 +3470,11 @@ func TestAgent_Metrics_SSH(t *testing.T) { registry := prometheus.NewRegistry() //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) { + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{ + // Make sure we always get a DERP connection for + // currently_reachable_peers. + DisableDirectConnections: true, + }, 0, func(_ *agenttest.Client, o *agent.Options) { o.PrometheusRegistry = registry }) @@ -3524,7 +3528,7 @@ func TestAgent_Metrics_SSH(t *testing.T) { { Name: "coderd_agentstats_currently_reachable_peers", Type: proto.Stats_Metric_GAUGE, - Value: 0, + Value: 1, Labels: []*proto.Stats_Metric_Label{ { Name: "connection_type", @@ -3535,7 +3539,7 @@ func TestAgent_Metrics_SSH(t *testing.T) { { Name: "coderd_agentstats_currently_reachable_peers", Type: proto.Stats_Metric_GAUGE, - Value: 1, + Value: 0, Labels: []*proto.Stats_Metric_Label{ { Name: "connection_type", diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index b956e17d5efaa..263f1698a7117 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -1675,6 +1675,8 @@ func TestAPI(t *testing.T) { coderBin, err := os.Executable() require.NoError(t, err) + coderBin, err = filepath.EvalSymlinks(coderBin) + require.NoError(t, err) mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{testContainer}, @@ -2096,9 +2098,6 @@ func TestAPI(t *testing.T) { } ) - coderBin, err := os.Executable() - require.NoError(t, err) - // Mock the `List` function to always return the test container. mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{testContainer}, @@ -2139,7 +2138,7 @@ func TestAPI(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) var response codersdk.WorkspaceAgentListContainersResponse - err = json.NewDecoder(rec.Body).Decode(&response) + err := json.NewDecoder(rec.Body).Decode(&response) require.NoError(t, err) // Then: We expect that there will be an error associated with the devcontainer. @@ -2149,7 +2148,7 @@ func TestAPI(t *testing.T) { gomock.InOrder( mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil), mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), - mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil), + mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, gomock.Any(), "/.coder-agent/coder").Return(nil), mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil), ) @@ -2157,8 +2156,8 @@ func TestAPI(t *testing.T) { // Given: We allow creation to succeed. testutil.RequireSend(ctx, t, fSAC.createErrC, nil) - _, aw := mClock.AdvanceNext() - aw.MustWait(ctx) + err = api.RefreshContainers(ctx) + require.NoError(t, err) req = httptest.NewRequest(http.MethodGet, "/", nil) rec = httptest.NewRecorder() @@ -2458,6 +2457,8 @@ func TestAPI(t *testing.T) { coderBin, err := os.Executable() require.NoError(t, err) + coderBin, err = filepath.EvalSymlinks(coderBin) + require.NoError(t, err) // Mock the `List` function to always return out test container. mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ @@ -2552,6 +2553,8 @@ func TestAPI(t *testing.T) { coderBin, err := os.Executable() require.NoError(t, err) + coderBin, err = filepath.EvalSymlinks(coderBin) + require.NoError(t, err) // Mock the `List` function to always return out test container. mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ @@ -2657,6 +2660,8 @@ func TestAPI(t *testing.T) { coderBin, err := os.Executable() require.NoError(t, err) + coderBin, err = filepath.EvalSymlinks(coderBin) + require.NoError(t, err) // Mock the `List` function to always return our test container. mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index f53fe207c72cf..f9c28a3e6ee25 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -46,6 +46,8 @@ const ( // MagicProcessCmdlineJetBrains is a string in a process's command line that // uniquely identifies it as JetBrains software. MagicProcessCmdlineJetBrains = "idea.vendor.name=JetBrains" + MagicProcessCmdlineToolbox = "com.jetbrains.toolbox" + MagicProcessCmdlineGateway = "remote-dev-server" // BlockedFileTransferErrorCode indicates that SSH server restricted the raw command from performing // the file transfer. diff --git a/agent/agentssh/jetbrainstrack.go b/agent/agentssh/jetbrainstrack.go index 9b2fdf83b21d0..874f4c278ce79 100644 --- a/agent/agentssh/jetbrainstrack.go +++ b/agent/agentssh/jetbrainstrack.go @@ -53,7 +53,7 @@ func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger, reportConne // If this is not JetBrains, then we do not need to do anything special. We // attempt to match on something that appears unique to JetBrains software. - if !strings.Contains(strings.ToLower(cmdline), strings.ToLower(MagicProcessCmdlineJetBrains)) { + if !isJetbrainsProcess(cmdline) { return newChannel } @@ -104,3 +104,18 @@ func (c *ChannelOnClose) Close() error { c.once.Do(c.done) return c.Channel.Close() } + +func isJetbrainsProcess(cmdline string) bool { + opts := []string{ + MagicProcessCmdlineJetBrains, + MagicProcessCmdlineToolbox, + MagicProcessCmdlineGateway, + } + + for _, opt := range opts { + if strings.Contains(strings.ToLower(cmdline), strings.ToLower(opt)) { + return true + } + } + return false +} diff --git a/cli/create.go b/cli/create.go index 3f52e59e8ad90..59ab0ba0fa6d7 100644 --- a/cli/create.go +++ b/cli/create.go @@ -29,7 +29,12 @@ const PresetNone = "none" var ErrNoPresetFound = xerrors.New("no preset found") -func (r *RootCmd) create() *serpent.Command { +type CreateOptions struct { + BeforeCreate func(ctx context.Context, client *codersdk.Client, template codersdk.Template, templateVersionID uuid.UUID) error + AfterCreate func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error +} + +func (r *RootCmd) Create(opts CreateOptions) *serpent.Command { var ( templateName string templateVersion string @@ -305,6 +310,13 @@ func (r *RootCmd) create() *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied.")) } + if opts.BeforeCreate != nil { + err = opts.BeforeCreate(inv.Context(), client, template, templateVersionID) + if err != nil { + return xerrors.Errorf("before create: %w", err) + } + } + richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ Action: WorkspaceCreate, TemplateVersionID: templateVersionID, @@ -366,6 +378,14 @@ func (r *RootCmd) create() *serpent.Command { cliui.Keyword(workspace.Name), cliui.Timestamp(time.Now()), ) + + if opts.AfterCreate != nil { + err = opts.AfterCreate(inv.Context(), inv, client, workspace) + if err != nil { + return err + } + } + return nil }, } diff --git a/cli/delete_test.go b/cli/delete_test.go index c01893419f80f..2e550d74849ab 100644 --- a/cli/delete_test.go +++ b/cli/delete_test.go @@ -111,7 +111,6 @@ func TestDelete(t *testing.T) { // The API checks if the user has any workspaces, so we cannot delete a user // this way. ctx := testutil.Context(t, testutil.WaitShort) - // nolint:gocritic // Unit test err := api.Database.UpdateUserDeletedByID(dbauthz.AsSystemRestricted(ctx), deleteMeUser.ID) require.NoError(t, err) diff --git a/cli/exp_rpty.go b/cli/exp_rpty.go index 70154c57ea9bc..196328b64732c 100644 --- a/cli/exp_rpty.go +++ b/cli/exp_rpty.go @@ -97,7 +97,7 @@ func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPT reconnectID = uuid.New() } - ws, agt, _, err := getWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace) + ws, agt, _, err := GetWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace) if err != nil { return err } diff --git a/cli/list.go b/cli/list.go index 083d32c6e8fa1..278895dfd7218 100644 --- a/cli/list.go +++ b/cli/list.go @@ -18,7 +18,7 @@ import ( // workspaceListRow is the type provided to the OutputFormatter. This is a bit // dodgy but it's the only way to do complex display code for one format vs. the // other. -type workspaceListRow struct { +type WorkspaceListRow struct { // For JSON format: codersdk.Workspace `table:"-"` @@ -40,7 +40,7 @@ type workspaceListRow struct { DailyCost string `json:"-" table:"daily cost"` } -func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) workspaceListRow { +func WorkspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) WorkspaceListRow { status := codersdk.WorkspaceDisplayStatus(workspace.LatestBuild.Job.Status, workspace.LatestBuild.Transition) lastBuilt := now.UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second) @@ -55,7 +55,7 @@ func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) favIco = "★" } workspaceName := favIco + " " + workspace.OwnerName + "/" + workspace.Name - return workspaceListRow{ + return WorkspaceListRow{ Favorite: workspace.Favorite, Workspace: workspace, WorkspaceName: workspaceName, @@ -80,7 +80,7 @@ func (r *RootCmd) list() *serpent.Command { filter cliui.WorkspaceFilter formatter = cliui.NewOutputFormatter( cliui.TableFormat( - []workspaceListRow{}, + []WorkspaceListRow{}, []string{ "workspace", "template", @@ -107,7 +107,7 @@ func (r *RootCmd) list() *serpent.Command { r.InitClient(client), ), Handler: func(inv *serpent.Invocation) error { - res, err := queryConvertWorkspaces(inv.Context(), client, filter.Filter(), workspaceListRowFromWorkspace) + res, err := QueryConvertWorkspaces(inv.Context(), client, filter.Filter(), WorkspaceListRowFromWorkspace) if err != nil { return err } @@ -137,9 +137,9 @@ func (r *RootCmd) list() *serpent.Command { // queryConvertWorkspaces is a helper function for converting // codersdk.Workspaces to a different type. // It's used by the list command to convert workspaces to -// workspaceListRow, and by the schedule command to +// WorkspaceListRow, and by the schedule command to // convert workspaces to scheduleListRow. -func queryConvertWorkspaces[T any](ctx context.Context, client *codersdk.Client, filter codersdk.WorkspaceFilter, convertF func(time.Time, codersdk.Workspace) T) ([]T, error) { +func QueryConvertWorkspaces[T any](ctx context.Context, client *codersdk.Client, filter codersdk.WorkspaceFilter, convertF func(time.Time, codersdk.Workspace) T) ([]T, error) { var empty []T workspaces, err := client.Workspaces(ctx, filter) if err != nil { diff --git a/cli/open.go b/cli/open.go index cc21ea863430d..83569e87e241a 100644 --- a/cli/open.go +++ b/cli/open.go @@ -72,7 +72,7 @@ func (r *RootCmd) openVSCode() *serpent.Command { // need to wait for the agent to start. workspaceQuery := inv.Args[0] autostart := true - workspace, workspaceAgent, otherWorkspaceAgents, err := getWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery) + workspace, workspaceAgent, otherWorkspaceAgents, err := GetWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery) if err != nil { return xerrors.Errorf("get workspace and agent: %w", err) } @@ -316,7 +316,7 @@ func (r *RootCmd) openApp() *serpent.Command { } workspaceName := inv.Args[0] - ws, agt, _, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName) + ws, agt, _, err := GetWorkspaceAndAgent(ctx, inv, client, false, workspaceName) if err != nil { var sdkErr *codersdk.Error if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { diff --git a/cli/ping.go b/cli/ping.go index 0836aa8a135db..29af06affeaee 100644 --- a/cli/ping.go +++ b/cli/ping.go @@ -110,7 +110,7 @@ func (r *RootCmd) ping() *serpent.Command { defer notifyCancel() workspaceName := inv.Args[0] - _, workspaceAgent, _, err := getWorkspaceAndAgent( + _, workspaceAgent, _, err := GetWorkspaceAndAgent( ctx, inv, client, false, // Do not autostart for a ping. workspaceName, @@ -147,7 +147,7 @@ func (r *RootCmd) ping() *serpent.Command { } defer conn.Close() - derpMap := conn.DERPMap() + derpMap := conn.TailnetConn().DERPMap() diagCtx, diagCancel := context.WithTimeout(inv.Context(), 30*time.Second) defer diagCancel() @@ -156,7 +156,7 @@ func (r *RootCmd) ping() *serpent.Command { // Silent ping to determine whether we should show diags _, didP2p, _, _ := conn.Ping(ctx) - ni := conn.GetNetInfo() + ni := conn.TailnetConn().GetNetInfo() connDiags := cliui.ConnDiags{ DisableDirect: r.disableDirect, LocalNetInfo: ni, diff --git a/cli/portforward.go b/cli/portforward.go index 7a7723213f760..1b055d9e4362e 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -84,7 +84,7 @@ func (r *RootCmd) portForward() *serpent.Command { return xerrors.New("no port-forwards requested") } - workspace, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, inv.Args[0]) + workspace, workspaceAgent, _, err := GetWorkspaceAndAgent(ctx, inv, client, !disableAutostart, inv.Args[0]) if err != nil { return err } @@ -221,7 +221,7 @@ func (r *RootCmd) portForward() *serpent.Command { func listenAndPortForward( ctx context.Context, inv *serpent.Invocation, - conn *workspacesdk.AgentConn, + conn workspacesdk.AgentConn, wg *sync.WaitGroup, spec portForwardSpec, logger slog.Logger, diff --git a/cli/provisionerjobs_test.go b/cli/provisionerjobs_test.go index b33fd8b984dc7..4db42e8e3c9e7 100644 --- a/cli/provisionerjobs_test.go +++ b/cli/provisionerjobs_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/aws/smithy-go/ptr" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,6 +19,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/testutil" ) @@ -36,67 +36,43 @@ func TestProvisionerJobs(t *testing.T) { templateAdminClient, templateAdmin := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID)) memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - // Create initial resources with a running provisioner. - firstProvisioner := coderdtest.NewTaggedProvisionerDaemon(t, coderdAPI, "default-provisioner", map[string]string{"owner": "", "scope": "organization"}) - t.Cleanup(func() { _ = firstProvisioner.Close() }) - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent()) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(req *codersdk.CreateTemplateRequest) { - req.AllowUserCancelWorkspaceJobs = ptr.Bool(true) + // These CLI tests are related to provisioner job CRUD operations and as such + // do not require the overhead of starting a provisioner. Other provisioner job + // functionalities (acquisition etc.) are tested elsewhere. + template := dbgen.Template(t, db, database.Template{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + AllowUserCancelWorkspaceJobs: true, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, }) - - // Stop the provisioner so it doesn't grab any more jobs. - firstProvisioner.Close() t.Run("Cancel", func(t *testing.T) { t.Parallel() - // Set up test helpers. - type jobInput struct { - WorkspaceBuildID string `json:"workspace_build_id,omitempty"` - TemplateVersionID string `json:"template_version_id,omitempty"` - DryRun bool `json:"dry_run,omitempty"` - } - prepareJob := func(t *testing.T, input jobInput) database.ProvisionerJob { + // Test helper to create a provisioner job of a given type with a given input. + prepareJob := func(t *testing.T, jobType database.ProvisionerJobType, input json.RawMessage) database.ProvisionerJob { t.Helper() - - inputBytes, err := json.Marshal(input) - require.NoError(t, err) - - var typ database.ProvisionerJobType - switch { - case input.WorkspaceBuildID != "": - typ = database.ProvisionerJobTypeWorkspaceBuild - case input.TemplateVersionID != "": - if input.DryRun { - typ = database.ProvisionerJobTypeTemplateVersionDryRun - } else { - typ = database.ProvisionerJobTypeTemplateVersionImport - } - default: - t.Fatal("invalid input") - } - - var ( - tags = database.StringMap{"owner": "", "scope": "organization", "foo": uuid.New().String()} - _ = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{Tags: tags}) - job = dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{ - InitiatorID: member.ID, - Input: json.RawMessage(inputBytes), - Type: typ, - Tags: tags, - StartedAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(-time.Minute), Valid: true}, - }) - ) - return job + return dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{ + InitiatorID: member.ID, + Input: input, + Type: jobType, + StartedAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(-time.Minute), Valid: true}, + Tags: database.StringMap{provisionersdk.TagOwner: "", provisionersdk.TagScope: provisionersdk.ScopeOrganization, "foo": uuid.NewString()}, + }) } + // Test helper to create a workspace build job with a predefined input. prepareWorkspaceBuildJob := func(t *testing.T) database.ProvisionerJob { t.Helper() var ( - wbID = uuid.New() - job = prepareJob(t, jobInput{WorkspaceBuildID: wbID.String()}) - w = dbgen.Workspace(t, db, database.WorkspaceTable{ + wbID = uuid.New() + input, _ = json.Marshal(map[string]string{"workspace_build_id": wbID.String()}) + job = prepareJob(t, database.ProvisionerJobTypeWorkspaceBuild, input) + w = dbgen.Workspace(t, db, database.WorkspaceTable{ OrganizationID: owner.OrganizationID, OwnerID: member.ID, TemplateID: template.ID, @@ -112,12 +88,14 @@ func TestProvisionerJobs(t *testing.T) { return job } - prepareTemplateVersionImportJobBuilder := func(t *testing.T, dryRun bool) database.ProvisionerJob { + // Test helper to create a template version import job with a predefined input. + prepareTemplateVersionImportJob := func(t *testing.T) database.ProvisionerJob { t.Helper() var ( - tvID = uuid.New() - job = prepareJob(t, jobInput{TemplateVersionID: tvID.String(), DryRun: dryRun}) - _ = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + tvID = uuid.New() + input, _ = json.Marshal(map[string]string{"template_version_id": tvID.String()}) + job = prepareJob(t, database.ProvisionerJobTypeTemplateVersionImport, input) + _ = dbgen.TemplateVersion(t, db, database.TemplateVersion{ OrganizationID: owner.OrganizationID, CreatedBy: templateAdmin.ID, ID: tvID, @@ -127,11 +105,26 @@ func TestProvisionerJobs(t *testing.T) { ) return job } - prepareTemplateVersionImportJob := func(t *testing.T) database.ProvisionerJob { - return prepareTemplateVersionImportJobBuilder(t, false) - } + + // Test helper to create a template version import dry run job with a predefined input. prepareTemplateVersionImportJobDryRun := func(t *testing.T) database.ProvisionerJob { - return prepareTemplateVersionImportJobBuilder(t, true) + t.Helper() + var ( + tvID = uuid.New() + input, _ = json.Marshal(map[string]interface{}{ + "template_version_id": tvID.String(), + "dry_run": true, + }) + job = prepareJob(t, database.ProvisionerJobTypeTemplateVersionDryRun, input) + _ = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: owner.OrganizationID, + CreatedBy: templateAdmin.ID, + ID: tvID, + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + JobID: job.ID, + }) + ) + return job } // Run the cancellation test suite. diff --git a/cli/provisioners.go b/cli/provisioners.go index 8f90a52589939..77f5e7705edd5 100644 --- a/cli/provisioners.go +++ b/cli/provisioners.go @@ -2,10 +2,12 @@ package cli import ( "fmt" + "time" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -39,7 +41,10 @@ func (r *RootCmd) provisionerList() *serpent.Command { cliui.TableFormat([]provisionerDaemonRow{}, []string{"created at", "last seen at", "key name", "name", "version", "status", "tags"}), cliui.JSONFormat(), ) - limit int64 + limit int64 + offline bool + status []string + maxAge time.Duration ) cmd := &serpent.Command{ @@ -59,7 +64,10 @@ func (r *RootCmd) provisionerList() *serpent.Command { } daemons, err := client.OrganizationProvisionerDaemons(ctx, org.ID, &codersdk.OrganizationProvisionerDaemonsOptions{ - Limit: int(limit), + Limit: int(limit), + Offline: offline, + Status: slice.StringEnums[codersdk.ProvisionerDaemonStatus](status), + MaxAge: maxAge, }) if err != nil { return xerrors.Errorf("list provisioner daemons: %w", err) @@ -98,6 +106,27 @@ func (r *RootCmd) provisionerList() *serpent.Command { Default: "50", Value: serpent.Int64Of(&limit), }, + { + Flag: "show-offline", + FlagShorthand: "f", + Env: "CODER_PROVISIONER_SHOW_OFFLINE", + Description: "Show offline provisioners.", + Value: serpent.BoolOf(&offline), + }, + { + Flag: "status", + FlagShorthand: "s", + Env: "CODER_PROVISIONER_LIST_STATUS", + Description: "Filter by provisioner status.", + Value: serpent.EnumArrayOf(&status, slice.ToStrings(codersdk.ProvisionerDaemonStatusEnums())...), + }, + { + Flag: "max-age", + FlagShorthand: "m", + Env: "CODER_PROVISIONER_LIST_MAX_AGE", + Description: "Filter provisioners by maximum age.", + Value: serpent.DurationOf(&maxAge), + }, }...) orgContext.AttachOptions(cmd) diff --git a/cli/provisioners_test.go b/cli/provisioners_test.go index 30a89714ff57f..f70029e7fa366 100644 --- a/cli/provisioners_test.go +++ b/cli/provisioners_test.go @@ -31,7 +31,6 @@ func TestProvisioners_Golden(t *testing.T) { // Replace UUIDs with predictable values for golden files. replace := make(map[string]string) updateReplaceUUIDs := func(coderdAPI *coderd.API) { - //nolint:gocritic // This is a test. systemCtx := dbauthz.AsSystemRestricted(context.Background()) provisioners, err := coderdAPI.Database.GetProvisionerDaemons(systemCtx) require.NoError(t, err) @@ -198,6 +197,74 @@ func TestProvisioners_Golden(t *testing.T) { clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) }) + t.Run("list with offline provisioner daemons", func(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + inv, root := clitest.New(t, + "provisioners", + "list", + "--show-offline", + ) + inv.Stdout = &got + clitest.SetupConfig(t, templateAdminClient, root) + err := inv.Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) + }) + + t.Run("list provisioner daemons by status", func(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + inv, root := clitest.New(t, + "provisioners", + "list", + "--status=idle,offline,busy", + ) + inv.Stdout = &got + clitest.SetupConfig(t, templateAdminClient, root) + err := inv.Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) + }) + + t.Run("list provisioner daemons without offline", func(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + inv, root := clitest.New(t, + "provisioners", + "list", + "--status=idle,busy", + ) + inv.Stdout = &got + clitest.SetupConfig(t, templateAdminClient, root) + err := inv.Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) + }) + + t.Run("list provisioner daemons by max age", func(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + inv, root := clitest.New(t, + "provisioners", + "list", + "--max-age=1h", + ) + inv.Stdout = &got + clitest.SetupConfig(t, templateAdminClient, root) + err := inv.Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) + }) + // Test jobs list with template admin as members are currently // unable to access provisioner jobs. In the future (with RBAC // changes), we may allow them to view _their_ jobs. diff --git a/cli/root.go b/cli/root.go index 54215a67401dd..b3e67a46ad463 100644 --- a/cli/root.go +++ b/cli/root.go @@ -108,7 +108,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { // Workspace Commands r.autoupdate(), r.configSSH(), - r.create(), + r.Create(CreateOptions{}), r.deleteWorkspace(), r.favorite(), r.list(), diff --git a/cli/schedule.go b/cli/schedule.go index c09d275eb7bb3..b7d1ff9b1f2bf 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -117,7 +117,7 @@ func (r *RootCmd) scheduleShow() *serpent.Command { f.FilterQuery = fmt.Sprintf("owner:me name:%s", inv.Args[0]) } } - res, err := queryConvertWorkspaces(inv.Context(), client, f, scheduleListRowFromWorkspace) + res, err := QueryConvertWorkspaces(inv.Context(), client, f, scheduleListRowFromWorkspace) if err != nil { return err } @@ -307,7 +307,7 @@ func (r *RootCmd) scheduleExtend() *serpent.Command { } func displaySchedule(ws codersdk.Workspace, out io.Writer) error { - rows := []workspaceListRow{workspaceListRowFromWorkspace(time.Now(), ws)} + rows := []WorkspaceListRow{WorkspaceListRowFromWorkspace(time.Now(), ws)} rendered, err := cliui.DisplayTable(rows, "workspace", []string{ "workspace", "starts at", "starts next", "stops after", "stops next", }) diff --git a/cli/schedule_test.go b/cli/schedule_test.go index 02997a9a4c40d..b161f41cbcebc 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -353,7 +353,7 @@ func TestScheduleOverride(t *testing.T) { ownerClient, _, _, ws := setupTestSchedule(t, sched) now := time.Now() // To avoid the likelihood of time-related flakes, only matching up to the hour. - expectedDeadline := time.Now().In(loc).Add(10 * time.Hour).Format("2006-01-02T15:") + expectedDeadline := now.In(loc).Add(10 * time.Hour).Format("2006-01-02T15:") // When: we override the stop schedule inv, root := clitest.New(t, diff --git a/cli/speedtest.go b/cli/speedtest.go index 08112f50cce2c..86d0e6a9ee63c 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -83,7 +83,7 @@ func (r *RootCmd) speedtest() *serpent.Command { return xerrors.Errorf("--direct (-d) is incompatible with --%s", varDisableDirect) } - _, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, false, inv.Args[0]) + _, workspaceAgent, _, err := GetWorkspaceAndAgent(ctx, inv, client, false, inv.Args[0]) if err != nil { return err } @@ -139,7 +139,7 @@ func (r *RootCmd) speedtest() *serpent.Command { if err != nil { continue } - status := conn.Status() + status := conn.TailnetConn().Status() if len(status.Peers()) != 1 { continue } @@ -189,7 +189,7 @@ func (r *RootCmd) speedtest() *serpent.Command { outputResult.Intervals[i] = interval } } - conn.Conn.SendSpeedtestTelemetry(outputResult.Overall.ThroughputMbits) + conn.TailnetConn().SendSpeedtestTelemetry(outputResult.Overall.ThroughputMbits) out, err := formatter.Format(inv.Context(), outputResult) if err != nil { return err diff --git a/cli/ssh.go b/cli/ssh.go index a2bca46c72f32..a2f0db7327bef 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -590,7 +590,7 @@ func (r *RootCmd) ssh() *serpent.Command { } err = sshSession.Wait() - conn.SendDisconnectedTelemetry() + conn.TailnetConn().SendDisconnectedTelemetry() if err != nil { if exitErr := (&gossh.ExitError{}); errors.As(err, &exitErr) { // Clear the error since it's not useful beyond @@ -754,7 +754,7 @@ func findWorkspaceAndAgentByHostname( hostname = strings.TrimSuffix(hostname, qualifiedSuffix) } hostname = normalizeWorkspaceInput(hostname) - ws, agent, _, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, hostname) + ws, agent, _, err := GetWorkspaceAndAgent(ctx, inv, client, !disableAutostart, hostname) return ws, agent, err } @@ -827,11 +827,11 @@ startWatchLoop: } } -// getWorkspaceAgent returns the workspace and agent selected using either the +// GetWorkspaceAndAgent returns the workspace and agent selected using either the // `[.]` syntax via `in`. It will also return any other agents // in the workspace as a slice for use in child->parent lookups. // If autoStart is true, the workspace will be started if it is not already running. -func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, autostart bool, input string) (codersdk.Workspace, codersdk.WorkspaceAgent, []codersdk.WorkspaceAgent, error) { //nolint:revive +func GetWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, autostart bool, input string) (codersdk.Workspace, codersdk.WorkspaceAgent, []codersdk.WorkspaceAgent, error) { //nolint:revive var ( workspace codersdk.Workspace // The input will be `owner/name.agent` @@ -880,7 +880,7 @@ func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client * switch cerr.StatusCode() { case http.StatusConflict: _, _ = fmt.Fprintln(inv.Stderr, "Unable to start the workspace due to conflict, the workspace may be starting, retrying without autostart...") - return getWorkspaceAndAgent(ctx, inv, client, false, input) + return GetWorkspaceAndAgent(ctx, inv, client, false, input) case http.StatusForbidden: _, err = startWorkspace(inv, client, workspace, workspaceParameterFlags{}, buildFlags{}, WorkspaceUpdate) @@ -1364,7 +1364,7 @@ func getUsageAppName(usageApp string) codersdk.UsageAppName { func setStatsCallback( ctx context.Context, - agentConn *workspacesdk.AgentConn, + agentConn workspacesdk.AgentConn, logger slog.Logger, networkInfoDir string, networkInfoInterval time.Duration, @@ -1437,7 +1437,7 @@ func setStatsCallback( now := time.Now() cb(now, now.Add(time.Nanosecond), map[netlogtype.Connection]netlogtype.Counts{}, map[netlogtype.Connection]netlogtype.Counts{}) - agentConn.SetConnStatsCallback(networkInfoInterval, 2048, cb) + agentConn.TailnetConn().SetConnStatsCallback(networkInfoInterval, 2048, cb) return errCh, nil } @@ -1451,13 +1451,13 @@ type sshNetworkStats struct { UsingCoderConnect bool `json:"using_coder_connect"` } -func collectNetworkStats(ctx context.Context, agentConn *workspacesdk.AgentConn, start, end time.Time, counts map[netlogtype.Connection]netlogtype.Counts) (*sshNetworkStats, error) { +func collectNetworkStats(ctx context.Context, agentConn workspacesdk.AgentConn, start, end time.Time, counts map[netlogtype.Connection]netlogtype.Counts) (*sshNetworkStats, error) { latency, p2p, pingResult, err := agentConn.Ping(ctx) if err != nil { return nil, err } - node := agentConn.Node() - derpMap := agentConn.DERPMap() + node := agentConn.TailnetConn().Node() + derpMap := agentConn.TailnetConn().DERPMap() totalRx := uint64(0) totalTx := uint64(0) diff --git a/cli/support.go b/cli/support.go index 70fadc3994580..c55bab92cd6ff 100644 --- a/cli/support.go +++ b/cli/support.go @@ -251,7 +251,7 @@ func summarizeBundle(inv *serpent.Invocation, bun *support.Bundle) { clientNetcheckSummary := bun.Network.Netcheck.Summarize("Client netcheck:", docsURL) if len(clientNetcheckSummary) > 0 { - cliui.Warn(inv.Stdout, "Networking issues detected:", deployHealthSummary...) + cliui.Warn(inv.Stdout, "Networking issues detected:", clientNetcheckSummary...) } } diff --git a/cli/testdata/TestProvisioners_Golden/list.golden b/cli/testdata/TestProvisioners_Golden/list.golden index 3f50f90746744..8f10eec458f7d 100644 --- a/cli/testdata/TestProvisioners_Golden/list.golden +++ b/cli/testdata/TestProvisioners_Golden/list.golden @@ -1,5 +1,4 @@ -ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION -00000000-0000-0000-aaaa-000000000000 ====[timestamp]===== ====[timestamp]===== default-provisioner v0.0.0-devel map[owner: scope:organization] built-in idle 00000000-0000-0000-bbbb-000000000001 succeeded Coder -00000000-0000-0000-aaaa-000000000001 ====[timestamp]===== ====[timestamp]===== provisioner-1 v0.0.0 map[foo:bar owner: scope:organization] built-in busy 00000000-0000-0000-bbbb-000000000002 running Coder -00000000-0000-0000-aaaa-000000000002 ====[timestamp]===== ====[timestamp]===== provisioner-2 v0.0.0 map[owner: scope:organization] built-in offline 00000000-0000-0000-bbbb-000000000003 succeeded Coder -00000000-0000-0000-aaaa-000000000003 ====[timestamp]===== ====[timestamp]===== provisioner-3 v0.0.0 map[owner: scope:organization] built-in idle Coder +ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION +00000000-0000-0000-aaaa-000000000000 ====[timestamp]===== ====[timestamp]===== default-provisioner v0.0.0-devel map[owner: scope:organization] built-in idle 00000000-0000-0000-bbbb-000000000001 succeeded Coder +00000000-0000-0000-aaaa-000000000001 ====[timestamp]===== ====[timestamp]===== provisioner-1 v0.0.0 map[foo:bar owner: scope:organization] built-in busy 00000000-0000-0000-bbbb-000000000002 running Coder +00000000-0000-0000-aaaa-000000000003 ====[timestamp]===== ====[timestamp]===== provisioner-3 v0.0.0 map[owner: scope:organization] built-in idle Coder diff --git a/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_max_age.golden b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_max_age.golden new file mode 100644 index 0000000000000..bc383a839408d --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_max_age.golden @@ -0,0 +1,4 @@ +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +====[timestamp]===== ====[timestamp]===== built-in default-provisioner v0.0.0-devel idle map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-1 v0.0.0 busy map[foo:bar owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-3 v0.0.0 idle map[owner: scope:organization] diff --git a/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.golden b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.golden new file mode 100644 index 0000000000000..fd7b966d8d982 --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.golden @@ -0,0 +1,5 @@ +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +====[timestamp]===== ====[timestamp]===== built-in default-provisioner v0.0.0-devel idle map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-1 v0.0.0 busy map[foo:bar owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-2 v0.0.0 offline map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-3 v0.0.0 idle map[owner: scope:organization] diff --git a/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_without_offline.golden b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_without_offline.golden new file mode 100644 index 0000000000000..bc383a839408d --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_without_offline.golden @@ -0,0 +1,4 @@ +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +====[timestamp]===== ====[timestamp]===== built-in default-provisioner v0.0.0-devel idle map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-1 v0.0.0 busy map[foo:bar owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-3 v0.0.0 idle map[owner: scope:organization] diff --git a/cli/testdata/TestProvisioners_Golden/list_with_offline_provisioner_daemons.golden b/cli/testdata/TestProvisioners_Golden/list_with_offline_provisioner_daemons.golden new file mode 100644 index 0000000000000..fd7b966d8d982 --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_with_offline_provisioner_daemons.golden @@ -0,0 +1,5 @@ +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +====[timestamp]===== ====[timestamp]===== built-in default-provisioner v0.0.0-devel idle map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-1 v0.0.0 busy map[foo:bar owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-2 v0.0.0 offline map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-3 v0.0.0 idle map[owner: scope:organization] diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index ba560a39f59d7..82b73f7b24989 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -70,7 +70,8 @@ "most_recently_seen": null }, "template_version_preset_id": null, - "has_ai_task": false + "has_ai_task": false, + "has_external_agent": false }, "latest_app_status": null, "outdated": false, diff --git a/cli/testdata/coder_provisioner_list_--help.golden b/cli/testdata/coder_provisioner_list_--help.golden index 7a1807bb012f5..ce6d0754073a4 100644 --- a/cli/testdata/coder_provisioner_list_--help.golden +++ b/cli/testdata/coder_provisioner_list_--help.golden @@ -17,8 +17,17 @@ OPTIONS: -l, --limit int, $CODER_PROVISIONER_LIST_LIMIT (default: 50) Limit the number of provisioners returned. + -m, --max-age duration, $CODER_PROVISIONER_LIST_MAX_AGE + Filter provisioners by maximum age. + -o, --output table|json (default: table) Output format. + -f, --show-offline bool, $CODER_PROVISIONER_SHOW_OFFLINE + Show offline provisioners. + + -s, --status [offline|idle|busy], $CODER_PROVISIONER_LIST_STATUS + Filter by provisioner status. + ——— Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_provisioner_list_--output_json.golden b/cli/testdata/coder_provisioner_list_--output_json.golden index b92794ab07e18..ad26225c2ed10 100644 --- a/cli/testdata/coder_provisioner_list_--output_json.golden +++ b/cli/testdata/coder_provisioner_list_--output_json.golden @@ -7,7 +7,7 @@ "last_seen_at": "====[timestamp]=====", "name": "test-daemon", "version": "v0.0.0-devel", - "api_version": "1.8", + "api_version": "1.9", "provisioners": [ "echo" ], diff --git a/cli/vscodessh.go b/cli/vscodessh.go index e0b963b7ed80d..bd249b0a6f4ca 100644 --- a/cli/vscodessh.go +++ b/cli/vscodessh.go @@ -102,7 +102,7 @@ func (r *RootCmd) vscodeSSH() *serpent.Command { // will call this command after the workspace is started. autostart := false - workspace, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, autostart, fmt.Sprintf("%s/%s", owner, name)) + workspace, workspaceAgent, _, err := GetWorkspaceAndAgent(ctx, inv, client, autostart, fmt.Sprintf("%s/%s", owner, name)) if err != nil { return xerrors.Errorf("find workspace and agent: %w", err) } diff --git a/coderd/agentapi/stats_test.go b/coderd/agentapi/stats_test.go index 3ebf99aa6bc4b..aec2d68b71c12 100644 --- a/coderd/agentapi/stats_test.go +++ b/coderd/agentapi/stats_test.go @@ -41,11 +41,12 @@ func TestUpdateStates(t *testing.T) { Name: "tpl", } workspace = database.Workspace{ - ID: uuid.New(), - OwnerID: user.ID, - TemplateID: template.ID, - Name: "xyz", - TemplateName: template.Name, + ID: uuid.New(), + OwnerID: user.ID, + OwnerUsername: user.Username, + TemplateID: template.ID, + Name: "xyz", + TemplateName: template.Name, } agent = database.WorkspaceAgent{ ID: uuid.New(), @@ -138,9 +139,6 @@ func TestUpdateStates(t *testing.T) { // Workspace gets fetched. dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil) - // User gets fetched to hit the UpdateAgentMetricsFn. - dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) - // We expect an activity bump because ConnectionCount > 0. dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ WorkspaceID: workspace.ID, @@ -380,9 +378,6 @@ func TestUpdateStates(t *testing.T) { LastUsedAt: now.UTC(), }).Return(nil) - // User gets fetched to hit the UpdateAgentMetricsFn. - dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) - resp, err := api.UpdateStats(context.Background(), req) require.NoError(t, err) require.Equal(t, &agentproto.UpdateStatsResponse{ @@ -498,9 +493,6 @@ func TestUpdateStates(t *testing.T) { LastUsedAt: now, }).Return(nil) - // User gets fetched to hit the UpdateAgentMetricsFn. - dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) - // Ensure that pubsub notifications are sent. notifyDescription := make(chan struct{}) ps.SubscribeWithErr(wspubsub.WorkspaceEventChannel(workspace.OwnerID), diff --git a/coderd/agentapi/subagent_test.go b/coderd/agentapi/subagent_test.go index 0a95a70e5216d..1b6eef936f827 100644 --- a/coderd/agentapi/subagent_test.go +++ b/coderd/agentapi/subagent_test.go @@ -163,7 +163,7 @@ func TestSubAgentAPI(t *testing.T) { agentID, err := uuid.FromBytes(createResp.Agent.Id) require.NoError(t, err) - agent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + agent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) require.NoError(t, err) assert.Equal(t, tt.agentName, agent.Name) @@ -621,7 +621,7 @@ func TestSubAgentAPI(t *testing.T) { agentID, err := uuid.FromBytes(createResp.Agent.Id) require.NoError(t, err) - apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) require.NoError(t, err) // Sort the apps for determinism @@ -751,7 +751,7 @@ func TestSubAgentAPI(t *testing.T) { agentID, err := uuid.FromBytes(createResp.Agent.Id) require.NoError(t, err) - apps, err := db.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + apps, err := db.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) require.NoError(t, err) require.Len(t, apps, 1) require.Equal(t, "k5jd7a99-duplicate-slug", apps[0].Slug) @@ -789,7 +789,7 @@ func TestSubAgentAPI(t *testing.T) { require.NoError(t, err) // Then: It is deleted. - _, err = db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgent.ID) //nolint:gocritic // this is a test. + _, err = db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgent.ID) require.ErrorIs(t, err, sql.ErrNoRows) }) @@ -830,10 +830,10 @@ func TestSubAgentAPI(t *testing.T) { require.NoError(t, err) // Then: The correct one is deleted. - _, err = api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentOne.ID) //nolint:gocritic // this is a test. + _, err = api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentOne.ID) require.ErrorIs(t, err, sql.ErrNoRows) - _, err = api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentTwo.ID) //nolint:gocritic // this is a test. + _, err = api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentTwo.ID) require.NoError(t, err) }) @@ -871,7 +871,7 @@ func TestSubAgentAPI(t *testing.T) { var notAuthorizedError dbauthz.NotAuthorizedError require.ErrorAs(t, err, ¬AuthorizedError) - _, err = db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentOne.ID) //nolint:gocritic // this is a test. + _, err = db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgentOne.ID) require.NoError(t, err) }) @@ -912,7 +912,7 @@ func TestSubAgentAPI(t *testing.T) { require.NoError(t, err) // Verify that the apps were created - apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), subAgentID) //nolint:gocritic // this is a test. + apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), subAgentID) require.NoError(t, err) require.Len(t, apps, 2) @@ -923,7 +923,7 @@ func TestSubAgentAPI(t *testing.T) { require.NoError(t, err) // Then: The agent is deleted - _, err = api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), subAgentID) //nolint:gocritic // this is a test. + _, err = api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), subAgentID) require.ErrorIs(t, err, sql.ErrNoRows) // And: The apps are *retained* to avoid causing issues @@ -1068,7 +1068,7 @@ func TestSubAgentAPI(t *testing.T) { agentID, err := uuid.FromBytes(createResp.Agent.Id) require.NoError(t, err) - subAgent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + subAgent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) require.NoError(t, err) require.Equal(t, len(tt.expectedApps), len(subAgent.DisplayApps), "display apps count mismatch") @@ -1118,14 +1118,14 @@ func TestSubAgentAPI(t *testing.T) { require.NoError(t, err) // Verify display apps - subAgent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + subAgent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) require.NoError(t, err) require.Len(t, subAgent.DisplayApps, 2) require.Equal(t, database.DisplayAppVscode, subAgent.DisplayApps[0]) require.Equal(t, database.DisplayAppWebTerminal, subAgent.DisplayApps[1]) // Verify regular apps - apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) require.NoError(t, err) require.Len(t, apps, 1) require.Equal(t, "v4qhkq17-custom-app", apps[0].Slug) @@ -1190,7 +1190,7 @@ func TestSubAgentAPI(t *testing.T) { }) // When: We list the sub agents. - listResp, err := api.ListSubAgents(ctx, &proto.ListSubAgentsRequest{}) //nolint:gocritic // this is a test. + listResp, err := api.ListSubAgents(ctx, &proto.ListSubAgentsRequest{}) require.NoError(t, err) listedChildAgents := listResp.Agents diff --git a/coderd/aitasks.go b/coderd/aitasks.go index e1d72f264a025..de607e7619f77 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "database/sql" "errors" "fmt" @@ -8,13 +9,20 @@ import ( "slices" "strings" + "github.com/go-chi/chi/v5" "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/searchquery" + "github.com/coder/coder/v2/coderd/taskname" "github.com/coder/coder/v2/codersdk" ) @@ -104,8 +112,20 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { return } + taskName := taskname.GenerateFallback() + if anthropicAPIKey := taskname.GetAnthropicAPIKeyFromEnv(); anthropicAPIKey != "" { + anthropicModel := taskname.GetAnthropicModelFromEnv() + + generatedName, err := taskname.Generate(ctx, req.Prompt, taskname.WithAPIKey(anthropicAPIKey), taskname.WithModel(anthropicModel)) + if err != nil { + api.Logger.Error(ctx, "unable to generate task name", slog.Error(err)) + } else { + taskName = generatedName + } + } + createReq := codersdk.CreateWorkspaceRequest{ - Name: req.Name, + Name: taskName, TemplateVersionID: req.TemplateVersionID, TemplateVersionPresetID: req.TemplateVersionPresetID, RichParameterValues: []codersdk.WorkspaceBuildParameter{ @@ -171,3 +191,252 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { defer commitAudit() createWorkspace(ctx, aReq, apiKey.UserID, api, owner, createReq, rw, r) } + +// tasksFromWorkspaces converts a slice of API workspaces into tasks, fetching +// prompts and mapping status/state. This method enforces that only AI task +// workspaces are given. +func (api *API) tasksFromWorkspaces(ctx context.Context, apiWorkspaces []codersdk.Workspace) ([]codersdk.Task, error) { + // Enforce that only AI task workspaces are given. + for _, ws := range apiWorkspaces { + if ws.LatestBuild.HasAITask == nil || !*ws.LatestBuild.HasAITask { + return nil, xerrors.Errorf("workspace %s is not an AI task workspace", ws.ID) + } + } + + // Fetch prompts for each workspace build and map by build ID. + buildIDs := make([]uuid.UUID, 0, len(apiWorkspaces)) + for _, ws := range apiWorkspaces { + buildIDs = append(buildIDs, ws.LatestBuild.ID) + } + parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs) + if err != nil { + return nil, err + } + promptsByBuildID := make(map[uuid.UUID]string, len(parameters)) + for _, p := range parameters { + if p.Name == codersdk.AITaskPromptParameterName { + promptsByBuildID[p.WorkspaceBuildID] = p.Value + } + } + + tasks := make([]codersdk.Task, 0, len(apiWorkspaces)) + for _, ws := range apiWorkspaces { + var currentState *codersdk.TaskStateEntry + if ws.LatestAppStatus != nil { + currentState = &codersdk.TaskStateEntry{ + Timestamp: ws.LatestAppStatus.CreatedAt, + State: codersdk.TaskState(ws.LatestAppStatus.State), + Message: ws.LatestAppStatus.Message, + URI: ws.LatestAppStatus.URI, + } + } + tasks = append(tasks, codersdk.Task{ + ID: ws.ID, + OrganizationID: ws.OrganizationID, + OwnerID: ws.OwnerID, + Name: ws.Name, + TemplateID: ws.TemplateID, + WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID}, + CreatedAt: ws.CreatedAt, + UpdatedAt: ws.UpdatedAt, + InitialPrompt: promptsByBuildID[ws.LatestBuild.ID], + Status: ws.LatestBuild.Status, + CurrentState: currentState, + }) + } + + return tasks, nil +} + +// tasksListResponse wraps a list of experimental tasks. +// +// Experimental: Response shape is experimental and may change. +type tasksListResponse struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` +} + +// tasksList is an experimental endpoint to list AI tasks by mapping +// workspaces to a task-shaped response. +func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + // Support standard pagination/filters for workspaces. + page, ok := ParsePagination(rw, r) + if !ok { + return + } + queryStr := r.URL.Query().Get("q") + filter, errs := searchquery.Workspaces(ctx, api.Database, queryStr, page, api.AgentInactiveDisconnectTimeout) + if len(errs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid workspace search query.", + Validations: errs, + }) + return + } + + // Ensure that we only include AI task workspaces in the results. + filter.HasAITask = sql.NullBool{Valid: true, Bool: true} + + if filter.OwnerUsername == "me" || filter.OwnerUsername == "" { + filter.OwnerID = apiKey.UserID + filter.OwnerUsername = "" + } + + prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error preparing sql filter.", + Detail: err.Error(), + }) + return + } + + // Order with requester's favorites first, include summary row. + filter.RequesterID = apiKey.UserID + filter.WithSummary = true + + workspaceRows, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, prepared) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspaces.", + Detail: err.Error(), + }) + return + } + if len(workspaceRows) == 0 { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspaces.", + Detail: "Workspace summary row is missing.", + }) + return + } + if len(workspaceRows) == 1 { + httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{ + Tasks: []codersdk.Task{}, + Count: 0, + }) + return + } + + // Skip summary row. + workspaceRows = workspaceRows[:len(workspaceRows)-1] + + workspaces := database.ConvertWorkspaceRows(workspaceRows) + + // Gather associated data and convert to API workspaces. + data, err := api.workspaceData(ctx, workspaces) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace resources.", + Detail: err.Error(), + }) + return + } + apiWorkspaces, err := convertWorkspaces(apiKey.UserID, workspaces, data) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error converting workspaces.", + Detail: err.Error(), + }) + return + } + + tasks, err := api.tasksFromWorkspaces(ctx, apiWorkspaces) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task prompts and states.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{ + Tasks: tasks, + Count: len(tasks), + }) +} + +// taskGet is an experimental endpoint to fetch a single AI task by ID +// (workspace ID). It returns a synthesized task response including +// prompt and status. +func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + idStr := chi.URLParam(r, "id") + taskID, err := uuid.Parse(idStr) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr), + }) + return + } + + // For now, taskID = workspaceID, once we have a task data model in + // the DB, we can change this lookup. + workspaceID := taskID + workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace.", + Detail: err.Error(), + }) + return + } + + 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.builds) == 0 || len(data.templates) == 0 { + httpapi.ResourceNotFound(rw) + return + } + if data.builds[0].HasAITask == nil || !*data.builds[0].HasAITask { + httpapi.ResourceNotFound(rw) + return + } + + appStatus := codersdk.WorkspaceAppStatus{} + if len(data.appStatuses) > 0 { + appStatus = data.appStatuses[0] + } + + ws, err := convertWorkspace( + apiKey.UserID, + workspace, + data.builds[0], + data.templates[0], + api.Options.AllowWorkspaceRenames, + appStatus, + ) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error converting workspace.", + Detail: err.Error(), + }) + return + } + + tasks, err := api.tasksFromWorkspaces(ctx, []codersdk.Workspace{ws}) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task prompt and state.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, tasks[0]) +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 8d12dd3a5ec95..131238de8a5bd 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -10,6 +10,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" @@ -142,7 +143,131 @@ func TestAITasksPrompts(t *testing.T) { }) } -func TestTaskCreate(t *testing.T) { +func TestTasks(t *testing.T) { + t.Parallel() + + createAITemplate := func(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse) codersdk.Template { + t.Helper() + + // Create a template version that supports AI tasks with the AI Prompt parameter. + taskAppID := uuid.New() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, + HasAiTasks: true, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ + { + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{ + { + Id: uuid.NewString(), + Name: "example", + Apps: []*proto.App{ + { + Id: taskAppID.String(), + Slug: "task-sidebar", + DisplayName: "Task Sidebar", + }, + }, + }, + }, + }, + }, + AiTasks: []*proto.AITask{ + { + SidebarApp: &proto.AITaskSidebarApp{ + Id: taskAppID.String(), + }, + }, + }, + }, + }, + }, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + return template + } + + t.Run("List", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + + // Create a workspace (task) with a specific prompt. + wantPrompt := "build me a web app" + workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { + req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: codersdk.AITaskPromptParameterName, Value: wantPrompt}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // List tasks via experimental API and verify the prompt and status mapping. + exp := codersdk.NewExperimentalClient(client) + tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{Owner: codersdk.Me}) + require.NoError(t, err) + + got, ok := slice.Find(tasks, func(task codersdk.Task) bool { return task.ID == workspace.ID }) + require.True(t, ok, "task should be found in the list") + assert.Equal(t, wantPrompt, got.InitialPrompt, "task prompt should match the AI Prompt parameter") + assert.Equal(t, workspace.Name, got.Name, "task name should map from workspace name") + assert.Equal(t, workspace.ID, got.WorkspaceID.UUID, "workspace id should match") + // Status should be populated via app status or workspace status mapping. + assert.NotEmpty(t, got.Status, "task status should not be empty") + }) + + t.Run("Get", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + + // Create a workspace (task) with a specific prompt. + wantPrompt := "review my code" + workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { + req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: codersdk.AITaskPromptParameterName, Value: wantPrompt}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Fetch the task by ID via experimental API and verify fields. + exp := codersdk.NewExperimentalClient(client) + task, err := exp.TaskByID(ctx, workspace.ID) + require.NoError(t, err) + + assert.Equal(t, workspace.ID, task.ID, "task ID should match workspace ID") + assert.Equal(t, workspace.Name, task.Name, "task name should map from workspace name") + assert.Equal(t, wantPrompt, task.InitialPrompt, "task prompt should match the AI Prompt parameter") + assert.Equal(t, workspace.ID, task.WorkspaceID.UUID, "workspace id should match") + assert.NotEmpty(t, task.Status, "task status should not be empty") + }) +} + +func TestTasksCreate(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { @@ -151,7 +276,6 @@ func TestTaskCreate(t *testing.T) { var ( ctx = testutil.Context(t, testutil.WaitShort) - taskName = "task-foo-bar-baz" taskPrompt = "Some task prompt" ) @@ -176,7 +300,6 @@ func TestTaskCreate(t *testing.T) { // When: We attempt to create a Task. workspace, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ - Name: taskName, TemplateVersionID: template.ActiveVersionID, Prompt: taskPrompt, }) @@ -184,7 +307,7 @@ func TestTaskCreate(t *testing.T) { coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) // Then: We expect a workspace to have been created. - assert.Equal(t, taskName, workspace.Name) + assert.NotEmpty(t, workspace.Name) assert.Equal(t, template.ID, workspace.TemplateID) // And: We expect it to have the "AI Prompt" parameter correctly set. @@ -201,7 +324,6 @@ func TestTaskCreate(t *testing.T) { var ( ctx = testutil.Context(t, testutil.WaitShort) - taskName = "task-foo-bar-baz" taskPrompt = "Some task prompt" ) @@ -217,7 +339,6 @@ func TestTaskCreate(t *testing.T) { // When: We attempt to create a Task. _, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ - Name: taskName, TemplateVersionID: template.ActiveVersionID, Prompt: taskPrompt, }) @@ -235,7 +356,6 @@ func TestTaskCreate(t *testing.T) { var ( ctx = testutil.Context(t, testutil.WaitShort) - taskName = "task-foo-bar-baz" taskPrompt = "Some task prompt" ) @@ -251,7 +371,6 @@ func TestTaskCreate(t *testing.T) { // When: We attempt to create a Task with an invalid template version ID. _, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ - Name: taskName, TemplateVersionID: uuid.New(), Prompt: taskPrompt, }) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e7830ef285836..96034721a5af2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1280,6 +1280,39 @@ const docTemplate = `{ } } }, + "/init-script/{os}/{arch}": { + "get": { + "produces": [ + "text/plain" + ], + "tags": [ + "InitScript" + ], + "summary": "Get agent init script", + "operationId": "get-agent-init-script", + "parameters": [ + { + "type": "string", + "description": "Operating system", + "name": "os", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Architecture", + "name": "arch", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/insights/daus": { "get": { "security": [ @@ -5140,8 +5173,8 @@ const docTemplate = `{ "tags": [ "Templates" ], - "summary": "Get template metadata by ID", - "operationId": "get-template-metadata-by-id", + "summary": "Get template settings by ID", + "operationId": "get-template-settings-by-id", "parameters": [ { "type": "string", @@ -5200,14 +5233,17 @@ const docTemplate = `{ "CoderSessionToken": [] } ], + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "Templates" ], - "summary": "Update template metadata by ID", - "operationId": "update-template-metadata-by-id", + "summary": "Update template settings by ID", + "operationId": "update-template-settings-by-id", "parameters": [ { "type": "string", @@ -5216,6 +5252,15 @@ const docTemplate = `{ "name": "template", "in": "path", "required": true + }, + { + "description": "Patch template settings request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTemplateMeta" + } } ], "responses": { @@ -9835,7 +9880,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task.", + "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent.", "name": "q", "in": "query" }, @@ -10271,6 +10316,48 @@ const docTemplate = `{ } } }, + "/workspaces/{workspace}/external-agent/{agent}/credentials": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get workspace external agent credentials", + "operationId": "get-workspace-external-agent-credentials", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Agent name", + "name": "agent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ExternalAgentCredentials" + } + } + } + } + }, "/workspaces/{workspace}/favorite": { "put": { "security": [ @@ -12901,6 +12988,17 @@ const docTemplate = `{ "ExperimentWorkspaceSharing" ] }, + "codersdk.ExternalAgentCredentials": { + "type": "object", + "properties": { + "agent_token": { + "type": "string" + }, + "command": { + "type": "string" + } + } + }, "codersdk.ExternalAuth": { "type": "object", "properties": { @@ -16816,6 +16914,9 @@ const docTemplate = `{ "created_by": { "$ref": "#/definitions/codersdk.MinimalUser" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" @@ -17215,6 +17316,89 @@ const docTemplate = `{ } } }, + "codersdk.UpdateTemplateMeta": { + "type": "object", + "properties": { + "activity_bump_ms": { + "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", + "type": "integer" + }, + "allow_user_autostart": { + "type": "boolean" + }, + "allow_user_autostop": { + "type": "boolean" + }, + "allow_user_cancel_workspace_jobs": { + "type": "boolean" + }, + "autostart_requirement": { + "$ref": "#/definitions/codersdk.TemplateAutostartRequirement" + }, + "autostop_requirement": { + "description": "AutostopRequirement and AutostartRequirement can only be set if your license\nincludes the advanced template scheduling feature. If you attempt to set this\nvalue while unlicensed, it will be ignored.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateAutostopRequirement" + } + ] + }, + "cors_behavior": { + "$ref": "#/definitions/codersdk.CORSBehavior" + }, + "default_ttl_ms": { + "type": "integer" + }, + "deprecation_message": { + "description": "DeprecationMessage if set, will mark the template as deprecated and block\nany new workspaces from using this template.\nIf passed an empty string, will remove the deprecated message, making\nthe template usable for new workspaces again.", + "type": "string" + }, + "description": { + "type": "string" + }, + "disable_everyone_group_access": { + "description": "DisableEveryoneGroupAccess allows optionally disabling the default\nbehavior of granting the 'everyone' group access to use the template.\nIf this is set to true, the template will not be available to all users,\nand must be explicitly granted to users or groups in the permissions settings\nof the template.", + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "failure_ttl_ms": { + "type": "integer" + }, + "icon": { + "type": "string" + }, + "max_port_share_level": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel" + }, + "name": { + "type": "string" + }, + "require_active_version": { + "description": "RequireActiveVersion mandates workspaces built using this template\nuse the active version of the template. This option has no\neffect on template admins.", + "type": "boolean" + }, + "time_til_dormant_autodelete_ms": { + "type": "integer" + }, + "time_til_dormant_ms": { + "type": "integer" + }, + "update_workspace_dormant_at": { + "description": "UpdateWorkspaceDormant updates the dormant_at field of workspaces spawned\nfrom the template. This is useful for preventing dormant workspaces being immediately\ndeleted when updating the dormant_ttl field to a new, shorter value.", + "type": "boolean" + }, + "update_workspace_last_used_at": { + "description": "UpdateWorkspaceLastUsedAt updates the last_used_at field of workspaces\nspawned from the template. This is useful for preventing workspaces being\nimmediately locked when updating the inactivity_ttl field to a new, shorter\nvalue.", + "type": "boolean" + }, + "use_classic_parameter_flow": { + "description": "UseClassicParameterFlow is a flag that switches the default behavior to use the classic\nparameter flow when creating a workspace. This only affects deployments with the experiment\n\"dynamic-parameters\" enabled. This setting will live for a period after the experiment is\nmade the default.\nAn \"opt-out\" is present in case the new feature breaks some existing templates.", + "type": "boolean" + } + } + }, "codersdk.UpdateUserAppearanceSettingsRequest": { "type": "object", "required": [ @@ -18675,6 +18859,9 @@ const docTemplate = `{ "has_ai_task": { "type": "boolean" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ecb04b352017a..107943e186c40 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1108,6 +1108,35 @@ } } }, + "/init-script/{os}/{arch}": { + "get": { + "produces": ["text/plain"], + "tags": ["InitScript"], + "summary": "Get agent init script", + "operationId": "get-agent-init-script", + "parameters": [ + { + "type": "string", + "description": "Operating system", + "name": "os", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Architecture", + "name": "arch", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/insights/daus": { "get": { "security": [ @@ -4527,8 +4556,8 @@ ], "produces": ["application/json"], "tags": ["Templates"], - "summary": "Get template metadata by ID", - "operationId": "get-template-metadata-by-id", + "summary": "Get template settings by ID", + "operationId": "get-template-settings-by-id", "parameters": [ { "type": "string", @@ -4583,10 +4612,11 @@ "CoderSessionToken": [] } ], + "consumes": ["application/json"], "produces": ["application/json"], "tags": ["Templates"], - "summary": "Update template metadata by ID", - "operationId": "update-template-metadata-by-id", + "summary": "Update template settings by ID", + "operationId": "update-template-settings-by-id", "parameters": [ { "type": "string", @@ -4595,6 +4625,15 @@ "name": "template", "in": "path", "required": true + }, + { + "description": "Patch template settings request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTemplateMeta" + } } ], "responses": { @@ -8693,7 +8732,7 @@ "parameters": [ { "type": "string", - "description": "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task.", + "description": "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent.", "name": "q", "in": "query" }, @@ -9085,6 +9124,44 @@ } } }, + "/workspaces/{workspace}/external-agent/{agent}/credentials": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get workspace external agent credentials", + "operationId": "get-workspace-external-agent-credentials", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Agent name", + "name": "agent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ExternalAgentCredentials" + } + } + } + } + }, "/workspaces/{workspace}/favorite": { "put": { "security": [ @@ -11563,6 +11640,17 @@ "ExperimentWorkspaceSharing" ] }, + "codersdk.ExternalAgentCredentials": { + "type": "object", + "properties": { + "agent_token": { + "type": "string" + }, + "command": { + "type": "string" + } + } + }, "codersdk.ExternalAuth": { "type": "object", "properties": { @@ -15337,6 +15425,9 @@ "created_by": { "$ref": "#/definitions/codersdk.MinimalUser" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" @@ -15716,6 +15807,89 @@ } } }, + "codersdk.UpdateTemplateMeta": { + "type": "object", + "properties": { + "activity_bump_ms": { + "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", + "type": "integer" + }, + "allow_user_autostart": { + "type": "boolean" + }, + "allow_user_autostop": { + "type": "boolean" + }, + "allow_user_cancel_workspace_jobs": { + "type": "boolean" + }, + "autostart_requirement": { + "$ref": "#/definitions/codersdk.TemplateAutostartRequirement" + }, + "autostop_requirement": { + "description": "AutostopRequirement and AutostartRequirement can only be set if your license\nincludes the advanced template scheduling feature. If you attempt to set this\nvalue while unlicensed, it will be ignored.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateAutostopRequirement" + } + ] + }, + "cors_behavior": { + "$ref": "#/definitions/codersdk.CORSBehavior" + }, + "default_ttl_ms": { + "type": "integer" + }, + "deprecation_message": { + "description": "DeprecationMessage if set, will mark the template as deprecated and block\nany new workspaces from using this template.\nIf passed an empty string, will remove the deprecated message, making\nthe template usable for new workspaces again.", + "type": "string" + }, + "description": { + "type": "string" + }, + "disable_everyone_group_access": { + "description": "DisableEveryoneGroupAccess allows optionally disabling the default\nbehavior of granting the 'everyone' group access to use the template.\nIf this is set to true, the template will not be available to all users,\nand must be explicitly granted to users or groups in the permissions settings\nof the template.", + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "failure_ttl_ms": { + "type": "integer" + }, + "icon": { + "type": "string" + }, + "max_port_share_level": { + "$ref": "#/definitions/codersdk.WorkspaceAgentPortShareLevel" + }, + "name": { + "type": "string" + }, + "require_active_version": { + "description": "RequireActiveVersion mandates workspaces built using this template\nuse the active version of the template. This option has no\neffect on template admins.", + "type": "boolean" + }, + "time_til_dormant_autodelete_ms": { + "type": "integer" + }, + "time_til_dormant_ms": { + "type": "integer" + }, + "update_workspace_dormant_at": { + "description": "UpdateWorkspaceDormant updates the dormant_at field of workspaces spawned\nfrom the template. This is useful for preventing dormant workspaces being immediately\ndeleted when updating the dormant_ttl field to a new, shorter value.", + "type": "boolean" + }, + "update_workspace_last_used_at": { + "description": "UpdateWorkspaceLastUsedAt updates the last_used_at field of workspaces\nspawned from the template. This is useful for preventing workspaces being\nimmediately locked when updating the inactivity_ttl field to a new, shorter\nvalue.", + "type": "boolean" + }, + "use_classic_parameter_flow": { + "description": "UseClassicParameterFlow is a flag that switches the default behavior to use the classic\nparameter flow when creating a workspace. This only affects deployments with the experiment\n\"dynamic-parameters\" enabled. This setting will live for a period after the experiment is\nmade the default.\nAn \"opt-out\" is present in case the new feature breaks some existing templates.", + "type": "boolean" + } + } + }, "codersdk.UpdateUserAppearanceSettingsRequest": { "type": "object", "required": ["terminal_font", "theme_preference"], @@ -17079,6 +17253,9 @@ "has_ai_task": { "type": "boolean" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/coderd.go b/coderd/coderd.go index 2aa30c9d7a45c..bb6f7b4fef4e5 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -23,6 +23,7 @@ import ( "github.com/coder/coder/v2/coderd/oauth2provider" "github.com/coder/coder/v2/coderd/pproflabel" "github.com/coder/coder/v2/coderd/prebuilds" + "github.com/coder/coder/v2/coderd/usage" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/andybalholm/brotli" @@ -200,6 +201,7 @@ type Options struct { TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore] + UsageInserter *atomic.Pointer[usage.Inserter] // CoordinatorResumeTokenProvider is used to provide and validate resume // tokens issued by and passed to the coordinator DRPC API. CoordinatorResumeTokenProvider tailnet.ResumeTokenProvider @@ -325,6 +327,9 @@ func New(options *Options) *API { }) } + if options.PrometheusRegistry == nil { + options.PrometheusRegistry = prometheus.NewRegistry() + } if options.Authorizer == nil { options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry) if buildinfo.IsDev() { @@ -381,9 +386,6 @@ func New(options *Options) *API { if options.FilesRateLimit == 0 { options.FilesRateLimit = 12 } - if options.PrometheusRegistry == nil { - options.PrometheusRegistry = prometheus.NewRegistry() - } if options.Clock == nil { options.Clock = quartz.NewReal() } @@ -428,6 +430,13 @@ func New(options *Options) *API { v := schedule.NewAGPLUserQuietHoursScheduleStore() options.UserQuietHoursScheduleStore.Store(&v) } + if options.UsageInserter == nil { + options.UsageInserter = &atomic.Pointer[usage.Inserter]{} + } + if options.UsageInserter.Load() == nil { + inserter := usage.NewAGPLInserter() + options.UsageInserter.Store(&inserter) + } if options.OneTimePasscodeValidityPeriod == 0 { options.OneTimePasscodeValidityPeriod = 20 * time.Minute } @@ -590,6 +599,7 @@ func New(options *Options) *API { UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, AccessControlStore: options.AccessControlStore, BuildUsageChecker: &buildUsageChecker, + UsageInserter: options.UsageInserter, FileCache: files.New(options.PrometheusRegistry, options.Authorizer), Experiments: experiments, WebpushDispatcher: options.WebPushDispatcher, @@ -1001,6 +1011,8 @@ func New(options *Options) *API { r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize)) + r.Get("/", api.tasksList) + r.Get("/{id}", api.taskGet) r.Post("/", api.tasksCreate) }) }) @@ -1566,6 +1578,9 @@ func New(options *Options) *API { r.Use(apiKeyMiddleware) r.Get("/", api.tailnetRPCConn) }) + r.Route("/init-script", func(r chi.Router) { + r.Get("/{os}/{arch}", api.initScript) + }) }) if options.SwaggerEndpoint { @@ -1687,6 +1702,9 @@ type API struct { // BuildUsageChecker is a pointer as it's passed around to multiple // components. BuildUsageChecker *atomic.Pointer[wsbuilder.UsageChecker] + // UsageInserter is a pointer to an atomic pointer because it is passed to + // multiple components. + UsageInserter *atomic.Pointer[usage.Inserter] UpdatesProvider tailnet.WorkspaceUpdatesProvider @@ -1902,6 +1920,7 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n &api.Auditor, api.TemplateScheduleStore, api.UserQuietHoursScheduleStore, + api.UsageInserter, api.DeploymentValues, provisionerdserver.Options{ OIDCConfig: api.OIDCConfig, diff --git a/coderd/coderdtest/swaggerparser.go b/coderd/coderdtest/swaggerparser.go index 7cef0d8d9f9cb..b94473ee83bda 100644 --- a/coderd/coderdtest/swaggerparser.go +++ b/coderd/coderdtest/swaggerparser.go @@ -310,7 +310,8 @@ func assertSecurityDefined(t *testing.T, comment SwaggerComment) { comment.router == "/" || comment.router == "/users/login" || comment.router == "/users/otp/request" || - comment.router == "/users/otp/change-password" { + comment.router == "/users/otp/change-password" || + comment.router == "/init-script/{os}/{arch}" { return // endpoints do not require authorization } assert.Containsf(t, authorizedSecurityTags, comment.security, "@Security must be either of these options: %v", authorizedSecurityTags) @@ -361,7 +362,8 @@ func assertProduce(t *testing.T, comment SwaggerComment) { (comment.router == "/licenses/{id}" && comment.method == "delete") || (comment.router == "/debug/coordinator" && comment.method == "get") || (comment.router == "/debug/tailnet" && comment.method == "get") || - (comment.router == "/workspaces/{workspace}/acl" && comment.method == "patch") { + (comment.router == "/workspaces/{workspace}/acl" && comment.method == "patch") || + (comment.router == "/init-script/{os}/{arch}" && comment.method == "get") { return // Exception: HTTP 200 is returned without response entity } diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index e827ef3f02d24..ac204f85f5603 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -7,6 +7,7 @@ type CheckConstraint string // CheckConstraint enums. const ( CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users + CheckUsersUsernameMinLength CheckConstraint = "users_username_min_length" // users CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 321543fca68f8..94e60db47cb30 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -213,6 +213,8 @@ var ( // Provisionerd creates workspaces resources monitor rbac.ResourceWorkspaceAgentResourceMonitor.Type: {policy.ActionCreate}, rbac.ResourceWorkspaceAgentDevcontainers.Type: {policy.ActionCreate}, + // Provisionerd creates usage events + rbac.ResourceUsageEvent.Type: {policy.ActionCreate}, }), Org: map[string][]rbac.Permission{}, User: []rbac.Permission{}, @@ -510,17 +512,19 @@ var ( Scope: rbac.ScopeAll, }.WithCachedASTValue() - subjectUsageTracker = rbac.Subject{ - Type: rbac.SubjectTypeUsageTracker, - FriendlyName: "Usage Tracker", + subjectUsagePublisher = rbac.Subject{ + Type: rbac.SubjectTypeUsagePublisher, + FriendlyName: "Usage Publisher", ID: uuid.Nil.String(), Roles: rbac.Roles([]rbac.Role{ { - Identifier: rbac.RoleIdentifier{Name: "usage-tracker"}, - DisplayName: "Usage Tracker", + Identifier: rbac.RoleIdentifier{Name: "usage-publisher"}, + DisplayName: "Usage Publisher", Site: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceLicense.Type: {policy.ActionRead}, - rbac.ResourceUsageEvent.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, + rbac.ResourceLicense.Type: {policy.ActionRead}, + // The usage publisher doesn't create events, just + // reads/processes them. + rbac.ResourceUsageEvent.Type: {policy.ActionRead, policy.ActionUpdate}, }), Org: map[string][]rbac.Permission{}, User: []rbac.Permission{}, @@ -604,10 +608,10 @@ func AsFileReader(ctx context.Context) context.Context { return As(ctx, subjectFileReader) } -// AsUsageTracker returns a context with an actor that has permissions required -// for creating, reading, and updating usage events. -func AsUsageTracker(ctx context.Context) context.Context { - return As(ctx, subjectUsageTracker) +// AsUsagePublisher returns a context with an actor that has permissions +// required for creating, reading, and updating usage events. +func AsUsagePublisher(ctx context.Context) context.Context { + return As(ctx, subjectUsagePublisher) } var AsRemoveActor = rbac.Subject{ @@ -1837,6 +1841,14 @@ func (q *querier) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, return q.db.FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt) } +func (q *querier) FindMatchingPresetID(ctx context.Context, arg database.FindMatchingPresetIDParams) (uuid.UUID, error) { + _, err := q.GetTemplateVersionByID(ctx, arg.TemplateVersionID) + if err != nil { + return uuid.Nil, err + } + return q.db.FindMatchingPresetID(ctx, arg) +} + func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id) } @@ -3030,7 +3042,7 @@ func (q *querier) GetTemplatesWithFilter(ctx context.Context, arg database.GetTe } func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceLicense); err != nil { return nil, err } return q.db.GetUnexpiredLicenses(ctx) @@ -4733,9 +4745,9 @@ func (q *querier) UpdateTemplateScheduleByID(ctx context.Context, arg database.U return update(q.log, q.auth, fetch, q.db.UpdateTemplateScheduleByID)(ctx, arg) } -func (q *querier) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { - // An actor is allowed to update the template version AI task flag if they are authorized to update the template. - tv, err := q.db.GetTemplateVersionByJobID(ctx, arg.JobID) +func (q *querier) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { + // An actor is allowed to update the template version if they are authorized to update the template. + tv, err := q.db.GetTemplateVersionByID(ctx, arg.ID) if err != nil { return err } @@ -4752,12 +4764,12 @@ func (q *querier) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg da if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil { return err } - return q.db.UpdateTemplateVersionAITaskByJobID(ctx, arg) + return q.db.UpdateTemplateVersionByID(ctx, arg) } -func (q *querier) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { - // An actor is allowed to update the template version if they are authorized to update the template. - tv, err := q.db.GetTemplateVersionByID(ctx, arg.ID) +func (q *querier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg database.UpdateTemplateVersionDescriptionByJobIDParams) error { + // An actor is allowed to update the template version description if they are authorized to update the template. + tv, err := q.db.GetTemplateVersionByJobID(ctx, arg.JobID) if err != nil { return err } @@ -4774,11 +4786,11 @@ func (q *querier) UpdateTemplateVersionByID(ctx context.Context, arg database.Up if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil { return err } - return q.db.UpdateTemplateVersionByID(ctx, arg) + return q.db.UpdateTemplateVersionDescriptionByJobID(ctx, arg) } -func (q *querier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg database.UpdateTemplateVersionDescriptionByJobIDParams) error { - // An actor is allowed to update the template version description if they are authorized to update the template. +func (q *querier) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error { + // An actor is allowed to update the template version external auth providers if they are authorized to update the template. tv, err := q.db.GetTemplateVersionByJobID(ctx, arg.JobID) if err != nil { return err @@ -4796,11 +4808,11 @@ func (q *querier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context, a if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil { return err } - return q.db.UpdateTemplateVersionDescriptionByJobID(ctx, arg) + return q.db.UpdateTemplateVersionExternalAuthProvidersByJobID(ctx, arg) } -func (q *querier) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error { - // An actor is allowed to update the template version external auth providers if they are authorized to update the template. +func (q *querier) UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg database.UpdateTemplateVersionFlagsByJobIDParams) error { + // An actor is allowed to update the template version ai task and external agent flag if they are authorized to update the template. tv, err := q.db.GetTemplateVersionByJobID(ctx, arg.JobID) if err != nil { return err @@ -4818,7 +4830,7 @@ func (q *querier) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context. if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil { return err } - return q.db.UpdateTemplateVersionExternalAuthProvidersByJobID(ctx, arg) + return q.db.UpdateTemplateVersionFlagsByJobID(ctx, arg) } func (q *querier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { @@ -5143,7 +5155,15 @@ func (q *querier) UpdateWorkspaceAutostart(ctx context.Context, arg database.Upd return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceAutostart)(ctx, arg) } -func (q *querier) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { +// UpdateWorkspaceBuildCostByID is used by the provisioning system to update the cost of a workspace build. +func (q *querier) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { + return err + } + return q.db.UpdateWorkspaceBuildCostByID(ctx, arg) +} + +func (q *querier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg database.UpdateWorkspaceBuildDeadlineByIDParams) error { build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) if err != nil { return err @@ -5158,18 +5178,10 @@ func (q *querier) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg databa if err != nil { return err } - return q.db.UpdateWorkspaceBuildAITaskByID(ctx, arg) -} - -// UpdateWorkspaceBuildCostByID is used by the provisioning system to update the cost of a workspace build. -func (q *querier) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { - return err - } - return q.db.UpdateWorkspaceBuildCostByID(ctx, arg) + return q.db.UpdateWorkspaceBuildDeadlineByID(ctx, arg) } -func (q *querier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg database.UpdateWorkspaceBuildDeadlineByIDParams) error { +func (q *querier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg database.UpdateWorkspaceBuildFlagsByIDParams) error { build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) if err != nil { return err @@ -5184,7 +5196,7 @@ func (q *querier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg data if err != nil { return err } - return q.db.UpdateWorkspaceBuildDeadlineByID(ctx, arg) + return q.db.UpdateWorkspaceBuildFlagsByID(ctx, arg) } func (q *querier) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index b78aaab8013b5..971335c34019b 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7,7 +7,6 @@ import ( "fmt" "net" "reflect" - "strings" "testing" "time" @@ -750,88 +749,91 @@ func (s *MethodTestSuite) TestProvisionerJob() { } func (s *MethodTestSuite) TestLicense() { - s.Run("GetLicenses", s.Subtest(func(db database.Store, check *expects) { - l, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{ + s.Run("GetLicenses", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + a := database.License{ID: 1} + b := database.License{ID: 2} + dbm.EXPECT().GetLicenses(gomock.Any()).Return([]database.License{a, b}, nil).AnyTimes() + check.Args().Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns([]database.License{a, b}) + })) + s.Run("GetUnexpiredLicenses", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + l := database.License{ + ID: 1, + Exp: time.Now().Add(time.Hour * 24 * 30), UUID: uuid.New(), - }) - require.NoError(s.T(), err) - check.Args().Asserts(l, policy.ActionRead). + } + db.EXPECT().GetUnexpiredLicenses(gomock.Any()). + Return([]database.License{l}, nil). + AnyTimes() + check.Args().Asserts(rbac.ResourceLicense, policy.ActionRead). Returns([]database.License{l}) })) - s.Run("InsertLicense", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertLicenseParams{}). - Asserts(rbac.ResourceLicense, policy.ActionCreate) + s.Run("InsertLicense", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().InsertLicense(gomock.Any(), database.InsertLicenseParams{}).Return(database.License{}, nil).AnyTimes() + check.Args(database.InsertLicenseParams{}).Asserts(rbac.ResourceLicense, policy.ActionCreate) })) - s.Run("UpsertLogoURL", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertLogoURL", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertLogoURL(gomock.Any(), "value").Return(nil).AnyTimes() check.Args("value").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("UpsertAnnouncementBanners", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertAnnouncementBanners", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertAnnouncementBanners(gomock.Any(), "value").Return(nil).AnyTimes() check.Args("value").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("GetLicenseByID", s.Subtest(func(db database.Store, check *expects) { - l, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{ - UUID: uuid.New(), - }) - require.NoError(s.T(), err) - check.Args(l.ID).Asserts(l, policy.ActionRead).Returns(l) + s.Run("GetLicenseByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + l := database.License{ID: 1} + dbm.EXPECT().GetLicenseByID(gomock.Any(), int32(1)).Return(l, nil).AnyTimes() + check.Args(int32(1)).Asserts(l, policy.ActionRead).Returns(l) })) - s.Run("DeleteLicense", s.Subtest(func(db database.Store, check *expects) { - l, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{ - UUID: uuid.New(), - }) - require.NoError(s.T(), err) + s.Run("DeleteLicense", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + l := database.License{ID: 1} + dbm.EXPECT().GetLicenseByID(gomock.Any(), l.ID).Return(l, nil).AnyTimes() + dbm.EXPECT().DeleteLicense(gomock.Any(), l.ID).Return(int32(1), nil).AnyTimes() check.Args(l.ID).Asserts(l, policy.ActionDelete) })) - s.Run("GetDeploymentID", s.Subtest(func(db database.Store, check *expects) { - db.InsertDeploymentID(context.Background(), "value") + s.Run("GetDeploymentID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetDeploymentID(gomock.Any()).Return("value", nil).AnyTimes() check.Args().Asserts().Returns("value") })) - s.Run("GetDefaultProxyConfig", s.Subtest(func(db database.Store, check *expects) { - check.Args().Asserts().Returns(database.GetDefaultProxyConfigRow{ - DisplayName: "Default", - IconUrl: "/emojis/1f3e1.png", - }) + s.Run("GetDefaultProxyConfig", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetDefaultProxyConfig(gomock.Any()).Return(database.GetDefaultProxyConfigRow{DisplayName: "Default", IconUrl: "/emojis/1f3e1.png"}, nil).AnyTimes() + check.Args().Asserts().Returns(database.GetDefaultProxyConfigRow{DisplayName: "Default", IconUrl: "/emojis/1f3e1.png"}) })) - s.Run("GetLogoURL", s.Subtest(func(db database.Store, check *expects) { - err := db.UpsertLogoURL(context.Background(), "value") - require.NoError(s.T(), err) + s.Run("GetLogoURL", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetLogoURL(gomock.Any()).Return("value", nil).AnyTimes() check.Args().Asserts().Returns("value") })) - s.Run("GetAnnouncementBanners", s.Subtest(func(db database.Store, check *expects) { - err := db.UpsertAnnouncementBanners(context.Background(), "value") - require.NoError(s.T(), err) + s.Run("GetAnnouncementBanners", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetAnnouncementBanners(gomock.Any()).Return("value", nil).AnyTimes() check.Args().Asserts().Returns("value") })) - s.Run("GetManagedAgentCount", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetManagedAgentCount", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { start := dbtime.Now() end := start.Add(time.Hour) - check.Args(database.GetManagedAgentCountParams{ - StartTime: start, - EndTime: end, - }).Asserts(rbac.ResourceWorkspace, policy.ActionRead).Returns(int64(0)) + dbm.EXPECT().GetManagedAgentCount(gomock.Any(), database.GetManagedAgentCountParams{StartTime: start, EndTime: end}).Return(int64(0), nil).AnyTimes() + check.Args(database.GetManagedAgentCountParams{StartTime: start, EndTime: end}).Asserts(rbac.ResourceWorkspace, policy.ActionRead).Returns(int64(0)) })) } func (s *MethodTestSuite) TestOrganization() { - s.Run("Deployment/OIDCClaimFields", s.Subtest(func(db database.Store, check *expects) { + s.Run("Deployment/OIDCClaimFields", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().OIDCClaimFields(gomock.Any(), uuid.Nil).Return([]string{}, nil).AnyTimes() check.Args(uuid.Nil).Asserts(rbac.ResourceIdpsyncSettings, policy.ActionRead).Returns([]string{}) })) - s.Run("Organization/OIDCClaimFields", s.Subtest(func(db database.Store, check *expects) { + s.Run("Organization/OIDCClaimFields", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { id := uuid.New() + dbm.EXPECT().OIDCClaimFields(gomock.Any(), id).Return([]string{}, nil).AnyTimes() check.Args(id).Asserts(rbac.ResourceIdpsyncSettings.InOrg(id), policy.ActionRead).Returns([]string{}) })) - s.Run("Deployment/OIDCClaimFieldValues", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.OIDCClaimFieldValuesParams{ - ClaimField: "claim-field", - OrganizationID: uuid.Nil, - }).Asserts(rbac.ResourceIdpsyncSettings, policy.ActionRead).Returns([]string{}) + s.Run("Deployment/OIDCClaimFieldValues", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.OIDCClaimFieldValuesParams{ClaimField: "claim-field", OrganizationID: uuid.Nil} + dbm.EXPECT().OIDCClaimFieldValues(gomock.Any(), arg).Return([]string{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceIdpsyncSettings, policy.ActionRead).Returns([]string{}) })) - s.Run("Organization/OIDCClaimFieldValues", s.Subtest(func(db database.Store, check *expects) { + s.Run("Organization/OIDCClaimFieldValues", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { id := uuid.New() - check.Args(database.OIDCClaimFieldValuesParams{ - ClaimField: "claim-field", - OrganizationID: id, - }).Asserts(rbac.ResourceIdpsyncSettings.InOrg(id), policy.ActionRead).Returns([]string{}) + arg := database.OIDCClaimFieldValuesParams{ClaimField: "claim-field", OrganizationID: id} + dbm.EXPECT().OIDCClaimFieldValues(gomock.Any(), arg).Return([]string{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceIdpsyncSettings.InOrg(id), policy.ActionRead).Returns([]string{}) })) s.Run("ByOrganization/GetGroups", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) @@ -1138,41 +1140,43 @@ func (s *MethodTestSuite) TestOrganization() { } func (s *MethodTestSuite) TestWorkspaceProxy() { - s.Run("InsertWorkspaceProxy", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertWorkspaceProxyParams{ - ID: uuid.New(), - }).Asserts(rbac.ResourceWorkspaceProxy, policy.ActionCreate) - })) - s.Run("RegisterWorkspaceProxy", s.Subtest(func(db database.Store, check *expects) { - p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{}) - check.Args(database.RegisterWorkspaceProxyParams{ - ID: p.ID, - }).Asserts(p, policy.ActionUpdate) - })) - s.Run("GetWorkspaceProxyByID", s.Subtest(func(db database.Store, check *expects) { - p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{}) + s.Run("InsertWorkspaceProxy", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertWorkspaceProxyParams{ID: uuid.New()} + dbm.EXPECT().InsertWorkspaceProxy(gomock.Any(), arg).Return(database.WorkspaceProxy{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceWorkspaceProxy, policy.ActionCreate) + })) + s.Run("RegisterWorkspaceProxy", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + p := testutil.Fake(s.T(), faker, database.WorkspaceProxy{}) + dbm.EXPECT().GetWorkspaceProxyByID(gomock.Any(), p.ID).Return(p, nil).AnyTimes() + dbm.EXPECT().RegisterWorkspaceProxy(gomock.Any(), database.RegisterWorkspaceProxyParams{ID: p.ID}).Return(p, nil).AnyTimes() + check.Args(database.RegisterWorkspaceProxyParams{ID: p.ID}).Asserts(p, policy.ActionUpdate) + })) + s.Run("GetWorkspaceProxyByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + p := testutil.Fake(s.T(), faker, database.WorkspaceProxy{}) + dbm.EXPECT().GetWorkspaceProxyByID(gomock.Any(), p.ID).Return(p, nil).AnyTimes() check.Args(p.ID).Asserts(p, policy.ActionRead).Returns(p) })) - s.Run("GetWorkspaceProxyByName", s.Subtest(func(db database.Store, check *expects) { - p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{}) + s.Run("GetWorkspaceProxyByName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + p := testutil.Fake(s.T(), faker, database.WorkspaceProxy{}) + dbm.EXPECT().GetWorkspaceProxyByName(gomock.Any(), p.Name).Return(p, nil).AnyTimes() check.Args(p.Name).Asserts(p, policy.ActionRead).Returns(p) })) - s.Run("UpdateWorkspaceProxyDeleted", s.Subtest(func(db database.Store, check *expects) { - p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{}) - check.Args(database.UpdateWorkspaceProxyDeletedParams{ - ID: p.ID, - Deleted: true, - }).Asserts(p, policy.ActionDelete) - })) - s.Run("UpdateWorkspaceProxy", s.Subtest(func(db database.Store, check *expects) { - p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{}) - check.Args(database.UpdateWorkspaceProxyParams{ - ID: p.ID, - }).Asserts(p, policy.ActionUpdate) - })) - s.Run("GetWorkspaceProxies", s.Subtest(func(db database.Store, check *expects) { - p1, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{}) - p2, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{}) + s.Run("UpdateWorkspaceProxyDeleted", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + p := testutil.Fake(s.T(), faker, database.WorkspaceProxy{}) + dbm.EXPECT().GetWorkspaceProxyByID(gomock.Any(), p.ID).Return(p, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceProxyDeleted(gomock.Any(), database.UpdateWorkspaceProxyDeletedParams{ID: p.ID, Deleted: true}).Return(nil).AnyTimes() + check.Args(database.UpdateWorkspaceProxyDeletedParams{ID: p.ID, Deleted: true}).Asserts(p, policy.ActionDelete) + })) + s.Run("UpdateWorkspaceProxy", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + p := testutil.Fake(s.T(), faker, database.WorkspaceProxy{}) + dbm.EXPECT().GetWorkspaceProxyByID(gomock.Any(), p.ID).Return(p, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceProxy(gomock.Any(), database.UpdateWorkspaceProxyParams{ID: p.ID}).Return(p, nil).AnyTimes() + check.Args(database.UpdateWorkspaceProxyParams{ID: p.ID}).Asserts(p, policy.ActionUpdate) + })) + s.Run("GetWorkspaceProxies", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + p1 := testutil.Fake(s.T(), faker, database.WorkspaceProxy{}) + p2 := testutil.Fake(s.T(), faker, database.WorkspaceProxy{}) + dbm.EXPECT().GetWorkspaceProxies(gomock.Any()).Return([]database.WorkspaceProxy{p1, p2}, nil).AnyTimes() check.Args().Asserts(p1, policy.ActionRead, p2, policy.ActionRead).Returns(slice.New(p1, p2)) })) } @@ -1355,13 +1359,13 @@ func (s *MethodTestSuite) TestTemplate() { dbm.EXPECT().UpdateTemplateScheduleByID(gomock.Any(), arg).Return(nil).AnyTimes() check.Args(arg).Asserts(t1, policy.ActionUpdate) })) - s.Run("UpdateTemplateVersionAITaskByJobID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + s.Run("UpdateTemplateVersionFlagsByJobID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { t := testutil.Fake(s.T(), faker, database.Template{}) tv := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}}) - arg := database.UpdateTemplateVersionAITaskByJobIDParams{JobID: tv.JobID, HasAITask: sql.NullBool{Bool: true, Valid: true}} + arg := database.UpdateTemplateVersionFlagsByJobIDParams{JobID: tv.JobID, HasAITask: sql.NullBool{Bool: true, Valid: true}, HasExternalAgent: sql.NullBool{Bool: true, Valid: true}} dbm.EXPECT().GetTemplateVersionByJobID(gomock.Any(), tv.JobID).Return(tv, nil).AnyTimes() dbm.EXPECT().GetTemplateByID(gomock.Any(), t.ID).Return(t, nil).AnyTimes() - dbm.EXPECT().UpdateTemplateVersionAITaskByJobID(gomock.Any(), arg).Return(nil).AnyTimes() + dbm.EXPECT().UpdateTemplateVersionFlagsByJobID(gomock.Any(), arg).Return(nil).AnyTimes() check.Args(arg).Asserts(t, policy.ActionUpdate) })) s.Run("UpdateTemplateWorkspacesLastUsedAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { @@ -2955,38 +2959,44 @@ func (s *MethodTestSuite) TestWorkspace() { Deadline: b.Deadline, }).Asserts(w, policy.ActionUpdate) })) - s.Run("UpdateWorkspaceBuildAITaskByID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - tpl := dbgen.Template(s.T(), db, database.Template{ + s.Run("UpdateWorkspaceBuildFlagsByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + o := testutil.Fake(s.T(), faker, database.Organization{}) + tpl := testutil.Fake(s.T(), faker, database.Template{ OrganizationID: o.ID, CreatedBy: u.ID, }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{ TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, OrganizationID: o.ID, CreatedBy: u.ID, }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + w := testutil.Fake(s.T(), faker, database.Workspace{ TemplateID: tpl.ID, OrganizationID: o.ID, OwnerID: u.ID, }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{ Type: database.ProvisionerJobTypeWorkspaceBuild, }) - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ + b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{ JobID: j.ID, WorkspaceID: w.ID, TemplateVersionID: tv.ID, }) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: b.JobID}) - agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - app := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agt.ID}) - check.Args(database.UpdateWorkspaceBuildAITaskByIDParams{ - HasAITask: sql.NullBool{Bool: true, Valid: true}, - SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, - ID: b.ID, + res := testutil.Fake(s.T(), faker, database.WorkspaceResource{JobID: b.JobID}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{ResourceID: res.ID}) + app := testutil.Fake(s.T(), faker, database.WorkspaceApp{AgentID: agt.ID}) + + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceBuildByID(gomock.Any(), b.ID).Return(b, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceBuildFlagsByID(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + check.Args(database.UpdateWorkspaceBuildFlagsByIDParams{ + ID: b.ID, + HasAITask: sql.NullBool{Bool: true, Valid: true}, + HasExternalAgent: sql.NullBool{Bool: true, Valid: true}, + SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + UpdatedAt: b.UpdatedAt, }).Asserts(w, policy.ActionUpdate) })) s.Run("SoftDeleteWorkspaceByID", s.Subtest(func(db database.Store, check *expects) { @@ -3327,73 +3337,50 @@ func (s *MethodTestSuite) TestWorkspacePortSharing() { } func (s *MethodTestSuite) TestProvisionerKeys() { - s.Run("InsertProvisionerKey", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - pk := database.ProvisionerKey{ - ID: uuid.New(), - CreatedAt: dbtestutil.NowInDefaultTimezone(), - OrganizationID: org.ID, - Name: strings.ToLower(coderdtest.RandomName(s.T())), - HashedSecret: []byte(coderdtest.RandomName(s.T())), - } - //nolint:gosimple // casting is not a simplification - check.Args(database.InsertProvisionerKeyParams{ - ID: pk.ID, - CreatedAt: pk.CreatedAt, - OrganizationID: pk.OrganizationID, - Name: pk.Name, - HashedSecret: pk.HashedSecret, - }).Asserts(pk, policy.ActionCreate).Returns(pk) - })) - s.Run("GetProvisionerKeyByID", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - pk := dbgen.ProvisionerKey(s.T(), db, database.ProvisionerKey{OrganizationID: org.ID}) + s.Run("InsertProvisionerKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) + pk := testutil.Fake(s.T(), faker, database.ProvisionerKey{OrganizationID: org.ID}) + arg := database.InsertProvisionerKeyParams{ID: pk.ID, CreatedAt: pk.CreatedAt, OrganizationID: pk.OrganizationID, Name: pk.Name, HashedSecret: pk.HashedSecret} + dbm.EXPECT().InsertProvisionerKey(gomock.Any(), arg).Return(pk, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceProvisionerDaemon.InOrg(org.ID).WithID(pk.ID), policy.ActionCreate).Returns(pk) + })) + s.Run("GetProvisionerKeyByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) + pk := testutil.Fake(s.T(), faker, database.ProvisionerKey{OrganizationID: org.ID}) + dbm.EXPECT().GetProvisionerKeyByID(gomock.Any(), pk.ID).Return(pk, nil).AnyTimes() check.Args(pk.ID).Asserts(pk, policy.ActionRead).Returns(pk) })) - s.Run("GetProvisionerKeyByHashedSecret", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - pk := dbgen.ProvisionerKey(s.T(), db, database.ProvisionerKey{OrganizationID: org.ID, HashedSecret: []byte("foo")}) + s.Run("GetProvisionerKeyByHashedSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) + pk := testutil.Fake(s.T(), faker, database.ProvisionerKey{OrganizationID: org.ID, HashedSecret: []byte("foo")}) + dbm.EXPECT().GetProvisionerKeyByHashedSecret(gomock.Any(), []byte("foo")).Return(pk, nil).AnyTimes() check.Args([]byte("foo")).Asserts(pk, policy.ActionRead).Returns(pk) })) - s.Run("GetProvisionerKeyByName", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - pk := dbgen.ProvisionerKey(s.T(), db, database.ProvisionerKey{OrganizationID: org.ID}) - check.Args(database.GetProvisionerKeyByNameParams{ - OrganizationID: org.ID, - Name: pk.Name, - }).Asserts(pk, policy.ActionRead).Returns(pk) - })) - s.Run("ListProvisionerKeysByOrganization", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - pk := dbgen.ProvisionerKey(s.T(), db, database.ProvisionerKey{OrganizationID: org.ID}) - pks := []database.ProvisionerKey{ - { - ID: pk.ID, - CreatedAt: pk.CreatedAt, - OrganizationID: pk.OrganizationID, - Name: pk.Name, - HashedSecret: pk.HashedSecret, - }, - } - check.Args(org.ID).Asserts(pk, policy.ActionRead).Returns(pks) - })) - s.Run("ListProvisionerKeysByOrganizationExcludeReserved", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - pk := dbgen.ProvisionerKey(s.T(), db, database.ProvisionerKey{OrganizationID: org.ID}) - pks := []database.ProvisionerKey{ - { - ID: pk.ID, - CreatedAt: pk.CreatedAt, - OrganizationID: pk.OrganizationID, - Name: pk.Name, - HashedSecret: pk.HashedSecret, - }, - } - check.Args(org.ID).Asserts(pk, policy.ActionRead).Returns(pks) - })) - s.Run("DeleteProvisionerKey", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - pk := dbgen.ProvisionerKey(s.T(), db, database.ProvisionerKey{OrganizationID: org.ID}) + s.Run("GetProvisionerKeyByName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) + pk := testutil.Fake(s.T(), faker, database.ProvisionerKey{OrganizationID: org.ID}) + arg := database.GetProvisionerKeyByNameParams{OrganizationID: org.ID, Name: pk.Name} + dbm.EXPECT().GetProvisionerKeyByName(gomock.Any(), arg).Return(pk, nil).AnyTimes() + check.Args(arg).Asserts(pk, policy.ActionRead).Returns(pk) + })) + s.Run("ListProvisionerKeysByOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) + a := testutil.Fake(s.T(), faker, database.ProvisionerKey{OrganizationID: org.ID}) + b := testutil.Fake(s.T(), faker, database.ProvisionerKey{OrganizationID: org.ID}) + dbm.EXPECT().ListProvisionerKeysByOrganization(gomock.Any(), org.ID).Return([]database.ProvisionerKey{a, b}, nil).AnyTimes() + check.Args(org.ID).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns([]database.ProvisionerKey{a, b}) + })) + s.Run("ListProvisionerKeysByOrganizationExcludeReserved", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) + pk := testutil.Fake(s.T(), faker, database.ProvisionerKey{OrganizationID: org.ID}) + dbm.EXPECT().ListProvisionerKeysByOrganizationExcludeReserved(gomock.Any(), org.ID).Return([]database.ProvisionerKey{pk}, nil).AnyTimes() + check.Args(org.ID).Asserts(pk, policy.ActionRead).Returns([]database.ProvisionerKey{pk}) + })) + s.Run("DeleteProvisionerKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) + pk := testutil.Fake(s.T(), faker, database.ProvisionerKey{OrganizationID: org.ID}) + dbm.EXPECT().GetProvisionerKeyByID(gomock.Any(), pk.ID).Return(pk, nil).AnyTimes() + dbm.EXPECT().DeleteProvisionerKey(gomock.Any(), pk.ID).Return(nil).AnyTimes() check.Args(pk.ID).Asserts(pk, policy.ActionDelete).Returns() })) } @@ -3647,21 +3634,20 @@ func (s *MethodTestSuite) TestTailnetFunctions() { } func (s *MethodTestSuite) TestDBCrypt() { - s.Run("GetDBCryptKeys", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetDBCryptKeys", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetDBCryptKeys(gomock.Any()).Return([]database.DBCryptKey{}, nil).AnyTimes() check.Args(). Asserts(rbac.ResourceSystem, policy.ActionRead). Returns([]database.DBCryptKey{}) })) - s.Run("InsertDBCryptKey", s.Subtest(func(db database.Store, check *expects) { + s.Run("InsertDBCryptKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().InsertDBCryptKey(gomock.Any(), database.InsertDBCryptKeyParams{}).Return(nil).AnyTimes() check.Args(database.InsertDBCryptKeyParams{}). Asserts(rbac.ResourceSystem, policy.ActionCreate). Returns() })) - s.Run("RevokeDBCryptKey", s.Subtest(func(db database.Store, check *expects) { - err := db.InsertDBCryptKey(context.Background(), database.InsertDBCryptKeyParams{ - ActiveKeyDigest: "revoke me", - }) - s.NoError(err) + s.Run("RevokeDBCryptKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().RevokeDBCryptKey(gomock.Any(), "revoke me").Return(nil).AnyTimes() check.Args("revoke me"). Asserts(rbac.ResourceSystem, policy.ActionUpdate). Returns() @@ -3669,56 +3655,44 @@ func (s *MethodTestSuite) TestDBCrypt() { } func (s *MethodTestSuite) TestCryptoKeys() { - s.Run("GetCryptoKeys", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetCryptoKeys", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetCryptoKeys(gomock.Any()).Return([]database.CryptoKey{}, nil).AnyTimes() check.Args(). Asserts(rbac.ResourceCryptoKey, policy.ActionRead) })) - s.Run("InsertCryptoKey", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertCryptoKeyParams{ - Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey, - }). + s.Run("InsertCryptoKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertCryptoKeyParams{Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey} + dbm.EXPECT().InsertCryptoKey(gomock.Any(), arg).Return(database.CryptoKey{}, nil).AnyTimes() + check.Args(arg). Asserts(rbac.ResourceCryptoKey, policy.ActionCreate) })) - s.Run("DeleteCryptoKey", s.Subtest(func(db database.Store, check *expects) { - key := dbgen.CryptoKey(s.T(), db, database.CryptoKey{ - Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey, - Sequence: 4, - }) - check.Args(database.DeleteCryptoKeyParams{ - Feature: key.Feature, - Sequence: key.Sequence, - }).Asserts(rbac.ResourceCryptoKey, policy.ActionDelete) - })) - s.Run("GetCryptoKeyByFeatureAndSequence", s.Subtest(func(db database.Store, check *expects) { - key := dbgen.CryptoKey(s.T(), db, database.CryptoKey{ - Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey, - Sequence: 4, - }) - check.Args(database.GetCryptoKeyByFeatureAndSequenceParams{ - Feature: key.Feature, - Sequence: key.Sequence, - }).Asserts(rbac.ResourceCryptoKey, policy.ActionRead).Returns(key) - })) - s.Run("GetLatestCryptoKeyByFeature", s.Subtest(func(db database.Store, check *expects) { - dbgen.CryptoKey(s.T(), db, database.CryptoKey{ - Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey, - Sequence: 4, - }) - check.Args(database.CryptoKeyFeatureWorkspaceAppsAPIKey).Asserts(rbac.ResourceCryptoKey, policy.ActionRead) - })) - s.Run("UpdateCryptoKeyDeletesAt", s.Subtest(func(db database.Store, check *expects) { - key := dbgen.CryptoKey(s.T(), db, database.CryptoKey{ - Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey, - Sequence: 4, - }) - check.Args(database.UpdateCryptoKeyDeletesAtParams{ - Feature: key.Feature, - Sequence: key.Sequence, - DeletesAt: sql.NullTime{Time: time.Now(), Valid: true}, - }).Asserts(rbac.ResourceCryptoKey, policy.ActionUpdate) - })) - s.Run("GetCryptoKeysByFeature", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.CryptoKeyFeatureWorkspaceAppsAPIKey). + s.Run("DeleteCryptoKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.CryptoKey{Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey, Sequence: 4}) + arg := database.DeleteCryptoKeyParams{Feature: key.Feature, Sequence: key.Sequence} + dbm.EXPECT().DeleteCryptoKey(gomock.Any(), arg).Return(key, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceCryptoKey, policy.ActionDelete) + })) + s.Run("GetCryptoKeyByFeatureAndSequence", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.CryptoKey{Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey, Sequence: 4}) + arg := database.GetCryptoKeyByFeatureAndSequenceParams{Feature: key.Feature, Sequence: key.Sequence} + dbm.EXPECT().GetCryptoKeyByFeatureAndSequence(gomock.Any(), arg).Return(key, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceCryptoKey, policy.ActionRead).Returns(key) + })) + s.Run("GetLatestCryptoKeyByFeature", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + feature := database.CryptoKeyFeatureWorkspaceAppsAPIKey + dbm.EXPECT().GetLatestCryptoKeyByFeature(gomock.Any(), feature).Return(database.CryptoKey{}, nil).AnyTimes() + check.Args(feature).Asserts(rbac.ResourceCryptoKey, policy.ActionRead) + })) + s.Run("UpdateCryptoKeyDeletesAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.CryptoKey{Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey, Sequence: 4}) + arg := database.UpdateCryptoKeyDeletesAtParams{Feature: key.Feature, Sequence: key.Sequence, DeletesAt: sql.NullTime{Time: time.Now(), Valid: true}} + dbm.EXPECT().UpdateCryptoKeyDeletesAt(gomock.Any(), arg).Return(key, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceCryptoKey, policy.ActionUpdate) + })) + s.Run("GetCryptoKeysByFeature", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + feature := database.CryptoKeyFeatureWorkspaceAppsAPIKey + dbm.EXPECT().GetCryptoKeysByFeature(gomock.Any(), feature).Return([]database.CryptoKey{}, nil).AnyTimes() + check.Args(feature). Asserts(rbac.ResourceCryptoKey, policy.ActionRead) })) } @@ -3764,9 +3738,6 @@ func (s *MethodTestSuite) TestSystemFunctions() { s.Run("GetActiveUserCount", s.Subtest(func(db database.Store, check *expects) { check.Args(false).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(int64(0)) })) - s.Run("GetUnexpiredLicenses", s.Subtest(func(db database.Store, check *expects) { - check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead) - })) s.Run("GetAuthorizationUserRoles", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) check.Args(u.ID).Asserts(rbac.ResourceSystem, policy.ActionRead) @@ -4959,6 +4930,22 @@ func (s *MethodTestSuite) TestPrebuilds() { template, policy.ActionUse, ).Errors(sql.ErrNoRows) })) + s.Run("FindMatchingPresetID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}}) + dbm.EXPECT().FindMatchingPresetID(gomock.Any(), database.FindMatchingPresetIDParams{ + TemplateVersionID: tv.ID, + ParameterNames: []string{"test"}, + ParameterValues: []string{"test"}, + }).Return(uuid.Nil, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + check.Args(database.FindMatchingPresetIDParams{ + TemplateVersionID: tv.ID, + ParameterNames: []string{"test"}, + ParameterValues: []string{"test"}, + }).Asserts(tv.RBACObject(t1), policy.ActionRead).Returns(uuid.Nil) + })) s.Run("GetPrebuildMetrics", s.Subtest(func(_ database.Store, check *expects) { check.Args(). Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead) @@ -5607,63 +5594,56 @@ func (s *MethodTestSuite) TestAuthorizePrebuiltWorkspace() { } func (s *MethodTestSuite) TestUserSecrets() { - s.Run("GetUserSecretByUserIDAndName", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - userSecret := dbgen.UserSecret(s.T(), db, database.UserSecret{ - UserID: user.ID, - }) - arg := database.GetUserSecretByUserIDAndNameParams{ - UserID: user.ID, - Name: userSecret.Name, - } + s.Run("GetUserSecretByUserIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + secret := testutil.Fake(s.T(), faker, database.UserSecret{UserID: user.ID}) + arg := database.GetUserSecretByUserIDAndNameParams{UserID: user.ID, Name: secret.Name} + dbm.EXPECT().GetUserSecretByUserIDAndName(gomock.Any(), arg).Return(secret, nil).AnyTimes() check.Args(arg). - Asserts(rbac.ResourceUserSecret.WithOwner(arg.UserID.String()), policy.ActionRead). - Returns(userSecret) - })) - s.Run("GetUserSecret", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - userSecret := dbgen.UserSecret(s.T(), db, database.UserSecret{ - UserID: user.ID, - }) - check.Args(userSecret.ID). - Asserts(userSecret, policy.ActionRead). - Returns(userSecret) - })) - s.Run("ListUserSecrets", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - userSecret := dbgen.UserSecret(s.T(), db, database.UserSecret{ - UserID: user.ID, - }) + Asserts(rbac.ResourceUserSecret.WithOwner(user.ID.String()), policy.ActionRead). + Returns(secret) + })) + s.Run("GetUserSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + secret := testutil.Fake(s.T(), faker, database.UserSecret{}) + dbm.EXPECT().GetUserSecret(gomock.Any(), secret.ID).Return(secret, nil).AnyTimes() + check.Args(secret.ID). + Asserts(secret, policy.ActionRead). + Returns(secret) + })) + s.Run("ListUserSecrets", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + secret := testutil.Fake(s.T(), faker, database.UserSecret{UserID: user.ID}) + dbm.EXPECT().ListUserSecrets(gomock.Any(), user.ID).Return([]database.UserSecret{secret}, nil).AnyTimes() check.Args(user.ID). Asserts(rbac.ResourceUserSecret.WithOwner(user.ID.String()), policy.ActionRead). - Returns([]database.UserSecret{userSecret}) + Returns([]database.UserSecret{secret}) })) - s.Run("CreateUserSecret", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - arg := database.CreateUserSecretParams{ - UserID: user.ID, - } + s.Run("CreateUserSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + arg := database.CreateUserSecretParams{UserID: user.ID} + ret := testutil.Fake(s.T(), faker, database.UserSecret{UserID: user.ID}) + dbm.EXPECT().CreateUserSecret(gomock.Any(), arg).Return(ret, nil).AnyTimes() check.Args(arg). - Asserts(rbac.ResourceUserSecret.WithOwner(arg.UserID.String()), policy.ActionCreate) - })) - s.Run("UpdateUserSecret", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - userSecret := dbgen.UserSecret(s.T(), db, database.UserSecret{ - UserID: user.ID, - }) - arg := database.UpdateUserSecretParams{ - ID: userSecret.ID, - } + Asserts(rbac.ResourceUserSecret.WithOwner(user.ID.String()), policy.ActionCreate). + Returns(ret) + })) + s.Run("UpdateUserSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + secret := testutil.Fake(s.T(), faker, database.UserSecret{}) + updated := testutil.Fake(s.T(), faker, database.UserSecret{ID: secret.ID}) + arg := database.UpdateUserSecretParams{ID: secret.ID} + dbm.EXPECT().GetUserSecret(gomock.Any(), secret.ID).Return(secret, nil).AnyTimes() + dbm.EXPECT().UpdateUserSecret(gomock.Any(), arg).Return(updated, nil).AnyTimes() check.Args(arg). - Asserts(userSecret, policy.ActionUpdate) - })) - s.Run("DeleteUserSecret", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - userSecret := dbgen.UserSecret(s.T(), db, database.UserSecret{ - UserID: user.ID, - }) - check.Args(userSecret.ID). - Asserts(userSecret, policy.ActionRead, userSecret, policy.ActionDelete) + Asserts(secret, policy.ActionUpdate). + Returns(updated) + })) + s.Run("DeleteUserSecret", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + secret := testutil.Fake(s.T(), faker, database.UserSecret{}) + dbm.EXPECT().GetUserSecret(gomock.Any(), secret.ID).Return(secret, nil).AnyTimes() + dbm.EXPECT().DeleteUserSecret(gomock.Any(), secret.ID).Return(nil).AnyTimes() + check.Args(secret.ID). + Asserts(secret, policy.ActionRead, secret, policy.ActionDelete). + Returns() })) } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 56927507b6109..fbf886f860d4c 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -437,6 +437,7 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil jobID := takeFirst(orig.JobID, uuid.New()) hasAITask := takeFirst(orig.HasAITask, sql.NullBool{}) sidebarAppID := takeFirst(orig.AITaskSidebarAppID, uuid.NullUUID{}) + hasExternalAgent := takeFirst(orig.HasExternalAgent, sql.NullBool{}) var build database.WorkspaceBuild err := db.InTx(func(db database.Store) error { err := db.InsertWorkspaceBuild(genCtx, database.InsertWorkspaceBuildParams{ @@ -470,12 +471,13 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil require.NoError(t, err) } - if hasAITask.Valid { - require.NoError(t, db.UpdateWorkspaceBuildAITaskByID(genCtx, database.UpdateWorkspaceBuildAITaskByIDParams{ - HasAITask: hasAITask, - SidebarAppID: sidebarAppID, - UpdatedAt: dbtime.Now(), - ID: buildID, + if hasAITask.Valid || hasExternalAgent.Valid { + require.NoError(t, db.UpdateWorkspaceBuildFlagsByID(genCtx, database.UpdateWorkspaceBuildFlagsByIDParams{ + ID: buildID, + HasAITask: hasAITask, + HasExternalAgent: hasExternalAgent, + SidebarAppID: sidebarAppID, + UpdatedAt: dbtime.Now(), })) } @@ -1028,6 +1030,7 @@ func ExternalAuthLink(t testing.TB, db database.Store, orig database.ExternalAut func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVersion) database.TemplateVersion { var version database.TemplateVersion hasAITask := takeFirst(orig.HasAITask, sql.NullBool{}) + hasExternalAgent := takeFirst(orig.HasExternalAgent, sql.NullBool{}) jobID := takeFirst(orig.JobID, uuid.New()) err := db.InTx(func(db database.Store) error { versionID := takeFirst(orig.ID, uuid.New()) @@ -1048,11 +1051,12 @@ func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVers return err } - if hasAITask.Valid { - require.NoError(t, db.UpdateTemplateVersionAITaskByJobID(genCtx, database.UpdateTemplateVersionAITaskByJobIDParams{ - JobID: jobID, - HasAITask: hasAITask, - UpdatedAt: dbtime.Now(), + if hasAITask.Valid || hasExternalAgent.Valid { + require.NoError(t, db.UpdateTemplateVersionFlagsByJobID(genCtx, database.UpdateTemplateVersionFlagsByJobIDParams{ + JobID: jobID, + HasAITask: hasAITask, + HasExternalAgent: hasExternalAgent, + UpdatedAt: dbtime.Now(), })) } diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 01576c80f3544..11d21eab3b593 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -565,6 +565,13 @@ func (m queryMetricsStore) FetchVolumesResourceMonitorsUpdatedAfter(ctx context. return r0, r1 } +func (m queryMetricsStore) FindMatchingPresetID(ctx context.Context, arg database.FindMatchingPresetIDParams) (uuid.UUID, error) { + start := time.Now() + r0, r1 := m.s.FindMatchingPresetID(ctx, arg) + m.queryLatencies.WithLabelValues("FindMatchingPresetID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { start := time.Now() apiKey, err := m.s.GetAPIKeyByID(ctx, id) @@ -2917,13 +2924,6 @@ func (m queryMetricsStore) UpdateTemplateScheduleByID(ctx context.Context, arg d return err } -func (m queryMetricsStore) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { - start := time.Now() - r0 := m.s.UpdateTemplateVersionAITaskByJobID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateTemplateVersionAITaskByJobID").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { start := time.Now() err := m.s.UpdateTemplateVersionByID(ctx, arg) @@ -2945,6 +2945,13 @@ func (m queryMetricsStore) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx return err } +func (m queryMetricsStore) UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg database.UpdateTemplateVersionFlagsByJobIDParams) error { + start := time.Now() + r0 := m.s.UpdateTemplateVersionFlagsByJobID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateVersionFlagsByJobID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { start := time.Now() r0 := m.s.UpdateTemplateWorkspacesLastUsedAt(ctx, arg) @@ -3148,13 +3155,6 @@ func (m queryMetricsStore) UpdateWorkspaceAutostart(ctx context.Context, arg dat return err } -func (m queryMetricsStore) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { - start := time.Now() - r0 := m.s.UpdateWorkspaceBuildAITaskByID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildAITaskByID").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { start := time.Now() err := m.s.UpdateWorkspaceBuildCostByID(ctx, arg) @@ -3169,6 +3169,13 @@ func (m queryMetricsStore) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, return r0 } +func (m queryMetricsStore) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg database.UpdateWorkspaceBuildFlagsByIDParams) error { + start := time.Now() + r0 := m.s.UpdateWorkspaceBuildFlagsByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildFlagsByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { start := time.Now() r0 := m.s.UpdateWorkspaceBuildProvisionerStateByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 6ecb3e49faf04..67244cf2b01e9 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1051,6 +1051,21 @@ func (mr *MockStoreMockRecorder) FetchVolumesResourceMonitorsUpdatedAfter(ctx, u return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchVolumesResourceMonitorsUpdatedAfter", reflect.TypeOf((*MockStore)(nil).FetchVolumesResourceMonitorsUpdatedAfter), ctx, updatedAt) } +// FindMatchingPresetID mocks base method. +func (m *MockStore) FindMatchingPresetID(ctx context.Context, arg database.FindMatchingPresetIDParams) (uuid.UUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindMatchingPresetID", ctx, arg) + ret0, _ := ret[0].(uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindMatchingPresetID indicates an expected call of FindMatchingPresetID. +func (mr *MockStoreMockRecorder) FindMatchingPresetID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindMatchingPresetID", reflect.TypeOf((*MockStore)(nil).FindMatchingPresetID), ctx, arg) +} + // GetAPIKeyByID mocks base method. func (m *MockStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { m.ctrl.T.Helper() @@ -6229,20 +6244,6 @@ func (mr *MockStoreMockRecorder) UpdateTemplateScheduleByID(ctx, arg any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateScheduleByID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateScheduleByID), ctx, arg) } -// UpdateTemplateVersionAITaskByJobID mocks base method. -func (m *MockStore) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTemplateVersionAITaskByJobID", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateTemplateVersionAITaskByJobID indicates an expected call of UpdateTemplateVersionAITaskByJobID. -func (mr *MockStoreMockRecorder) UpdateTemplateVersionAITaskByJobID(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionAITaskByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionAITaskByJobID), ctx, arg) -} - // UpdateTemplateVersionByID mocks base method. func (m *MockStore) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { m.ctrl.T.Helper() @@ -6285,6 +6286,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateVersionExternalAuthProvidersByJob return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionExternalAuthProvidersByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionExternalAuthProvidersByJobID), ctx, arg) } +// UpdateTemplateVersionFlagsByJobID mocks base method. +func (m *MockStore) UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg database.UpdateTemplateVersionFlagsByJobIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTemplateVersionFlagsByJobID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTemplateVersionFlagsByJobID indicates an expected call of UpdateTemplateVersionFlagsByJobID. +func (mr *MockStoreMockRecorder) UpdateTemplateVersionFlagsByJobID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionFlagsByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionFlagsByJobID), ctx, arg) +} + // UpdateTemplateWorkspacesLastUsedAt mocks base method. func (m *MockStore) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { m.ctrl.T.Helper() @@ -6704,20 +6719,6 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceAutostart(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAutostart", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAutostart), ctx, arg) } -// UpdateWorkspaceBuildAITaskByID mocks base method. -func (m *MockStore) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspaceBuildAITaskByID", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateWorkspaceBuildAITaskByID indicates an expected call of UpdateWorkspaceBuildAITaskByID. -func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildAITaskByID(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildAITaskByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildAITaskByID), ctx, arg) -} - // UpdateWorkspaceBuildCostByID mocks base method. func (m *MockStore) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { m.ctrl.T.Helper() @@ -6746,6 +6747,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildDeadlineByID(ctx, arg any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildDeadlineByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildDeadlineByID), ctx, arg) } +// UpdateWorkspaceBuildFlagsByID mocks base method. +func (m *MockStore) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg database.UpdateWorkspaceBuildFlagsByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWorkspaceBuildFlagsByID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateWorkspaceBuildFlagsByID indicates an expected call of UpdateWorkspaceBuildFlagsByID. +func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildFlagsByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildFlagsByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildFlagsByID), ctx, arg) +} + // UpdateWorkspaceBuildProvisionerStateByID mocks base method. func (m *MockStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 34162ffb06d57..066fe0b1b8847 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1015,7 +1015,8 @@ CREATE TABLE users ( hashed_one_time_passcode bytea, one_time_passcode_expires_at timestamp with time zone, is_system boolean DEFAULT false NOT NULL, - CONSTRAINT one_time_passcode_set CHECK ((((hashed_one_time_passcode IS NULL) AND (one_time_passcode_expires_at IS NULL)) OR ((hashed_one_time_passcode IS NOT NULL) AND (one_time_passcode_expires_at IS NOT NULL)))) + CONSTRAINT one_time_passcode_set CHECK ((((hashed_one_time_passcode IS NULL) AND (one_time_passcode_expires_at IS NULL)) OR ((hashed_one_time_passcode IS NOT NULL) AND (one_time_passcode_expires_at IS NOT NULL)))), + CONSTRAINT users_username_min_length CHECK ((length(username) >= 1)) ); COMMENT ON COLUMN users.quiet_hours_schedule IS 'Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user''s quiet hours. If empty, the default quiet hours on the instance is used instead.'; @@ -1691,7 +1692,8 @@ CREATE TABLE template_versions ( message character varying(1048576) DEFAULT ''::character varying NOT NULL, archived boolean DEFAULT false NOT NULL, source_example_id text, - has_ai_task boolean + has_ai_task boolean, + has_external_agent boolean ); COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of External auth providers for a specific template version'; @@ -1722,6 +1724,7 @@ CREATE VIEW template_version_with_user AS template_versions.archived, template_versions.source_example_id, template_versions.has_ai_task, + template_versions.has_external_agent, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, COALESCE(visible_users.username, ''::text) AS created_by_username, COALESCE(visible_users.name, ''::text) AS created_by_name @@ -2264,6 +2267,7 @@ CREATE TABLE workspace_builds ( template_version_preset_id uuid, has_ai_task boolean, ai_task_sidebar_app_id uuid, + has_external_agent boolean, CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL)))), CONSTRAINT workspace_builds_deadline_below_max_deadline CHECK ((((deadline <> '0001-01-01 00:00:00+00'::timestamp with time zone) AND (deadline <= max_deadline)) OR (max_deadline = '0001-01-01 00:00:00+00'::timestamp with time zone))) ); @@ -2286,6 +2290,7 @@ CREATE VIEW workspace_build_with_user AS workspace_builds.template_version_preset_id, workspace_builds.has_ai_task, workspace_builds.ai_task_sidebar_app_id, + workspace_builds.has_external_agent, COALESCE(visible_users.avatar_url, ''::text) AS initiator_by_avatar_url, COALESCE(visible_users.username, ''::text) AS initiator_by_username, COALESCE(visible_users.name, ''::text) AS initiator_by_name diff --git a/coderd/database/migrations/000360_external_agents.down.sql b/coderd/database/migrations/000360_external_agents.down.sql new file mode 100644 index 0000000000000..a17d0cc7982a6 --- /dev/null +++ b/coderd/database/migrations/000360_external_agents.down.sql @@ -0,0 +1,77 @@ +DROP VIEW template_version_with_user; +DROP VIEW workspace_build_with_user; + +ALTER TABLE template_versions DROP COLUMN has_external_agent; +ALTER TABLE workspace_builds DROP COLUMN has_external_agent; + +-- Recreate `template_version_with_user` as defined in dump.sql +CREATE VIEW template_version_with_user AS +SELECT + template_versions.id, + template_versions.template_id, + template_versions.organization_id, + template_versions.created_at, + template_versions.updated_at, + template_versions.name, + template_versions.readme, + template_versions.job_id, + template_versions.created_by, + template_versions.external_auth_providers, + template_versions.message, + template_versions.archived, + template_versions.source_example_id, + template_versions.has_ai_task, + COALESCE(visible_users.avatar_url, '' :: text) AS created_by_avatar_url, + COALESCE(visible_users.username, '' :: text) AS created_by_username, + COALESCE(visible_users.name, '' :: text) AS created_by_name +FROM + ( + template_versions + LEFT JOIN visible_users ON ( + (template_versions.created_by = visible_users.id) + ) + ); + +COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.'; + +-- Recreate `workspace_build_with_user` as defined in dump.sql +CREATE VIEW workspace_build_with_user AS +SELECT + workspace_builds.id, + workspace_builds.created_at, + workspace_builds.updated_at, + workspace_builds.workspace_id, + workspace_builds.template_version_id, + workspace_builds.build_number, + workspace_builds.transition, + workspace_builds.initiator_id, + workspace_builds.provisioner_state, + workspace_builds.job_id, + workspace_builds.deadline, + workspace_builds.reason, + workspace_builds.daily_cost, + workspace_builds.max_deadline, + workspace_builds.template_version_preset_id, + workspace_builds.has_ai_task, + workspace_builds.ai_task_sidebar_app_id, + COALESCE( + visible_users.avatar_url, + '' :: text + ) AS initiator_by_avatar_url, + COALESCE( + visible_users.username, + '' :: text + ) AS initiator_by_username, + COALESCE(visible_users.name, '' :: text) AS initiator_by_name +FROM + ( + workspace_builds + LEFT JOIN visible_users ON ( + ( + workspace_builds.initiator_id = visible_users.id + ) + ) + ); + +COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.'; + diff --git a/coderd/database/migrations/000360_external_agents.up.sql b/coderd/database/migrations/000360_external_agents.up.sql new file mode 100644 index 0000000000000..00b7d865dfd30 --- /dev/null +++ b/coderd/database/migrations/000360_external_agents.up.sql @@ -0,0 +1,89 @@ +-- Determines if a coder_external_agent resource is defined in a template version. +ALTER TABLE + template_versions +ADD + COLUMN has_external_agent BOOLEAN; + +DROP VIEW template_version_with_user; + +-- We're adding the external_agents column. +CREATE VIEW template_version_with_user AS +SELECT + template_versions.id, + template_versions.template_id, + template_versions.organization_id, + template_versions.created_at, + template_versions.updated_at, + template_versions.name, + template_versions.readme, + template_versions.job_id, + template_versions.created_by, + template_versions.external_auth_providers, + template_versions.message, + template_versions.archived, + template_versions.source_example_id, + template_versions.has_ai_task, + template_versions.has_external_agent, + COALESCE(visible_users.avatar_url, '' :: text) AS created_by_avatar_url, + COALESCE(visible_users.username, '' :: text) AS created_by_username, + COALESCE(visible_users.name, '' :: text) AS created_by_name +FROM + ( + template_versions + LEFT JOIN visible_users ON ( + (template_versions.created_by = visible_users.id) + ) + ); + +COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.'; + +-- Determines if a coder_external_agent resource was included in a +-- workspace build. +ALTER TABLE + workspace_builds +ADD + COLUMN has_external_agent BOOLEAN; + +DROP VIEW workspace_build_with_user; + +-- We're adding the has_external_agent column. +CREATE VIEW workspace_build_with_user AS +SELECT + workspace_builds.id, + workspace_builds.created_at, + workspace_builds.updated_at, + workspace_builds.workspace_id, + workspace_builds.template_version_id, + workspace_builds.build_number, + workspace_builds.transition, + workspace_builds.initiator_id, + workspace_builds.provisioner_state, + workspace_builds.job_id, + workspace_builds.deadline, + workspace_builds.reason, + workspace_builds.daily_cost, + workspace_builds.max_deadline, + workspace_builds.template_version_preset_id, + workspace_builds.has_ai_task, + workspace_builds.ai_task_sidebar_app_id, + workspace_builds.has_external_agent, + COALESCE( + visible_users.avatar_url, + '' :: text + ) AS initiator_by_avatar_url, + COALESCE( + visible_users.username, + '' :: text + ) AS initiator_by_username, + COALESCE(visible_users.name, '' :: text) AS initiator_by_name +FROM + ( + workspace_builds + LEFT JOIN visible_users ON ( + ( + workspace_builds.initiator_id = visible_users.id + ) + ) + ); + +COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.'; diff --git a/coderd/database/migrations/000361_username_length_constraint.down.sql b/coderd/database/migrations/000361_username_length_constraint.down.sql new file mode 100644 index 0000000000000..cb3fccad73098 --- /dev/null +++ b/coderd/database/migrations/000361_username_length_constraint.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +DROP CONSTRAINT IF EXISTS users_username_min_length; diff --git a/coderd/database/migrations/000361_username_length_constraint.up.sql b/coderd/database/migrations/000361_username_length_constraint.up.sql new file mode 100644 index 0000000000000..526d31c0a7246 --- /dev/null +++ b/coderd/database/migrations/000361_username_length_constraint.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +ADD CONSTRAINT users_username_min_length +CHECK (length(username) >= 1); diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index dceddd2e8c3da..69bea8d81adab 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -84,6 +84,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate arg.HasAITask, arg.AuthorID, arg.AuthorUsername, + arg.HasExternalAgent, ) if err != nil { return nil, err @@ -271,6 +272,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.LastUsedAfter, arg.UsingActive, arg.HasAITask, + arg.HasExternalAgent, arg.RequesterID, arg.Offset, arg.Limit, @@ -321,6 +323,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.LatestBuildTransition, &i.LatestBuildStatus, &i.LatestBuildHasAITask, + &i.LatestBuildHasExternalAgent, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/models.go b/coderd/database/models.go index cb0215ec1fed5..effd436f4d18d 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3634,6 +3634,7 @@ type TemplateVersion struct { Archived bool `db:"archived" json:"archived"` SourceExampleID sql.NullString `db:"source_example_id" json:"source_example_id"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` CreatedByAvatarURL string `db:"created_by_avatar_url" json:"created_by_avatar_url"` CreatedByUsername string `db:"created_by_username" json:"created_by_username"` CreatedByName string `db:"created_by_name" json:"created_by_name"` @@ -3720,10 +3721,11 @@ type TemplateVersionTable struct { // IDs of External auth providers for a specific template version ExternalAuthProviders json.RawMessage `db:"external_auth_providers" json:"external_auth_providers"` // Message describing the changes in this version of the template, similar to a Git commit message. Like a commit message, this should be a short, high-level description of the changes in this version of the template. This message is immutable and should not be updated after the fact. - Message string `db:"message" json:"message"` - Archived bool `db:"archived" json:"archived"` - SourceExampleID sql.NullString `db:"source_example_id" json:"source_example_id"` - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + Message string `db:"message" json:"message"` + Archived bool `db:"archived" json:"archived"` + SourceExampleID sql.NullString `db:"source_example_id" json:"source_example_id"` + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` } type TemplateVersionTerraformValue struct { @@ -4173,6 +4175,7 @@ type WorkspaceBuild struct { TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` AITaskSidebarAppID uuid.NullUUID `db:"ai_task_sidebar_app_id" json:"ai_task_sidebar_app_id"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` InitiatorByAvatarUrl string `db:"initiator_by_avatar_url" json:"initiator_by_avatar_url"` InitiatorByUsername string `db:"initiator_by_username" json:"initiator_by_username"` InitiatorByName string `db:"initiator_by_name" json:"initiator_by_name"` @@ -4204,6 +4207,7 @@ type WorkspaceBuildTable struct { TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` AITaskSidebarAppID uuid.NullUUID `db:"ai_task_sidebar_app_id" json:"ai_task_sidebar_app_id"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` } type WorkspaceLatestBuild struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 8b60920086ca3..c490a04d2b653 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -137,6 +137,11 @@ type sqlcQuerier interface { FetchNewMessageMetadata(ctx context.Context, arg FetchNewMessageMetadataParams) (FetchNewMessageMetadataRow, error) FetchVolumesResourceMonitorsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceAgentVolumeResourceMonitor, error) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentVolumeResourceMonitor, error) + // FindMatchingPresetID finds a preset ID that is the largest exact subset of the provided parameters. + // It returns the preset ID if a match is found, or NULL if no match is found. + // The query finds presets where all preset parameters are present in the provided parameters, + // and returns the preset with the most parameters (largest subset). + FindMatchingPresetID(ctx context.Context, arg FindMatchingPresetIDParams) (uuid.UUID, error) GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) // there is no unique constraint on empty token names GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) @@ -617,10 +622,10 @@ type sqlcQuerier interface { UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error - UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskByJobIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error + UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg UpdateTemplateVersionFlagsByJobIDParams) error UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error UpdateUsageEventsPostPublish(ctx context.Context, arg UpdateUsageEventsPostPublishParams) error UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error @@ -650,9 +655,9 @@ type sqlcQuerier interface { UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error - UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskByIDParams) error UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error + UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg UpdateWorkspaceBuildFlagsByIDParams) error UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 0e11886765da6..18c10d6388f37 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -397,6 +397,7 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, IDs: []uuid.UUID{matchingDaemon0.ID, matchingDaemon1.ID}, + Offline: sql.NullBool{Bool: true, Valid: true}, }) require.NoError(t, err) require.Len(t, daemons, 2) @@ -430,6 +431,7 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, Tags: database.StringMap{"foo": "bar"}, + Offline: sql.NullBool{Bool: true, Valid: true}, }) require.NoError(t, err) require.Len(t, daemons, 1) @@ -463,6 +465,7 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, StaleIntervalMS: 45 * time.Minute.Milliseconds(), + Offline: sql.NullBool{Bool: true, Valid: true}, }) require.NoError(t, err) require.Len(t, daemons, 2) @@ -475,6 +478,230 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { require.Equal(t, database.ProvisionerDaemonStatusOffline, daemons[0].Status) require.Equal(t, database.ProvisionerDaemonStatusIdle, daemons[1].Status) }) + + t.Run("ExcludeOffline", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "offline-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-time.Hour), + }, + }) + fooDaemon := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "foo-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(30 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(30 * time.Minute)), + }, + }) + + daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ + OrganizationID: org.ID, + StaleIntervalMS: 45 * time.Minute.Milliseconds(), + }) + require.NoError(t, err) + require.Len(t, daemons, 1) + + require.Equal(t, fooDaemon.ID, daemons[0].ProvisionerDaemon.ID) + require.Equal(t, database.ProvisionerDaemonStatusIdle, daemons[0].Status) + }) + + t.Run("IncludeOffline", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "offline-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-time.Hour), + }, + }) + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "foo-daemon", + OrganizationID: org.ID, + Tags: database.StringMap{ + "foo": "bar", + }, + }) + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "bar-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(30 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(30 * time.Minute)), + }, + }) + + daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ + OrganizationID: org.ID, + StaleIntervalMS: 45 * time.Minute.Milliseconds(), + Offline: sql.NullBool{Bool: true, Valid: true}, + }) + require.NoError(t, err) + require.Len(t, daemons, 3) + + statusCounts := make(map[database.ProvisionerDaemonStatus]int) + for _, daemon := range daemons { + statusCounts[daemon.Status]++ + } + + require.Equal(t, 2, statusCounts[database.ProvisionerDaemonStatusIdle]) + require.Equal(t, 1, statusCounts[database.ProvisionerDaemonStatusOffline]) + }) + + t.Run("MatchesStatuses", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "offline-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-time.Hour), + }, + }) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "foo-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(30 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(30 * time.Minute)), + }, + }) + + type testCase struct { + name string + statuses []database.ProvisionerDaemonStatus + expectedNum int + } + + tests := []testCase{ + { + name: "Get idle and offline", + statuses: []database.ProvisionerDaemonStatus{ + database.ProvisionerDaemonStatusOffline, + database.ProvisionerDaemonStatusIdle, + }, + expectedNum: 2, + }, + { + name: "Get offline", + statuses: []database.ProvisionerDaemonStatus{ + database.ProvisionerDaemonStatusOffline, + }, + expectedNum: 1, + }, + // Offline daemons should not be included without Offline param + { + name: "Get idle - empty statuses", + statuses: []database.ProvisionerDaemonStatus{}, + expectedNum: 1, + }, + { + name: "Get idle - nil statuses", + statuses: nil, + expectedNum: 1, + }, + } + + for _, tc := range tests { + //nolint:tparallel,paralleltest + t.Run(tc.name, func(t *testing.T) { + daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ + OrganizationID: org.ID, + StaleIntervalMS: 45 * time.Minute.Milliseconds(), + Statuses: tc.statuses, + }) + require.NoError(t, err) + require.Len(t, daemons, tc.expectedNum) + }) + } + }) + + t.Run("FilterByMaxAge", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "foo-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(45 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(45 * time.Minute)), + }, + }) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "bar-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(25 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(25 * time.Minute)), + }, + }) + + type testCase struct { + name string + maxAge sql.NullInt64 + expectedNum int + } + + tests := []testCase{ + { + name: "Max age 1 hour", + maxAge: sql.NullInt64{Int64: time.Hour.Milliseconds(), Valid: true}, + expectedNum: 2, + }, + { + name: "Max age 30 minutes", + maxAge: sql.NullInt64{Int64: (30 * time.Minute).Milliseconds(), Valid: true}, + expectedNum: 1, + }, + { + name: "Max age 15 minutes", + maxAge: sql.NullInt64{Int64: (15 * time.Minute).Milliseconds(), Valid: true}, + expectedNum: 0, + }, + { + name: "No max age", + maxAge: sql.NullInt64{Valid: false}, + expectedNum: 2, + }, + } + for _, tc := range tests { + //nolint:tparallel,paralleltest + t.Run(tc.name, func(t *testing.T) { + daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ + OrganizationID: org.ID, + StaleIntervalMS: 60 * time.Minute.Milliseconds(), + MaxAgeMs: tc.maxAge, + }) + require.NoError(t, err) + require.Len(t, daemons, tc.expectedNum) + }) + } + }) } func TestGetWorkspaceAgentUsageStats(t *testing.T) { @@ -1552,8 +1779,11 @@ func TestUpdateSystemUser(t *testing.T) { // When: attempting to update a system user's name. _, err = db.UpdateUserProfile(ctx, database.UpdateUserProfileParams{ - ID: systemUser.ID, - Name: "not prebuilds", + ID: systemUser.ID, + Email: systemUser.Email, + Username: systemUser.Username, + AvatarURL: systemUser.AvatarURL, + Name: "not prebuilds", }) // Then: the attempt is rejected by a postgres trigger. // require.ErrorContains(t, err, "Cannot modify or delete system users") diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9615cc2c1a42a..3a41cf63c1630 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -32,7 +32,7 @@ WITH latest AS ( -- be as if the workspace auto started at the given time and the -- original TTL was applied. -- - -- Sadly we can't define ` + "`" + `activity_bump_interval` + "`" + ` above since + -- Sadly we can't define 'activity_bump_interval' above since -- it won't be available for this CASE statement, so we have to -- copy the cast twice. WHEN NOW() + (templates.activity_bump / 1000 / 1000 / 1000 || ' seconds')::interval > $1 :: timestamptz @@ -62,7 +62,11 @@ WITH latest AS ( ON workspaces.id = workspace_builds.workspace_id JOIN templates ON templates.id = workspaces.template_id - WHERE workspace_builds.workspace_id = $2::uuid + WHERE + workspace_builds.workspace_id = $2::uuid + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop and not subject to activity bumping + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ORDER BY workspace_builds.build_number DESC LIMIT 1 ) @@ -7252,6 +7256,47 @@ func (q *sqlQuerier) CountInProgressPrebuilds(ctx context.Context) ([]CountInPro return items, nil } +const findMatchingPresetID = `-- name: FindMatchingPresetID :one +WITH provided_params AS ( + SELECT + unnest($1::text[]) AS name, + unnest($2::text[]) AS value +), +preset_matches AS ( + SELECT + tvp.id AS template_version_preset_id, + COALESCE(COUNT(tvpp.name), 0) AS total_preset_params, + COALESCE(COUNT(pp.name), 0) AS matching_params + FROM template_version_presets tvp + LEFT JOIN template_version_preset_parameters tvpp ON tvpp.template_version_preset_id = tvp.id + LEFT JOIN provided_params pp ON pp.name = tvpp.name AND pp.value = tvpp.value + WHERE tvp.template_version_id = $3 + GROUP BY tvp.id +) +SELECT pm.template_version_preset_id +FROM preset_matches pm +WHERE pm.total_preset_params = pm.matching_params -- All preset parameters must match +ORDER BY pm.total_preset_params DESC -- Return the preset with the most parameters +LIMIT 1 +` + +type FindMatchingPresetIDParams struct { + ParameterNames []string `db:"parameter_names" json:"parameter_names"` + ParameterValues []string `db:"parameter_values" json:"parameter_values"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` +} + +// FindMatchingPresetID finds a preset ID that is the largest exact subset of the provided parameters. +// It returns the preset ID if a match is found, or NULL if no match is found. +// The query finds presets where all preset parameters are present in the provided parameters, +// and returns the preset with the most parameters (largest subset). +func (q *sqlQuerier) FindMatchingPresetID(ctx context.Context, arg FindMatchingPresetIDParams) (uuid.UUID, error) { + row := q.db.QueryRowContext(ctx, findMatchingPresetID, pq.Array(arg.ParameterNames), pq.Array(arg.ParameterValues), arg.TemplateVersionID) + var template_version_preset_id uuid.UUID + err := row.Scan(&template_version_preset_id) + return template_version_preset_id, err +} + const getPrebuildMetrics = `-- name: GetPrebuildMetrics :many SELECT t.name as template_name, @@ -8218,13 +8263,13 @@ const getProvisionerDaemonsWithStatusByOrganization = `-- name: GetProvisionerDa SELECT pd.id, pd.created_at, pd.name, pd.provisioners, pd.replica_id, pd.tags, pd.last_seen_at, pd.version, pd.api_version, pd.organization_id, pd.key_id, CASE - WHEN pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($1::bigint || ' ms')::interval) - THEN 'offline' - ELSE CASE - WHEN current_job.id IS NOT NULL THEN 'busy' - ELSE 'idle' - END - END::provisioner_daemon_status AS status, + WHEN current_job.id IS NOT NULL THEN 'busy'::provisioner_daemon_status + WHEN (COALESCE($1::bool, false) = true + OR 'offline'::provisioner_daemon_status = ANY($2::provisioner_daemon_status[])) + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($3::bigint || ' ms')::interval)) + THEN 'offline'::provisioner_daemon_status + ELSE 'idle'::provisioner_daemon_status + END AS status, pk.name AS key_name, -- NOTE(mafredri): sqlc.embed doesn't support nullable tables nor renaming them. current_job.id AS current_job_id, @@ -8291,21 +8336,56 @@ LEFT JOIN AND previous_template.organization_id = pd.organization_id ) WHERE - pd.organization_id = $2::uuid - AND (COALESCE(array_length($3::uuid[], 1), 0) = 0 OR pd.id = ANY($3::uuid[])) - AND ($4::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, $4::tagset)) + pd.organization_id = $4::uuid + AND (COALESCE(array_length($5::uuid[], 1), 0) = 0 OR pd.id = ANY($5::uuid[])) + AND ($6::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, $6::tagset)) + -- Filter by max age if provided + AND ( + $7::bigint IS NULL + OR pd.last_seen_at IS NULL + OR pd.last_seen_at >= (NOW() - ($7::bigint || ' ms')::interval) + ) + AND ( + -- Always include online daemons + (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - ($3::bigint || ' ms')::interval)) + -- Include offline daemons if offline param is true or 'offline' status is requested + OR ( + (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($3::bigint || ' ms')::interval)) + AND ( + COALESCE($1::bool, false) = true + OR 'offline'::provisioner_daemon_status = ANY($2::provisioner_daemon_status[]) + ) + ) + ) + AND ( + -- Filter daemons by any statuses if provided + COALESCE(array_length($2::provisioner_daemon_status[], 1), 0) = 0 + OR (current_job.id IS NOT NULL AND 'busy'::provisioner_daemon_status = ANY($2::provisioner_daemon_status[])) + OR (current_job.id IS NULL AND 'idle'::provisioner_daemon_status = ANY($2::provisioner_daemon_status[])) + OR ( + 'offline'::provisioner_daemon_status = ANY($2::provisioner_daemon_status[]) + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($3::bigint || ' ms')::interval)) + ) + OR ( + COALESCE($1::bool, false) = true + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($3::bigint || ' ms')::interval)) + ) + ) ORDER BY pd.created_at DESC LIMIT - $5::int + $8::int ` type GetProvisionerDaemonsWithStatusByOrganizationParams struct { - StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - IDs []uuid.UUID `db:"ids" json:"ids"` - Tags StringMap `db:"tags" json:"tags"` - Limit sql.NullInt32 `db:"limit" json:"limit"` + Offline sql.NullBool `db:"offline" json:"offline"` + Statuses []ProvisionerDaemonStatus `db:"statuses" json:"statuses"` + StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + IDs []uuid.UUID `db:"ids" json:"ids"` + Tags StringMap `db:"tags" json:"tags"` + MaxAgeMs sql.NullInt64 `db:"max_age_ms" json:"max_age_ms"` + Limit sql.NullInt32 `db:"limit" json:"limit"` } type GetProvisionerDaemonsWithStatusByOrganizationRow struct { @@ -8328,10 +8408,13 @@ type GetProvisionerDaemonsWithStatusByOrganizationRow struct { // Previous job information. func (q *sqlQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.Context, arg GetProvisionerDaemonsWithStatusByOrganizationParams) ([]GetProvisionerDaemonsWithStatusByOrganizationRow, error) { rows, err := q.db.QueryContext(ctx, getProvisionerDaemonsWithStatusByOrganization, + arg.Offline, + pq.Array(arg.Statuses), arg.StaleIntervalMS, arg.OrganizationID, pq.Array(arg.IDs), arg.Tags, + arg.MaxAgeMs, arg.Limit, ) if err != nil { @@ -11440,15 +11523,17 @@ func (q *sqlQuerier) GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]Tailn } const getTailnetTunnelPeerBindings = `-- name: GetTailnetTunnelPeerBindings :many -SELECT tailnet_tunnels.dst_id as peer_id, tailnet_peers.coordinator_id, tailnet_peers.updated_at, tailnet_peers.node, tailnet_peers.status -FROM tailnet_tunnels -INNER JOIN tailnet_peers ON tailnet_tunnels.dst_id = tailnet_peers.id -WHERE tailnet_tunnels.src_id = $1 -UNION -SELECT tailnet_tunnels.src_id as peer_id, tailnet_peers.coordinator_id, tailnet_peers.updated_at, tailnet_peers.node, tailnet_peers.status -FROM tailnet_tunnels -INNER JOIN tailnet_peers ON tailnet_tunnels.src_id = tailnet_peers.id -WHERE tailnet_tunnels.dst_id = $1 +SELECT id AS peer_id, coordinator_id, updated_at, node, status +FROM tailnet_peers +WHERE id IN ( + SELECT dst_id as peer_id + FROM tailnet_tunnels + WHERE tailnet_tunnels.src_id = $1 + UNION + SELECT src_id as peer_id + FROM tailnet_tunnels + WHERE tailnet_tunnels.dst_id = $1 +) ` type GetTailnetTunnelPeerBindingsRow struct { @@ -11528,7 +11613,7 @@ func (q *sqlQuerier) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUI } const updateTailnetPeerStatusByCoordinator = `-- name: UpdateTailnetPeerStatusByCoordinator :exec -UPDATE +UPDATE tailnet_peers SET status = $2 @@ -12141,21 +12226,28 @@ WHERE ELSE true END + -- Filter by has_external_agent in latest version + AND CASE + WHEN $10 :: boolean IS NOT NULL THEN + tv.has_external_agent = $10 :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates -- @authorize_filter ORDER BY (t.name, t.id) ASC ` type GetTemplatesWithFilterParams struct { - Deleted bool `db:"deleted" json:"deleted"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - ExactName string `db:"exact_name" json:"exact_name"` - FuzzyName string `db:"fuzzy_name" json:"fuzzy_name"` - IDs []uuid.UUID `db:"ids" json:"ids"` - Deprecated sql.NullBool `db:"deprecated" json:"deprecated"` - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` - AuthorID uuid.UUID `db:"author_id" json:"author_id"` - AuthorUsername string `db:"author_username" json:"author_username"` + Deleted bool `db:"deleted" json:"deleted"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + ExactName string `db:"exact_name" json:"exact_name"` + FuzzyName string `db:"fuzzy_name" json:"fuzzy_name"` + IDs []uuid.UUID `db:"ids" json:"ids"` + Deprecated sql.NullBool `db:"deprecated" json:"deprecated"` + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + AuthorID uuid.UUID `db:"author_id" json:"author_id"` + AuthorUsername string `db:"author_username" json:"author_username"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` } func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) { @@ -12169,6 +12261,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate arg.HasAITask, arg.AuthorID, arg.AuthorUsername, + arg.HasExternalAgent, ) if err != nil { return nil, err @@ -12653,7 +12746,7 @@ FROM -- Scope an archive to a single template and ignore already archived template versions ( SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agent FROM template_versions WHERE @@ -12754,7 +12847,7 @@ func (q *sqlQuerier) ArchiveUnusedTemplateVersions(ctx context.Context, arg Arch const getPreviousTemplateVersion = `-- name: GetPreviousTemplateVersion :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12793,6 +12886,7 @@ func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPrev &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12802,7 +12896,7 @@ func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPrev const getTemplateVersionByID = `-- name: GetTemplateVersionByID :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12827,6 +12921,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12836,7 +12931,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( const getTemplateVersionByJobID = `-- name: GetTemplateVersionByJobID :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12861,6 +12956,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12870,7 +12966,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U const getTemplateVersionByTemplateIDAndName = `-- name: GetTemplateVersionByTemplateIDAndName :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12901,6 +12997,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12925,7 +13022,7 @@ func (q *sqlQuerier) GetTemplateVersionHasAITask(ctx context.Context, id uuid.UU const getTemplateVersionsByIDs = `-- name: GetTemplateVersionsByIDs :many SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12956,6 +13053,7 @@ func (q *sqlQuerier) GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UU &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12975,7 +13073,7 @@ func (q *sqlQuerier) GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UU const getTemplateVersionsByTemplateID = `-- name: GetTemplateVersionsByTemplateID :many SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -13053,6 +13151,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -13071,7 +13170,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge } const getTemplateVersionsCreatedAfter = `-- name: GetTemplateVersionsCreatedAfter :many -SELECT id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE created_at > $1 +SELECT id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE created_at > $1 ` func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error) { @@ -13098,6 +13197,7 @@ func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, create &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -13186,27 +13286,6 @@ func (q *sqlQuerier) UnarchiveTemplateVersion(ctx context.Context, arg Unarchive return err } -const updateTemplateVersionAITaskByJobID = `-- name: UpdateTemplateVersionAITaskByJobID :exec -UPDATE - template_versions -SET - has_ai_task = $2, - updated_at = $3 -WHERE - job_id = $1 -` - -type UpdateTemplateVersionAITaskByJobIDParams struct { - JobID uuid.UUID `db:"job_id" json:"job_id"` - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` -} - -func (q *sqlQuerier) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskByJobIDParams) error { - _, err := q.db.ExecContext(ctx, updateTemplateVersionAITaskByJobID, arg.JobID, arg.HasAITask, arg.UpdatedAt) - return err -} - const updateTemplateVersionByID = `-- name: UpdateTemplateVersionByID :exec UPDATE template_versions @@ -13280,6 +13359,34 @@ func (q *sqlQuerier) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx conte return err } +const updateTemplateVersionFlagsByJobID = `-- name: UpdateTemplateVersionFlagsByJobID :exec +UPDATE + template_versions +SET + has_ai_task = $2, + has_external_agent = $3, + updated_at = $4 +WHERE + job_id = $1 +` + +type UpdateTemplateVersionFlagsByJobIDParams struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg UpdateTemplateVersionFlagsByJobIDParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateVersionFlagsByJobID, + arg.JobID, + arg.HasAITask, + arg.HasExternalAgent, + arg.UpdatedAt, + ) + return err +} + const getTemplateVersionTerraformValues = `-- name: GetTemplateVersionTerraformValues :one SELECT template_version_terraform_values.template_version_id, template_version_terraform_values.updated_at, template_version_terraform_values.cached_plan, template_version_terraform_values.cached_module_files, template_version_terraform_values.provisionerd_version @@ -15910,7 +16017,7 @@ const getWorkspaceAgentAndLatestBuildByAuthToken = `-- name: GetWorkspaceAgentAn SELECT workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, 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.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.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope, workspace_agents.deleted, - workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.has_ai_task, workspace_build_with_user.ai_task_sidebar_app_id, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name + workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.has_ai_task, workspace_build_with_user.ai_task_sidebar_app_id, workspace_build_with_user.has_external_agent, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name FROM workspace_agents JOIN @@ -16023,6 +16130,7 @@ func (q *sqlQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Cont &i.WorkspaceBuild.TemplateVersionPresetID, &i.WorkspaceBuild.HasAITask, &i.WorkspaceBuild.AITaskSidebarAppID, + &i.WorkspaceBuild.HasExternalAgent, &i.WorkspaceBuild.InitiatorByAvatarUrl, &i.WorkspaceBuild.InitiatorByUsername, &i.WorkspaceBuild.InitiatorByName, @@ -18677,7 +18785,7 @@ func (q *sqlQuerier) InsertWorkspaceBuildParameters(ctx context.Context, arg Ins } 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.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +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.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.has_external_agent, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -18734,6 +18842,7 @@ func (q *sqlQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, t &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18833,7 +18942,7 @@ func (q *sqlQuerier) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, a 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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + 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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18865,6 +18974,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18873,7 +18983,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w } const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :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.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +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.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.has_external_agent, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -18916,6 +19026,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18935,7 +19046,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + 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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18965,6 +19076,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18974,7 +19086,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + 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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -19004,6 +19116,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -19013,7 +19126,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU const getWorkspaceBuildByWorkspaceIDAndBuildNumber = `-- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + 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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -19047,6 +19160,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -19123,7 +19237,7 @@ func (q *sqlQuerier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, sinc const getWorkspaceBuildsByWorkspaceID = `-- name: GetWorkspaceBuildsByWorkspaceID :many 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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + 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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -19196,6 +19310,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -19214,7 +19329,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge } const getWorkspaceBuildsCreatedAfter = `-- name: GetWorkspaceBuildsCreatedAfter :many -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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user WHERE created_at > $1 +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, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) { @@ -19244,6 +19359,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, created &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -19320,33 +19436,6 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa return err } -const updateWorkspaceBuildAITaskByID = `-- name: UpdateWorkspaceBuildAITaskByID :exec -UPDATE - workspace_builds -SET - has_ai_task = $1, - ai_task_sidebar_app_id = $2, - updated_at = $3::timestamptz -WHERE id = $4::uuid -` - -type UpdateWorkspaceBuildAITaskByIDParams struct { - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` - SidebarAppID uuid.NullUUID `db:"sidebar_app_id" json:"sidebar_app_id"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ID uuid.UUID `db:"id" json:"id"` -} - -func (q *sqlQuerier) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskByIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceBuildAITaskByID, - arg.HasAITask, - arg.SidebarAppID, - arg.UpdatedAt, - arg.ID, - ) - return err -} - const updateWorkspaceBuildCostByID = `-- name: UpdateWorkspaceBuildCostByID :exec UPDATE workspace_builds @@ -19401,6 +19490,36 @@ func (q *sqlQuerier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg U return err } +const updateWorkspaceBuildFlagsByID = `-- name: UpdateWorkspaceBuildFlagsByID :exec +UPDATE + workspace_builds +SET + has_ai_task = $1, + ai_task_sidebar_app_id = $2, + has_external_agent = $3, + updated_at = $4::timestamptz +WHERE id = $5::uuid +` + +type UpdateWorkspaceBuildFlagsByIDParams struct { + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + SidebarAppID uuid.NullUUID `db:"sidebar_app_id" json:"sidebar_app_id"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg UpdateWorkspaceBuildFlagsByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceBuildFlagsByID, + arg.HasAITask, + arg.SidebarAppID, + arg.HasExternalAgent, + arg.UpdatedAt, + arg.ID, + ) + return err +} + const updateWorkspaceBuildProvisionerStateByID = `-- name: UpdateWorkspaceBuildProvisionerStateByID :exec UPDATE workspace_builds @@ -20368,7 +20487,8 @@ SELECT latest_build.error as latest_build_error, latest_build.transition as latest_build_transition, latest_build.job_status as latest_build_status, - latest_build.has_ai_task as latest_build_has_ai_task + latest_build.has_ai_task as latest_build_has_ai_task, + latest_build.has_external_agent as latest_build_has_external_agent FROM workspaces_expanded as workspaces JOIN @@ -20381,6 +20501,7 @@ LEFT JOIN LATERAL ( workspace_builds.transition, workspace_builds.template_version_id, workspace_builds.has_ai_task, + workspace_builds.has_external_agent, template_versions.name AS template_version_name, provisioner_jobs.id AS provisioner_job_id, provisioner_jobs.started_at, @@ -20621,16 +20742,22 @@ WHERE )) = ($19 :: boolean) ELSE true END + -- Filter by has_external_agent in latest build + AND CASE + WHEN $20 :: boolean IS NOT NULL THEN + latest_build.has_external_agent = $20 :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ), filtered_workspaces_order AS ( SELECT - fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_ai_task + fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_ai_task, fw.latest_build_has_external_agent FROM filtered_workspaces fw ORDER BY -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN owner_id = $20 AND favorite THEN 0 ELSE 1 END ASC, + CASE WHEN owner_id = $21 AND favorite THEN 0 ELSE 1 END ASC, (latest_build_completed_at IS NOT NULL AND latest_build_canceled_at IS NULL AND latest_build_error IS NULL AND @@ -20639,14 +20766,14 @@ WHERE LOWER(name) ASC LIMIT CASE - WHEN $22 :: integer > 0 THEN - $22 + WHEN $23 :: integer > 0 THEN + $23 END OFFSET - $21 + $22 ), filtered_workspaces_order_with_summary AS ( SELECT - fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_ai_task + fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_ai_task, fwo.latest_build_has_external_agent FROM filtered_workspaces_order fwo -- Return a technical summary row with total count of workspaces. @@ -20690,9 +20817,10 @@ WHERE '', -- latest_build_error 'start'::workspace_transition, -- latest_build_transition 'unknown'::provisioner_job_status, -- latest_build_status - false -- latest_build_has_ai_task + false, -- latest_build_has_ai_task + false -- latest_build_has_external_agent WHERE - $23 :: boolean = true + $24 :: boolean = true ), total_count AS ( SELECT count(*) AS count @@ -20700,7 +20828,7 @@ WHERE filtered_workspaces ) SELECT - fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_ai_task, + fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_ai_task, fwos.latest_build_has_external_agent, tc.count FROM filtered_workspaces_order_with_summary fwos @@ -20728,6 +20856,7 @@ type GetWorkspacesParams struct { LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` UsingActive sql.NullBool `db:"using_active" json:"using_active"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` RequesterID uuid.UUID `db:"requester_id" json:"requester_id"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` @@ -20735,44 +20864,45 @@ type GetWorkspacesParams struct { } type GetWorkspacesRow struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Deleted bool `db:"deleted" json:"deleted"` - Name string `db:"name" json:"name"` - AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` - Ttl sql.NullInt64 `db:"ttl" json:"ttl"` - LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` - DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` - AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` - Favorite bool `db:"favorite" json:"favorite"` - NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` - GroupACL json.RawMessage `db:"group_acl" json:"group_acl"` - UserACL json.RawMessage `db:"user_acl" json:"user_acl"` - OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"` - OwnerUsername string `db:"owner_username" json:"owner_username"` - OwnerName string `db:"owner_name" json:"owner_name"` - OrganizationName string `db:"organization_name" json:"organization_name"` - OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` - OrganizationIcon string `db:"organization_icon" json:"organization_icon"` - OrganizationDescription string `db:"organization_description" json:"organization_description"` - TemplateName string `db:"template_name" json:"template_name"` - TemplateDisplayName string `db:"template_display_name" json:"template_display_name"` - TemplateIcon string `db:"template_icon" json:"template_icon"` - TemplateDescription string `db:"template_description" json:"template_description"` - TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` - TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` - LatestBuildCompletedAt sql.NullTime `db:"latest_build_completed_at" json:"latest_build_completed_at"` - LatestBuildCanceledAt sql.NullTime `db:"latest_build_canceled_at" json:"latest_build_canceled_at"` - LatestBuildError sql.NullString `db:"latest_build_error" json:"latest_build_error"` - LatestBuildTransition WorkspaceTransition `db:"latest_build_transition" json:"latest_build_transition"` - LatestBuildStatus ProvisionerJobStatus `db:"latest_build_status" json:"latest_build_status"` - LatestBuildHasAITask sql.NullBool `db:"latest_build_has_ai_task" json:"latest_build_has_ai_task"` - Count int64 `db:"count" json:"count"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` + DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` + AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` + Favorite bool `db:"favorite" json:"favorite"` + NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` + GroupACL json.RawMessage `db:"group_acl" json:"group_acl"` + UserACL json.RawMessage `db:"user_acl" json:"user_acl"` + OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"` + OwnerUsername string `db:"owner_username" json:"owner_username"` + OwnerName string `db:"owner_name" json:"owner_name"` + OrganizationName string `db:"organization_name" json:"organization_name"` + OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` + OrganizationIcon string `db:"organization_icon" json:"organization_icon"` + OrganizationDescription string `db:"organization_description" json:"organization_description"` + TemplateName string `db:"template_name" json:"template_name"` + TemplateDisplayName string `db:"template_display_name" json:"template_display_name"` + TemplateIcon string `db:"template_icon" json:"template_icon"` + TemplateDescription string `db:"template_description" json:"template_description"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` + LatestBuildCompletedAt sql.NullTime `db:"latest_build_completed_at" json:"latest_build_completed_at"` + LatestBuildCanceledAt sql.NullTime `db:"latest_build_canceled_at" json:"latest_build_canceled_at"` + LatestBuildError sql.NullString `db:"latest_build_error" json:"latest_build_error"` + LatestBuildTransition WorkspaceTransition `db:"latest_build_transition" json:"latest_build_transition"` + LatestBuildStatus ProvisionerJobStatus `db:"latest_build_status" json:"latest_build_status"` + LatestBuildHasAITask sql.NullBool `db:"latest_build_has_ai_task" json:"latest_build_has_ai_task"` + LatestBuildHasExternalAgent sql.NullBool `db:"latest_build_has_external_agent" json:"latest_build_has_external_agent"` + Count int64 `db:"count" json:"count"` } // build_params is used to filter by build parameters if present. @@ -20799,6 +20929,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.LastUsedAfter, arg.UsingActive, arg.HasAITask, + arg.HasExternalAgent, arg.RequesterID, arg.Offset, arg.Limit, @@ -20849,6 +20980,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.LatestBuildTransition, &i.LatestBuildStatus, &i.LatestBuildHasAITask, + &i.LatestBuildHasExternalAgent, &i.Count, ); err != nil { return nil, err @@ -21505,14 +21637,17 @@ UPDATE workspaces SET deleting_at = CASE WHEN $1::bigint = 0 THEN NULL - WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN ($2::timestamptz) + interval '1 milliseconds' * $1::bigint + WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN ($2::timestamptz) + interval '1 milliseconds' * $1::bigint ELSE dormant_at + interval '1 milliseconds' * $1::bigint END, dormant_at = CASE WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN $2::timestamptz ELSE dormant_at END WHERE template_id = $3 -AND - dormant_at IS NOT NULL + AND dormant_at IS NOT NULL + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their dormant or deleting at set, as these are handled by the + -- prebuilds reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl ` @@ -21571,6 +21706,10 @@ SET ttl = $2 WHERE template_id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their TTL updated, as they are handled by the prebuilds + -- reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspacesTTLByTemplateIDParams struct { diff --git a/coderd/database/queries/activitybump.sql b/coderd/database/queries/activitybump.sql index 09349d29e5d06..e367a93abf778 100644 --- a/coderd/database/queries/activitybump.sql +++ b/coderd/database/queries/activitybump.sql @@ -22,7 +22,7 @@ WITH latest AS ( -- be as if the workspace auto started at the given time and the -- original TTL was applied. -- - -- Sadly we can't define `activity_bump_interval` above since + -- Sadly we can't define 'activity_bump_interval' above since -- it won't be available for this CASE statement, so we have to -- copy the cast twice. WHEN NOW() + (templates.activity_bump / 1000 / 1000 / 1000 || ' seconds')::interval > @next_autostart :: timestamptz @@ -52,7 +52,11 @@ WITH latest AS ( ON workspaces.id = workspace_builds.workspace_id JOIN templates ON templates.id = workspaces.template_id - WHERE workspace_builds.workspace_id = @workspace_id::uuid + WHERE + workspace_builds.workspace_id = @workspace_id::uuid + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop and not subject to activity bumping + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ORDER BY workspace_builds.build_number DESC LIMIT 1 ) diff --git a/coderd/database/queries/prebuilds.sql b/coderd/database/queries/prebuilds.sql index 87a713974c563..8654453554e8c 100644 --- a/coderd/database/queries/prebuilds.sql +++ b/coderd/database/queries/prebuilds.sql @@ -245,3 +245,30 @@ INNER JOIN organizations o ON o.id = w.organization_id WHERE NOT t.deleted AND wpb.build_number = 1 GROUP BY t.name, tvp.name, o.name ORDER BY t.name, tvp.name, o.name; + +-- name: FindMatchingPresetID :one +-- FindMatchingPresetID finds a preset ID that is the largest exact subset of the provided parameters. +-- It returns the preset ID if a match is found, or NULL if no match is found. +-- The query finds presets where all preset parameters are present in the provided parameters, +-- and returns the preset with the most parameters (largest subset). +WITH provided_params AS ( + SELECT + unnest(@parameter_names::text[]) AS name, + unnest(@parameter_values::text[]) AS value +), +preset_matches AS ( + SELECT + tvp.id AS template_version_preset_id, + COALESCE(COUNT(tvpp.name), 0) AS total_preset_params, + COALESCE(COUNT(pp.name), 0) AS matching_params + FROM template_version_presets tvp + LEFT JOIN template_version_preset_parameters tvpp ON tvpp.template_version_preset_id = tvp.id + LEFT JOIN provided_params pp ON pp.name = tvpp.name AND pp.value = tvpp.value + WHERE tvp.template_version_id = @template_version_id + GROUP BY tvp.id +) +SELECT pm.template_version_preset_id +FROM preset_matches pm +WHERE pm.total_preset_params = pm.matching_params -- All preset parameters must match +ORDER BY pm.total_preset_params DESC -- Return the preset with the most parameters +LIMIT 1; diff --git a/coderd/database/queries/provisionerdaemons.sql b/coderd/database/queries/provisionerdaemons.sql index 4f7c7a8b2200a..ad6c0948eb448 100644 --- a/coderd/database/queries/provisionerdaemons.sql +++ b/coderd/database/queries/provisionerdaemons.sql @@ -32,13 +32,13 @@ WHERE SELECT sqlc.embed(pd), CASE - WHEN pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval) - THEN 'offline' - ELSE CASE - WHEN current_job.id IS NOT NULL THEN 'busy' - ELSE 'idle' - END - END::provisioner_daemon_status AS status, + WHEN current_job.id IS NOT NULL THEN 'busy'::provisioner_daemon_status + WHEN (COALESCE(sqlc.narg('offline')::bool, false) = true + OR 'offline'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[])) + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + THEN 'offline'::provisioner_daemon_status + ELSE 'idle'::provisioner_daemon_status + END AS status, pk.name AS key_name, -- NOTE(mafredri): sqlc.embed doesn't support nullable tables nor renaming them. current_job.id AS current_job_id, @@ -110,6 +110,38 @@ WHERE pd.organization_id = @organization_id::uuid AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pd.id = ANY(@ids::uuid[])) AND (@tags::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, @tags::tagset)) + -- Filter by max age if provided + AND ( + sqlc.narg('max_age_ms')::bigint IS NULL + OR pd.last_seen_at IS NULL + OR pd.last_seen_at >= (NOW() - (sqlc.narg('max_age_ms')::bigint || ' ms')::interval) + ) + AND ( + -- Always include online daemons + (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + -- Include offline daemons if offline param is true or 'offline' status is requested + OR ( + (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + AND ( + COALESCE(sqlc.narg('offline')::bool, false) = true + OR 'offline'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[]) + ) + ) + ) + AND ( + -- Filter daemons by any statuses if provided + COALESCE(array_length(@statuses::provisioner_daemon_status[], 1), 0) = 0 + OR (current_job.id IS NOT NULL AND 'busy'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[])) + OR (current_job.id IS NULL AND 'idle'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[])) + OR ( + 'offline'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[]) + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + ) + OR ( + COALESCE(sqlc.narg('offline')::bool, false) = true + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + ) + ) ORDER BY pd.created_at DESC LIMIT diff --git a/coderd/database/queries/tailnet.sql b/coderd/database/queries/tailnet.sql index 07936e277bc52..614d718789d63 100644 --- a/coderd/database/queries/tailnet.sql +++ b/coderd/database/queries/tailnet.sql @@ -150,7 +150,7 @@ DO UPDATE SET RETURNING *; -- name: UpdateTailnetPeerStatusByCoordinator :exec -UPDATE +UPDATE tailnet_peers SET status = $2 @@ -205,15 +205,17 @@ FROM tailnet_tunnels WHERE tailnet_tunnels.dst_id = $1; -- name: GetTailnetTunnelPeerBindings :many -SELECT tailnet_tunnels.dst_id as peer_id, tailnet_peers.coordinator_id, tailnet_peers.updated_at, tailnet_peers.node, tailnet_peers.status -FROM tailnet_tunnels -INNER JOIN tailnet_peers ON tailnet_tunnels.dst_id = tailnet_peers.id -WHERE tailnet_tunnels.src_id = $1 -UNION -SELECT tailnet_tunnels.src_id as peer_id, tailnet_peers.coordinator_id, tailnet_peers.updated_at, tailnet_peers.node, tailnet_peers.status -FROM tailnet_tunnels -INNER JOIN tailnet_peers ON tailnet_tunnels.src_id = tailnet_peers.id -WHERE tailnet_tunnels.dst_id = $1; +SELECT id AS peer_id, coordinator_id, updated_at, node, status +FROM tailnet_peers +WHERE id IN ( + SELECT dst_id as peer_id + FROM tailnet_tunnels + WHERE tailnet_tunnels.src_id = $1 + UNION + SELECT src_id as peer_id + FROM tailnet_tunnels + WHERE tailnet_tunnels.dst_id = $1 +); -- For PG Coordinator HTMLDebug diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index a922a9bef1918..4bb70c6580503 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -72,6 +72,12 @@ WHERE ELSE true END + -- Filter by has_external_agent in latest version + AND CASE + WHEN sqlc.narg('has_external_agent') :: boolean IS NOT NULL THEN + tv.has_external_agent = sqlc.narg('has_external_agent') :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates -- @authorize_filter ORDER BY (t.name, t.id) ASC diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index 97fb6bd9ecc08..128b2e5f582da 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -122,15 +122,6 @@ SET WHERE job_id = $1; --- name: UpdateTemplateVersionAITaskByJobID :exec -UPDATE - template_versions -SET - has_ai_task = $2, - updated_at = $3 -WHERE - job_id = $1; - -- name: GetPreviousTemplateVersion :one SELECT * @@ -241,3 +232,13 @@ SELECT EXISTS ( FROM template_versions WHERE id = $1 AND has_ai_task = TRUE ); + +-- name: UpdateTemplateVersionFlagsByJobID :exec +UPDATE + template_versions +SET + has_ai_task = $2, + has_external_agent = $3, + updated_at = $4 +WHERE + job_id = $1; diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 40bf0f18cf8c5..6c020f5a97f50 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -145,15 +145,6 @@ SET updated_at = @updated_at::timestamptz WHERE id = @id::uuid; --- name: UpdateWorkspaceBuildAITaskByID :exec -UPDATE - workspace_builds -SET - has_ai_task = @has_ai_task, - ai_task_sidebar_app_id = @sidebar_app_id, - updated_at = @updated_at::timestamptz -WHERE id = @id::uuid; - -- name: GetActiveWorkspaceBuildsByTemplateID :many SELECT wb.* FROM ( @@ -247,3 +238,13 @@ WHERE AND pj.job_status = 'failed' ORDER BY tv.name ASC, wb.build_number DESC; + +-- name: UpdateWorkspaceBuildFlagsByID :exec +UPDATE + workspace_builds +SET + has_ai_task = @has_ai_task, + ai_task_sidebar_app_id = @sidebar_app_id, + has_external_agent = @has_external_agent, + updated_at = @updated_at::timestamptz +WHERE id = @id::uuid; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 8b9a9e3076555..a3deda6863e85 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -117,7 +117,8 @@ SELECT latest_build.error as latest_build_error, latest_build.transition as latest_build_transition, latest_build.job_status as latest_build_status, - latest_build.has_ai_task as latest_build_has_ai_task + latest_build.has_ai_task as latest_build_has_ai_task, + latest_build.has_external_agent as latest_build_has_external_agent FROM workspaces_expanded as workspaces JOIN @@ -130,6 +131,7 @@ LEFT JOIN LATERAL ( workspace_builds.transition, workspace_builds.template_version_id, workspace_builds.has_ai_task, + workspace_builds.has_external_agent, template_versions.name AS template_version_name, provisioner_jobs.id AS provisioner_job_id, provisioner_jobs.started_at, @@ -370,6 +372,12 @@ WHERE )) = (sqlc.narg('has_ai_task') :: boolean) ELSE true END + -- Filter by has_external_agent in latest build + AND CASE + WHEN sqlc.narg('has_external_agent') :: boolean IS NOT NULL THEN + latest_build.has_external_agent = sqlc.narg('has_external_agent') :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ), filtered_workspaces_order AS ( @@ -439,7 +447,8 @@ WHERE '', -- latest_build_error 'start'::workspace_transition, -- latest_build_transition 'unknown'::provisioner_job_status, -- latest_build_status - false -- latest_build_has_ai_task + false, -- latest_build_has_ai_task + false -- latest_build_has_external_agent WHERE @with_summary :: boolean = true ), total_count AS ( @@ -570,7 +579,11 @@ UPDATE SET ttl = $2 WHERE - template_id = $1; + template_id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their TTL updated, as they are handled by the prebuilds + -- reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspaceLastUsedAt :exec UPDATE @@ -815,14 +828,17 @@ UPDATE workspaces SET deleting_at = CASE WHEN @time_til_dormant_autodelete_ms::bigint = 0 THEN NULL - WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@dormant_at::timestamptz) + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint + WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@dormant_at::timestamptz) + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint ELSE dormant_at + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint END, dormant_at = CASE WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN @dormant_at::timestamptz ELSE dormant_at END WHERE template_id = @template_id -AND - dormant_at IS NOT NULL + AND dormant_at IS NOT NULL + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their dormant or deleting at set, as these are handled by the + -- prebuilds reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID RETURNING *; -- name: UpdateTemplateWorkspacesLastUsedAt :exec diff --git a/coderd/database/sdk2db/sdk2db.go b/coderd/database/sdk2db/sdk2db.go new file mode 100644 index 0000000000000..02fe8578179c9 --- /dev/null +++ b/coderd/database/sdk2db/sdk2db.go @@ -0,0 +1,16 @@ +// Package sdk2db provides common conversion routines from codersdk types to database types +package sdk2db + +import ( + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/codersdk" +) + +func ProvisionerDaemonStatus(status codersdk.ProvisionerDaemonStatus) database.ProvisionerDaemonStatus { + return database.ProvisionerDaemonStatus(status) +} + +func ProvisionerDaemonStatuses(params []codersdk.ProvisionerDaemonStatus) []database.ProvisionerDaemonStatus { + return db2sdk.List(params, ProvisionerDaemonStatus) +} diff --git a/coderd/database/sdk2db/sdk2db_test.go b/coderd/database/sdk2db/sdk2db_test.go new file mode 100644 index 0000000000000..ff51dc0ffaaf4 --- /dev/null +++ b/coderd/database/sdk2db/sdk2db_test.go @@ -0,0 +1,36 @@ +package sdk2db_test + +import ( + "testing" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/sdk2db" + "github.com/coder/coder/v2/codersdk" +) + +func TestProvisionerDaemonStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input codersdk.ProvisionerDaemonStatus + expect database.ProvisionerDaemonStatus + }{ + {"busy", codersdk.ProvisionerDaemonBusy, database.ProvisionerDaemonStatusBusy}, + {"offline", codersdk.ProvisionerDaemonOffline, database.ProvisionerDaemonStatusOffline}, + {"idle", codersdk.ProvisionerDaemonIdle, database.ProvisionerDaemonStatusIdle}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := sdk2db.ProvisionerDaemonStatus(tc.input) + if !got.Valid() { + t.Errorf("ProvisionerDaemonStatus(%v) returned invalid status", tc.input) + } + if got != tc.expect { + t.Errorf("ProvisionerDaemonStatus(%v) = %v; want %v", tc.input, got, tc.expect) + } + }) + } +} diff --git a/coderd/entitlements/entitlements.go b/coderd/entitlements/entitlements.go index 6bbe32ade4a1b..1be422b4765ee 100644 --- a/coderd/entitlements/entitlements.go +++ b/coderd/entitlements/entitlements.go @@ -161,3 +161,9 @@ func (l *Set) Errors() []string { defer l.entitlementsMu.RUnlock() return slices.Clone(l.entitlements.Errors) } + +func (l *Set) HasLicense() bool { + l.entitlementsMu.RLock() + defer l.entitlementsMu.RUnlock() + return l.entitlements.HasLicense +} diff --git a/coderd/externalauth/externalauth_test.go b/coderd/externalauth/externalauth_test.go index 484d59beabb9b..8e46566ed2738 100644 --- a/coderd/externalauth/externalauth_test.go +++ b/coderd/externalauth/externalauth_test.go @@ -337,7 +337,6 @@ func TestRefreshToken(t *testing.T) { require.Equal(t, 1, validateCalls, "token is validated") require.Equal(t, 1, refreshCalls, "token is refreshed") require.NotEqualf(t, link.OAuthAccessToken, updated.OAuthAccessToken, "token is updated") - //nolint:gocritic // testing dbLink, err := db.GetExternalAuthLink(dbauthz.AsSystemRestricted(context.Background()), database.GetExternalAuthLinkParams{ ProviderID: link.ProviderID, UserID: link.UserID, diff --git a/coderd/files/cache_test.go b/coderd/files/cache_test.go index 6f8f74e74fe8e..b81deae5d9714 100644 --- a/coderd/files/cache_test.go +++ b/coderd/files/cache_test.go @@ -45,7 +45,6 @@ func TestCancelledFetch(t *testing.T) { cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) // Cancel the context for the first call; should fail. - //nolint:gocritic // Unit testing ctx, cancel := context.WithCancel(dbauthz.AsFileReader(testutil.Context(t, testutil.WaitShort))) cancel() _, err := cache.Acquire(ctx, dbM, fileID) @@ -71,7 +70,6 @@ func TestCancelledConcurrentFetch(t *testing.T) { cache := files.LeakCache{Cache: files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{})} - //nolint:gocritic // Unit testing ctx := dbauthz.AsFileReader(testutil.Context(t, testutil.WaitShort)) // Cancel the context for the first call; should fail. @@ -99,7 +97,6 @@ func TestConcurrentFetch(t *testing.T) { }) cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) - //nolint:gocritic // Unit testing ctx := dbauthz.AsFileReader(testutil.Context(t, testutil.WaitShort)) // Expect 2 calls to Acquire before we continue the test @@ -151,7 +148,6 @@ func TestCacheRBAC(t *testing.T) { Scope: rbac.ScopeAll, }) - //nolint:gocritic // Unit testing cacheReader := dbauthz.AsFileReader(ctx) t.Run("NoRolesOpen", func(t *testing.T) { @@ -207,7 +203,6 @@ func cachePromMetricName(metric string) string { func TestConcurrency(t *testing.T) { t.Parallel() - //nolint:gocritic // Unit testing ctx := dbauthz.AsFileReader(t.Context()) const fileSize = 10 @@ -268,7 +263,6 @@ func TestConcurrency(t *testing.T) { func TestRelease(t *testing.T) { t.Parallel() - //nolint:gocritic // Unit testing ctx := dbauthz.AsFileReader(t.Context()) const fileSize = 10 diff --git a/coderd/httpapi/queryparams.go b/coderd/httpapi/queryparams.go index 0e4a20920e526..e1bd983ea12a3 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -287,6 +287,29 @@ func (p *QueryParamParser) JSONStringMap(vals url.Values, def map[string]string, return v } +func (p *QueryParamParser) ProvisionerDaemonStatuses(vals url.Values, def []codersdk.ProvisionerDaemonStatus, queryParam string) []codersdk.ProvisionerDaemonStatus { + return ParseCustomList(p, vals, def, queryParam, func(v string) (codersdk.ProvisionerDaemonStatus, error) { + return codersdk.ProvisionerDaemonStatus(v), nil + }) +} + +func (p *QueryParamParser) Duration(vals url.Values, def time.Duration, queryParam string) time.Duration { + v, err := parseQueryParam(p, vals, func(v string) (time.Duration, error) { + d, err := time.ParseDuration(v) + if err != nil { + return 0, err + } + return d, nil + }, def, queryParam) + if err != nil { + p.Errors = append(p.Errors, codersdk.ValidationError{ + Field: queryParam, + Detail: fmt.Sprintf("Query param %q must be a valid duration (e.g., '24h', '30m', '1h30m'): %s", queryParam, err.Error()), + }) + } + return v +} + // ValidEnum represents an enum that can be parsed and validated. type ValidEnum interface { // Add more types as needed (avoid importing large dependency trees). diff --git a/coderd/idpsync/group.go b/coderd/idpsync/group.go index 0b21c5b9ac84c..63ac0360f0cb3 100644 --- a/coderd/idpsync/group.go +++ b/coderd/idpsync/group.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" @@ -71,9 +72,49 @@ func (s AGPLIDPSync) GroupSyncSettings(ctx context.Context, orgID uuid.UUID, db return settings, nil } -func (s AGPLIDPSync) ParseGroupClaims(_ context.Context, _ jwt.MapClaims) (GroupParams, *HTTPError) { +func (s AGPLIDPSync) ParseGroupClaims(_ context.Context, mergedClaims jwt.MapClaims) (GroupParams, *HTTPError) { + if s.GroupField != "" && len(s.GroupAllowList) > 0 { + groupsRaw, ok := mergedClaims[s.GroupField] + if !ok { + return GroupParams{}, &HTTPError{ + Code: http.StatusForbidden, + Msg: "Not a member of an allowed group", + Detail: "You have no groups in your claims!", + RenderStaticPage: true, + } + } + parsedGroups, err := ParseStringSliceClaim(groupsRaw) + if err != nil { + return GroupParams{}, &HTTPError{ + Code: http.StatusBadRequest, + Msg: "Failed read groups from claims for allow list check. Ask an administrator for help.", + Detail: err.Error(), + RenderStaticPage: true, + } + } + + inAllowList := false + AllowListCheckLoop: + for _, group := range parsedGroups { + if _, ok := s.GroupAllowList[group]; ok { + inAllowList = true + break AllowListCheckLoop + } + } + + if !inAllowList { + return GroupParams{}, &HTTPError{ + Code: http.StatusForbidden, + Msg: "Not a member of an allowed group", + Detail: "Ask an administrator to add one of your groups to the allow list.", + RenderStaticPage: true, + } + } + } + return GroupParams{ SyncEntitled: s.GroupSyncEntitled(), + MergedClaims: mergedClaims, }, nil } diff --git a/coderd/idpsync/group_test.go b/coderd/idpsync/group_test.go index 478d6557de551..459a5dbcfaab0 100644 --- a/coderd/idpsync/group_test.go +++ b/coderd/idpsync/group_test.go @@ -44,8 +44,7 @@ func TestParseGroupClaims(t *testing.T) { require.False(t, params.SyncEntitled) }) - // AllowList has no effect in AGPL - t.Run("AllowList", func(t *testing.T) { + t.Run("NotInAllowList", func(t *testing.T) { t.Parallel() s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}), @@ -59,9 +58,39 @@ func TestParseGroupClaims(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) - params, err := s.ParseGroupClaims(ctx, jwt.MapClaims{}) + // Invalid group + _, err := s.ParseGroupClaims(ctx, jwt.MapClaims{ + "groups": []string{"bar"}, + }) + require.NotNil(t, err) + require.Equal(t, 403, err.Code) + + // No groups + _, err = s.ParseGroupClaims(ctx, jwt.MapClaims{}) + require.NotNil(t, err) + require.Equal(t, 403, err.Code) + }) + + t.Run("InAllowList", func(t *testing.T) { + t.Parallel() + + s := idpsync.NewAGPLSync(slogtest.Make(t, &slogtest.Options{}), + runtimeconfig.NewManager(), + idpsync.DeploymentSyncSettings{ + GroupField: "groups", + GroupAllowList: map[string]struct{}{ + "foo": {}, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + + claims := jwt.MapClaims{ + "groups": []string{"foo", "bar"}, + } + params, err := s.ParseGroupClaims(ctx, claims) require.Nil(t, err) - require.False(t, params.SyncEntitled) + require.Equal(t, claims, params.MergedClaims) }) } @@ -328,7 +357,6 @@ func TestGroupSyncTable(t *testing.T) { }, } - //nolint:gocritic // testing defOrg, err := db.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx)) require.NoError(t, err) SetupOrganization(t, s, db, user, defOrg.ID, def) @@ -527,7 +555,6 @@ func TestApplyGroupDifference(t *testing.T) { db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitMedium) - //nolint:gocritic // testing ctx = dbauthz.AsSystemRestricted(ctx) org := dbgen.Organization(t, db, database.Organization{}) diff --git a/coderd/idpsync/role_test.go b/coderd/idpsync/role_test.go index 6df091097b966..db172e0ee4237 100644 --- a/coderd/idpsync/role_test.go +++ b/coderd/idpsync/role_test.go @@ -273,7 +273,6 @@ func TestRoleSyncTable(t *testing.T) { } // Also assert site wide roles - //nolint:gocritic // unit testing assertions allRoles, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), user.ID) require.NoError(t, err) diff --git a/coderd/initscript.go b/coderd/initscript.go new file mode 100644 index 0000000000000..2051ca7f5f6e4 --- /dev/null +++ b/coderd/initscript.go @@ -0,0 +1,45 @@ +package coderd + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisionersdk" +) + +// @Summary Get agent init script +// @ID get-agent-init-script +// @Produce text/plain +// @Tags InitScript +// @Param os path string true "Operating system" +// @Param arch path string true "Architecture" +// @Success 200 "Success" +// @Router /init-script/{os}/{arch} [get] +func (api *API) initScript(rw http.ResponseWriter, r *http.Request) { + os := strings.ToLower(chi.URLParam(r, "os")) + arch := strings.ToLower(chi.URLParam(r, "arch")) + + script, exists := provisionersdk.AgentScriptEnv()[fmt.Sprintf("CODER_AGENT_SCRIPT_%s_%s", os, arch)] + if !exists { + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Unknown os/arch: %s/%s", os, arch), + }) + return + } + script = strings.ReplaceAll(script, "${ACCESS_URL}", api.AccessURL.String()+"/") + script = strings.ReplaceAll(script, "${AUTH_TYPE}", "token") + + scriptBytes := []byte(script) + hash := sha256.Sum256(scriptBytes) + rw.Header().Set("Content-Digest", fmt.Sprintf("sha256:%x", base64.StdEncoding.EncodeToString(hash[:]))) + rw.Header().Set("Content-Type", "text/plain; charset=utf-8") + rw.WriteHeader(http.StatusOK) + _, _ = rw.Write(scriptBytes) +} diff --git a/coderd/initscript_test.go b/coderd/initscript_test.go new file mode 100644 index 0000000000000..bad0577f0218f --- /dev/null +++ b/coderd/initscript_test.go @@ -0,0 +1,67 @@ +package coderd_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" +) + +func TestInitScript(t *testing.T) { + t.Parallel() + + t.Run("OK Windows amd64", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + script, err := client.InitScript(context.Background(), "windows", "amd64") + require.NoError(t, err) + require.NotEmpty(t, script) + require.Contains(t, script, "$env:CODER_AGENT_AUTH = \"token\"") + require.Contains(t, script, "/bin/coder-windows-amd64.exe") + }) + + t.Run("OK Windows arm64", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + script, err := client.InitScript(context.Background(), "windows", "arm64") + require.NoError(t, err) + require.NotEmpty(t, script) + require.Contains(t, script, "$env:CODER_AGENT_AUTH = \"token\"") + require.Contains(t, script, "/bin/coder-windows-arm64.exe") + }) + + t.Run("OK Linux amd64", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + script, err := client.InitScript(context.Background(), "linux", "amd64") + require.NoError(t, err) + require.NotEmpty(t, script) + require.Contains(t, script, "export CODER_AGENT_AUTH=\"token\"") + require.Contains(t, script, "/bin/coder-linux-amd64") + }) + + t.Run("OK Linux arm64", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + script, err := client.InitScript(context.Background(), "linux", "arm64") + require.NoError(t, err) + require.NotEmpty(t, script) + require.Contains(t, script, "export CODER_AGENT_AUTH=\"token\"") + require.Contains(t, script, "/bin/coder-linux-arm64") + }) + + t.Run("BadRequest", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _, err := client.InitScript(context.Background(), "darwin", "armv7") + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Equal(t, "Unknown os/arch: darwin/armv7", apiErr.Message) + }) +} diff --git a/coderd/insights_test.go b/coderd/insights_test.go index d916b20fea26e..cf5f63065df99 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -754,7 +754,6 @@ func TestTemplateInsights_Golden(t *testing.T) { Database: db, AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize, }) - //nolint:gocritic // This is a test. err = reporter.ReportAppStats(dbauthz.AsSystemRestricted(ctx), stats) require.NoError(t, err, "want no error inserting app stats") @@ -1646,7 +1645,6 @@ func TestUserActivityInsights_Golden(t *testing.T) { Database: db, AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize, }) - //nolint:gocritic // This is a test. err = reporter.ReportAppStats(dbauthz.AsSystemRestricted(ctx), stats) require.NoError(t, err, "want no error inserting app stats") diff --git a/coderd/notifications/dispatch/webhook.go b/coderd/notifications/dispatch/webhook.go index 65d6ed030af98..7265602e5332d 100644 --- a/coderd/notifications/dispatch/webhook.go +++ b/coderd/notifications/dispatch/webhook.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "text/template" @@ -39,7 +40,22 @@ type WebhookPayload struct { } func NewWebhookHandler(cfg codersdk.NotificationsWebhookConfig, log slog.Logger) *WebhookHandler { - return &WebhookHandler{cfg: cfg, log: log, cl: &http.Client{}} + // Create a new transport in favor of reusing the default, since other http clients may interfere. + // http.Transport maintains its own connection pool, and we want to avoid cross-contamination. + var rt http.RoundTripper + + def := http.DefaultTransport + t, ok := def.(*http.Transport) + if !ok { + // The API has changed (very unlikely), so let's use the default transport (previous behavior) and log. + log.Warn(context.Background(), "failed to clone default HTTP transport, unexpected type", slog.F("type", fmt.Sprintf("%T", def))) + rt = def + } else { + // Clone the transport's exported fields, but not its connection pool. + rt = t.Clone() + } + + return &WebhookHandler{cfg: cfg, log: log, cl: &http.Client{Transport: rt}} } func (w *WebhookHandler) Dispatcher(payload types.MessagePayload, titleMarkdown, bodyMarkdown string, _ template.FuncMap) (DeliveryFunc, error) { diff --git a/coderd/notifications/dispatch/webhook_test.go b/coderd/notifications/dispatch/webhook_test.go index 9f898a6fd6efd..35443b9fbb840 100644 --- a/coderd/notifications/dispatch/webhook_test.go +++ b/coderd/notifications/dispatch/webhook_test.go @@ -131,7 +131,7 @@ func TestWebhook(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tc.serverFn(msgID, w, r) })) - defer server.Close() + t.Cleanup(server.Close) endpoint, err = url.Parse(server.URL) require.NoError(t, err) diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index e9c309f0a09d3..30af0c88b852c 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -31,7 +31,6 @@ func TestBufferedUpdates(t *testing.T) { // setup - // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) store, ps := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -108,7 +107,6 @@ func TestBuildPayload(t *testing.T) { // SETUP - // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) store, _ := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -166,7 +164,6 @@ func TestStopBeforeRun(t *testing.T) { // SETUP - // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) store, ps := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -187,7 +184,6 @@ func TestRunStopRace(t *testing.T) { // SETUP - // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitMedium)) store, ps := dbtestutil.NewDB(t) logger := testutil.Logger(t) diff --git a/coderd/notifications/metrics_test.go b/coderd/notifications/metrics_test.go index 5517f86061cc0..6ba6635a50c4c 100644 --- a/coderd/notifications/metrics_test.go +++ b/coderd/notifications/metrics_test.go @@ -37,7 +37,6 @@ func TestMetrics(t *testing.T) { t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") } - // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -226,7 +225,6 @@ func TestPendingUpdatesMetric(t *testing.T) { t.Parallel() // SETUP - // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -320,7 +318,6 @@ func TestInflightDispatchesMetric(t *testing.T) { t.Parallel() // SETUP - // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -400,7 +397,6 @@ func TestCustomMethodMetricCollection(t *testing.T) { t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") } - // nolint:gocritic // Unit test. ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index e213a62df9996..f5e72a8327d7e 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -70,7 +70,6 @@ func TestBasicNotificationRoundtrip(t *testing.T) { t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") } - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -137,7 +136,6 @@ func TestSMTPDispatch(t *testing.T) { // SETUP - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -203,7 +201,6 @@ func TestWebhookDispatch(t *testing.T) { // SETUP - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -287,7 +284,6 @@ func TestBackpressure(t *testing.T) { store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitShort)) const method = database.NotificationMethodWebhook @@ -416,7 +412,6 @@ func TestRetries(t *testing.T) { } const maxAttempts = 3 - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -516,7 +511,6 @@ func TestExpiredLeaseIsRequeued(t *testing.T) { t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") } - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -536,7 +530,6 @@ func TestExpiredLeaseIsRequeued(t *testing.T) { noopInterceptor := newNoopStoreSyncer(store) - // nolint:gocritic // Unit test. mgrCtx, cancelManagerCtx := context.WithCancel(dbauthz.AsNotifier(context.Background())) t.Cleanup(cancelManagerCtx) @@ -645,7 +638,6 @@ func TestNotifierPaused(t *testing.T) { // Setup. - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -1323,7 +1315,6 @@ func TestNotificationTemplates_Golden(t *testing.T) { return &db, &api.Logger, &user }() - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) _, pubsub := dbtestutil.NewDB(t) @@ -1406,13 +1397,11 @@ func TestNotificationTemplates_Golden(t *testing.T) { // as appearance changes are enterprise features and we do not want to mix those // can't use the api if tc.appName != "" { - // nolint:gocritic // Unit test. err = (*db).UpsertApplicationName(dbauthz.AsSystemRestricted(ctx), "Custom Application") require.NoError(t, err) } if tc.logoURL != "" { - // nolint:gocritic // Unit test. err = (*db).UpsertLogoURL(dbauthz.AsSystemRestricted(ctx), "https://custom.application/logo.png") require.NoError(t, err) } @@ -1510,7 +1499,6 @@ func TestNotificationTemplates_Golden(t *testing.T) { }() _, pubsub := dbtestutil.NewDB(t) - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) // Spin up the mock webhook server @@ -1650,7 +1638,6 @@ func TestDisabledByDefaultBeforeEnqueue(t *testing.T) { t.Skip("This test requires postgres; it is testing business-logic implemented in the database") } - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, _ := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -1676,7 +1663,6 @@ func TestDisabledBeforeEnqueue(t *testing.T) { t.Skip("This test requires postgres; it is testing business-logic implemented in the database") } - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, _ := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -1712,7 +1698,6 @@ func TestDisabledAfterEnqueue(t *testing.T) { t.Skip("This test requires postgres; it is testing business-logic implemented in the database") } - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -1769,7 +1754,6 @@ func TestCustomNotificationMethod(t *testing.T) { t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") } - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -1873,7 +1857,6 @@ func TestNotificationsTemplates(t *testing.T) { t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") } - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) api := coderdtest.New(t, createOpts(t)) @@ -1910,7 +1893,6 @@ func TestNotificationDuplicates(t *testing.T) { t.Skip("This test requires postgres; it is testing the dedupe hash trigger in the database") } - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -2007,7 +1989,6 @@ func TestNotificationTargetMatrix(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -2051,7 +2032,6 @@ func TestNotificationOneTimePasswordDeliveryTargets(t *testing.T) { t.Run("Inbox", func(t *testing.T) { t.Parallel() - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, _ := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -2076,7 +2056,6 @@ func TestNotificationOneTimePasswordDeliveryTargets(t *testing.T) { t.Run("SMTP", func(t *testing.T) { t.Parallel() - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, _ := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -2100,7 +2079,6 @@ func TestNotificationOneTimePasswordDeliveryTargets(t *testing.T) { t.Run("Webhook", func(t *testing.T) { t.Parallel() - // nolint:gocritic // Unit test. ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, _ := dbtestutil.NewDB(t) logger := testutil.Logger(t) diff --git a/coderd/notifications/reports/generator_internal_test.go b/coderd/notifications/reports/generator_internal_test.go index f61064c4e0b23..6dcff173118cb 100644 --- a/coderd/notifications/reports/generator_internal_test.go +++ b/coderd/notifications/reports/generator_internal_test.go @@ -505,7 +505,6 @@ func TestReportFailedWorkspaceBuilds(t *testing.T) { func setup(t *testing.T) (context.Context, slog.Logger, database.Store, pubsub.Pubsub, *notificationstest.FakeEnqueuer, *quartz.Mock) { t.Helper() - // nolint:gocritic // reportFailedWorkspaceBuilds is called by system. ctx := dbauthz.AsSystemRestricted(context.Background()) logger := slogtest.Make(t, &slogtest.Options{}) db, ps := dbtestutil.NewDB(t) diff --git a/coderd/prebuilds/parameters.go b/coderd/prebuilds/parameters.go new file mode 100644 index 0000000000000..63a1a7b78bfa7 --- /dev/null +++ b/coderd/prebuilds/parameters.go @@ -0,0 +1,42 @@ +package prebuilds + +import ( + "context" + "database/sql" + "errors" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" +) + +// FindMatchingPresetID finds a preset ID that matches the provided parameters. +// It returns the preset ID if a match is found, or uuid.Nil if no match is found. +// The function performs a bidirectional comparison to ensure all parameters match exactly. +func FindMatchingPresetID( + ctx context.Context, + store database.Store, + templateVersionID uuid.UUID, + parameterNames []string, + parameterValues []string, +) (uuid.UUID, error) { + if len(parameterNames) != len(parameterValues) { + return uuid.Nil, xerrors.New("parameter names and values must have the same length") + } + + result, err := store.FindMatchingPresetID(ctx, database.FindMatchingPresetIDParams{ + TemplateVersionID: templateVersionID, + ParameterNames: parameterNames, + ParameterValues: parameterValues, + }) + if err != nil { + // Handle the case where no matching preset is found (no rows returned) + if errors.Is(err, sql.ErrNoRows) { + return uuid.Nil, nil + } + return uuid.Nil, xerrors.Errorf("find matching preset ID: %w", err) + } + + return result, nil +} diff --git a/coderd/prebuilds/parameters_test.go b/coderd/prebuilds/parameters_test.go new file mode 100644 index 0000000000000..e9366bb1da02b --- /dev/null +++ b/coderd/prebuilds/parameters_test.go @@ -0,0 +1,198 @@ +package prebuilds_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/prebuilds" + "github.com/coder/coder/v2/testutil" +) + +func TestFindMatchingPresetID(t *testing.T) { + t.Parallel() + + presetIDs := []uuid.UUID{ + uuid.New(), + uuid.New(), + } + // Give each preset a meaningful name in alphabetical order + presetNames := map[uuid.UUID]string{ + presetIDs[0]: "development", + presetIDs[1]: "production", + } + tests := []struct { + name string + parameterNames []string + parameterValues []string + presetParameters []database.TemplateVersionPresetParameter + expectedPresetID uuid.UUID + expectError bool + errorContains string + }{ + { + name: "exact match", + parameterNames: []string{"region", "instance_type"}, + parameterValues: []string{"us-west-2", "t3.medium"}, + presetParameters: []database.TemplateVersionPresetParameter{ + {TemplateVersionPresetID: presetIDs[0], Name: "region", Value: "us-west-2"}, + {TemplateVersionPresetID: presetIDs[0], Name: "instance_type", Value: "t3.medium"}, + // antagonist: + {TemplateVersionPresetID: presetIDs[1], Name: "region", Value: "us-west-2"}, + {TemplateVersionPresetID: presetIDs[1], Name: "instance_type", Value: "t3.large"}, + }, + expectedPresetID: presetIDs[0], + expectError: false, + }, + { + name: "no match - different values", + parameterNames: []string{"region", "instance_type"}, + parameterValues: []string{"us-east-1", "t3.medium"}, + presetParameters: []database.TemplateVersionPresetParameter{ + {TemplateVersionPresetID: presetIDs[0], Name: "region", Value: "us-west-2"}, + {TemplateVersionPresetID: presetIDs[0], Name: "instance_type", Value: "t3.medium"}, + // antagonist: + {TemplateVersionPresetID: presetIDs[1], Name: "region", Value: "us-west-2"}, + {TemplateVersionPresetID: presetIDs[1], Name: "instance_type", Value: "t3.large"}, + }, + expectedPresetID: uuid.Nil, + expectError: false, + }, + { + name: "no match - fewer provided parameters", + parameterNames: []string{"region"}, + parameterValues: []string{"us-west-2"}, + presetParameters: []database.TemplateVersionPresetParameter{ + {TemplateVersionPresetID: presetIDs[0], Name: "region", Value: "us-west-2"}, + {TemplateVersionPresetID: presetIDs[0], Name: "instance_type", Value: "t3.medium"}, + // antagonist: + {TemplateVersionPresetID: presetIDs[1], Name: "region", Value: "us-west-2"}, + {TemplateVersionPresetID: presetIDs[1], Name: "instance_type", Value: "t3.large"}, + }, + expectedPresetID: uuid.Nil, + expectError: false, + }, + { + name: "subset match - extra provided parameter", + parameterNames: []string{"region", "instance_type", "extra_param"}, + parameterValues: []string{"us-west-2", "t3.medium", "extra_value"}, + presetParameters: []database.TemplateVersionPresetParameter{ + {TemplateVersionPresetID: presetIDs[0], Name: "region", Value: "us-west-2"}, + {TemplateVersionPresetID: presetIDs[0], Name: "instance_type", Value: "t3.medium"}, + // antagonist: + {TemplateVersionPresetID: presetIDs[1], Name: "region", Value: "us-west-2"}, + {TemplateVersionPresetID: presetIDs[1], Name: "instance_type", Value: "t3.large"}, + }, + expectedPresetID: presetIDs[0], // Should match because all preset parameters are present + expectError: false, + }, + { + name: "mismatched parameter names vs values", + parameterNames: []string{"region", "instance_type"}, + parameterValues: []string{"us-west-2"}, + presetParameters: []database.TemplateVersionPresetParameter{}, + expectedPresetID: uuid.Nil, + expectError: true, + errorContains: "parameter names and values must have the same length", + }, + { + name: "multiple presets - match first", + parameterNames: []string{"region", "instance_type"}, + parameterValues: []string{"us-west-2", "t3.medium"}, + presetParameters: []database.TemplateVersionPresetParameter{ + {TemplateVersionPresetID: presetIDs[0], Name: "region", Value: "us-west-2"}, + {TemplateVersionPresetID: presetIDs[0], Name: "instance_type", Value: "t3.medium"}, + {TemplateVersionPresetID: presetIDs[1], Name: "region", Value: "us-east-1"}, + {TemplateVersionPresetID: presetIDs[1], Name: "instance_type", Value: "t3.large"}, + }, + expectedPresetID: presetIDs[0], + expectError: false, + }, + { + name: "largest subset match", + parameterNames: []string{"region", "instance_type", "storage_size"}, + parameterValues: []string{"us-west-2", "t3.medium", "100gb"}, + presetParameters: []database.TemplateVersionPresetParameter{ + {TemplateVersionPresetID: presetIDs[0], Name: "region", Value: "us-west-2"}, + {TemplateVersionPresetID: presetIDs[0], Name: "instance_type", Value: "t3.medium"}, + {TemplateVersionPresetID: presetIDs[1], Name: "region", Value: "us-west-2"}, + }, + expectedPresetID: presetIDs[0], // Should match the larger subset (2 params vs 1 param) + expectError: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + JobID: uuid.New(), + }) + + // Group parameters by preset ID and create presets + presetMap := make(map[uuid.UUID][]database.TemplateVersionPresetParameter) + for _, param := range tt.presetParameters { + presetMap[param.TemplateVersionPresetID] = append(presetMap[param.TemplateVersionPresetID], param) + } + + // Create presets and insert their parameters + for presetID, params := range presetMap { + // Create the preset + _, err := db.InsertPreset(ctx, database.InsertPresetParams{ + ID: presetID, + TemplateVersionID: templateVersion.ID, + Name: presetNames[presetID], + CreatedAt: dbtestutil.NowInDefaultTimezone(), + }) + require.NoError(t, err) + + // Insert parameters for this preset + names := make([]string, len(params)) + values := make([]string, len(params)) + for i, param := range params { + names[i] = param.Name + values[i] = param.Value + } + + _, err = db.InsertPresetParameters(ctx, database.InsertPresetParametersParams{ + TemplateVersionPresetID: presetID, + Names: names, + Values: values, + }) + require.NoError(t, err) + } + + result, err := prebuilds.FindMatchingPresetID( + ctx, + db, + templateVersion.ID, + tt.parameterNames, + tt.parameterValues, + ) + + // Assert results + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedPresetID, result) + } + }) + } +} diff --git a/coderd/prometheusmetrics/insights/metricscollector_test.go b/coderd/prometheusmetrics/insights/metricscollector_test.go index 9382fa5013525..5c18ec6d1a60f 100644 --- a/coderd/prometheusmetrics/insights/metricscollector_test.go +++ b/coderd/prometheusmetrics/insights/metricscollector_test.go @@ -128,7 +128,6 @@ func TestCollectInsights(t *testing.T) { AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize, }) refTime := time.Now().Add(-3 * time.Minute).Truncate(time.Minute) - //nolint:gocritic // This is a test. err = reporter.ReportAppStats(dbauthz.AsSystemRestricted(context.Background()), []workspaceapps.StatsReport{ { UserID: user.ID, diff --git a/coderd/prometheusmetrics/prometheusmetrics.go b/coderd/prometheusmetrics/prometheusmetrics.go index cda274145e159..6ea8615f3779a 100644 --- a/coderd/prometheusmetrics/prometheusmetrics.go +++ b/coderd/prometheusmetrics/prometheusmetrics.go @@ -328,29 +328,24 @@ func Agents(ctx context.Context, logger slog.Logger, registerer prometheus.Regis templateVersionName = "unknown" } - user, err := db.GetUserByID(ctx, workspace.OwnerID) - if err != nil { - logger.Error(ctx, "can't get user from the database", slog.F("user_id", workspace.OwnerID), slog.Error(err)) - agentsGauge.WithLabelValues(VectorOperationAdd, 0, user.Username, workspace.Name, templateName, templateVersionName) - continue - } + // username := agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID) if err != nil { logger.Error(ctx, "can't get workspace agents", slog.F("workspace_id", workspace.ID), slog.Error(err)) - agentsGauge.WithLabelValues(VectorOperationAdd, 0, user.Username, workspace.Name, templateName, templateVersionName) + agentsGauge.WithLabelValues(VectorOperationAdd, 0, workspace.OwnerUsername, workspace.Name, templateName, templateVersionName) continue } if len(agents) == 0 { logger.Debug(ctx, "workspace agents are unavailable", slog.F("workspace_id", workspace.ID)) - agentsGauge.WithLabelValues(VectorOperationAdd, 0, user.Username, workspace.Name, templateName, templateVersionName) + agentsGauge.WithLabelValues(VectorOperationAdd, 0, workspace.OwnerUsername, workspace.Name, templateName, templateVersionName) continue } for _, agent := range agents { // Collect information about agents - agentsGauge.WithLabelValues(VectorOperationAdd, 1, user.Username, workspace.Name, templateName, templateVersionName) + agentsGauge.WithLabelValues(VectorOperationAdd, 1, workspace.OwnerUsername, workspace.Name, templateName, templateVersionName) connectionStatus := agent.Status(agentInactiveDisconnectTimeout) node := (*coordinator.Load()).Node(agent.ID) @@ -360,7 +355,7 @@ func Agents(ctx context.Context, logger slog.Logger, registerer prometheus.Regis tailnetNode = node.ID.String() } - agentsConnectionsGauge.WithLabelValues(VectorOperationSet, 1, agent.Name, user.Username, workspace.Name, string(connectionStatus.Status), string(agent.LifecycleState), tailnetNode) + agentsConnectionsGauge.WithLabelValues(VectorOperationSet, 1, agent.Name, workspace.OwnerUsername, workspace.Name, string(connectionStatus.Status), string(agent.LifecycleState), tailnetNode) if node == nil { logger.Debug(ctx, "can't read in-memory node for agent", slog.F("agent_id", agent.ID)) @@ -385,7 +380,7 @@ func Agents(ctx context.Context, logger slog.Logger, registerer prometheus.Regis } } - agentsConnectionLatenciesGauge.WithLabelValues(VectorOperationSet, latency, agent.Name, user.Username, workspace.Name, region.RegionName, fmt.Sprintf("%v", node.PreferredDERP == regionID)) + agentsConnectionLatenciesGauge.WithLabelValues(VectorOperationSet, latency, agent.Name, workspace.OwnerUsername, workspace.Name, region.RegionName, fmt.Sprintf("%v", node.PreferredDERP == regionID)) } } @@ -397,7 +392,7 @@ func Agents(ctx context.Context, logger slog.Logger, registerer prometheus.Regis } for _, app := range apps { - agentsAppsGauge.WithLabelValues(VectorOperationAdd, 1, agent.Name, user.Username, workspace.Name, app.DisplayName, string(app.Health)) + agentsAppsGauge.WithLabelValues(VectorOperationAdd, 1, agent.Name, workspace.OwnerUsername, workspace.Name, app.DisplayName, string(app.Health)) } } } diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 332ae3b352e0a..67a40b88f69e9 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -6,6 +6,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/sdk2db" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/provisionerdserver" @@ -45,6 +46,9 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { limit := p.PositiveInt32(qp, 50, "limit") ids := p.UUIDs(qp, nil, "ids") tags := p.JSONStringMap(qp, database.StringMap{}, "tags") + includeOffline := p.NullableBoolean(qp, sql.NullBool{}, "offline") + statuses := p.ProvisionerDaemonStatuses(qp, []codersdk.ProvisionerDaemonStatus{}, "status") + maxAge := p.Duration(qp, 0, "max_age") p.ErrorExcessParams(qp) if len(p.Errors) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -54,12 +58,17 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { return } + dbStatuses := sdk2db.ProvisionerDaemonStatuses(statuses) + daemons, err := api.Database.GetProvisionerDaemonsWithStatusByOrganization( ctx, database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, StaleIntervalMS: provisionerdserver.StaleInterval.Milliseconds(), Limit: sql.NullInt32{Int32: limit, Valid: limit > 0}, + Offline: includeOffline, + Statuses: dbStatuses, + MaxAgeMs: sql.NullInt64{Int64: maxAge.Milliseconds(), Valid: maxAge > 0}, IDs: ids, Tags: tags, }, diff --git a/coderd/provisionerdaemons_test.go b/coderd/provisionerdaemons_test.go index 249da9d6bc922..8bbaca551a151 100644 --- a/coderd/provisionerdaemons_test.go +++ b/coderd/provisionerdaemons_test.go @@ -146,7 +146,9 @@ func TestProvisionerDaemons(t *testing.T) { t.Run("Default limit", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, nil) + daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{ + Offline: true, + }) require.NoError(t, err) require.Len(t, daemons, 50) }) @@ -155,7 +157,8 @@ func TestProvisionerDaemons(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{ - IDs: []uuid.UUID{pd1.ID, pd2.ID}, + IDs: []uuid.UUID{pd1.ID, pd2.ID}, + Offline: true, }) require.NoError(t, err) require.Len(t, daemons, 2) @@ -167,7 +170,8 @@ func TestProvisionerDaemons(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{ - Tags: map[string]string{"count": "1"}, + Tags: map[string]string{"count": "1"}, + Offline: true, }) require.NoError(t, err) require.Len(t, daemons, 1) @@ -209,7 +213,8 @@ func TestProvisionerDaemons(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{ - IDs: []uuid.UUID{pd2.ID}, + IDs: []uuid.UUID{pd2.ID}, + Offline: true, }) require.NoError(t, err) require.Len(t, daemons, 1) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 83ca7669370ec..d7bc29aca3044 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -28,13 +28,6 @@ import ( protobuf "google.golang.org/protobuf/proto" "cdr.dev/slog" - - "github.com/coder/coder/v2/coderd/util/slice" - - "github.com/coder/coder/v2/codersdk/drpcsdk" - - "github.com/coder/quartz" - "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" @@ -48,13 +41,18 @@ import ( "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/coderd/usage" + "github.com/coder/coder/v2/coderd/usage/usagetypes" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/codersdk/drpcsdk" "github.com/coder/coder/v2/provisioner" "github.com/coder/coder/v2/provisionerd/proto" "github.com/coder/coder/v2/provisionersdk" sdkproto "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/quartz" ) const ( @@ -121,6 +119,7 @@ type server struct { DeploymentValues *codersdk.DeploymentValues NotificationsEnqueuer notifications.Enqueuer PrebuildsOrchestrator *atomic.Pointer[prebuilds.ReconciliationOrchestrator] + UsageInserter *atomic.Pointer[usage.Inserter] OIDCConfig promoauth.OAuth2Config @@ -174,6 +173,7 @@ func NewServer( auditor *atomic.Pointer[audit.Auditor], templateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore], userQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore], + usageInserter *atomic.Pointer[usage.Inserter], deploymentValues *codersdk.DeploymentValues, options Options, enqueuer notifications.Enqueuer, @@ -195,6 +195,9 @@ func NewServer( if userQuietHoursScheduleStore == nil { return nil, xerrors.New("userQuietHoursScheduleStore is nil") } + if usageInserter == nil { + return nil, xerrors.New("usageCollector is nil") + } if deploymentValues == nil { return nil, xerrors.New("deploymentValues is nil") } @@ -244,6 +247,7 @@ func NewServer( heartbeatInterval: options.HeartbeatInterval, heartbeatFn: options.HeartbeatFn, PrebuildsOrchestrator: prebuildsOrchestrator, + UsageInserter: usageInserter, } if s.heartbeatFn == nil { @@ -1727,16 +1731,20 @@ func (s *server) completeTemplateImportJob(ctx context.Context, job database.Pro if err != nil { return xerrors.Errorf("update template version external auth providers: %w", err) } - err = db.UpdateTemplateVersionAITaskByJobID(ctx, database.UpdateTemplateVersionAITaskByJobIDParams{ + err = db.UpdateTemplateVersionFlagsByJobID(ctx, database.UpdateTemplateVersionFlagsByJobIDParams{ JobID: jobID, HasAITask: sql.NullBool{ Bool: jobType.TemplateImport.HasAiTasks, Valid: true, }, + HasExternalAgent: sql.NullBool{ + Bool: jobType.TemplateImport.HasExternalAgents, + Valid: true, + }, UpdatedAt: now, }) if err != nil { - return xerrors.Errorf("update template version external auth providers: %w", err) + return xerrors.Errorf("update template version ai task and external agent: %w", err) } // Process terraform values @@ -2026,18 +2034,44 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro sidebarAppID = uuid.NullUUID{} } + if hasAITask && workspaceBuild.Transition == database.WorkspaceTransitionStart { + // Insert usage event for managed agents. + usageInserter := s.UsageInserter.Load() + if usageInserter != nil { + event := usagetypes.DCManagedAgentsV1{ + Count: 1, + } + err = (*usageInserter).InsertDiscreteUsageEvent(ctx, db, event) + if err != nil { + return xerrors.Errorf("insert %q event: %w", event.EventType(), err) + } + } + } + + hasExternalAgent := false + for _, resource := range jobType.WorkspaceBuild.Resources { + if resource.Type == "coder_external_agent" { + hasExternalAgent = true + break + } + } + // Regardless of whether there is an AI task or not, update the field to indicate one way or the other since it // always defaults to nil. ONLY if has_ai_task=true MUST ai_task_sidebar_app_id be set. - if err := db.UpdateWorkspaceBuildAITaskByID(ctx, database.UpdateWorkspaceBuildAITaskByIDParams{ + if err := db.UpdateWorkspaceBuildFlagsByID(ctx, database.UpdateWorkspaceBuildFlagsByIDParams{ ID: workspaceBuild.ID, HasAITask: sql.NullBool{ Bool: hasAITask, Valid: true, }, + HasExternalAgent: sql.NullBool{ + Bool: hasExternalAgent, + Valid: true, + }, SidebarAppID: sidebarAppID, UpdatedAt: now, }); err != nil { - return xerrors.Errorf("update workspace build ai tasks flag: %w", err) + return xerrors.Errorf("update workspace build ai tasks and external agent flag: %w", err) } // Insert timings inside the transaction now diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 7fb351bf0c0da..8baa7c99c30b9 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace" @@ -30,7 +31,9 @@ import ( "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -44,6 +47,8 @@ import ( "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule/cron" "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/coderd/usage" + "github.com/coder/coder/v2/coderd/usage/usagetypes" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -67,6 +72,13 @@ func testUserQuietHoursScheduleStore() *atomic.Pointer[schedule.UserQuietHoursSc return ptr } +func testUsageInserter() *atomic.Pointer[usage.Inserter] { + ptr := &atomic.Pointer[usage.Inserter]{} + inserter := usage.NewAGPLInserter() + ptr.Store(&inserter) + return ptr +} + func TestAcquireJob_LongPoll(t *testing.T) { t.Parallel() //nolint:dogsled @@ -681,12 +693,20 @@ func TestUpdateJob(t *testing.T) { t.Run("NotRunning", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, nil) + user := dbgen.User(t, db, database.User{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + OrganizationID: pd.OrganizationID, + JobID: uuid.New(), + }) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: uuid.New(), - Provisioner: database.ProvisionerTypeEcho, - StorageMethod: database.ProvisionerStorageMethodFile, - Type: database.ProvisionerJobTypeTemplateVersionDryRun, - Input: json.RawMessage("{}"), + ID: version.JobID, + Provisioner: database.ProvisionerTypeEcho, + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeTemplateVersionDryRun, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: version.ID, + })), OrganizationID: pd.OrganizationID, Tags: pd.Tags, }) @@ -700,12 +720,20 @@ func TestUpdateJob(t *testing.T) { t.Run("NotOwner", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, nil) + user := dbgen.User(t, db, database.User{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + OrganizationID: pd.OrganizationID, + JobID: uuid.New(), + }) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: uuid.New(), - Provisioner: database.ProvisionerTypeEcho, - StorageMethod: database.ProvisionerStorageMethodFile, - Type: database.ProvisionerJobTypeTemplateVersionDryRun, - Input: json.RawMessage("{}"), + ID: version.JobID, + Provisioner: database.ProvisionerTypeEcho, + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeTemplateVersionDryRun, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: version.ID, + })), OrganizationID: pd.OrganizationID, Tags: pd.Tags, }) @@ -730,38 +758,57 @@ func TestUpdateJob(t *testing.T) { require.ErrorContains(t, err, "you don't own this job") }) - setupJob := func(t *testing.T, db database.Store, srvID, orgID uuid.UUID, tags database.StringMap) uuid.UUID { - job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: uuid.New(), - OrganizationID: orgID, - Provisioner: database.ProvisionerTypeEcho, - Type: database.ProvisionerJobTypeTemplateVersionImport, - StorageMethod: database.ProvisionerStorageMethodFile, - Input: json.RawMessage("{}"), - Tags: tags, - }) - require.NoError(t, err) - _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ - WorkerID: uuid.NullUUID{ - UUID: srvID, - Valid: true, - }, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, - StartedAt: sql.NullTime{ - Time: dbtime.Now(), - Valid: true, - }, - OrganizationID: orgID, - ProvisionerTags: must(json.Marshal(job.Tags)), - }) + setupJob := func(t *testing.T, db database.Store, srvID, orgID uuid.UUID, tags database.StringMap) (templateVersionID, jobID uuid.UUID) { + templateVersionID = uuid.New() + jobID = uuid.New() + err := db.InTx(func(db database.Store) error { + user := dbgen.User(t, db, database.User{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + ID: templateVersionID, + CreatedBy: user.ID, + OrganizationID: orgID, + JobID: jobID, + }) + job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: version.JobID, + OrganizationID: orgID, + Provisioner: database.ProvisionerTypeEcho, + Type: database.ProvisionerJobTypeTemplateVersionImport, + StorageMethod: database.ProvisionerStorageMethodFile, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: version.ID, + })), + Tags: tags, + }) + if err != nil { + return xerrors.Errorf("insert provisioner job: %w", err) + } + _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + WorkerID: uuid.NullUUID{ + UUID: srvID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + }, + OrganizationID: orgID, + ProvisionerTags: must(json.Marshal(job.Tags)), + }) + if err != nil { + return xerrors.Errorf("acquire provisioner job: %w", err) + } + return nil + }, nil) require.NoError(t, err) - return job.ID + return templateVersionID, jobID } t.Run("Success", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) + _, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) _, err := srv.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.String(), }) @@ -771,7 +818,7 @@ func TestUpdateJob(t *testing.T) { t.Run("Logs", func(t *testing.T) { t.Parallel() srv, db, ps, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) + _, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) published := make(chan struct{}) @@ -796,23 +843,14 @@ func TestUpdateJob(t *testing.T) { t.Run("Readme", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) - versionID := uuid.New() - user := dbgen.User(t, db, database.User{}) - err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ - ID: versionID, - CreatedBy: user.ID, - OrganizationID: pd.OrganizationID, - JobID: job, - }) - require.NoError(t, err) - _, err = srv.UpdateJob(ctx, &proto.UpdateJobRequest{ + templateVersionID, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) + _, err := srv.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.String(), Readme: []byte("# hello world"), }) require.NoError(t, err) - version, err := db.GetTemplateVersionByID(ctx, versionID) + version, err := db.GetTemplateVersionByID(ctx, templateVersionID) require.NoError(t, err) require.Equal(t, "# hello world", version.Readme) }) @@ -825,16 +863,7 @@ func TestUpdateJob(t *testing.T) { defer cancel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) - versionID := uuid.New() - user := dbgen.User(t, db, database.User{}) - err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ - ID: versionID, - CreatedBy: user.ID, - JobID: job, - OrganizationID: pd.OrganizationID, - }) - require.NoError(t, err) + templateVersionID, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) firstTemplateVariable := &sdkproto.TemplateVariable{ Name: "first", Type: "string", @@ -863,7 +892,7 @@ func TestUpdateJob(t *testing.T) { require.NoError(t, err) require.Len(t, response.VariableValues, 2) - templateVariables, err := db.GetTemplateVersionVariables(ctx, versionID) + templateVariables, err := db.GetTemplateVersionVariables(ctx, templateVersionID) require.NoError(t, err) require.Len(t, templateVariables, 2) require.Equal(t, templateVariables[0].Value, firstTemplateVariable.DefaultValue) @@ -875,16 +904,7 @@ func TestUpdateJob(t *testing.T) { defer cancel() srv, db, _, pd := setup(t, false, &overrides{}) - user := dbgen.User(t, db, database.User{}) - job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) - versionID := uuid.New() - err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ - CreatedBy: user.ID, - ID: versionID, - JobID: job, - OrganizationID: pd.OrganizationID, - }) - require.NoError(t, err) + templateVersionID, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) firstTemplateVariable := &sdkproto.TemplateVariable{ Name: "first", Type: "string", @@ -909,7 +929,7 @@ func TestUpdateJob(t *testing.T) { // Even though there is an error returned, variables are stored in the database // to show the schema in the site UI. - templateVariables, err := db.GetTemplateVersionVariables(ctx, versionID) + templateVariables, err := db.GetTemplateVersionVariables(ctx, templateVersionID) require.NoError(t, err) require.Len(t, templateVariables, 2) require.Equal(t, templateVariables[0].Value, firstTemplateVariable.DefaultValue) @@ -923,18 +943,9 @@ func TestUpdateJob(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) - versionID := uuid.New() - user := dbgen.User(t, db, database.User{}) - err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ - ID: versionID, - CreatedBy: user.ID, - JobID: job, - OrganizationID: pd.OrganizationID, - }) - require.NoError(t, err) - _, err = srv.UpdateJob(ctx, &proto.UpdateJobRequest{ + srv, db, _, pd := setup(t, false, nil) + templateVersionID, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) + _, err := srv.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.String(), WorkspaceTags: map[string]string{ "bird": "tweety", @@ -943,7 +954,7 @@ func TestUpdateJob(t *testing.T) { }) require.NoError(t, err) - workspaceTags, err := db.GetTemplateVersionWorkspaceTags(ctx, versionID) + workspaceTags, err := db.GetTemplateVersionWorkspaceTags(ctx, templateVersionID) require.NoError(t, err) require.Len(t, workspaceTags, 2) require.Equal(t, workspaceTags[0].Key, "bird") @@ -955,7 +966,7 @@ func TestUpdateJob(t *testing.T) { t.Run("LogSizeLimit", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) + _, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) // Create a log message that exceeds the 1MB limit largeOutput := strings.Repeat("a", 1048577) // 1MB + 1 byte @@ -979,7 +990,7 @@ func TestUpdateJob(t *testing.T) { t.Run("IncrementalLogSizeOverflow", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) + _, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) // Send logs that together exceed the limit mediumOutput := strings.Repeat("b", 524289) // Half a MB + 1 byte @@ -1020,7 +1031,7 @@ func TestUpdateJob(t *testing.T) { t.Run("LogSizeTracking", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) + _, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) logOutput := "test log message" expectedSize := int32(len(logOutput)) // #nosec G115 - Log length is 16. @@ -1045,7 +1056,7 @@ func TestUpdateJob(t *testing.T) { t.Run("LogOverflowStopsProcessing", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) + _, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) // First: trigger overflow largeOutput := strings.Repeat("a", 1048577) // 1MB + 1 byte @@ -1108,12 +1119,20 @@ func TestFailJob(t *testing.T) { t.Run("NotOwner", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, nil) + user := dbgen.User(t, db, database.User{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + OrganizationID: pd.OrganizationID, + JobID: uuid.New(), + }) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: uuid.New(), - Provisioner: database.ProvisionerTypeEcho, - StorageMethod: database.ProvisionerStorageMethodFile, - Type: database.ProvisionerJobTypeTemplateVersionImport, - Input: json.RawMessage("{}"), + ID: version.JobID, + Provisioner: database.ProvisionerTypeEcho, + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeTemplateVersionImport, + Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{ + TemplateVersionID: version.ID, + })), OrganizationID: pd.OrganizationID, Tags: pd.Tags, }) @@ -1139,13 +1158,21 @@ func TestFailJob(t *testing.T) { }) t.Run("AlreadyCompleted", func(t *testing.T) { t.Parallel() - srv, db, _, pd := setup(t, false, &overrides{}) + srv, db, _, pd := setup(t, false, nil) + user := dbgen.User(t, db, database.User{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + OrganizationID: pd.OrganizationID, + JobID: uuid.New(), + }) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: uuid.New(), - Provisioner: database.ProvisionerTypeEcho, - Type: database.ProvisionerJobTypeTemplateVersionImport, - StorageMethod: database.ProvisionerStorageMethodFile, - Input: json.RawMessage("{}"), + ID: version.JobID, + Provisioner: database.ProvisionerTypeEcho, + Type: database.ProvisionerJobTypeTemplateVersionImport, + StorageMethod: database.ProvisionerStorageMethodFile, + Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{ + TemplateVersionID: version.ID, + })), OrganizationID: pd.OrganizationID, Tags: pd.Tags, }) @@ -1310,14 +1337,22 @@ func TestCompleteJob(t *testing.T) { t.Run("NotOwner", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, nil) + user := dbgen.User(t, db, database.User{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + OrganizationID: pd.OrganizationID, + JobID: uuid.New(), + }) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: uuid.New(), + ID: version.JobID, Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, - Type: database.ProvisionerJobTypeWorkspaceBuild, + Type: database.ProvisionerJobTypeTemplateVersionImport, OrganizationID: pd.OrganizationID, - Input: json.RawMessage("{}"), - Tags: pd.Tags, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: version.ID, + })), + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1361,10 +1396,12 @@ func TestCompleteJob(t *testing.T) { OrganizationID: pd.OrganizationID, ID: jobID, Provisioner: database.ProvisionerTypeEcho, - Input: []byte(`{"template_version_id": "` + versionID.String() + `"}`), - StorageMethod: database.ProvisionerStorageMethodFile, - Type: database.ProvisionerJobTypeTemplateVersionImport, - Tags: pd.Tags, + Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{ + TemplateVersionID: versionID, + })), + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeTemplateVersionImport, + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1410,14 +1447,22 @@ func TestCompleteJob(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + OrganizationID: org.ID, + JobID: uuid.New(), + }) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ ID: uuid.New(), OrganizationID: org.ID, Provisioner: database.ProvisionerTypeEcho, Type: database.ProvisionerJobTypeTemplateVersionDryRun, StorageMethod: database.ProvisionerStorageMethodFile, - Input: json.RawMessage("{}"), - Tags: pd.Tags, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: version.ID, + })), + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1628,25 +1673,49 @@ func TestCompleteJob(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) jobID := uuid.New() - versionID := uuid.New() user := dbgen.User(t, db, database.User{}) - err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ CreatedBy: user.ID, - ID: versionID, - JobID: jobID, OrganizationID: pd.OrganizationID, + JobID: jobID, + }) + template := dbgen.Template(t, db, database.Template{ + CreatedBy: user.ID, + OrganizationID: pd.OrganizationID, + ActiveVersionID: tv.ID, + }) + err := db.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ + ID: tv.ID, + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + UpdatedAt: dbtime.Now(), + Name: tv.Name, + Message: tv.Message, }) require.NoError(t, err) + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: pd.OrganizationID, + TemplateID: template.ID, + }) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ ID: jobID, Provisioner: database.ProvisionerTypeEcho, - Input: []byte(`{"template_version_id": "` + versionID.String() + `"}`), + Input: json.RawMessage("{}"), StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeWorkspaceBuild, OrganizationID: pd.OrganizationID, Tags: pd.Tags, }) require.NoError(t, err) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + TemplateVersionID: tv.ID, + InitiatorID: user.ID, + JobID: jobID, + }) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ OrganizationID: pd.OrganizationID, WorkerID: uuid.NullUUID{ @@ -1697,11 +1766,13 @@ func TestCompleteJob(t *testing.T) { }) require.NoError(t, err) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: jobID, - Provisioner: database.ProvisionerTypeEcho, - Input: []byte(`{"template_version_id": "` + versionID.String() + `"}`), + ID: jobID, + Provisioner: database.ProvisionerTypeEcho, + Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{ + TemplateVersionID: versionID, + })), StorageMethod: database.ProvisionerStorageMethodFile, - Type: database.ProvisionerJobTypeWorkspaceBuild, + Type: database.ProvisionerJobTypeTemplateVersionImport, OrganizationID: pd.OrganizationID, Tags: pd.Tags, }) @@ -1766,10 +1837,12 @@ func TestCompleteJob(t *testing.T) { OrganizationID: pd.OrganizationID, ID: jobID, Provisioner: database.ProvisionerTypeEcho, - Input: []byte(`{"template_version_id": "` + versionID.String() + `"}`), - StorageMethod: database.ProvisionerStorageMethodFile, - Type: database.ProvisionerJobTypeWorkspaceBuild, - Tags: pd.Tags, + Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{ + TemplateVersionID: versionID, + })), + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeTemplateVersionImport, + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -2091,12 +2164,20 @@ func TestCompleteJob(t *testing.T) { t.Run("TemplateDryRun", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) + user := dbgen.User(t, db, database.User{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + OrganizationID: pd.OrganizationID, + JobID: uuid.New(), + }) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: uuid.New(), - Provisioner: database.ProvisionerTypeEcho, - Type: database.ProvisionerJobTypeTemplateVersionDryRun, - StorageMethod: database.ProvisionerStorageMethodFile, - Input: json.RawMessage("{}"), + ID: version.JobID, + Provisioner: database.ProvisionerTypeEcho, + Type: database.ProvisionerJobTypeTemplateVersionDryRun, + StorageMethod: database.ProvisionerStorageMethodFile, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: version.ID, + })), OrganizationID: pd.OrganizationID, Tags: pd.Tags, }) @@ -2191,8 +2272,10 @@ func TestCompleteJob(t *testing.T) { Transition: database.WorkspaceTransitionStart, }}, provisionerJobParams: database.InsertProvisionerJobParams{ - Type: database.ProvisionerJobTypeTemplateVersionDryRun, - Input: json.RawMessage("{}"), + Type: database.ProvisionerJobTypeTemplateVersionDryRun, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: templateVersionID, + })), }, }, { @@ -2349,22 +2432,26 @@ func TestCompleteJob(t *testing.T) { OrganizationID: pd.OrganizationID, }) tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + ID: templateVersionID, CreatedBy: user.ID, OrganizationID: pd.OrganizationID, TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, JobID: job.ID, }) - workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: pd.OrganizationID, - OwnerID: user.ID, - }) - _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - ID: workspaceBuildID, - JobID: job.ID, - WorkspaceID: workspace.ID, - TemplateVersionID: tv.ID, - }) + + if jobParams.Type == database.ProvisionerJobTypeWorkspaceBuild { + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + TemplateID: tpl.ID, + OrganizationID: pd.OrganizationID, + OwnerID: user.ID, + }) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: workspaceBuildID, + JobID: job.ID, + WorkspaceID: workspace.ID, + TemplateVersionID: tv.ID, + }) + } require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -2672,7 +2759,10 @@ func TestCompleteJob(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - srv, db, _, pd := setup(t, false, &overrides{}) + fakeUsageInserter, usageInserterPtr := newFakeUsageInserter() + srv, db, _, pd := setup(t, false, &overrides{ + usageInserter: usageInserterPtr, + }) importJobID := uuid.New() tvID := uuid.New() @@ -2741,6 +2831,10 @@ func TestCompleteJob(t *testing.T) { require.NoError(t, err) require.True(t, version.HasAITask.Valid) // We ALWAYS expect a value to be set, therefore not nil, i.e. valid = true. require.Equal(t, tc.expected, version.HasAITask.Bool) + + // We never expect a usage event to be collected for + // template imports. + require.Empty(t, fakeUsageInserter.collectedEvents) }) } }) @@ -2750,22 +2844,27 @@ func TestCompleteJob(t *testing.T) { // will be set as well in that case. t.Run("WorkspaceBuild", func(t *testing.T) { type testcase struct { - name string - input *proto.CompletedJob_WorkspaceBuild - expected bool + name string + transition database.WorkspaceTransition + input *proto.CompletedJob_WorkspaceBuild + expectHasAiTask bool + expectUsageEvent bool } sidebarAppID := uuid.NewString() for _, tc := range []testcase{ { - name: "has_ai_task is false by default", - input: &proto.CompletedJob_WorkspaceBuild{ + name: "has_ai_task is false by default", + transition: database.WorkspaceTransitionStart, + input: &proto.CompletedJob_WorkspaceBuild{ // No AiTasks defined. }, - expected: false, + expectHasAiTask: false, + expectUsageEvent: false, }, { - name: "has_ai_task is set to true", + name: "has_ai_task is set to true", + transition: database.WorkspaceTransitionStart, input: &proto.CompletedJob_WorkspaceBuild{ AiTasks: []*sdkproto.AITask{ { @@ -2792,11 +2891,13 @@ func TestCompleteJob(t *testing.T) { }, }, }, - expected: true, + expectHasAiTask: true, + expectUsageEvent: true, }, // Checks regression for https://github.com/coder/coder/issues/18776 { - name: "non-existing app", + name: "non-existing app", + transition: database.WorkspaceTransitionStart, input: &proto.CompletedJob_WorkspaceBuild{ AiTasks: []*sdkproto.AITask{ { @@ -2808,13 +2909,49 @@ func TestCompleteJob(t *testing.T) { }, }, }, - expected: false, + expectHasAiTask: false, + expectUsageEvent: false, + }, + { + name: "has_ai_task is set to true, but transition is not start", + transition: database.WorkspaceTransitionStop, + input: &proto.CompletedJob_WorkspaceBuild{ + AiTasks: []*sdkproto.AITask{ + { + Id: uuid.NewString(), + SidebarApp: &sdkproto.AITaskSidebarApp{ + Id: sidebarAppID, + }, + }, + }, + Resources: []*sdkproto.Resource{ + { + Agents: []*sdkproto.Agent{ + { + Id: uuid.NewString(), + Name: "a", + Apps: []*sdkproto.App{ + { + Id: sidebarAppID, + Slug: "test-app", + }, + }, + }, + }, + }, + }, + }, + expectHasAiTask: true, + expectUsageEvent: false, }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - srv, db, _, pd := setup(t, false, &overrides{}) + fakeUsageInserter, usageInserterPtr := newFakeUsageInserter() + srv, db, _, pd := setup(t, false, &overrides{ + usageInserter: usageInserterPtr, + }) importJobID := uuid.New() tvID := uuid.New() @@ -2868,7 +3005,7 @@ func TestCompleteJob(t *testing.T) { WorkspaceID: workspaceTable.ID, TemplateVersionID: version.ID, InitiatorID: user.ID, - Transition: database.WorkspaceTransitionStart, + Transition: tc.transition, }) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -2899,11 +3036,22 @@ func TestCompleteJob(t *testing.T) { build, err = db.GetWorkspaceBuildByID(ctx, build.ID) require.NoError(t, err) require.True(t, build.HasAITask.Valid) // We ALWAYS expect a value to be set, therefore not nil, i.e. valid = true. - require.Equal(t, tc.expected, build.HasAITask.Bool) + require.Equal(t, tc.expectHasAiTask, build.HasAITask.Bool) - if tc.expected { + if tc.expectHasAiTask { require.Equal(t, sidebarAppID, build.AITaskSidebarAppID.UUID.String()) } + + if tc.expectUsageEvent { + // Check that a usage event was collected. + require.Len(t, fakeUsageInserter.collectedEvents, 1) + require.Equal(t, usagetypes.DCManagedAgentsV1{ + Count: 1, + }, fakeUsageInserter.collectedEvents[0]) + } else { + // Check that no usage event was collected. + require.Empty(t, fakeUsageInserter.collectedEvents) + } }) } }) @@ -3835,6 +3983,7 @@ type overrides struct { externalAuthConfigs []*externalauth.Config templateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] userQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] + usageInserter *atomic.Pointer[usage.Inserter] clock *quartz.Mock acquireJobLongPollDuration time.Duration heartbeatFn func(ctx context.Context) error @@ -3855,13 +4004,14 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi var externalAuthConfigs []*externalauth.Config tss := testTemplateScheduleStore() uqhss := testUserQuietHoursScheduleStore() + usageInserter := testUsageInserter() clock := quartz.NewReal() pollDur := time.Duration(0) if ov == nil { ov = &overrides{} } if ov.ctx == nil { - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(dbauthz.AsProvisionerd(context.Background())) t.Cleanup(cancel) ov.ctx = ctx } @@ -3892,6 +4042,15 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi require.True(t, swapped) } } + if ov.usageInserter != nil { + tUsageInserter := usageInserter.Load() + // keep the initial test value if the override hasn't set the atomic pointer. + usageInserter = ov.usageInserter + if usageInserter.Load() == nil { + swapped := usageInserter.CompareAndSwap(nil, tUsageInserter) + require.True(t, swapped) + } + } if ov.clock != nil { clock = ov.clock } @@ -3929,6 +4088,10 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi var op atomic.Pointer[agplprebuilds.ReconciliationOrchestrator] op.Store(&prebuildsOrchestrator) + // Use an authz wrapped database for the server to ensure permission checks + // work. + authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()) + serverDB := dbauthz.New(db, authorizer, logger, coderdtest.AccessControlStorePointer()) srv, err := provisionerdserver.NewServer( ov.ctx, proto.CurrentVersion.String(), @@ -3938,7 +4101,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), []database.ProvisionerType{database.ProvisionerTypeEcho}, provisionerdserver.Tags(daemon.Tags), - db, + serverDB, ps, provisionerdserver.NewAcquirer(ov.ctx, logger.Named("acquirer"), db, ps), telemetry.NewNoop(), @@ -3947,6 +4110,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi auditPtr, tss, uqhss, + usageInserter, deploymentValues, provisionerdserver.Options{ ExternalAuthConfigs: externalAuthConfigs, @@ -4061,3 +4225,22 @@ func (s *fakeStream) cancel() { s.canceled = true s.c.Broadcast() } + +type fakeUsageInserter struct { + collectedEvents []usagetypes.Event +} + +var _ usage.Inserter = &fakeUsageInserter{} + +func newFakeUsageInserter() (*fakeUsageInserter, *atomic.Pointer[usage.Inserter]) { + ptr := &atomic.Pointer[usage.Inserter]{} + fake := &fakeUsageInserter{} + var inserter usage.Inserter = fake + ptr.Store(&inserter) + return fake, ptr +} + +func (f *fakeUsageInserter) InsertDiscreteUsageEvent(_ context.Context, _ database.Store, event usagetypes.DiscreteEvent) error { + f.collectedEvents = append(f.collectedEvents, event) + return nil +} diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index a8130bea17ad3..0b48a24aebe83 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -76,7 +76,7 @@ const ( SubjectTypeNotifier SubjectType = "notifier" SubjectTypeSubAgentAPI SubjectType = "sub_agent_api" SubjectTypeFileReader SubjectType = "file_reader" - SubjectTypeUsageTracker SubjectType = "usage_tracker" + SubjectTypeUsagePublisher SubjectType = "usage_publisher" ) const ( diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index cbaaa74a848eb..974872973606c 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -223,6 +223,7 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder Valid: values.Has("outdated"), } filter.HasAITask = parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task") + filter.HasExternalAgent = parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent") filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization") type paramMatch struct { @@ -277,15 +278,16 @@ func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query parser := httpapi.NewQueryParamParser() filter := database.GetTemplatesWithFilterParams{ - Deleted: parser.Boolean(values, false, "deleted"), - OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), - ExactName: parser.String(values, "", "exact_name"), - FuzzyName: parser.String(values, "", "name"), - IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), - Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), - HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"), - AuthorID: parser.UUID(values, uuid.Nil, "author_id"), - AuthorUsername: parser.String(values, "", "author"), + Deleted: parser.Boolean(values, false, "deleted"), + OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), + ExactName: parser.String(values, "", "exact_name"), + FuzzyName: parser.String(values, "", "name"), + IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), + Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), + HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"), + AuthorID: parser.UUID(values, uuid.Nil, "author_id"), + AuthorUsername: parser.String(values, "", "author"), + HasExternalAgent: parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent"), } if filter.AuthorUsername == codersdk.Me { diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 5c45274668b25..2a8f4cd6cbb56 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -252,6 +252,36 @@ func TestSearchWorkspace(t *testing.T) { }, }, }, + { + Name: "HasExternalAgentTrue", + Query: "has_external_agent:true", + Expected: database.GetWorkspacesParams{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }, + }, + { + Name: "HasExternalAgentFalse", + Query: "has_external_agent:false", + Expected: database.GetWorkspacesParams{ + HasExternalAgent: sql.NullBool{ + Bool: false, + Valid: true, + }, + }, + }, + { + Name: "HasExternalAgentMissing", + Query: "", + Expected: database.GetWorkspacesParams{ + HasExternalAgent: sql.NullBool{ + Bool: false, + Valid: false, + }, + }, + }, // Failures { @@ -689,6 +719,36 @@ func TestSearchTemplates(t *testing.T) { }, }, }, + { + Name: "HasExternalAgent", + Query: "has_external_agent:true", + Expected: database.GetTemplatesWithFilterParams{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }, + }, + { + Name: "HasExternalAgentFalse", + Query: "has_external_agent:false", + Expected: database.GetTemplatesWithFilterParams{ + HasExternalAgent: sql.NullBool{ + Bool: false, + Valid: true, + }, + }, + }, + { + Name: "HasExternalAgentMissing", + Query: "", + Expected: database.GetTemplatesWithFilterParams{ + HasExternalAgent: sql.NullBool{ + Bool: false, + Valid: false, + }, + }, + }, { Name: "MyTemplates", Query: "author:me", diff --git a/coderd/tailnet.go b/coderd/tailnet.go index 172edea95a586..cdcf657fe732d 100644 --- a/coderd/tailnet.go +++ b/coderd/tailnet.go @@ -277,9 +277,9 @@ func (s *ServerTailnet) dialContext(ctx context.Context, network, addr string) ( }, nil } -func (s *ServerTailnet) AgentConn(ctx context.Context, agentID uuid.UUID) (*workspacesdk.AgentConn, func(), error) { +func (s *ServerTailnet) AgentConn(ctx context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { var ( - conn *workspacesdk.AgentConn + conn workspacesdk.AgentConn ret func() ) diff --git a/coderd/taskname/taskname.go b/coderd/taskname/taskname.go new file mode 100644 index 0000000000000..dff57dfd0c7f5 --- /dev/null +++ b/coderd/taskname/taskname.go @@ -0,0 +1,173 @@ +package taskname + +import ( + "context" + "fmt" + "io" + "math/rand/v2" + "os" + "strings" + + "github.com/anthropics/anthropic-sdk-go" + anthropicoption "github.com/anthropics/anthropic-sdk-go/option" + "github.com/moby/moby/pkg/namesgenerator" + "golang.org/x/xerrors" + + "github.com/coder/aisdk-go" + "github.com/coder/coder/v2/codersdk" +) + +const ( + defaultModel = anthropic.ModelClaude3_5HaikuLatest + systemPrompt = `Generate a short workspace name from this AI task prompt. + +Requirements: +- Only lowercase letters, numbers, and hyphens +- Start with "task-" +- Maximum 28 characters total +- Descriptive of the main task + +Examples: +- "Help me debug a Python script" → "task-python-debug" +- "Create a React dashboard component" → "task-react-dashboard" +- "Analyze sales data from Q3" → "task-analyze-q3-sales" +- "Set up CI/CD pipeline" → "task-setup-cicd" + +If you cannot create a suitable name: +- Respond with "task-unnamed"` +) + +var ( + ErrNoAPIKey = xerrors.New("no api key provided") + ErrNoNameGenerated = xerrors.New("no task name generated") +) + +type options struct { + apiKey string + model anthropic.Model +} + +type Option func(o *options) + +func WithAPIKey(apiKey string) Option { + return func(o *options) { + o.apiKey = apiKey + } +} + +func WithModel(model anthropic.Model) Option { + return func(o *options) { + o.model = model + } +} + +func GetAnthropicAPIKeyFromEnv() string { + return os.Getenv("ANTHROPIC_API_KEY") +} + +func GetAnthropicModelFromEnv() anthropic.Model { + return anthropic.Model(os.Getenv("ANTHROPIC_MODEL")) +} + +// generateSuffix generates a random hex string between `0000` and `ffff`. +func generateSuffix() string { + numMin := 0x00000 + numMax := 0x10000 + //nolint:gosec // We don't need a cryptographically secure random number generator for generating a task name suffix. + num := rand.IntN(numMax-numMin) + numMin + + return fmt.Sprintf("%04x", num) +} + +func GenerateFallback() string { + // We have a 32 character limit for the name. + // We have a 5 character prefix `task-`. + // We have a 5 character suffix `-ffff`. + // This leaves us with 22 characters for the middle. + // + // Unfortunately, `namesgenerator.GetRandomName(0)` will + // generate names that are longer than 22 characters, so + // we just trim these down to length. + name := strings.ReplaceAll(namesgenerator.GetRandomName(0), "_", "-") + name = name[:min(len(name), 22)] + name = strings.TrimSuffix(name, "-") + + return fmt.Sprintf("task-%s-%s", name, generateSuffix()) +} + +func Generate(ctx context.Context, prompt string, opts ...Option) (string, error) { + o := options{} + for _, opt := range opts { + opt(&o) + } + + if o.model == "" { + o.model = defaultModel + } + if o.apiKey == "" { + return "", ErrNoAPIKey + } + + conversation := []aisdk.Message{ + { + Role: "system", + Parts: []aisdk.Part{{ + Type: aisdk.PartTypeText, + Text: systemPrompt, + }}, + }, + { + Role: "user", + Parts: []aisdk.Part{{ + Type: aisdk.PartTypeText, + Text: prompt, + }}, + }, + } + + anthropicOptions := anthropic.DefaultClientOptions() + anthropicOptions = append(anthropicOptions, anthropicoption.WithAPIKey(o.apiKey)) + anthropicClient := anthropic.NewClient(anthropicOptions...) + + stream, err := anthropicDataStream(ctx, anthropicClient, o.model, conversation) + if err != nil { + return "", xerrors.Errorf("create anthropic data stream: %w", err) + } + + var acc aisdk.DataStreamAccumulator + stream = stream.WithAccumulator(&acc) + + if err := stream.Pipe(io.Discard); err != nil { + return "", xerrors.Errorf("pipe data stream") + } + + if len(acc.Messages()) == 0 { + return "", ErrNoNameGenerated + } + + generatedName := acc.Messages()[0].Content + + if err := codersdk.NameValid(generatedName); err != nil { + return "", xerrors.Errorf("generated name %v not valid: %w", generatedName, err) + } + + if generatedName == "task-unnamed" { + return "", ErrNoNameGenerated + } + + return fmt.Sprintf("%s-%s", generatedName, generateSuffix()), nil +} + +func anthropicDataStream(ctx context.Context, client anthropic.Client, model anthropic.Model, input []aisdk.Message) (aisdk.DataStream, error) { + messages, system, err := aisdk.MessagesToAnthropic(input) + if err != nil { + return nil, xerrors.Errorf("convert messages to anthropic format: %w", err) + } + + return aisdk.AnthropicToDataStream(client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ + Model: model, + MaxTokens: 24, + System: system, + Messages: messages, + })), nil +} diff --git a/coderd/taskname/taskname_test.go b/coderd/taskname/taskname_test.go new file mode 100644 index 0000000000000..3eb26ef1d4ac7 --- /dev/null +++ b/coderd/taskname/taskname_test.go @@ -0,0 +1,56 @@ +package taskname_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/taskname" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +const ( + anthropicEnvVar = "ANTHROPIC_API_KEY" +) + +func TestGenerateFallback(t *testing.T) { + t.Parallel() + + name := taskname.GenerateFallback() + err := codersdk.NameValid(name) + require.NoErrorf(t, err, "expected fallback to be valid workspace name, instead found %s", name) +} + +func TestGenerateTaskName(t *testing.T) { + t.Parallel() + + t.Run("Fallback", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + name, err := taskname.Generate(ctx, "Some random prompt") + require.ErrorIs(t, err, taskname.ErrNoAPIKey) + require.Equal(t, "", name) + }) + + t.Run("Anthropic", func(t *testing.T) { + t.Parallel() + + apiKey := os.Getenv(anthropicEnvVar) + if apiKey == "" { + t.Skipf("Skipping test as %s not set", anthropicEnvVar) + } + + ctx := testutil.Context(t, testutil.WaitShort) + + name, err := taskname.Generate(ctx, "Create a finance planning app", taskname.WithAPIKey(apiKey)) + require.NoError(t, err) + require.NotEqual(t, "", name) + + err = codersdk.NameValid(name) + require.NoError(t, err, "name should be valid") + }) +} diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 747cf2cb47de1..8f203126c99ba 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -768,7 +768,7 @@ func ConvertWorkspace(workspace database.Workspace) Workspace { // ConvertWorkspaceBuild anonymizes a workspace build. func ConvertWorkspaceBuild(build database.WorkspaceBuild) WorkspaceBuild { - return WorkspaceBuild{ + wb := WorkspaceBuild{ ID: build.ID, CreatedAt: build.CreatedAt, WorkspaceID: build.WorkspaceID, @@ -777,6 +777,10 @@ func ConvertWorkspaceBuild(build database.WorkspaceBuild) WorkspaceBuild { // #nosec G115 - Safe conversion as build numbers are expected to be positive and within uint32 range BuildNumber: uint32(build.BuildNumber), } + if build.HasAITask.Valid { + wb.HasAITask = ptr.Ref(build.HasAITask.Bool) + } + return wb } // ConvertProvisionerJob anonymizes a provisioner job. @@ -1105,6 +1109,9 @@ func ConvertTemplateVersion(version database.TemplateVersion) TemplateVersion { if version.SourceExampleID.Valid { snapVersion.SourceExampleID = &version.SourceExampleID.String } + if version.HasAITask.Valid { + snapVersion.HasAITask = ptr.Ref(version.HasAITask.Bool) + } return snapVersion } @@ -1357,6 +1364,7 @@ type WorkspaceBuild struct { TemplateVersionID uuid.UUID `json:"template_version_id"` JobID uuid.UUID `json:"job_id"` BuildNumber uint32 `json:"build_number"` + HasAITask *bool `json:"has_ai_task"` } type Workspace struct { @@ -1404,6 +1412,7 @@ type TemplateVersion struct { OrganizationID uuid.UUID `json:"organization_id"` JobID uuid.UUID `json:"job_id"` SourceExampleID *string `json:"source_example_id,omitempty"` + HasAITask *bool `json:"has_ai_task"` } type ProvisionerJob struct { diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 63bdc12870cb3..5508a7d8816f5 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" "sort" "testing" "time" @@ -105,6 +106,52 @@ func TestTelemetry(t *testing.T) { OpenIn: database.WorkspaceAppOpenInSlimWindow, AgentID: wsagent.ID, }) + + taskJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + Provisioner: database.ProvisionerTypeTerraform, + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeTemplateVersionDryRun, + OrganizationID: org.ID, + }) + taskTpl := dbgen.Template(t, db, database.Template{ + Provisioner: database.ProvisionerTypeTerraform, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + taskTV := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + TemplateID: uuid.NullUUID{UUID: taskTpl.ID, Valid: true}, + CreatedBy: user.ID, + JobID: taskJob.ID, + HasAITask: sql.NullBool{Bool: true, Valid: true}, + }) + taskWs := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: taskTpl.ID, + }) + taskWsResource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: taskJob.ID, + }) + taskWsAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: taskWsResource.ID, + }) + taskWsApp := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{ + SharingLevel: database.AppSharingLevelOwner, + Health: database.WorkspaceAppHealthDisabled, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + AgentID: taskWsAgent.ID, + }) + taskWB := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonAutostart, + WorkspaceID: taskWs.ID, + TemplateVersionID: tv.ID, + JobID: taskJob.ID, + HasAITask: sql.NullBool{Valid: true, Bool: true}, + AITaskSidebarAppID: uuid.NullUUID{Valid: true, UUID: taskWsApp.ID}, + }) + group := dbgen.Group(t, db, database.Group{ OrganizationID: org.ID, }) @@ -148,19 +195,19 @@ func TestTelemetry(t *testing.T) { }) _, snapshot := collectSnapshot(ctx, t, db, nil) - require.Len(t, snapshot.ProvisionerJobs, 1) + require.Len(t, snapshot.ProvisionerJobs, 2) require.Len(t, snapshot.Licenses, 1) - require.Len(t, snapshot.Templates, 1) - require.Len(t, snapshot.TemplateVersions, 2) + require.Len(t, snapshot.Templates, 2) + require.Len(t, snapshot.TemplateVersions, 3) require.Len(t, snapshot.Users, 1) require.Len(t, snapshot.Groups, 2) // 1 member in the everyone group + 1 member in the custom group require.Len(t, snapshot.GroupMembers, 2) - require.Len(t, snapshot.Workspaces, 1) - require.Len(t, snapshot.WorkspaceApps, 1) - require.Len(t, snapshot.WorkspaceAgents, 1) - require.Len(t, snapshot.WorkspaceBuilds, 1) - require.Len(t, snapshot.WorkspaceResources, 1) + require.Len(t, snapshot.Workspaces, 2) + require.Len(t, snapshot.WorkspaceApps, 2) + require.Len(t, snapshot.WorkspaceAgents, 2) + require.Len(t, snapshot.WorkspaceBuilds, 2) + require.Len(t, snapshot.WorkspaceResources, 2) require.Len(t, snapshot.WorkspaceAgentStats, 1) require.Len(t, snapshot.WorkspaceProxies, 1) require.Len(t, snapshot.WorkspaceModules, 1) @@ -169,11 +216,24 @@ func TestTelemetry(t *testing.T) { require.Len(t, snapshot.TelemetryItems, 2) require.Len(t, snapshot.WorkspaceAgentMemoryResourceMonitors, 1) require.Len(t, snapshot.WorkspaceAgentVolumeResourceMonitors, 1) - wsa := snapshot.WorkspaceAgents[0] + wsa := snapshot.WorkspaceAgents[1] require.Len(t, wsa.Subsystems, 2) require.Equal(t, string(database.WorkspaceAgentSubsystemEnvbox), wsa.Subsystems[0]) require.Equal(t, string(database.WorkspaceAgentSubsystemExectrace), wsa.Subsystems[1]) + require.True(t, slices.ContainsFunc(snapshot.TemplateVersions, func(ttv telemetry.TemplateVersion) bool { + if ttv.ID != taskTV.ID { + return false + } + return assert.NotNil(t, ttv.HasAITask) && assert.True(t, *ttv.HasAITask) + })) + require.True(t, slices.ContainsFunc(snapshot.WorkspaceBuilds, func(twb telemetry.WorkspaceBuild) bool { + if twb.ID != taskWB.ID { + return false + } + return assert.NotNil(t, twb.HasAITask) && assert.True(t, *twb.HasAITask) + })) + tvs := snapshot.TemplateVersions sort.Slice(tvs, func(i, j int) bool { // Sort by SourceExampleID presence (non-nil comes before nil) diff --git a/coderd/templates.go b/coderd/templates.go index 16ab5b3fa37a5..9202fc48234a6 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -38,8 +38,8 @@ import ( // Returns a single template. // -// @Summary Get template metadata by ID -// @ID get-template-metadata-by-id +// @Summary Get template settings by ID +// @ID get-template-settings-by-id // @Security CoderSessionToken // @Produce json // @Tags Templates @@ -629,12 +629,14 @@ func (api *API) templateByOrganizationAndName(rw http.ResponseWriter, r *http.Re httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplate(template)) } -// @Summary Update template metadata by ID -// @ID update-template-metadata-by-id +// @Summary Update template settings by ID +// @ID update-template-settings-by-id // @Security CoderSessionToken +// @Accept json // @Produce json // @Tags Templates // @Param template path string true "Template ID" format(uuid) +// @Param request body codersdk.UpdateTemplateMeta true "Patch template settings request" // @Success 200 {object} codersdk.Template // @Router /templates/{template} [patch] func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 325de6a18c8e3..c470dd17c664a 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -2015,3 +2015,59 @@ func TestTemplateFilterHasAITask(t *testing.T) { require.Contains(t, templates, templateWithAITask) require.Contains(t, templates, templateWithoutAITask) } + +func TestTemplateFilterHasExternalAgent(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + + jobWithExternalAgent := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ + OrganizationID: user.OrganizationID, + InitiatorID: user.UserID, + Tags: database.StringMap{}, + Type: database.ProvisionerJobTypeTemplateVersionImport, + }) + jobWithoutExternalAgent := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ + OrganizationID: user.OrganizationID, + InitiatorID: user.UserID, + Tags: database.StringMap{}, + Type: database.ProvisionerJobTypeTemplateVersionImport, + }) + versionWithExternalAgent := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: user.OrganizationID, + CreatedBy: user.UserID, + HasExternalAgent: sql.NullBool{Bool: true, Valid: true}, + JobID: jobWithExternalAgent.ID, + }) + versionWithoutExternalAgent := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: user.OrganizationID, + CreatedBy: user.UserID, + HasExternalAgent: sql.NullBool{Bool: false, Valid: true}, + JobID: jobWithoutExternalAgent.ID, + }) + templateWithExternalAgent := coderdtest.CreateTemplate(t, client, user.OrganizationID, versionWithExternalAgent.ID) + templateWithoutExternalAgent := coderdtest.CreateTemplate(t, client, user.OrganizationID, versionWithoutExternalAgent.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + templates, err := client.Templates(ctx, codersdk.TemplateFilter{ + SearchQuery: "has_external_agent:true", + }) + require.NoError(t, err) + require.Len(t, templates, 1) + require.Equal(t, templateWithExternalAgent.ID, templates[0].ID) + + templates, err = client.Templates(ctx, codersdk.TemplateFilter{ + SearchQuery: "has_external_agent:false", + }) + require.NoError(t, err) + require.Len(t, templates, 1) + require.Equal(t, templateWithoutExternalAgent.ID, templates[0].ID) +} diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 2c02268bba0a9..17a4d9b451e9c 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -1963,6 +1963,7 @@ func convertTemplateVersion(version database.TemplateVersion, job codersdk.Provi Archived: version.Archived, Warnings: warnings, MatchedProvisioners: matchedProvisioners, + HasExternalAgent: version.HasExternalAgent.Bool, } } diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 0b5bf6fcf2302..48f690d26d2eb 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -2221,3 +2221,36 @@ func TestTemplateArchiveVersions(t *testing.T) { require.NoError(t, err, "fetch all versions") require.Len(t, remaining, totalVersions-len(expArchived)-len(allFailed)+1, "remaining versions") } + +func TestTemplateVersionHasExternalAgent(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitMedium) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{ + { + Name: "example", + Type: "coder_external_agent", + }, + }, + HasExternalAgents: true, + }, + }, + }, + }, + ProvisionApply: echo.ApplyComplete, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + version, err := client.TemplateVersion(ctx, version.ID) + require.NoError(t, err) + require.True(t, version.HasExternalAgent) +} diff --git a/coderd/usage/events.go b/coderd/usage/events.go deleted file mode 100644 index f0910eefc2814..0000000000000 --- a/coderd/usage/events.go +++ /dev/null @@ -1,82 +0,0 @@ -package usage - -import ( - "strings" - - "golang.org/x/xerrors" -) - -// EventType is an enum of all usage event types. It mirrors the check -// constraint on the `event_type` column in the `usage_events` table. -type EventType string //nolint:revive - -const ( - UsageEventTypeDCManagedAgentsV1 EventType = "dc_managed_agents_v1" -) - -func (e EventType) Valid() bool { - switch e { - case UsageEventTypeDCManagedAgentsV1: - return true - default: - return false - } -} - -func (e EventType) IsDiscrete() bool { - return e.Valid() && strings.HasPrefix(string(e), "dc_") -} - -func (e EventType) IsHeartbeat() bool { - return e.Valid() && strings.HasPrefix(string(e), "hb_") -} - -// Event is a usage event that can be collected by the usage collector. -// -// Note that the following event types should not be updated once they are -// merged into the product. Please consult Dean before making any changes. -// -// Event types cannot be implemented outside of this package, as they are -// imported by the coder/tallyman repository. -type Event interface { - usageEvent() // to prevent external types from implementing this interface - EventType() EventType - Valid() error - Fields() map[string]any // fields to be marshaled and sent to tallyman/Metronome -} - -// DiscreteEvent is a usage event that is collected as a discrete event. -type DiscreteEvent interface { - Event - discreteUsageEvent() // marker method, also prevents external types from implementing this interface -} - -// DCManagedAgentsV1 is a discrete usage event for the number of managed agents. -// This event is sent in the following situations: -// - Once on first startup after usage tracking is added to the product with -// the count of all existing managed agents (count=N) -// - A new managed agent is created (count=1) -type DCManagedAgentsV1 struct { - Count uint64 `json:"count"` -} - -var _ DiscreteEvent = DCManagedAgentsV1{} - -func (DCManagedAgentsV1) usageEvent() {} -func (DCManagedAgentsV1) discreteUsageEvent() {} -func (DCManagedAgentsV1) EventType() EventType { - return UsageEventTypeDCManagedAgentsV1 -} - -func (e DCManagedAgentsV1) Valid() error { - if e.Count == 0 { - return xerrors.New("count must be greater than 0") - } - return nil -} - -func (e DCManagedAgentsV1) Fields() map[string]any { - return map[string]any{ - "count": e.Count, - } -} diff --git a/coderd/usage/inserter.go b/coderd/usage/inserter.go index 08ca8dec3e881..7a0f42daf4724 100644 --- a/coderd/usage/inserter.go +++ b/coderd/usage/inserter.go @@ -4,13 +4,16 @@ import ( "context" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/usage/usagetypes" ) // Inserter accepts usage events generated by the product. type Inserter interface { // InsertDiscreteUsageEvent writes a discrete usage event to the database // within the given transaction. - InsertDiscreteUsageEvent(ctx context.Context, tx database.Store, event DiscreteEvent) error + // The caller context must be authorized to create usage events in the + // database. + InsertDiscreteUsageEvent(ctx context.Context, tx database.Store, event usagetypes.DiscreteEvent) error } // AGPLInserter is a no-op implementation of Inserter. @@ -24,6 +27,6 @@ func NewAGPLInserter() Inserter { // InsertDiscreteUsageEvent is a no-op implementation of // InsertDiscreteUsageEvent. -func (AGPLInserter) InsertDiscreteUsageEvent(_ context.Context, _ database.Store, _ DiscreteEvent) error { +func (AGPLInserter) InsertDiscreteUsageEvent(_ context.Context, _ database.Store, _ usagetypes.DiscreteEvent) error { return nil } diff --git a/coderd/usage/usagetypes/events.go b/coderd/usage/usagetypes/events.go new file mode 100644 index 0000000000000..ef5ac79d455fa --- /dev/null +++ b/coderd/usage/usagetypes/events.go @@ -0,0 +1,152 @@ +// Package usagetypes contains the types for usage events. These are kept in +// their own package to avoid importing any real code from coderd. +// +// Imports in this package should be limited to the standard library and the +// following packages ONLY: +// - github.com/google/uuid +// - golang.org/x/xerrors +// +// This package is imported by the Tallyman codebase. +package usagetypes + +// Please read the package documentation before adding imports. +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "golang.org/x/xerrors" +) + +// UsageEventType is an enum of all usage event types. It mirrors the database +// type `usage_event_type`. +type UsageEventType string + +// All event types. +// +// When adding a new event type, ensure you add it to the Valid method and the +// ParseEventWithType function. +const ( + UsageEventTypeDCManagedAgentsV1 UsageEventType = "dc_managed_agents_v1" +) + +func (e UsageEventType) Valid() bool { + switch e { + case UsageEventTypeDCManagedAgentsV1: + return true + default: + return false + } +} + +func (e UsageEventType) IsDiscrete() bool { + return e.Valid() && strings.HasPrefix(string(e), "dc_") +} + +func (e UsageEventType) IsHeartbeat() bool { + return e.Valid() && strings.HasPrefix(string(e), "hb_") +} + +// ParseEvent parses the raw event data into the provided event. It fails if +// there is any unknown fields or extra data at the end of the JSON. The +// returned event is validated. +func ParseEvent(data json.RawMessage, out Event) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + + err := dec.Decode(out) + if err != nil { + return xerrors.Errorf("unmarshal %T event: %w", out, err) + } + if dec.More() { + return xerrors.Errorf("extra data after %T event", out) + } + err = out.Valid() + if err != nil { + return xerrors.Errorf("invalid %T event: %w", out, err) + } + + return nil +} + +// UnknownEventTypeError is returned by ParseEventWithType when an unknown event +// type is encountered. +type UnknownEventTypeError struct { + EventType string +} + +var _ error = UnknownEventTypeError{} + +// Error implements error. +func (e UnknownEventTypeError) Error() string { + return fmt.Sprintf("unknown usage event type: %q", e.EventType) +} + +// ParseEventWithType parses the raw event data into the specified Go type. It +// fails if there is any unknown fields or extra data after the event. The +// returned event is validated. +// +// If the event type is unknown, UnknownEventTypeError is returned. +func ParseEventWithType(eventType UsageEventType, data json.RawMessage) (Event, error) { + switch eventType { + case UsageEventTypeDCManagedAgentsV1: + var event DCManagedAgentsV1 + if err := ParseEvent(data, &event); err != nil { + return nil, err + } + return event, nil + default: + return nil, UnknownEventTypeError{EventType: string(eventType)} + } +} + +// Event is a usage event that can be collected by the usage collector. +// +// Note that the following event types should not be updated once they are +// merged into the product. Please consult Dean before making any changes. +// +// This type cannot be implemented outside of this package as it this package +// is the source of truth for the coder/tallyman repo. +type Event interface { + usageEvent() // to prevent external types from implementing this interface + EventType() UsageEventType + Valid() error + Fields() map[string]any // fields to be marshaled and sent to tallyman/Metronome +} + +// DiscreteEvent is a usage event that is collected as a discrete event. +type DiscreteEvent interface { + Event + discreteUsageEvent() // marker method, also prevents external types from implementing this interface +} + +// DCManagedAgentsV1 is a discrete usage event for the number of managed agents. +// This event is sent in the following situations: +// - Once on first startup after usage tracking is added to the product with +// the count of all existing managed agents (count=N) +// - A new managed agent is created (count=1) +type DCManagedAgentsV1 struct { + Count uint64 `json:"count"` +} + +var _ DiscreteEvent = DCManagedAgentsV1{} + +func (DCManagedAgentsV1) usageEvent() {} +func (DCManagedAgentsV1) discreteUsageEvent() {} +func (DCManagedAgentsV1) EventType() UsageEventType { + return UsageEventTypeDCManagedAgentsV1 +} + +func (e DCManagedAgentsV1) Valid() error { + if e.Count == 0 { + return xerrors.New("count must be greater than 0") + } + return nil +} + +func (e DCManagedAgentsV1) Fields() map[string]any { + return map[string]any{ + "count": e.Count, + } +} diff --git a/coderd/usage/usagetypes/events_test.go b/coderd/usage/usagetypes/events_test.go new file mode 100644 index 0000000000000..a04e5d4df025b --- /dev/null +++ b/coderd/usage/usagetypes/events_test.go @@ -0,0 +1,68 @@ +package usagetypes_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/usage/usagetypes" +) + +func TestParseEvent(t *testing.T) { + t.Parallel() + + t.Run("ExtraFields", func(t *testing.T) { + t.Parallel() + var event usagetypes.DCManagedAgentsV1 + err := usagetypes.ParseEvent([]byte(`{"count": 1, "extra": "field"}`), &event) + require.ErrorContains(t, err, "unmarshal *usagetypes.DCManagedAgentsV1 event") + }) + + t.Run("ExtraData", func(t *testing.T) { + t.Parallel() + var event usagetypes.DCManagedAgentsV1 + err := usagetypes.ParseEvent([]byte(`{"count": 1}{"count": 2}`), &event) + require.ErrorContains(t, err, "extra data after *usagetypes.DCManagedAgentsV1 event") + }) + + t.Run("DCManagedAgentsV1", func(t *testing.T) { + t.Parallel() + + var event usagetypes.DCManagedAgentsV1 + err := usagetypes.ParseEvent([]byte(`{"count": 1}`), &event) + require.NoError(t, err) + require.Equal(t, usagetypes.DCManagedAgentsV1{Count: 1}, event) + require.Equal(t, map[string]any{"count": uint64(1)}, event.Fields()) + + event = usagetypes.DCManagedAgentsV1{} + err = usagetypes.ParseEvent([]byte(`{"count": "invalid"}`), &event) + require.ErrorContains(t, err, "unmarshal *usagetypes.DCManagedAgentsV1 event") + + event = usagetypes.DCManagedAgentsV1{} + err = usagetypes.ParseEvent([]byte(`{}`), &event) + require.ErrorContains(t, err, "invalid *usagetypes.DCManagedAgentsV1 event: count must be greater than 0") + }) +} + +func TestParseEventWithType(t *testing.T) { + t.Parallel() + + t.Run("UnknownEvent", func(t *testing.T) { + t.Parallel() + _, err := usagetypes.ParseEventWithType(usagetypes.UsageEventType("fake"), []byte(`{}`)) + var unknownEventTypeError usagetypes.UnknownEventTypeError + require.ErrorAs(t, err, &unknownEventTypeError) + require.Equal(t, "fake", unknownEventTypeError.EventType) + }) + + t.Run("DCManagedAgentsV1", func(t *testing.T) { + t.Parallel() + + eventType := usagetypes.UsageEventTypeDCManagedAgentsV1 + event, err := usagetypes.ParseEventWithType(eventType, []byte(`{"count": 1}`)) + require.NoError(t, err) + require.Equal(t, usagetypes.DCManagedAgentsV1{Count: 1}, event) + require.Equal(t, eventType, event.EventType()) + require.Equal(t, map[string]any{"count": uint64(1)}, event.Fields()) + }) +} diff --git a/coderd/usage/usagetypes/tallyman.go b/coderd/usage/usagetypes/tallyman.go new file mode 100644 index 0000000000000..38358b7a6d518 --- /dev/null +++ b/coderd/usage/usagetypes/tallyman.go @@ -0,0 +1,70 @@ +package usagetypes + +// Please read the package documentation before adding imports. +import ( + "encoding/json" + "time" + + "golang.org/x/xerrors" +) + +const ( + TallymanCoderLicenseKeyHeader = "Coder-License-Key" + TallymanCoderDeploymentIDHeader = "Coder-Deployment-ID" +) + +// TallymanV1Response is a generic response with a message from the Tallyman +// API. It is typically returned when there is an error. +type TallymanV1Response struct { + Message string `json:"message"` +} + +// TallymanV1IngestRequest is a request to the Tallyman API to ingest usage +// events. +type TallymanV1IngestRequest struct { + Events []TallymanV1IngestEvent `json:"events"` +} + +// TallymanV1IngestEvent is an event to be ingested into the Tallyman API. +type TallymanV1IngestEvent struct { + ID string `json:"id"` + EventType UsageEventType `json:"event_type"` + EventData json.RawMessage `json:"event_data"` + CreatedAt time.Time `json:"created_at"` +} + +// Valid validates the TallymanV1IngestEvent. It does not validate the event +// body. +func (e TallymanV1IngestEvent) Valid() error { + if e.ID == "" { + return xerrors.New("id is required") + } + if !e.EventType.Valid() { + return xerrors.Errorf("event_type %q is invalid", e.EventType) + } + if e.CreatedAt.IsZero() { + return xerrors.New("created_at cannot be zero") + } + return nil +} + +// TallymanV1IngestResponse is a response from the Tallyman API to ingest usage +// events. +type TallymanV1IngestResponse struct { + AcceptedEvents []TallymanV1IngestAcceptedEvent `json:"accepted_events"` + RejectedEvents []TallymanV1IngestRejectedEvent `json:"rejected_events"` +} + +// TallymanV1IngestAcceptedEvent is an event that was accepted by the Tallyman +// API. +type TallymanV1IngestAcceptedEvent struct { + ID string `json:"id"` +} + +// TallymanV1IngestRejectedEvent is an event that was rejected by the Tallyman +// API. +type TallymanV1IngestRejectedEvent struct { + ID string `json:"id"` + Message string `json:"message"` + Permanent bool `json:"permanent"` +} diff --git a/coderd/usage/usagetypes/tallyman_test.go b/coderd/usage/usagetypes/tallyman_test.go new file mode 100644 index 0000000000000..f8f09446dff51 --- /dev/null +++ b/coderd/usage/usagetypes/tallyman_test.go @@ -0,0 +1,85 @@ +package usagetypes_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/usage/usagetypes" +) + +func TestTallymanV1UsageEvent(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + event usagetypes.TallymanV1IngestEvent + errorMessage string + }{ + { + name: "OK", + event: usagetypes.TallymanV1IngestEvent{ + ID: "123", + EventType: usagetypes.UsageEventTypeDCManagedAgentsV1, + // EventData is not validated. + EventData: json.RawMessage{}, + CreatedAt: time.Now(), + }, + errorMessage: "", + }, + { + name: "NoID", + event: usagetypes.TallymanV1IngestEvent{ + EventType: usagetypes.UsageEventTypeDCManagedAgentsV1, + EventData: json.RawMessage{}, + CreatedAt: time.Now(), + }, + errorMessage: "id is required", + }, + { + name: "NoEventType", + event: usagetypes.TallymanV1IngestEvent{ + ID: "123", + EventType: usagetypes.UsageEventType(""), + EventData: json.RawMessage{}, + CreatedAt: time.Now(), + }, + errorMessage: `event_type "" is invalid`, + }, + { + name: "UnknownEventType", + event: usagetypes.TallymanV1IngestEvent{ + ID: "123", + EventType: usagetypes.UsageEventType("unknown"), + EventData: json.RawMessage{}, + CreatedAt: time.Now(), + }, + errorMessage: `event_type "unknown" is invalid`, + }, + { + name: "NoCreatedAt", + event: usagetypes.TallymanV1IngestEvent{ + ID: "123", + EventType: usagetypes.UsageEventTypeDCManagedAgentsV1, + EventData: json.RawMessage{}, + CreatedAt: time.Time{}, + }, + errorMessage: "created_at cannot be zero", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.event.Valid() + if tc.errorMessage == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.errorMessage) + } + }) + } +} diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 4c9412fda3fb7..504b102e9ee5b 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -335,7 +335,6 @@ func TestUserOAuth2Github(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - // nolint:gocritic // Unit test count, err := db.GetUserCount(dbauthz.AsSystemRestricted(ctx), false) require.NoError(t, err) require.Equal(t, int64(1), count) @@ -897,7 +896,6 @@ func TestUserOAuth2Github(t *testing.T) { require.Empty(t, links) // Make sure a user_link cannot be created with a deleted user. - // nolint:gocritic // Unit test _, err = db.InsertUserLink(dbauthz.AsSystemRestricted(ctx), database.InsertUserLinkParams{ UserID: deleted.ID, LoginType: "github", diff --git a/coderd/users_test.go b/coderd/users_test.go index 5928fc6486f51..22c9fad5eebea 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1544,7 +1544,6 @@ func TestUsersFilter(t *testing.T) { } userClient, userData := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, roles...) // Set the last seen for each user to a unique day - // nolint:gocritic // Unit test _, err := api.Database.UpdateUserLastSeenAt(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLastSeenAtParams{ ID: userData.ID, LastSeenAt: lastSeenNow.Add(-1 * time.Hour * 24 * time.Duration(i)), @@ -1572,7 +1571,6 @@ func TestUsersFilter(t *testing.T) { // Add users with different creation dates for testing date filters for i := 0; i < 3; i++ { - // nolint:gocritic // Using system context is necessary to seed data in tests user1, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{ ID: uuid.New(), Email: fmt.Sprintf("before%d@coder.com", i), @@ -1594,7 +1592,6 @@ func TestUsersFilter(t *testing.T) { require.NoError(t, err) users = append(users, sdkUser1) - // nolint:gocritic //Using system context is necessary to seed data in tests user2, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{ ID: uuid.New(), Email: fmt.Sprintf("during%d@coder.com", i), @@ -1615,7 +1612,6 @@ func TestUsersFilter(t *testing.T) { require.NoError(t, err) users = append(users, sdkUser2) - // nolint:gocritic // Using system context is necessary to seed data in tests user3, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{ ID: uuid.New(), Email: fmt.Sprintf("after%d@coder.com", i), @@ -1912,7 +1908,6 @@ func TestGetUsers(t *testing.T) { Email: "test2@coder.com", Username: "test2", }) - // nolint:gocritic // Unit test err := db.UpdateUserGithubComUserID(dbauthz.AsSystemRestricted(ctx), database.UpdateUserGithubComUserIDParams{ ID: first.UserID, GithubComUserID: sql.NullInt64{ diff --git a/coderd/workspaceagents_internal_test.go b/coderd/workspaceagents_internal_test.go new file mode 100644 index 0000000000000..c7520f05ab503 --- /dev/null +++ b/coderd/workspaceagents_internal_test.go @@ -0,0 +1,186 @@ +package coderd + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/workspaceapps/appurl" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" + "github.com/coder/coder/v2/codersdk/wsjson" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/tailnettest" + "github.com/coder/coder/v2/testutil" + "github.com/coder/websocket" +) + +type fakeAgentProvider struct { + agentConn func(ctx context.Context, agentID uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) +} + +func (fakeAgentProvider) ReverseProxy(targetURL, dashboardURL *url.URL, agentID uuid.UUID, app appurl.ApplicationURL, wildcardHost string) *httputil.ReverseProxy { + panic("unimplemented") +} + +func (f fakeAgentProvider) AgentConn(ctx context.Context, agentID uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) { + if f.agentConn != nil { + return f.agentConn(ctx, agentID) + } + + panic("unimplemented") +} + +func (fakeAgentProvider) ServeHTTPDebug(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") +} + +func (fakeAgentProvider) Close() error { + return nil +} + +func TestWatchAgentContainers(t *testing.T) { + t.Parallel() + + t.Run("WebSocketClosesProperly", func(t *testing.T) { + t.Parallel() + + // This test ensures that the agent containers `/watch` websocket can gracefully + // handle the underlying websocket unexpectedly closing. This test was created in + // response to this issue: https://github.com/coder/coder/issues/19372 + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd") + + mCtrl = gomock.NewController(t) + mDB = dbmock.NewMockStore(mCtrl) + mCoordinator = tailnettest.NewMockCoordinator(mCtrl) + mAgentConn = agentconnmock.NewMockAgentConn(mCtrl) + + fAgentProvider = fakeAgentProvider{ + agentConn: func(ctx context.Context, agentID uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) { + return mAgentConn, func() {}, nil + }, + } + + workspaceID = uuid.New() + agentID = uuid.New() + resourceID = uuid.New() + jobID = uuid.New() + buildID = uuid.New() + + containersCh = make(chan codersdk.WorkspaceAgentListContainersResponse) + + r = chi.NewMux() + + api = API{ + ctx: ctx, + Options: &Options{ + AgentInactiveDisconnectTimeout: testutil.WaitShort, + Database: mDB, + Logger: logger, + DeploymentValues: &codersdk.DeploymentValues{}, + TailnetCoordinator: tailnettest.NewFakeCoordinator(), + }, + } + ) + + var tailnetCoordinator tailnet.Coordinator = mCoordinator + api.TailnetCoordinator.Store(&tailnetCoordinator) + api.agentProvider = fAgentProvider + + // Setup: Allow `ExtractWorkspaceAgentParams` to complete. + mDB.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agentID).Return(database.WorkspaceAgent{ + ID: agentID, + ResourceID: resourceID, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + FirstConnectedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + LastConnectedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + }, nil) + mDB.EXPECT().GetWorkspaceResourceByID(gomock.Any(), resourceID).Return(database.WorkspaceResource{ + ID: resourceID, + JobID: jobID, + }, nil) + mDB.EXPECT().GetProvisionerJobByID(gomock.Any(), jobID).Return(database.ProvisionerJob{ + ID: jobID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + }, nil) + mDB.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), jobID).Return(database.WorkspaceBuild{ + WorkspaceID: workspaceID, + ID: buildID, + }, nil) + + // And: Allow `db2dsk.WorkspaceAgent` to complete. + mCoordinator.EXPECT().Node(gomock.Any()).Return(nil) + + // And: Allow `WatchContainers` to be called, returing our `containersCh` channel. + mAgentConn.EXPECT().WatchContainers(gomock.Any(), gomock.Any()). + Return(containersCh, io.NopCloser(&bytes.Buffer{}), nil) + + // And: We mount the HTTP Handler + r.With(httpmw.ExtractWorkspaceAgentParam(mDB)). + Get("/workspaceagents/{workspaceagent}/containers/watch", api.watchWorkspaceAgentContainers) + + // Given: We create the HTTP server + srv := httptest.NewServer(r) + defer srv.Close() + + // And: Dial the WebSocket + wsURL := strings.Replace(srv.URL, "http://", "ws://", 1) + conn, resp, err := websocket.Dial(ctx, fmt.Sprintf("%s/workspaceagents/%s/containers/watch", wsURL, agentID), nil) + require.NoError(t, err) + if resp.Body != nil { + defer resp.Body.Close() + } + + // And: Create a streaming decoder + decoder := wsjson.NewDecoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText, logger) + defer decoder.Close() + decodeCh := decoder.Chan() + + // And: We can successfully send through the channel. + testutil.RequireSend(ctx, t, containersCh, codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{{ + ID: "test-container-id", + }}, + }) + + // And: Receive the data. + containerResp := testutil.RequireReceive(ctx, t, decodeCh) + require.Len(t, containerResp.Containers, 1) + require.Equal(t, "test-container-id", containerResp.Containers[0].ID) + + // When: We close the `containersCh` + close(containersCh) + + // Then: We expect `decodeCh` to be closed. + select { + case <-ctx.Done(): + t.Fail() + + case _, ok := <-decodeCh: + require.False(t, ok, "channel is expected to be closed") + } + }) +} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 1855ed8a7e8fc..6f28b12af5ae0 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -562,7 +562,6 @@ func TestWorkspaceAgentConnectRPC(t *testing.T) { seed := database.WorkspaceTable{OrganizationID: user.OrganizationID, OwnerID: user.UserID} wsb := dbfake.WorkspaceBuild(t, db, seed).WithAgent().Do() // When: the workspace is marked as soft-deleted - // nolint:gocritic // this is a test err := db.UpdateWorkspaceDeletedByID( dbauthz.AsProvisionerd(ctx), database.UpdateWorkspaceDeletedByIDParams{ID: wsb.Workspace.ID, Deleted: true}, @@ -593,7 +592,7 @@ func TestWorkspaceAgentTailnet(t *testing.T) { _ = agenttest.New(t, client.URL, r.AgentToken) resources := coderdtest.AwaitWorkspaceAgents(t, client, r.Workspace.ID) - conn, err := func() (*workspacesdk.AgentConn, error) { + conn, err := func() (workspacesdk.AgentConn, error) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() // Connection should remain open even if the dial context is canceled. @@ -633,7 +632,6 @@ func TestWorkspaceAgentClientCoordinate_BadVersion(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) agentToken, err := uuid.Parse(r.AgentToken) require.NoError(t, err) - //nolint: gocritic // testing ao, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(dbauthz.AsSystemRestricted(ctx), agentToken) require.NoError(t, err) @@ -724,7 +722,7 @@ func TestWorkspaceAgentClientCoordinate_ResumeToken(t *testing.T) { agentTokenUUID, err := uuid.Parse(r.AgentToken) require.NoError(t, err) ctx := testutil.Context(t, testutil.WaitLong) - agentAndBuild, err := api.Database.GetWorkspaceAgentAndLatestBuildByAuthToken(dbauthz.AsSystemRestricted(ctx), agentTokenUUID) //nolint + agentAndBuild, err := api.Database.GetWorkspaceAgentAndLatestBuildByAuthToken(dbauthz.AsSystemRestricted(ctx), agentTokenUUID) require.NoError(t, err) // Connect with no resume token, and ensure that the peer ID is set to a @@ -796,7 +794,7 @@ func TestWorkspaceAgentClientCoordinate_ResumeToken(t *testing.T) { agentTokenUUID, err := uuid.Parse(r.AgentToken) require.NoError(t, err) ctx := testutil.Context(t, testutil.WaitLong) - agentAndBuild, err := api.Database.GetWorkspaceAgentAndLatestBuildByAuthToken(dbauthz.AsSystemRestricted(ctx), agentTokenUUID) //nolint + agentAndBuild, err := api.Database.GetWorkspaceAgentAndLatestBuildByAuthToken(dbauthz.AsSystemRestricted(ctx), agentTokenUUID) require.NoError(t, err) // Connect with no resume token, and ensure that the peer ID is set to a @@ -1574,82 +1572,6 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { } } }) - - t.Run("PayloadTooLarge", func(t *testing.T) { - t.Parallel() - - var ( - ctx = testutil.Context(t, testutil.WaitSuperLong) - logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - mClock = quartz.NewMock(t) - updaterTickerTrap = mClock.Trap().TickerFunc("updaterLoop") - mCtrl = gomock.NewController(t) - mCCLI = acmock.NewMockContainerCLI(mCtrl) - - client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &logger}) - user = coderdtest.CreateFirstUser(t, client) - r = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { - return agents - }).Do() - ) - - // WebSocket limit is 4MiB, so we want to ensure we create _more_ than 4MiB worth of payload. - // Creating 20,000 fake containers creates a payload of roughly 7MiB. - var fakeContainers []codersdk.WorkspaceAgentContainer - for range 20_000 { - fakeContainers = append(fakeContainers, codersdk.WorkspaceAgentContainer{ - CreatedAt: time.Now(), - ID: uuid.NewString(), - FriendlyName: uuid.NewString(), - Image: "busybox:latest", - Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project", - agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project/.devcontainer/devcontainer.json", - }, - Running: false, - Ports: []codersdk.WorkspaceAgentContainerPort{}, - Status: string(codersdk.WorkspaceAgentDevcontainerStatusRunning), - Volumes: map[string]string{}, - }) - } - - mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: fakeContainers}, nil) - mCCLI.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("", nil).AnyTimes() - - _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { - o.Logger = logger.Named("agent") - o.Devcontainers = true - o.DevcontainerAPIOptions = []agentcontainers.Option{ - agentcontainers.WithClock(mClock), - agentcontainers.WithContainerCLI(mCCLI), - agentcontainers.WithWatcher(watcher.NewNoop()), - } - }) - - resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() - require.Len(t, resources, 1, "expected one resource") - require.Len(t, resources[0].Agents, 1, "expected one agent") - agentID := resources[0].Agents[0].ID - - updaterTickerTrap.MustWait(ctx).MustRelease(ctx) - defer updaterTickerTrap.Close() - - containers, closer, err := client.WatchWorkspaceAgentContainers(ctx, agentID) - require.NoError(t, err) - defer func() { - closer.Close() - }() - - select { - case <-ctx.Done(): - t.Fail() - case _, ok := <-containers: - require.False(t, ok) - } - }) } func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { @@ -2497,7 +2419,7 @@ func TestWorkspaceAgent_UpdatedDERP(t *testing.T) { agentID := resources[0].Agents[0].ID // Connect from a client. - conn1, err := func() (*workspacesdk.AgentConn, error) { + conn1, err := func() (workspacesdk.AgentConn, error) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() // Connection should remain open even if the dial context is canceled. @@ -2538,7 +2460,7 @@ func TestWorkspaceAgent_UpdatedDERP(t *testing.T) { // Wait for the DERP map to be updated on the existing client. require.Eventually(t, func() bool { - regionIDs := conn1.Conn.DERPMap().RegionIDs() + regionIDs := conn1.TailnetConn().DERPMap().RegionIDs() return len(regionIDs) == 1 && regionIDs[0] == 2 }, testutil.WaitLong, testutil.IntervalFast) @@ -2555,7 +2477,7 @@ func TestWorkspaceAgent_UpdatedDERP(t *testing.T) { defer conn2.Close() ok = conn2.AwaitReachable(ctx) require.True(t, ok) - require.Equal(t, []int{2}, conn2.DERPMap().RegionIDs()) + require.Equal(t, []int{2}, conn2.TailnetConn().DERPMap().RegionIDs()) } func TestWorkspaceAgentExternalAuthListen(t *testing.T) { diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index 2f1294558f67a..002bb1ea05aae 100644 --- a/coderd/workspaceapps/proxy.go +++ b/coderd/workspaceapps/proxy.go @@ -74,7 +74,7 @@ type AgentProvider interface { ReverseProxy(targetURL, dashboardURL *url.URL, agentID uuid.UUID, app appurl.ApplicationURL, wildcardHost string) *httputil.ReverseProxy // AgentConn returns a new connection to the specified agent. - AgentConn(ctx context.Context, agentID uuid.UUID) (_ *workspacesdk.AgentConn, release func(), _ error) + AgentConn(ctx context.Context, agentID uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) ServeHTTPDebug(w http.ResponseWriter, r *http.Request) diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 583b9c4edaf21..e54f75ef5cba6 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1157,6 +1157,11 @@ func (api *API) convertWorkspaceBuild( aiTasksSidebarAppID = &build.AITaskSidebarAppID.UUID } + var hasExternalAgent *bool + if build.HasExternalAgent.Valid { + hasExternalAgent = &build.HasExternalAgent.Bool + } + apiJob := convertProvisionerJob(job) transition := codersdk.WorkspaceTransition(build.Transition) return codersdk.WorkspaceBuild{ @@ -1185,6 +1190,7 @@ func (api *API) convertWorkspaceBuild( TemplateVersionPresetID: presetID, HasAITask: hasAITask, AITaskSidebarAppID: aiTasksSidebarAppID, + HasExternalAgent: hasExternalAgent, }, nil } diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 29c9cac0ffa13..e888115093a9b 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -55,7 +55,6 @@ func TestWorkspaceBuild(t *testing.T) { Auditor: auditor, }) user := coderdtest.CreateFirstUser(t, client) - //nolint:gocritic // testing up, err := db.UpdateUserProfile(dbauthz.AsSystemRestricted(ctx), database.UpdateUserProfileParams{ ID: user.UserID, Email: coderdtest.FirstUserParams.Email, @@ -518,7 +517,6 @@ func TestWorkspaceBuildsProvisionerState(t *testing.T) { OrganizationID: first.OrganizationID, }).Do() - // nolint:gocritic // For testing daemons, err := store.GetProvisionerDaemons(dbauthz.AsSystemReadProvisionerDaemons(ctx)) require.NoError(t, err) require.Empty(t, daemons, "Provisioner daemons should be empty for this test") @@ -1638,6 +1636,8 @@ func TestPostWorkspaceBuild(t *testing.T) { t.Run("SetsPresetID", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ @@ -1645,9 +1645,20 @@ func TestPostWorkspaceBuild(t *testing.T) { ProvisionPlan: []*proto.Response{{ Type: &proto.Response_Plan{ Plan: &proto.PlanComplete{ - Presets: []*proto.Preset{{ - Name: "test", - }}, + Presets: []*proto.Preset{ + { + Name: "autodetected", + }, + { + Name: "manual", + Parameters: []*proto.PresetParameter{ + { + Name: "param1", + Value: "value1", + }, + }, + }, + }, }, }, }}, @@ -1655,28 +1666,29 @@ func TestPostWorkspaceBuild(t *testing.T) { }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - require.Nil(t, workspace.LatestBuild.TemplateVersionPresetID) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() presets, err := client.TemplateVersionPresets(ctx, version.ID) require.NoError(t, err) - require.Equal(t, 1, len(presets)) - require.Equal(t, "test", presets[0].Name) + require.Equal(t, 2, len(presets)) + require.Equal(t, "autodetected", presets[0].Name) + require.Equal(t, "manual", presets[1].Name) + + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + // Preset ID was detected based on the workspace parameters: + require.Equal(t, presets[0].ID, *workspace.LatestBuild.TemplateVersionPresetID) build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: version.ID, Transition: codersdk.WorkspaceTransitionStart, - TemplateVersionPresetID: presets[0].ID, + TemplateVersionPresetID: presets[1].ID, }) require.NoError(t, err) require.NotNil(t, build.TemplateVersionPresetID) workspace, err = client.Workspace(ctx, workspace.ID) require.NoError(t, err) + require.Equal(t, presets[1].ID, *workspace.LatestBuild.TemplateVersionPresetID) require.Equal(t, build.TemplateVersionPresetID, workspace.LatestBuild.TemplateVersionPresetID) }) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 99ca6e03a5201..e998aeb894c13 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -138,7 +138,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Produce json // @Tags Workspaces -// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task." +// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent." // @Param limit query int false "Page limit" // @Param offset query int false "Page offset" // @Success 200 {object} codersdk.WorkspacesResponse @@ -636,16 +636,37 @@ func createWorkspace( ) // Use injected Clock to allow time mocking in tests - now := api.Clock.Now() + now := dbtime.Time(api.Clock.Now()) + + templateVersionPresetID := req.TemplateVersionPresetID - // If a template preset was chosen, try claim a prebuilt workspace. - if req.TemplateVersionPresetID != uuid.Nil { + // If no preset was chosen, look for a matching preset by parameter values. + if templateVersionPresetID == uuid.Nil { + parameterNames := make([]string, len(req.RichParameterValues)) + parameterValues := make([]string, len(req.RichParameterValues)) + for i, parameter := range req.RichParameterValues { + parameterNames[i] = parameter.Name + parameterValues[i] = parameter.Value + } + var err error + templateVersionID := req.TemplateVersionID + if templateVersionID == uuid.Nil { + templateVersionID = template.ActiveVersionID + } + templateVersionPresetID, err = prebuilds.FindMatchingPresetID(ctx, db, templateVersionID, parameterNames, parameterValues) + if err != nil { + return xerrors.Errorf("find matching preset: %w", err) + } + } + + // Try to claim a prebuilt workspace. + if templateVersionPresetID != uuid.Nil { // Try and claim an eligible prebuild, if available. // On successful claim, initialize all lifecycle fields from template and workspace-level config // so the newly claimed workspace is properly managed by the lifecycle executor. claimedWorkspace, err = claimPrebuild( - ctx, prebuildsClaimer, db, api.Logger, now, req, owner, - dbAutostartSchedule, nextStartAt, dbTTL) + ctx, prebuildsClaimer, db, api.Logger, now, req.Name, owner, + templateVersionPresetID, dbAutostartSchedule, nextStartAt, dbTTL) // If claiming fails with an expected error (no claimable prebuilds or AGPL does not support prebuilds), // we fall back to creating a new workspace. Otherwise, propagate the unexpected error. if err != nil { @@ -654,7 +675,7 @@ func createWorkspace( fields := []any{ slog.Error(err), slog.F("workspace_name", req.Name), - slog.F("template_version_preset_id", req.TemplateVersionPresetID), + slog.F("template_version_preset_id", templateVersionPresetID), } if !isExpectedError { @@ -718,8 +739,8 @@ func createWorkspace( if req.TemplateVersionID != uuid.Nil { builder = builder.VersionID(req.TemplateVersionID) } - if req.TemplateVersionPresetID != uuid.Nil { - builder = builder.TemplateVersionPresetID(req.TemplateVersionPresetID) + if templateVersionPresetID != uuid.Nil { + builder = builder.TemplateVersionPresetID(templateVersionPresetID) } if claimedWorkspace != nil { builder = builder.MarkPrebuiltWorkspaceClaim() @@ -884,13 +905,14 @@ func claimPrebuild( db database.Store, logger slog.Logger, now time.Time, - req codersdk.CreateWorkspaceRequest, + name string, owner workspaceOwner, + templateVersionPresetID uuid.UUID, autostartSchedule sql.NullString, nextStartAt sql.NullTime, ttl sql.NullInt64, ) (*database.Workspace, error) { - claimedID, err := claimer.Claim(ctx, now, owner.ID, req.Name, req.TemplateVersionPresetID, autostartSchedule, nextStartAt, ttl) + claimedID, err := claimer.Claim(ctx, now, owner.ID, name, templateVersionPresetID, autostartSchedule, nextStartAt, ttl) if err != nil { // TODO: enhance this by clarifying whether this *specific* prebuild failed or whether there are none to claim. return nil, xerrors.Errorf("claim prebuild: %w", err) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 443098036af62..4df83114c68a1 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1427,7 +1427,6 @@ func TestWorkspaceFilterAllStatus(t *testing.T) { t.Parallel() // For this test, we do not care about permissions. - // nolint:gocritic // unit testing ctx := dbauthz.AsSystemRestricted(context.Background()) db, pubsub := dbtestutil.NewDB(t) client := coderdtest.New(t, &coderdtest.Options{ @@ -2215,15 +2214,12 @@ func TestWorkspaceFilterManual(t *testing.T) { after := coderdtest.CreateWorkspace(t, client, template.ID) _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, after.LatestBuild.ID) - //nolint:gocritic // Unit testing context err := api.Database.UpdateWorkspaceLastUsedAt(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceLastUsedAtParams{ ID: before.ID, LastUsedAt: now.UTC().Add(time.Hour * -1), }) require.NoError(t, err) - // Unit testing context - //nolint:gocritic // Unit testing context err = api.Database.UpdateWorkspaceLastUsedAt(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceLastUsedAtParams{ ID: after.ID, LastUsedAt: now.UTC().Add(time.Hour * 1), @@ -2916,14 +2912,14 @@ func TestWorkspaceUpdateTTL(t *testing.T) { // This is a hack, but the max_deadline isn't precisely configurable // without a lot of unnecessary hassle. - dbBuild, err := db.GetWorkspaceBuildByID(dbauthz.AsSystemRestricted(ctx), build.ID) //nolint:gocritic // test + dbBuild, err := db.GetWorkspaceBuildByID(dbauthz.AsSystemRestricted(ctx), build.ID) require.NoError(t, err) - dbJob, err := db.GetProvisionerJobByID(dbauthz.AsSystemRestricted(ctx), dbBuild.JobID) //nolint:gocritic // test + dbJob, err := db.GetProvisionerJobByID(dbauthz.AsSystemRestricted(ctx), dbBuild.JobID) require.NoError(t, err) require.True(t, dbJob.CompletedAt.Valid) initialDeadline := dbJob.CompletedAt.Time.Add(deadline) expectedMaxDeadline := dbJob.CompletedAt.Time.Add(maxDeadline) - err = db.UpdateWorkspaceBuildDeadlineByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceBuildDeadlineByIDParams{ //nolint:gocritic // test + err = db.UpdateWorkspaceBuildDeadlineByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceBuildDeadlineByIDParams{ ID: build.ID, Deadline: initialDeadline, MaxDeadline: expectedMaxDeadline, @@ -4507,14 +4503,12 @@ func TestOIDCRemoved(t *testing.T) { user, userData := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) ctx := testutil.Context(t, testutil.WaitMedium) - //nolint:gocritic // unit test _, err := db.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{ NewLoginType: database.LoginTypeOIDC, UserID: userData.ID, }) require.NoError(t, err) - //nolint:gocritic // unit test _, err = db.InsertUserLink(dbauthz.AsSystemRestricted(ctx), database.InsertUserLinkParams{ UserID: userData.ID, LoginType: database.LoginTypeOIDC, @@ -4603,7 +4597,6 @@ func TestWorkspaceFilterHasAITask(t *testing.T) { }) if aiTaskPrompt != nil { - //nolint:gocritic // unit test err := db.InsertWorkspaceBuildParameters(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceBuildParametersParams{ WorkspaceBuildID: build.ID, Name: []string{provider.TaskPromptParameterName}, @@ -4806,7 +4799,6 @@ func TestMultipleAITasksDisallowed(t *testing.T) { ws := coderdtest.CreateWorkspace(t, client, template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - //nolint: gocritic // testing ctx := dbauthz.AsSystemRestricted(t.Context()) pj, err := db.GetProvisionerJobByID(ctx, ws.LatestBuild.Job.ID) require.NoError(t, err) @@ -4915,3 +4907,285 @@ func TestUpdateWorkspaceACL(t *testing.T) { require.Equal(t, cerr.Validations[0].Field, "user_roles") }) } + +func TestWorkspaceCreateWithImplicitPreset(t *testing.T) { + t.Parallel() + + // Helper function to create template with presets + createTemplateWithPresets := func(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse, presets []*proto.Preset) (codersdk.Template, codersdk.TemplateVersion) { + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Presets: presets, + }, + }, + }, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + return template, version + } + + // Helper function to create workspace and verify preset usage + createWorkspaceAndVerifyPreset := func(t *testing.T, client *codersdk.Client, template codersdk.Template, expectedPresetID *uuid.UUID, params []codersdk.WorkspaceBuildParameter) codersdk.Workspace { + wsName := testutil.GetRandomNameHyphenated(t) + var ws codersdk.Workspace + if len(params) > 0 { + ws = coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.Name = wsName + cwr.RichParameterValues = params + }) + } else { + ws = coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.Name = wsName + }) + } + require.Equal(t, wsName, ws.Name) + + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + // Verify the preset was used if expected + if expectedPresetID != nil { + require.NotNil(t, ws.LatestBuild.TemplateVersionPresetID) + require.Equal(t, *expectedPresetID, *ws.LatestBuild.TemplateVersionPresetID) + } else { + require.Nil(t, ws.LatestBuild.TemplateVersionPresetID) + } + + return ws + } + + t.Run("NoPresets", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + // Create template with no presets + template, _ := createTemplateWithPresets(t, client, user, []*proto.Preset{}) + + // Test workspace creation with no parameters + createWorkspaceAndVerifyPreset(t, client, template, nil, nil) + + // Test workspace creation with parameters (should still work, no preset matching) + createWorkspaceAndVerifyPreset(t, client, template, nil, []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1"}, + }) + }) + + t.Run("SinglePresetNoParameters", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + // Create template with single preset that has no parameters + preset := &proto.Preset{ + Name: "empty-preset", + Description: "A preset with no parameters", + Parameters: []*proto.PresetParameter{}, + } + template, version := createTemplateWithPresets(t, client, user, []*proto.Preset{preset}) + + // Get the preset ID from the database + ctx := context.Background() + presets, err := client.TemplateVersionPresets(ctx, version.ID) + require.NoError(t, err) + require.Len(t, presets, 1) + presetID := presets[0].ID + + // Test workspace creation with no parameters - should match the preset + createWorkspaceAndVerifyPreset(t, client, template, &presetID, nil) + + // Test workspace creation with parameters - should not match the preset + createWorkspaceAndVerifyPreset(t, client, template, &presetID, []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1"}, + }) + }) + + t.Run("SinglePresetWithParameters", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + // Create template with single preset that has parameters + preset := &proto.Preset{ + Name: "param-preset", + Description: "A preset with parameters", + Parameters: []*proto.PresetParameter{ + {Name: "param1", Value: "value1"}, + {Name: "param2", Value: "value2"}, + }, + } + template, version := createTemplateWithPresets(t, client, user, []*proto.Preset{preset}) + + // Get the preset ID from the database + ctx := context.Background() + presets, err := client.TemplateVersionPresets(ctx, version.ID) + require.NoError(t, err) + require.Len(t, presets, 1) + presetID := presets[0].ID + + // Test workspace creation with no parameters - should not match the preset + createWorkspaceAndVerifyPreset(t, client, template, nil, nil) + + // Test workspace creation with exact matching parameters - should match the preset + createWorkspaceAndVerifyPreset(t, client, template, &presetID, []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1"}, + {Name: "param2", Value: "value2"}, + }) + + // Test workspace creation with partial matching parameters - should not match the preset + createWorkspaceAndVerifyPreset(t, client, template, nil, []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1"}, + }) + + // Test workspace creation with different parameter values - should not match the preset + createWorkspaceAndVerifyPreset(t, client, template, nil, []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1"}, + {Name: "param2", Value: "different"}, + }) + + // Test workspace creation with extra parameters - should match the preset + createWorkspaceAndVerifyPreset(t, client, template, &presetID, []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1"}, + {Name: "param2", Value: "value2"}, + {Name: "param3", Value: "value3"}, + }) + }) + + t.Run("MultiplePresets", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + // Create template with multiple presets + preset1 := &proto.Preset{ + Name: "empty-preset", + Description: "A preset with no parameters", + Parameters: []*proto.PresetParameter{}, + } + preset2 := &proto.Preset{ + Name: "single-param-preset", + Description: "A preset with one parameter", + Parameters: []*proto.PresetParameter{ + {Name: "param1", Value: "value1"}, + }, + } + preset3 := &proto.Preset{ + Name: "multi-param-preset", + Description: "A preset with multiple parameters", + Parameters: []*proto.PresetParameter{ + {Name: "param1", Value: "value1"}, + {Name: "param2", Value: "value2"}, + }, + } + template, version := createTemplateWithPresets(t, client, user, []*proto.Preset{preset1, preset2, preset3}) + + // Get the preset IDs from the database + ctx := context.Background() + presets, err := client.TemplateVersionPresets(ctx, version.ID) + require.NoError(t, err) + require.Len(t, presets, 3) + + // Sort presets by name to get consistent ordering + var emptyPresetID, singleParamPresetID, multiParamPresetID uuid.UUID + for _, p := range presets { + switch p.Name { + case "empty-preset": + emptyPresetID = p.ID + case "single-param-preset": + singleParamPresetID = p.ID + case "multi-param-preset": + multiParamPresetID = p.ID + } + } + + // Test workspace creation with no parameters - should match empty preset + createWorkspaceAndVerifyPreset(t, client, template, &emptyPresetID, nil) + + // Test workspace creation with single parameter - should match single param preset + createWorkspaceAndVerifyPreset(t, client, template, &singleParamPresetID, []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1"}, + }) + + // Test workspace creation with multiple parameters - should match multi param preset + createWorkspaceAndVerifyPreset(t, client, template, &multiParamPresetID, []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1"}, + {Name: "param2", Value: "value2"}, + }) + + // Test workspace creation with non-matching parameters - should not match any preset + createWorkspaceAndVerifyPreset(t, client, template, &emptyPresetID, []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "different"}, + }) + }) + + t.Run("PresetSpecifiedExplicitly", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + // Create template with multiple presets + preset1 := &proto.Preset{ + Name: "preset1", + Description: "First preset", + Parameters: []*proto.PresetParameter{ + {Name: "param1", Value: "value1"}, + }, + } + preset2 := &proto.Preset{ + Name: "preset2", + Description: "Second preset", + Parameters: []*proto.PresetParameter{ + {Name: "param1", Value: "value2"}, + }, + } + template, version := createTemplateWithPresets(t, client, user, []*proto.Preset{preset1, preset2}) + + // Get the preset IDs from the database + ctx := context.Background() + presets, err := client.TemplateVersionPresets(ctx, version.ID) + require.NoError(t, err) + require.Len(t, presets, 2) + + var preset1ID, preset2ID uuid.UUID + for _, p := range presets { + switch p.Name { + case "preset1": + preset1ID = p.ID + case "preset2": + preset2ID = p.ID + } + } + + // Test workspace creation with preset1 specified explicitly - should use preset1 regardless of parameters + ws := coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TemplateVersionPresetID = preset1ID + cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value2"}, // This would normally match preset2 + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + require.NotNil(t, ws.LatestBuild.TemplateVersionPresetID) + require.Equal(t, preset1ID, *ws.LatestBuild.TemplateVersionPresetID) + + // Test workspace creation with preset2 specified explicitly - should use preset2 regardless of parameters + ws2 := coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TemplateVersionPresetID = preset2ID + cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: "param1", Value: "value1"}, // This would normally match preset1 + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws2.LatestBuild.ID) + require.NotNil(t, ws2.LatestBuild.TemplateVersionPresetID) + require.Equal(t, preset2ID, *ws2.LatestBuild.TemplateVersionPresetID) + }) +} diff --git a/coderd/workspacestats/reporter.go b/coderd/workspacestats/reporter.go index 58d177f1c2071..7a6b1d50034a8 100644 --- a/coderd/workspacestats/reporter.go +++ b/coderd/workspacestats/reporter.go @@ -126,13 +126,8 @@ func (r *Reporter) ReportAgentStats(ctx context.Context, now time.Time, workspac // update prometheus metrics if r.opts.UpdateAgentMetricsFn != nil { - user, err := r.opts.Database.GetUserByID(ctx, workspace.OwnerID) - if err != nil { - return xerrors.Errorf("get user: %w", err) - } - r.opts.UpdateAgentMetricsFn(ctx, prometheusmetrics.AgentMetricLabels{ - Username: user.Username, + Username: workspace.OwnerUsername, WorkspaceName: workspace.Name, AgentName: workspaceAgent.Name, TemplateName: templateName, @@ -149,33 +144,36 @@ func (r *Reporter) ReportAgentStats(ctx context.Context, now time.Time, workspac return nil } - // check next autostart - var nextAutostart time.Time - if workspace.AutostartSchedule.String != "" { - templateSchedule, err := (*(r.opts.TemplateScheduleStore.Load())).Get(ctx, r.opts.Database, workspace.TemplateID) - // If the template schedule fails to load, just default to bumping - // without the next transition and log it. - switch { - case err == nil: - next, allowed := schedule.NextAutostart(now, workspace.AutostartSchedule.String, templateSchedule) - if allowed { - nextAutostart = next + // Prebuilds are not subject to activity-based deadline bumps + if !workspace.IsPrebuild() { + // check next autostart + var nextAutostart time.Time + if workspace.AutostartSchedule.String != "" { + templateSchedule, err := (*(r.opts.TemplateScheduleStore.Load())).Get(ctx, r.opts.Database, workspace.TemplateID) + // If the template schedule fails to load, just default to bumping + // without the next transition and log it. + switch { + case err == nil: + next, allowed := schedule.NextAutostart(now, workspace.AutostartSchedule.String, templateSchedule) + if allowed { + nextAutostart = next + } + case database.IsQueryCanceledError(err): + r.opts.Logger.Debug(ctx, "query canceled while loading template schedule", + slog.F("workspace_id", workspace.ID), + slog.F("template_id", workspace.TemplateID)) + default: + r.opts.Logger.Error(ctx, "failed to load template schedule bumping activity, defaulting to bumping by 60min", + slog.F("workspace_id", workspace.ID), + slog.F("template_id", workspace.TemplateID), + slog.Error(err), + ) } - case database.IsQueryCanceledError(err): - r.opts.Logger.Debug(ctx, "query canceled while loading template schedule", - slog.F("workspace_id", workspace.ID), - slog.F("template_id", workspace.TemplateID)) - default: - r.opts.Logger.Error(ctx, "failed to load template schedule bumping activity, defaulting to bumping by 60min", - slog.F("workspace_id", workspace.ID), - slog.F("template_id", workspace.TemplateID), - slog.Error(err), - ) } - } - // bump workspace activity - ActivityBumpWorkspace(ctx, r.opts.Logger.Named("activity_bump"), r.opts.Database, workspace.ID, nextAutostart) + // bump workspace activity + ActivityBumpWorkspace(ctx, r.opts.Logger.Named("activity_bump"), r.opts.Database, workspace.ID, nextAutostart) + } // bump workspace last_used_at r.opts.UsageTracker.Add(workspace.ID) diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 73e449ee5bb93..223b8bec084ad 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/v2/coderd/dynamicparameters" "github.com/coder/coder/v2/coderd/files" + "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/provisioner/terraform/tfparse" @@ -442,6 +443,20 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object var workspaceBuild database.WorkspaceBuild err = b.store.InTx(func(store database.Store) error { + names, values, err := b.getParameters() + if err != nil { + // getParameters already wraps errors in BuildError + return err + } + + if b.templateVersionPresetID == uuid.Nil { + presetID, err := prebuilds.FindMatchingPresetID(b.ctx, b.store, templateVersionID, names, values) + if err != nil { + return BuildError{http.StatusInternalServerError, "find matching preset", err} + } + b.templateVersionPresetID = presetID + } + err = store.InsertWorkspaceBuild(b.ctx, database.InsertWorkspaceBuildParams{ ID: workspaceBuildID, CreatedAt: now, @@ -473,12 +488,6 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object return BuildError{code, "insert workspace build", err} } - names, values, err := b.getParameters() - if err != nil { - // getParameters already wraps errors in BuildError - return err - } - err = store.InsertWorkspaceBuildParameters(b.ctx, database.InsertWorkspaceBuildParametersParams{ WorkspaceBuildID: workspaceBuildID, Name: names, diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index ee421a8adb649..b862e6459c285 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -82,6 +82,7 @@ func TestBuilder_NoOptions(t *testing.T) { }), withInTx, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), expectBuild(func(bld database.InsertWorkspaceBuildParams) { asrt.Equal(inactiveVersionID, bld.TemplateVersionID) asrt.Equal(workspaceID, bld.WorkspaceID) @@ -132,6 +133,7 @@ func TestBuilder_Initiator(t *testing.T) { asrt.Equal(otherUserID, job.InitiatorID) }), withInTx, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), expectBuild(func(bld database.InsertWorkspaceBuildParams) { asrt.Equal(otherUserID, bld.InitiatorID) }), @@ -180,6 +182,7 @@ func TestBuilder_Baggage(t *testing.T) { asrt.Contains(string(job.TraceMetadata.RawMessage), "ip=127.0.0.1") }), withInTx, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), expectBuild(func(bld database.InsertWorkspaceBuildParams) { }), expectBuildParameters(func(params database.InsertWorkspaceBuildParametersParams) { @@ -219,6 +222,7 @@ func TestBuilder_Reason(t *testing.T) { expectProvisionerJob(func(_ database.InsertProvisionerJobParams) { }), withInTx, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), expectBuild(func(bld database.InsertWorkspaceBuildParams) { asrt.Equal(database.BuildReasonAutostart, bld.Reason) }), @@ -261,6 +265,7 @@ func TestBuilder_ActiveVersion(t *testing.T) { }), withInTx, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), expectBuild(func(bld database.InsertWorkspaceBuildParams) { asrt.Equal(activeVersionID, bld.TemplateVersionID) // no previous build... @@ -386,6 +391,7 @@ func TestWorkspaceBuildWithTags(t *testing.T) { expectBuildParameters(func(_ database.InsertWorkspaceBuildParametersParams) { }), withBuild, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), ) fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) @@ -470,6 +476,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { } }), withBuild, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), ) fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) @@ -519,6 +526,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { } }), withBuild, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), ) fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) @@ -661,6 +669,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { } }), withBuild, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), ) fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) @@ -713,6 +722,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), withInTx, expectBuild(func(bld database.InsertWorkspaceBuildParams) {}), @@ -775,6 +785,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), withInTx, expectBuild(func(bld database.InsertWorkspaceBuildParams) {}), @@ -906,6 +917,7 @@ func TestWorkspaceBuildDeleteOrphan(t *testing.T) { }), withInTx, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), expectBuild(func(bld database.InsertWorkspaceBuildParams) { asrt.Equal(inactiveVersionID, bld.TemplateVersionID) asrt.Equal(workspaceID, bld.WorkspaceID) @@ -968,6 +980,7 @@ func TestWorkspaceBuildDeleteOrphan(t *testing.T) { }), withInTx, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), expectBuild(func(bld database.InsertWorkspaceBuildParams) { asrt.Equal(inactiveVersionID, bld.TemplateVersionID) asrt.Equal(workspaceID, bld.WorkspaceID) @@ -1041,6 +1054,7 @@ func TestWorkspaceBuildUsageChecker(t *testing.T) { // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), withInTx, + expectFindMatchingPresetID(uuid.Nil, sql.ErrNoRows), expectBuild(func(bld database.InsertWorkspaceBuildParams) {}), withBuild, expectBuildParameters(func(params database.InsertWorkspaceBuildParametersParams) {}), @@ -1485,6 +1499,14 @@ func withProvisionerDaemons(provisionerDaemons []database.GetEligibleProvisioner } } +func expectFindMatchingPresetID(id uuid.UUID, err error) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().FindMatchingPresetID(gomock.Any(), gomock.Any()). + Times(1). + Return(id, err) + } +} + type fakeUsageChecker struct { checkBuildUsageFunc func(ctx context.Context, store database.Store, templateVersion *database.TemplateVersion) (wsbuilder.UsageCheckResponse, error) } diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 49d89bf5e2656..965b0fac1d493 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/google/uuid" @@ -47,7 +48,6 @@ func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid. } type CreateTaskRequest struct { - Name string `json:"name"` TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid"` TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"` Prompt string `json:"prompt"` @@ -71,3 +71,105 @@ func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, reques return workspace, nil } + +// TaskState represents the high-level lifecycle of a task. +// +// Experimental: This type is experimental and may change in the future. +type TaskState string + +const ( + TaskStateWorking TaskState = "working" + TaskStateIdle TaskState = "idle" + TaskStateCompleted TaskState = "completed" + TaskStateFailed TaskState = "failed" +) + +// Task represents a task. +// +// Experimental: This type is experimental and may change in the future. +type Task struct { + ID uuid.UUID `json:"id" format:"uuid"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + OwnerID uuid.UUID `json:"owner_id" format:"uuid"` + Name string `json:"name"` + TemplateID uuid.UUID `json:"template_id" format:"uuid"` + WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid"` + InitialPrompt string `json:"initial_prompt"` + Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted"` + CurrentState *TaskStateEntry `json:"current_state"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` +} + +// TaskStateEntry represents a single entry in the task's state history. +// +// Experimental: This type is experimental and may change in the future. +type TaskStateEntry struct { + Timestamp time.Time `json:"timestamp" format:"date-time"` + State TaskState `json:"state" enum:"working,idle,completed,failed"` + Message string `json:"message"` + URI string `json:"uri"` +} + +// TasksFilter filters the list of tasks. +// +// Experimental: This type is experimental and may change in the future. +type TasksFilter struct { + // Owner can be a username, UUID, or "me" + Owner string `json:"owner,omitempty"` +} + +// Tasks lists all tasks belonging to the user or specified owner. +// +// Experimental: This method is experimental and may change in the future. +func (c *ExperimentalClient) Tasks(ctx context.Context, filter *TasksFilter) ([]Task, error) { + if filter == nil { + filter = &TasksFilter{} + } + user := filter.Owner + if user == "" { + user = "me" + } + + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s", user), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + // Experimental response shape for tasks list (server returns []Task). + type tasksListResponse struct { + Tasks []Task `json:"tasks"` + Count int `json:"count"` + } + var tres tasksListResponse + if err := json.NewDecoder(res.Body).Decode(&tres); err != nil { + return nil, err + } + + return tres.Tasks, nil +} + +// TaskByID fetches a single experimental task by its ID. +// +// Experimental: This method is experimental and may change in the future. +func (c *ExperimentalClient) TaskByID(ctx context.Context, id uuid.UUID) (Task, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/tasks/%s/%s", "me", id.String()), nil) + if err != nil { + return Task{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return Task{}, ReadBodyAsError(res) + } + + var task Task + if err := json.NewDecoder(res.Body).Decode(&task); err != nil { + return Task{}, err + } + + return task, nil +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 1d6fa4572772e..a70a6b55500d2 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -88,7 +88,8 @@ const ( // ManagedAgentLimit is a usage period feature, so the value in the license // contains both a soft and hard limit. Refer to // enterprise/coderd/license/license.go for the license format. - FeatureManagedAgentLimit FeatureName = "managed_agent_limit" + FeatureManagedAgentLimit FeatureName = "managed_agent_limit" + FeatureWorkspaceExternalAgent FeatureName = "workspace_external_agent" ) var ( @@ -115,6 +116,7 @@ var ( FeatureMultipleOrganizations, FeatureWorkspacePrebuilds, FeatureManagedAgentLimit, + FeatureWorkspaceExternalAgent, } // FeatureNamesMap is a map of all feature names for quick lookups. @@ -155,6 +157,7 @@ func (n FeatureName) AlwaysEnable() bool { FeatureCustomRoles: true, FeatureMultipleOrganizations: true, FeatureWorkspacePrebuilds: true, + FeatureWorkspaceExternalAgent: true, }[n] } diff --git a/codersdk/initscript.go b/codersdk/initscript.go new file mode 100644 index 0000000000000..d1adbf79460f0 --- /dev/null +++ b/codersdk/initscript.go @@ -0,0 +1,28 @@ +package codersdk + +import ( + "context" + "fmt" + "io" + "net/http" +) + +func (c *Client) InitScript(ctx context.Context, os, arch string) (string, error) { + url := fmt.Sprintf("/api/v2/init-script/%s/%s", os, arch) + res, err := c.Request(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return "", ReadBodyAsError(res) + } + + script, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + return string(script), nil +} diff --git a/codersdk/organizations.go b/codersdk/organizations.go index f87d0eae188ba..bca87c7bd4591 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -344,9 +344,12 @@ func (c *Client) ProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, e } type OrganizationProvisionerDaemonsOptions struct { - Limit int - IDs []uuid.UUID - Tags map[string]string + Limit int + Offline bool + Status []ProvisionerDaemonStatus + MaxAge time.Duration + IDs []uuid.UUID + Tags map[string]string } func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizationID uuid.UUID, opts *OrganizationProvisionerDaemonsOptions) ([]ProvisionerDaemon, error) { @@ -355,6 +358,15 @@ func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizatio if opts.Limit > 0 { qp.Add("limit", strconv.Itoa(opts.Limit)) } + if opts.Offline { + qp.Add("offline", "true") + } + if len(opts.Status) > 0 { + qp.Add("status", joinSlice(opts.Status)) + } + if opts.MaxAge > 0 { + qp.Add("max_age", opts.MaxAge.String()) + } if len(opts.IDs) > 0 { qp.Add("ids", joinSliceStringer(opts.IDs)) } diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index e36f995f1688e..4bff7d7827aa1 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -49,6 +49,14 @@ const ( ProvisionerDaemonBusy ProvisionerDaemonStatus = "busy" ) +func ProvisionerDaemonStatusEnums() []ProvisionerDaemonStatus { + return []ProvisionerDaemonStatus{ + ProvisionerDaemonOffline, + ProvisionerDaemonIdle, + ProvisionerDaemonBusy, + } +} + type ProvisionerDaemon struct { ID uuid.UUID `json:"id" format:"uuid" table:"id"` OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"` diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index a47cbb685898b..992797578630d 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -33,6 +33,8 @@ type TemplateVersion struct { Warnings []TemplateVersionWarning `json:"warnings,omitempty" enums:"DEPRECATED_PARAMETERS"` MatchedProvisioners *MatchedProvisioners `json:"matched_provisioners,omitempty"` + + HasExternalAgent bool `json:"has_external_agent"` } type TemplateVersionExternalAuth struct { diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index 53d2a89290bca..bb9511178c7f4 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -90,6 +90,7 @@ type WorkspaceBuild struct { TemplateVersionPresetID *uuid.UUID `json:"template_version_preset_id" format:"uuid"` HasAITask *bool `json:"has_ai_task,omitempty"` AITaskSidebarAppID *uuid.UUID `json:"ai_task_sidebar_app_id,omitempty" format:"uuid"` + HasExternalAgent *bool `json:"has_external_agent,omitempty"` } // WorkspaceResource describes resources used to create a workspace, for instance: diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 13cb778ab0ae0..39d52325df448 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -689,3 +689,23 @@ func (c *Client) UpdateWorkspaceACL(ctx context.Context, workspaceID uuid.UUID, } return nil } + +// ExternalAgentCredentials contains the credentials needed for an external agent to connect to Coder. +type ExternalAgentCredentials struct { + Command string `json:"command"` + AgentToken string `json:"agent_token"` +} + +func (c *Client) WorkspaceExternalAgentCredentials(ctx context.Context, workspaceID uuid.UUID, agentName string) (ExternalAgentCredentials, error) { + path := fmt.Sprintf("/api/v2/workspaces/%s/external-agent/%s/credentials", workspaceID.String(), agentName) + res, err := c.Request(ctx, http.MethodGet, path, nil) + if err != nil { + return ExternalAgentCredentials{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ExternalAgentCredentials{}, ReadBodyAsError(res) + } + var credentials ExternalAgentCredentials + return credentials, json.NewDecoder(res.Body).Decode(&credentials) +} diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 9c65b7ee9a1e1..bb929c9ba2a04 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -34,8 +34,8 @@ import ( // to the WorkspaceAgentConn, or it may be shared in the case of coderd. If the // conn is shared and closing it is undesirable, you may return ErrNoClose from // opts.CloseFunc. This will ensure the underlying conn is not closed. -func NewAgentConn(conn *tailnet.Conn, opts AgentConnOptions) *AgentConn { - return &AgentConn{ +func NewAgentConn(conn *tailnet.Conn, opts AgentConnOptions) AgentConn { + return &agentConn{ Conn: conn, opts: opts, } @@ -43,23 +43,54 @@ func NewAgentConn(conn *tailnet.Conn, opts AgentConnOptions) *AgentConn { // AgentConn represents a connection to a workspace agent. // @typescript-ignore AgentConn -type AgentConn struct { +type AgentConn interface { + TailnetConn() *tailnet.Conn + + AwaitReachable(ctx context.Context) bool + Close() error + DebugLogs(ctx context.Context) ([]byte, error) + DebugMagicsock(ctx context.Context) ([]byte, error) + DebugManifest(ctx context.Context) ([]byte, error) + DialContext(ctx context.Context, network string, addr string) (net.Conn, error) + GetPeerDiagnostics() tailnet.PeerDiagnostics + ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) + ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error) + Netcheck(ctx context.Context) (healthsdk.AgentNetcheckReport, error) + Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error) + PrometheusMetrics(ctx context.Context) ([]byte, error) + ReconnectingPTY(ctx context.Context, id uuid.UUID, height uint16, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error) + RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error) + SSH(ctx context.Context) (*gonet.TCPConn, error) + SSHClient(ctx context.Context) (*ssh.Client, error) + SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error) + SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn, error) + Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) + WatchContainers(ctx context.Context, logger slog.Logger) (<-chan codersdk.WorkspaceAgentListContainersResponse, io.Closer, error) +} + +// AgentConn represents a connection to a workspace agent. +// @typescript-ignore AgentConn +type agentConn struct { *tailnet.Conn opts AgentConnOptions } +func (c *agentConn) TailnetConn() *tailnet.Conn { + return c.Conn +} + // @typescript-ignore AgentConnOptions type AgentConnOptions struct { AgentID uuid.UUID CloseFunc func() error } -func (c *AgentConn) agentAddress() netip.Addr { +func (c *agentConn) agentAddress() netip.Addr { return tailnet.TailscaleServicePrefix.AddrFromUUID(c.opts.AgentID) } // AwaitReachable waits for the agent to be reachable. -func (c *AgentConn) AwaitReachable(ctx context.Context) bool { +func (c *agentConn) AwaitReachable(ctx context.Context) bool { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -68,7 +99,7 @@ func (c *AgentConn) AwaitReachable(ctx context.Context) bool { // Ping pings the agent and returns the round-trip time. // The bool returns true if the ping was made P2P. -func (c *AgentConn) Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error) { +func (c *agentConn) Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -76,7 +107,7 @@ func (c *AgentConn) Ping(ctx context.Context) (time.Duration, bool, *ipnstate.Pi } // Close ends the connection to the workspace agent. -func (c *AgentConn) Close() error { +func (c *agentConn) Close() error { var cerr error if c.opts.CloseFunc != nil { cerr = c.opts.CloseFunc() @@ -131,7 +162,7 @@ type ReconnectingPTYRequest struct { // ReconnectingPTY spawns a new reconnecting terminal session. // `ReconnectingPTYRequest` should be JSON marshaled and written to the returned net.Conn. // Raw terminal output will be read from the returned net.Conn. -func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error) { +func (c *agentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -171,13 +202,13 @@ func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, w // SSH pipes the SSH protocol over the returned net.Conn. // This connects to the built-in SSH server in the workspace agent. -func (c *AgentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) { +func (c *agentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) { return c.SSHOnPort(ctx, AgentSSHPort) } // SSHOnPort pipes the SSH protocol over the returned net.Conn. // This connects to the built-in SSH server in the workspace agent on the specified port. -func (c *AgentConn) SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn, error) { +func (c *agentConn) SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -190,12 +221,12 @@ func (c *AgentConn) SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn, } // SSHClient calls SSH to create a client -func (c *AgentConn) SSHClient(ctx context.Context) (*ssh.Client, error) { +func (c *agentConn) SSHClient(ctx context.Context) (*ssh.Client, error) { return c.SSHClientOnPort(ctx, AgentSSHPort) } // SSHClientOnPort calls SSH to create a client on a specific port -func (c *AgentConn) SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error) { +func (c *agentConn) SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -218,7 +249,7 @@ func (c *AgentConn) SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Clie } // Speedtest runs a speedtest against the workspace agent. -func (c *AgentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) { +func (c *agentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -242,7 +273,7 @@ func (c *AgentConn) Speedtest(ctx context.Context, direction speedtest.Direction // DialContext dials the address provided in the workspace agent. // The network must be "tcp" or "udp". -func (c *AgentConn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) { +func (c *agentConn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -265,7 +296,7 @@ func (c *AgentConn) DialContext(ctx context.Context, network string, addr string } // ListeningPorts lists the ports that are currently in use by the workspace. -func (c *AgentConn) ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error) { +func (c *agentConn) ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/listening-ports", nil) @@ -282,7 +313,7 @@ func (c *AgentConn) ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgent } // Netcheck returns a network check report from the workspace agent. -func (c *AgentConn) Netcheck(ctx context.Context) (healthsdk.AgentNetcheckReport, error) { +func (c *agentConn) Netcheck(ctx context.Context) (healthsdk.AgentNetcheckReport, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/netcheck", nil) @@ -299,7 +330,7 @@ func (c *AgentConn) Netcheck(ctx context.Context) (healthsdk.AgentNetcheckReport } // DebugMagicsock makes a request to the workspace agent's magicsock debug endpoint. -func (c *AgentConn) DebugMagicsock(ctx context.Context) ([]byte, error) { +func (c *agentConn) DebugMagicsock(ctx context.Context) ([]byte, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() res, err := c.apiRequest(ctx, http.MethodGet, "/debug/magicsock", nil) @@ -319,7 +350,7 @@ func (c *AgentConn) DebugMagicsock(ctx context.Context) ([]byte, error) { // DebugManifest returns the agent's in-memory manifest. Unfortunately this must // be returns as a []byte to avoid an import cycle. -func (c *AgentConn) DebugManifest(ctx context.Context) ([]byte, error) { +func (c *agentConn) DebugManifest(ctx context.Context) ([]byte, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() res, err := c.apiRequest(ctx, http.MethodGet, "/debug/manifest", nil) @@ -338,7 +369,7 @@ func (c *AgentConn) DebugManifest(ctx context.Context) ([]byte, error) { } // DebugLogs returns up to the last 10MB of `/tmp/coder-agent.log` -func (c *AgentConn) DebugLogs(ctx context.Context) ([]byte, error) { +func (c *agentConn) DebugLogs(ctx context.Context) ([]byte, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() res, err := c.apiRequest(ctx, http.MethodGet, "/debug/logs", nil) @@ -357,7 +388,7 @@ func (c *AgentConn) DebugLogs(ctx context.Context) ([]byte, error) { } // PrometheusMetrics returns a response from the agent's prometheus metrics endpoint -func (c *AgentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) { +func (c *agentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() res, err := c.apiRequest(ctx, http.MethodGet, "/debug/prometheus", nil) @@ -376,7 +407,7 @@ func (c *AgentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) { } // ListContainers returns a response from the agent's containers endpoint -func (c *AgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { +func (c *agentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/containers", nil) @@ -391,7 +422,7 @@ func (c *AgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgent return resp, json.NewDecoder(res.Body).Decode(&resp) } -func (c *AgentConn) WatchContainers(ctx context.Context, logger slog.Logger) (<-chan codersdk.WorkspaceAgentListContainersResponse, io.Closer, error) { +func (c *agentConn) WatchContainers(ctx context.Context, logger slog.Logger) (<-chan codersdk.WorkspaceAgentListContainersResponse, io.Closer, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -427,7 +458,7 @@ func (c *AgentConn) WatchContainers(ctx context.Context, logger slog.Logger) (<- // RecreateDevcontainer recreates a devcontainer with the given container. // This is a blocking call and will wait for the container to be recreated. -func (c *AgentConn) RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error) { +func (c *agentConn) RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/containers/devcontainers/"+devcontainerID+"/recreate", nil) @@ -446,7 +477,7 @@ func (c *AgentConn) RecreateDevcontainer(ctx context.Context, devcontainerID str } // apiRequest makes a request to the workspace agent's HTTP API server. -func (c *AgentConn) apiRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { +func (c *agentConn) apiRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -463,7 +494,7 @@ func (c *AgentConn) apiRequest(ctx context.Context, method, path string, body io // apiClient returns an HTTP client that can be used to make // requests to the workspace agent's HTTP API server. -func (c *AgentConn) apiClient() *http.Client { +func (c *agentConn) apiClient() *http.Client { return &http.Client{ Transport: &http.Transport{ // Disable keep alives as we're usually only making a single @@ -504,6 +535,6 @@ func (c *AgentConn) apiClient() *http.Client { } } -func (c *AgentConn) GetPeerDiagnostics() tailnet.PeerDiagnostics { +func (c *agentConn) GetPeerDiagnostics() tailnet.PeerDiagnostics { return c.Conn.GetPeerDiagnostics(c.opts.AgentID) } diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go new file mode 100644 index 0000000000000..eb55bb27938c0 --- /dev/null +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -0,0 +1,373 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: .. (interfaces: AgentConn) +// +// Generated by this command: +// +// mockgen -destination ./agentconnmock.go -package agentconnmock .. AgentConn +// + +// Package agentconnmock is a generated GoMock package. +package agentconnmock + +import ( + context "context" + io "io" + net "net" + reflect "reflect" + time "time" + + slog "cdr.dev/slog" + codersdk "github.com/coder/coder/v2/codersdk" + healthsdk "github.com/coder/coder/v2/codersdk/healthsdk" + workspacesdk "github.com/coder/coder/v2/codersdk/workspacesdk" + tailnet "github.com/coder/coder/v2/tailnet" + uuid "github.com/google/uuid" + gomock "go.uber.org/mock/gomock" + ssh "golang.org/x/crypto/ssh" + gonet "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + ipnstate "tailscale.com/ipn/ipnstate" + speedtest "tailscale.com/net/speedtest" +) + +// MockAgentConn is a mock of AgentConn interface. +type MockAgentConn struct { + ctrl *gomock.Controller + recorder *MockAgentConnMockRecorder + isgomock struct{} +} + +// MockAgentConnMockRecorder is the mock recorder for MockAgentConn. +type MockAgentConnMockRecorder struct { + mock *MockAgentConn +} + +// NewMockAgentConn creates a new mock instance. +func NewMockAgentConn(ctrl *gomock.Controller) *MockAgentConn { + mock := &MockAgentConn{ctrl: ctrl} + mock.recorder = &MockAgentConnMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAgentConn) EXPECT() *MockAgentConnMockRecorder { + return m.recorder +} + +// AwaitReachable mocks base method. +func (m *MockAgentConn) AwaitReachable(ctx context.Context) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AwaitReachable", ctx) + ret0, _ := ret[0].(bool) + return ret0 +} + +// AwaitReachable indicates an expected call of AwaitReachable. +func (mr *MockAgentConnMockRecorder) AwaitReachable(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AwaitReachable", reflect.TypeOf((*MockAgentConn)(nil).AwaitReachable), ctx) +} + +// Close mocks base method. +func (m *MockAgentConn) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockAgentConnMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockAgentConn)(nil).Close)) +} + +// DebugLogs mocks base method. +func (m *MockAgentConn) DebugLogs(ctx context.Context) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DebugLogs", ctx) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DebugLogs indicates an expected call of DebugLogs. +func (mr *MockAgentConnMockRecorder) DebugLogs(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DebugLogs", reflect.TypeOf((*MockAgentConn)(nil).DebugLogs), ctx) +} + +// DebugMagicsock mocks base method. +func (m *MockAgentConn) DebugMagicsock(ctx context.Context) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DebugMagicsock", ctx) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DebugMagicsock indicates an expected call of DebugMagicsock. +func (mr *MockAgentConnMockRecorder) DebugMagicsock(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DebugMagicsock", reflect.TypeOf((*MockAgentConn)(nil).DebugMagicsock), ctx) +} + +// DebugManifest mocks base method. +func (m *MockAgentConn) DebugManifest(ctx context.Context) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DebugManifest", ctx) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DebugManifest indicates an expected call of DebugManifest. +func (mr *MockAgentConnMockRecorder) DebugManifest(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DebugManifest", reflect.TypeOf((*MockAgentConn)(nil).DebugManifest), ctx) +} + +// DialContext mocks base method. +func (m *MockAgentConn) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DialContext", ctx, network, addr) + ret0, _ := ret[0].(net.Conn) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DialContext indicates an expected call of DialContext. +func (mr *MockAgentConnMockRecorder) DialContext(ctx, network, addr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DialContext", reflect.TypeOf((*MockAgentConn)(nil).DialContext), ctx, network, addr) +} + +// GetPeerDiagnostics mocks base method. +func (m *MockAgentConn) GetPeerDiagnostics() tailnet.PeerDiagnostics { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerDiagnostics") + ret0, _ := ret[0].(tailnet.PeerDiagnostics) + return ret0 +} + +// GetPeerDiagnostics indicates an expected call of GetPeerDiagnostics. +func (mr *MockAgentConnMockRecorder) GetPeerDiagnostics() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerDiagnostics", reflect.TypeOf((*MockAgentConn)(nil).GetPeerDiagnostics)) +} + +// ListContainers mocks base method. +func (m *MockAgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListContainers", ctx) + ret0, _ := ret[0].(codersdk.WorkspaceAgentListContainersResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListContainers indicates an expected call of ListContainers. +func (mr *MockAgentConnMockRecorder) ListContainers(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListContainers", reflect.TypeOf((*MockAgentConn)(nil).ListContainers), ctx) +} + +// ListeningPorts mocks base method. +func (m *MockAgentConn) ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListeningPorts", ctx) + ret0, _ := ret[0].(codersdk.WorkspaceAgentListeningPortsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListeningPorts indicates an expected call of ListeningPorts. +func (mr *MockAgentConnMockRecorder) ListeningPorts(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListeningPorts", reflect.TypeOf((*MockAgentConn)(nil).ListeningPorts), ctx) +} + +// Netcheck mocks base method. +func (m *MockAgentConn) Netcheck(ctx context.Context) (healthsdk.AgentNetcheckReport, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Netcheck", ctx) + ret0, _ := ret[0].(healthsdk.AgentNetcheckReport) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Netcheck indicates an expected call of Netcheck. +func (mr *MockAgentConnMockRecorder) Netcheck(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Netcheck", reflect.TypeOf((*MockAgentConn)(nil).Netcheck), ctx) +} + +// Ping mocks base method. +func (m *MockAgentConn) Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Ping", ctx) + ret0, _ := ret[0].(time.Duration) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(*ipnstate.PingResult) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// Ping indicates an expected call of Ping. +func (mr *MockAgentConnMockRecorder) Ping(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockAgentConn)(nil).Ping), ctx) +} + +// PrometheusMetrics mocks base method. +func (m *MockAgentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PrometheusMetrics", ctx) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PrometheusMetrics indicates an expected call of PrometheusMetrics. +func (mr *MockAgentConnMockRecorder) PrometheusMetrics(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrometheusMetrics", reflect.TypeOf((*MockAgentConn)(nil).PrometheusMetrics), ctx) +} + +// ReconnectingPTY mocks base method. +func (m *MockAgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string, initOpts ...workspacesdk.AgentReconnectingPTYInitOption) (net.Conn, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, id, height, width, command} + for _, a := range initOpts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ReconnectingPTY", varargs...) + ret0, _ := ret[0].(net.Conn) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReconnectingPTY indicates an expected call of ReconnectingPTY. +func (mr *MockAgentConnMockRecorder) ReconnectingPTY(ctx, id, height, width, command any, initOpts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, id, height, width, command}, initOpts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReconnectingPTY", reflect.TypeOf((*MockAgentConn)(nil).ReconnectingPTY), varargs...) +} + +// RecreateDevcontainer mocks base method. +func (m *MockAgentConn) RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RecreateDevcontainer", ctx, devcontainerID) + ret0, _ := ret[0].(codersdk.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RecreateDevcontainer indicates an expected call of RecreateDevcontainer. +func (mr *MockAgentConnMockRecorder) RecreateDevcontainer(ctx, devcontainerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecreateDevcontainer", reflect.TypeOf((*MockAgentConn)(nil).RecreateDevcontainer), ctx, devcontainerID) +} + +// SSH mocks base method. +func (m *MockAgentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SSH", ctx) + ret0, _ := ret[0].(*gonet.TCPConn) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SSH indicates an expected call of SSH. +func (mr *MockAgentConnMockRecorder) SSH(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SSH", reflect.TypeOf((*MockAgentConn)(nil).SSH), ctx) +} + +// SSHClient mocks base method. +func (m *MockAgentConn) SSHClient(ctx context.Context) (*ssh.Client, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SSHClient", ctx) + ret0, _ := ret[0].(*ssh.Client) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SSHClient indicates an expected call of SSHClient. +func (mr *MockAgentConnMockRecorder) SSHClient(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SSHClient", reflect.TypeOf((*MockAgentConn)(nil).SSHClient), ctx) +} + +// SSHClientOnPort mocks base method. +func (m *MockAgentConn) SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SSHClientOnPort", ctx, port) + ret0, _ := ret[0].(*ssh.Client) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SSHClientOnPort indicates an expected call of SSHClientOnPort. +func (mr *MockAgentConnMockRecorder) SSHClientOnPort(ctx, port any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SSHClientOnPort", reflect.TypeOf((*MockAgentConn)(nil).SSHClientOnPort), ctx, port) +} + +// SSHOnPort mocks base method. +func (m *MockAgentConn) SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SSHOnPort", ctx, port) + ret0, _ := ret[0].(*gonet.TCPConn) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SSHOnPort indicates an expected call of SSHOnPort. +func (mr *MockAgentConnMockRecorder) SSHOnPort(ctx, port any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SSHOnPort", reflect.TypeOf((*MockAgentConn)(nil).SSHOnPort), ctx, port) +} + +// Speedtest mocks base method. +func (m *MockAgentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Speedtest", ctx, direction, duration) + ret0, _ := ret[0].([]speedtest.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Speedtest indicates an expected call of Speedtest. +func (mr *MockAgentConnMockRecorder) Speedtest(ctx, direction, duration any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Speedtest", reflect.TypeOf((*MockAgentConn)(nil).Speedtest), ctx, direction, duration) +} + +// TailnetConn mocks base method. +func (m *MockAgentConn) TailnetConn() *tailnet.Conn { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TailnetConn") + ret0, _ := ret[0].(*tailnet.Conn) + return ret0 +} + +// TailnetConn indicates an expected call of TailnetConn. +func (mr *MockAgentConnMockRecorder) TailnetConn() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TailnetConn", reflect.TypeOf((*MockAgentConn)(nil).TailnetConn)) +} + +// WatchContainers mocks base method. +func (m *MockAgentConn) WatchContainers(ctx context.Context, logger slog.Logger) (<-chan codersdk.WorkspaceAgentListContainersResponse, io.Closer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WatchContainers", ctx, logger) + ret0, _ := ret[0].(<-chan codersdk.WorkspaceAgentListContainersResponse) + ret1, _ := ret[1].(io.Closer) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// WatchContainers indicates an expected call of WatchContainers. +func (mr *MockAgentConnMockRecorder) WatchContainers(ctx, logger any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WatchContainers", reflect.TypeOf((*MockAgentConn)(nil).WatchContainers), ctx, logger) +} diff --git a/codersdk/workspacesdk/agentconnmock/doc.go b/codersdk/workspacesdk/agentconnmock/doc.go new file mode 100644 index 0000000000000..a795b21a4a89d --- /dev/null +++ b/codersdk/workspacesdk/agentconnmock/doc.go @@ -0,0 +1,4 @@ +// Package agentconnmock contains a mock implementation of workspacesdk.AgentConn for use in tests. +package agentconnmock + +//go:generate mockgen -destination ./agentconnmock.go -package agentconnmock .. AgentConn diff --git a/codersdk/workspacesdk/workspacesdk.go b/codersdk/workspacesdk/workspacesdk.go index 9f587cf5267a8..ddaec06388238 100644 --- a/codersdk/workspacesdk/workspacesdk.go +++ b/codersdk/workspacesdk/workspacesdk.go @@ -202,7 +202,7 @@ func (c *Client) RewriteDERPMap(derpMap *tailcfg.DERPMap) { tailnet.RewriteDERPMapDefaultRelay(context.Background(), c.client.Logger(), derpMap, c.client.URL) } -func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *DialAgentOptions) (agentConn *AgentConn, err error) { +func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *DialAgentOptions) (agentConn AgentConn, err error) { if options == nil { options = &DialAgentOptions{} } diff --git a/cryptorand/numbers.go b/cryptorand/numbers.go index d6a4889b80562..ea1e522a37b0a 100644 --- a/cryptorand/numbers.go +++ b/cryptorand/numbers.go @@ -47,6 +47,12 @@ func Int63() (int64, error) { return rng.Int63(), cs.err } +// Int63n returns a non-negative integer in [0,maxVal) as an int64. +func Int63n(maxVal int64) (int64, error) { + rng, cs := secureRand() + return rng.Int63n(maxVal), cs.err +} + // Intn returns a non-negative integer in [0,maxVal) as an int. func Intn(maxVal int) (int, error) { rng, cs := secureRand() diff --git a/cryptorand/numbers_test.go b/cryptorand/numbers_test.go index aec9c89a7476c..dd47d942dc4e4 100644 --- a/cryptorand/numbers_test.go +++ b/cryptorand/numbers_test.go @@ -19,6 +19,27 @@ func TestInt63(t *testing.T) { } } +func TestInt63n(t *testing.T) { + t.Parallel() + + for i := 0; i < 20; i++ { + v, err := cryptorand.Int63n(100) + require.NoError(t, err, "unexpected error from Int63n") + t.Logf("value: %v <- random?", v) + require.GreaterOrEqual(t, v, int64(0), "values must be positive") + require.Less(t, v, int64(100), "values must be less than 100") + } + + // Ensure Int63n works for int larger than 32 bits + _, err := cryptorand.Int63n(1 << 35) + require.NoError(t, err, "expected Int63n to work for 64-bit int") + + // Expect a panic if max is negative + require.PanicsWithValue(t, "invalid argument to Int63n", func() { + cryptorand.Int63n(0) + }) +} + func TestIntn(t *testing.T) { t.Parallel() diff --git a/docs/about/contributing/AI_CONTRIBUTING.md b/docs/about/contributing/AI_CONTRIBUTING.md new file mode 100644 index 0000000000000..8771528f0c1ce --- /dev/null +++ b/docs/about/contributing/AI_CONTRIBUTING.md @@ -0,0 +1,32 @@ +# AI Contribution Guidelines + +This document defines rules for contributions where an AI system is the primary author of the code (i.e., most of the pull request was generated by AI). +It applies to all Coder repositories and is a supplement to the [existing contributing guidelines](./CONTRIBUTING.md), not a replacement. + +For minor AI-assisted edits, suggestions, or completions where the human contributor is clearly the primary author, these rules do not apply — standard contributing guidelines are sufficient. + +## Disclosure + +Contributors must **disclose AI involvement** in the pull request description whenever these guidelines apply. + +## Human Ownership & Attribution + +- All pull requests must be opened under **user accounts linked to a human**, and not an application ("bot account"). +- Contributors are personally accountable for the content of their PRs, regardless of how it was generated. + +## Verification & Evidence + +All AI-assisted contributions require **manual verification**. +Contributions without verification evidence will be rejected. + +- Test your changes yourself. Don’t assume AI is correct. +- Provide screenshots showing that the change works as intended. + - For visual/UI changes: include before/after screenshots. + - For CLI or backend changes: include terminal or api output. + +## Why These Rules Exist + +Traditionally, maintainers assumed that producing a pull request required more effort than reviewing it. +With AI-assisted tools, the balance has shifted: generating code is often faster than reviewing it. + +Our guidelines exist to safeguard maintainers’ time, uphold contributor accountability, and preserve the overall quality of the project. diff --git a/docs/about/contributing/CONTRIBUTING.md b/docs/about/contributing/CONTRIBUTING.md index 7eedebb146dc5..98243d3790f77 100644 --- a/docs/about/contributing/CONTRIBUTING.md +++ b/docs/about/contributing/CONTRIBUTING.md @@ -236,6 +236,11 @@ Breaking changes can be triggered in two ways: [`release/breaking`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Arelease%2Fbreaking) label to a PR that has, or will be, merged into `main`. +### Generative AI + +Using AI to help with contributions is acceptable, but only if the [AI Contribution Guidelines](./AI_CONTRIBUTING.md) +are followed. If most of your PR was generated by AI, please read and comply with these rules before submitting. + ### Security > [!CAUTION] diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 0232c3d45a0c2..69d85b0d67f72 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -33,9 +33,9 @@ We track the following resources: | PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| | RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| | Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| -| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| +| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| | User
create, write, delete | |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| WorkspaceBuild
start, stop | |
FieldTracked
ai_task_sidebar_app_idfalse
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceBuild
start, stop | |
FieldTracked
ai_task_sidebar_app_idfalse
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| | WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| diff --git a/docs/admin/templates/extending-templates/prebuilt-workspaces.md b/docs/admin/templates/extending-templates/prebuilt-workspaces.md index 8e61687ce0f01..739e13d9130e5 100644 --- a/docs/admin/templates/extending-templates/prebuilt-workspaces.md +++ b/docs/admin/templates/extending-templates/prebuilt-workspaces.md @@ -1,18 +1,12 @@ # Prebuilt workspaces -> [!WARNING] -> Prebuilds Compatibility Limitations: -> Prebuilt workspaces currently do not work reliably with [DevContainers feature](../managing-templates/devcontainers/index.md). -> If your project relies on DevContainer configuration, we recommend disabling prebuilds or carefully testing behavior before enabling them. -> -> We’re actively working to improve compatibility, but for now, please avoid using prebuilds with this feature to ensure stability and expected behavior. +Prebuilt workspaces (prebuilds) reduce workspace creation time with an automatically-maintained pool of +ready-to-use workspaces for specific parameter presets. -Prebuilt workspaces allow template administrators to improve the developer experience by reducing workspace -creation time with an automatically maintained pool of ready-to-use workspaces for specific parameter presets. - -The template administrator configures a template to provision prebuilt workspaces in the background, and then when a developer creates -a new workspace that matches the preset, Coder assigns them an existing prebuilt instance. -Prebuilt workspaces significantly reduce wait times, especially for templates with complex provisioning or lengthy startup procedures. +The template administrator defines the prebuilt workspace's parameters and number of instances to keep provisioned. +The desired number of workspaces are then provisioned transparently. +When a developer creates a new workspace that matches the definition, Coder assigns them an existing prebuilt workspace. +This significantly reduces wait times, especially for templates with complex provisioning or lengthy startup procedures. Prebuilt workspaces are: @@ -21,6 +15,9 @@ Prebuilt workspaces are: - Monitored and replaced automatically to maintain your desired pool size. - Automatically scaled based on time-based schedules to optimize resource usage. +Prebuilt workspaces are a special type of workspace that don't follow the +[regular workspace scheduling features](../../../user-guides/workspace-scheduling.md) like autostart and autostop. Instead, they have their own reconciliation loop that handles prebuild-specific scheduling features such as TTL and prebuild scheduling. + ## Relationship to workspace presets Prebuilt workspaces are tightly integrated with [workspace presets](./parameters.md#workspace-presets): @@ -29,6 +26,7 @@ Prebuilt workspaces are tightly integrated with [workspace presets](./parameters 1. The preset must define all required parameters needed to build the workspace. 1. The preset parameters define the base configuration and are immutable once a prebuilt workspace is provisioned. 1. Parameters that are not defined in the preset can still be customized by users when they claim a workspace. +1. If a user does not select a preset but provides parameters that match one or more presets, Coder will automatically select the most specific matching preset and assign a prebuilt workspace if one is available. ## Prerequisites @@ -52,7 +50,7 @@ instances your Coder deployment should maintain, and optionally configure a `exp prebuilds { instances = 3 # Number of prebuilt workspaces to maintain expiration_policy { - ttl = 86400 # Time (in seconds) after which unclaimed prebuilds are expired (1 day) + ttl = 86400 # Time (in seconds) after which unclaimed prebuilds are expired (86400 = 1 day) } } } @@ -158,17 +156,17 @@ data "coder_workspace_preset" "goland" { **Scheduling configuration:** -- **`timezone`**: The timezone for all cron expressions (required). Only a single timezone is supported per scheduling configuration. -- **`schedule`**: One or more schedule blocks defining when to scale to specific instance counts. - - **`cron`**: Cron expression interpreted as continuous time ranges (required). - - **`instances`**: Number of prebuilt workspaces to maintain during this schedule (required). +- `timezone`: (Required) The timezone for all cron expressions. Only a single timezone is supported per scheduling configuration. +- `schedule`: One or more schedule blocks defining when to scale to specific instance counts. + - `cron`: (Required) Cron expression interpreted as continuous time ranges. + - `instances`: (Required) Number of prebuilt workspaces to maintain during this schedule. **How scheduling works:** 1. The reconciliation loop evaluates all active schedules every reconciliation interval (`CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL`). -2. The schedule that matches the current time becomes active. Overlapping schedules are disallowed by validation rules. -3. If no schedules match the current time, the base `instances` count is used. -4. The reconciliation loop automatically creates or destroys prebuilt workspaces to match the target count. +1. The schedule that matches the current time becomes active. Overlapping schedules are disallowed by validation rules. +1. If no schedules match the current time, the base `instances` count is used. +1. The reconciliation loop automatically creates or destroys prebuilt workspaces to match the target count. **Cron expression format:** @@ -226,7 +224,7 @@ When a template's active version is updated: 1. Prebuilt workspaces for old versions are automatically deleted. 1. New prebuilt workspaces are created for the active template version. 1. If dependencies change (e.g., an [AMI](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AMIs.html) update) without a template version change: - - You may delete the existing prebuilt workspaces manually. + - You can delete the existing prebuilt workspaces manually. - Coder will automatically create new prebuilt workspaces with the updated dependencies. The system always maintains the desired number of prebuilt workspaces for the active template version. @@ -284,23 +282,6 @@ For example, the [`ami`](https://registry.terraform.io/providers/hashicorp/aws/l has [`ForceNew`](https://github.com/hashicorp/terraform-provider-aws/blob/main/internal/service/ec2/ec2_instance.go#L75-L81) set, since the AMI cannot be changed in-place._ -#### Updating claimed prebuilt workspace templates - -Once a prebuilt workspace has been claimed, and if its template uses `ignore_changes`, users may run into an issue where the agent -does not reconnect after a template update. This shortcoming is described in [this issue](https://github.com/coder/coder/issues/17840) -and will be addressed before the next release (v2.23). In the interim, a simple workaround is to restart the workspace -when it is in this problematic state. - -### Current limitations - -The prebuilt workspaces feature has these current limitations: - -- **Organizations** - - Prebuilt workspaces can only be used with the default organization. - - [View issue](https://github.com/coder/internal/issues/364) - ### Monitoring and observability #### Available metrics diff --git a/docs/images/icons/ai_intelligence.svg b/docs/images/icons/ai_intelligence.svg new file mode 100644 index 0000000000000..bcef647bf3c3a --- /dev/null +++ b/docs/images/icons/ai_intelligence.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/manifest.json b/docs/manifest.json index 6e943aa56f697..4a382da8ec25a 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -76,6 +76,12 @@ "description": "Security vulnerability disclosure policy", "path": "./about/contributing/SECURITY.md", "icon_path": "./images/icons/lock.svg" + }, + { + "title": "AI Contribution Guidelines", + "description": "Guidelines for AI-generated contributions.", + "path": "./about/contributing/AI_CONTRIBUTING.md", + "icon_path": "./images/icons/ai_intelligence.svg" } ] } @@ -714,8 +720,8 @@ "path": "./admin/integrations/platformx.md" }, { - "title": "DX", - "description": "Tag Coder Users with DX", + "title": "DX Data Cloud", + "description": "Tag Coder Users with DX Data Cloud", "path": "./admin/integrations/dx-data-cloud.md" }, { @@ -1171,6 +1177,26 @@ "description": "Print auth for an external provider", "path": "reference/cli/external-auth_access-token.md" }, + { + "title": "external-workspaces", + "description": "Create or manage external workspaces", + "path": "reference/cli/external-workspaces.md" + }, + { + "title": "external-workspaces agent-instructions", + "description": "Get the instructions for an external agent", + "path": "reference/cli/external-workspaces_agent-instructions.md" + }, + { + "title": "external-workspaces create", + "description": "Create a new external workspace", + "path": "reference/cli/external-workspaces_create.md" + }, + { + "title": "external-workspaces list", + "description": "List external workspaces", + "path": "reference/cli/external-workspaces_list.md" + }, { "title": "favorite", "description": "Add a workspace to your favorites", diff --git a/docs/reference/api/builds.md b/docs/reference/api/builds.md index a465575baeaa3..526f5bfd25ff1 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -33,6 +33,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -271,6 +272,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -998,6 +1000,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1309,6 +1312,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1528,6 +1532,7 @@ Status Code **200** | `» daily_cost` | integer | false | | | | `» deadline` | string(date-time) | false | | | | `» has_ai_task` | boolean | false | | | +| `» has_external_agent` | boolean | false | | | | `» id` | string(uuid) | false | | | | `» initiator_id` | string(uuid) | false | | | | `» initiator_name` | string | false | | | @@ -1802,6 +1807,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 0ffae1116097d..b6043544d4766 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -4254,3 +4254,42 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get workspace external agent credentials + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/external-agent/{agent}/credentials \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaces/{workspace}/external-agent/{agent}/credentials` + +### Parameters + +| Name | In | Type | Required | Description | +|-------------|------|--------------|----------|--------------| +| `workspace` | path | string(uuid) | true | Workspace ID | +| `agent` | path | string | true | Agent name | + +### Example responses + +> 200 Response + +```json +{ + "agent_token": "string", + "command": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ExternalAgentCredentials](schemas.md#codersdkexternalagentcredentials) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/initscript.md b/docs/reference/api/initscript.md new file mode 100644 index 0000000000000..ecd8c8008a6a4 --- /dev/null +++ b/docs/reference/api/initscript.md @@ -0,0 +1,26 @@ +# InitScript + +## Get agent init script + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/init-script/{os}/{arch} + +``` + +`GET /init-script/{os}/{arch}` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|------------------| +| `os` | path | string | true | Operating system | +| `arch` | path | string | true | Architecture | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | Success | | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index dade031c61bcf..c5e99fcdbfc72 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3322,6 +3322,22 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `mcp-server-http` | | `workspace-sharing` | +## codersdk.ExternalAgentCredentials + +```json +{ + "agent_token": "string", + "command": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `agent_token` | string | false | | | +| `command` | string | false | | | + ## codersdk.ExternalAuth ```json @@ -7614,6 +7630,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -7678,6 +7695,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `archived` | boolean | false | | | | `created_at` | string | false | | | | `created_by` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | +| `has_external_agent` | boolean | false | | | | `id` | string | false | | | | `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | | | `matched_provisioners` | [codersdk.MatchedProvisioners](#codersdkmatchedprovisioners) | false | | | @@ -8069,6 +8087,71 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `user_perms` | object | false | | User perms should be a mapping of user ID to role. The user ID must be the uuid of the user, not a username or email address. | | » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +## codersdk.UpdateTemplateMeta + +```json +{ + "activity_bump_ms": 0, + "allow_user_autostart": true, + "allow_user_autostop": true, + "allow_user_cancel_workspace_jobs": true, + "autostart_requirement": { + "days_of_week": [ + "monday" + ] + }, + "autostop_requirement": { + "days_of_week": [ + "monday" + ], + "weeks": 0 + }, + "cors_behavior": "simple", + "default_ttl_ms": 0, + "deprecation_message": "string", + "description": "string", + "disable_everyone_group_access": true, + "display_name": "string", + "failure_ttl_ms": 0, + "icon": "string", + "max_port_share_level": "owner", + "name": "string", + "require_active_version": true, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, + "update_workspace_dormant_at": true, + "update_workspace_last_used_at": true, + "use_classic_parameter_flow": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------------------------------|--------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `activity_bump_ms` | integer | false | | Activity bump ms allows optionally specifying the activity bump duration for all workspaces created from this template. Defaults to 1h but can be set to 0 to disable activity bumping. | +| `allow_user_autostart` | boolean | false | | | +| `allow_user_autostop` | boolean | false | | | +| `allow_user_cancel_workspace_jobs` | boolean | false | | | +| `autostart_requirement` | [codersdk.TemplateAutostartRequirement](#codersdktemplateautostartrequirement) | false | | | +| `autostop_requirement` | [codersdk.TemplateAutostopRequirement](#codersdktemplateautostoprequirement) | false | | Autostop requirement and AutostartRequirement can only be set if your license includes the advanced template scheduling feature. If you attempt to set this value while unlicensed, it will be ignored. | +| `cors_behavior` | [codersdk.CORSBehavior](#codersdkcorsbehavior) | false | | | +| `default_ttl_ms` | integer | false | | | +| `deprecation_message` | string | false | | Deprecation message if set, will mark the template as deprecated and block any new workspaces from using this template. If passed an empty string, will remove the deprecated message, making the template usable for new workspaces again. | +| `description` | string | false | | | +| `disable_everyone_group_access` | boolean | false | | Disable everyone group access allows optionally disabling the default behavior of granting the 'everyone' group access to use the template. If this is set to true, the template will not be available to all users, and must be explicitly granted to users or groups in the permissions settings of the template. | +| `display_name` | string | false | | | +| `failure_ttl_ms` | integer | false | | | +| `icon` | string | false | | | +| `max_port_share_level` | [codersdk.WorkspaceAgentPortShareLevel](#codersdkworkspaceagentportsharelevel) | false | | | +| `name` | string | false | | | +| `require_active_version` | boolean | false | | Require active version mandates workspaces built using this template use the active version of the template. This option has no effect on template admins. | +| `time_til_dormant_autodelete_ms` | integer | false | | | +| `time_til_dormant_ms` | integer | false | | | +| `update_workspace_dormant_at` | boolean | false | | Update workspace dormant at updates the dormant_at field of workspaces spawned from the template. This is useful for preventing dormant workspaces being immediately deleted when updating the dormant_ttl field to a new, shorter value. | +| `update_workspace_last_used_at` | boolean | false | | Update workspace last used at updates the last_used_at field of workspaces spawned from the template. This is useful for preventing workspaces being immediately locked when updating the inactivity_ttl field to a new, shorter value. | +| `use_classic_parameter_flow` | boolean | false | | Use classic parameter flow is a flag that switches the default behavior to use the classic parameter flow when creating a workspace. This only affects deployments with the experiment "dynamic-parameters" enabled. This setting will live for a period after the experiment is made the default. An "opt-out" is present in case the new feature breaks some existing templates. | + ## codersdk.UpdateUserAppearanceSettingsRequest ```json @@ -8813,6 +8896,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -9923,6 +10007,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -10132,6 +10217,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `daily_cost` | integer | false | | | | `deadline` | string | false | | | | `has_ai_task` | boolean | false | | | +| `has_external_agent` | boolean | false | | | | `id` | string | false | | | | `initiator_id` | string | false | | | | `initiator_name` | string | false | | | @@ -10671,6 +10757,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index ea2e2c50cca7f..db5213bdf8ef5 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -462,6 +462,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -561,6 +562,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -684,6 +686,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -952,7 +955,7 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get template metadata by ID +## Get template settings by ID ### Code samples @@ -1083,24 +1086,64 @@ curl -X DELETE http://coder-server:8080/api/v2/templates/{template} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Update template metadata by ID +## Update template settings by ID ### Code samples ```shell # Example request using curl curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ + -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` `PATCH /templates/{template}` +> Body parameter + +```json +{ + "activity_bump_ms": 0, + "allow_user_autostart": true, + "allow_user_autostop": true, + "allow_user_cancel_workspace_jobs": true, + "autostart_requirement": { + "days_of_week": [ + "monday" + ] + }, + "autostop_requirement": { + "days_of_week": [ + "monday" + ], + "weeks": 0 + }, + "cors_behavior": "simple", + "default_ttl_ms": 0, + "deprecation_message": "string", + "description": "string", + "disable_everyone_group_access": true, + "display_name": "string", + "failure_ttl_ms": 0, + "icon": "string", + "max_port_share_level": "owner", + "name": "string", + "require_active_version": true, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, + "update_workspace_dormant_at": true, + "update_workspace_last_used_at": true, + "use_classic_parameter_flow": true +} +``` + ### Parameters -| Name | In | Type | Required | Description | -|------------|------|--------------|----------|-------------| -| `template` | path | string(uuid) | true | Template ID | +| Name | In | Type | Required | Description | +|------------|------|----------------------------------------------------------------------|----------|---------------------------------| +| `template` | path | string(uuid) | true | Template ID | +| `body` | body | [codersdk.UpdateTemplateMeta](schemas.md#codersdkupdatetemplatemeta) | true | Patch template settings request | ### Example responses @@ -1250,6 +1293,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/versions \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -1327,6 +1371,7 @@ Status Code **200** | `»» avatar_url` | string(uri) | false | | | | `»» id` | string(uuid) | true | | | | `»» username` | string | true | | | +| `» has_external_agent` | boolean | false | | | | `» id` | string(uuid) | false | | | | `» job` | [codersdk.ProvisionerJob](schemas.md#codersdkprovisionerjob) | false | | | | `»» available_workers` | array | false | | | @@ -1531,6 +1576,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/versions/{templ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -1608,6 +1654,7 @@ Status Code **200** | `»» avatar_url` | string(uri) | false | | | | `»» id` | string(uuid) | true | | | | `»» username` | string | true | | | +| `» has_external_agent` | boolean | false | | | | `» id` | string(uuid) | false | | | | `» job` | [codersdk.ProvisionerJob](schemas.md#codersdkprovisionerjob) | false | | | | `»» available_workers` | array | false | | | @@ -1702,6 +1749,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion} \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -1810,6 +1858,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templateversions/{templateversion} "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 70338fdeb1814..ffa18b46c8df9 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -88,6 +88,7 @@ of the template will be used. "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -376,6 +377,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -689,6 +691,7 @@ of the template will be used. "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -930,11 +933,11 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ ### Parameters -| Name | In | Type | Required | Description | -|----------|-------|---------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `q` | query | string | false | Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task. | -| `limit` | query | integer | false | Page limit | -| `offset` | query | integer | false | Page offset | +| Name | In | Type | Required | Description | +|----------|-------|---------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `q` | query | string | false | Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent. | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | ### Example responses @@ -980,6 +983,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1252,6 +1256,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1699,6 +1704,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", diff --git a/docs/reference/cli/external-workspaces.md b/docs/reference/cli/external-workspaces.md new file mode 100644 index 0000000000000..5e1f27a7794ad --- /dev/null +++ b/docs/reference/cli/external-workspaces.md @@ -0,0 +1,29 @@ + +# external-workspaces + +Create or manage external workspaces + +## Usage + +```console +coder external-workspaces [flags] [subcommand] +``` + +## Subcommands + +| Name | Purpose | +|--------------------------------------------------------------------------------|--------------------------------------------| +| [create](./external-workspaces_create.md) | Create a new external workspace | +| [agent-instructions](./external-workspaces_agent-instructions.md) | Get the instructions for an external agent | +| [list](./external-workspaces_list.md) | List external workspaces | + +## Options + +### -O, --org + +| | | +|-------------|----------------------------------| +| Type | string | +| Environment | $CODER_ORGANIZATION | + +Select which organization (uuid or name) to use. diff --git a/docs/reference/cli/external-workspaces_agent-instructions.md b/docs/reference/cli/external-workspaces_agent-instructions.md new file mode 100644 index 0000000000000..d284a48de7173 --- /dev/null +++ b/docs/reference/cli/external-workspaces_agent-instructions.md @@ -0,0 +1,21 @@ + +# external-workspaces agent-instructions + +Get the instructions for an external agent + +## Usage + +```console +coder external-workspaces agent-instructions [flags] [user/]workspace[.agent] +``` + +## Options + +### -o, --output + +| | | +|---------|-------------------------| +| Type | text\|json | +| Default | text | + +Output format. diff --git a/docs/reference/cli/external-workspaces_create.md b/docs/reference/cli/external-workspaces_create.md new file mode 100644 index 0000000000000..b0744387a1d70 --- /dev/null +++ b/docs/reference/cli/external-workspaces_create.md @@ -0,0 +1,128 @@ + +# external-workspaces create + +Create a new external workspace + +## Usage + +```console +coder external-workspaces create [flags] [workspace] +``` + +## Description + +```console + - Create a workspace for another user (if you have permission): + + $ coder create / +``` + +## Options + +### -t, --template + +| | | +|-------------|-----------------------------------| +| Type | string | +| Environment | $CODER_TEMPLATE_NAME | + +Specify a template name. + +### --template-version + +| | | +|-------------|--------------------------------------| +| Type | string | +| Environment | $CODER_TEMPLATE_VERSION | + +Specify a template version name. + +### --preset + +| | | +|-------------|---------------------------------| +| Type | string | +| Environment | $CODER_PRESET_NAME | + +Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used. + +### --start-at + +| | | +|-------------|----------------------------------------| +| Type | string | +| Environment | $CODER_WORKSPACE_START_AT | + +Specify the workspace autostart schedule. Check coder schedule start --help for the syntax. + +### --stop-after + +| | | +|-------------|------------------------------------------| +| Type | duration | +| Environment | $CODER_WORKSPACE_STOP_AFTER | + +Specify a duration after which the workspace should shut down (e.g. 8h). + +### --automatic-updates + +| | | +|-------------|-------------------------------------------------| +| Type | string | +| Environment | $CODER_WORKSPACE_AUTOMATIC_UPDATES | +| Default | never | + +Specify automatic updates setting for the workspace (accepts 'always' or 'never'). + +### --copy-parameters-from + +| | | +|-------------|----------------------------------------------------| +| Type | string | +| Environment | $CODER_WORKSPACE_COPY_PARAMETERS_FROM | + +Specify the source workspace name to copy parameters from. + +### -y, --yes + +| | | +|------|-------------------| +| Type | bool | + +Bypass prompts. + +### --parameter + +| | | +|-------------|------------------------------------| +| Type | string-array | +| Environment | $CODER_RICH_PARAMETER | + +Rich parameter value in the format "name=value". + +### --rich-parameter-file + +| | | +|-------------|-----------------------------------------| +| Type | string | +| Environment | $CODER_RICH_PARAMETER_FILE | + +Specify a file path with values for rich parameters defined in the template. The file should be in YAML format, containing key-value pairs for the parameters. + +### --parameter-default + +| | | +|-------------|--------------------------------------------| +| Type | string-array | +| Environment | $CODER_RICH_PARAMETER_DEFAULT | + +Rich parameter default values in the format "name=value". + +### -O, --org + +| | | +|-------------|----------------------------------| +| Type | string | +| Environment | $CODER_ORGANIZATION | + +Select which organization (uuid or name) to use. diff --git a/docs/reference/cli/external-workspaces_list.md b/docs/reference/cli/external-workspaces_list.md new file mode 100644 index 0000000000000..061aaa29d7a0b --- /dev/null +++ b/docs/reference/cli/external-workspaces_list.md @@ -0,0 +1,51 @@ + +# external-workspaces list + +List external workspaces + +Aliases: + +* ls + +## Usage + +```console +coder external-workspaces list [flags] +``` + +## Options + +### -a, --all + +| | | +|------|-------------------| +| Type | bool | + +Specifies whether all workspaces will be listed or not. + +### --search + +| | | +|---------|-----------------------| +| Type | string | +| Default | owner:me | + +Search for a workspace with a query. + +### -c, --column + +| | | +|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Type | [favorite\|workspace\|organization id\|organization name\|template\|status\|healthy\|last built\|current version\|outdated\|starts at\|starts next\|stops after\|stops next\|daily cost] | +| Default | workspace,template,status,healthy,last built,current version,outdated | + +Columns to display in table output. + +### -o, --output + +| | | +|---------|--------------------------| +| Type | table\|json | +| Default | table | + +Output format. diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 1992e5d6e9ac3..101186eeea91e 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -22,51 +22,52 @@ Coder — A tool for provisioning self-hosted development environments with Terr ## Subcommands -| Name | Purpose | -|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------| -| [completion](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. | -| [dotfiles](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | -| [external-auth](./external-auth.md) | Manage external authentication | -| [login](./login.md) | Authenticate with Coder deployment | -| [logout](./logout.md) | Unauthenticate your local session | -| [netcheck](./netcheck.md) | Print network debug information for DERP and STUN | -| [notifications](./notifications.md) | Manage Coder notifications | -| [organizations](./organizations.md) | Organization related commands | -| [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | -| [publickey](./publickey.md) | Output your Coder public key used for Git operations | -| [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | -| [state](./state.md) | Manually manage Terraform state to fix broken workspaces | -| [templates](./templates.md) | Manage templates | -| [tokens](./tokens.md) | Manage personal access tokens | -| [users](./users.md) | Manage users | -| [version](./version.md) | Show coder version | -| [autoupdate](./autoupdate.md) | Toggle auto-update policy for a workspace | -| [config-ssh](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" | -| [create](./create.md) | Create a workspace | -| [delete](./delete.md) | Delete a workspace | -| [favorite](./favorite.md) | Add a workspace to your favorites | -| [list](./list.md) | List workspaces | -| [open](./open.md) | Open a workspace | -| [ping](./ping.md) | Ping a workspace | -| [rename](./rename.md) | Rename a workspace | -| [restart](./restart.md) | Restart a workspace | -| [schedule](./schedule.md) | Schedule automated start and stop times for workspaces | -| [show](./show.md) | Display details of a workspace's resources and agents | -| [speedtest](./speedtest.md) | Run upload and download tests from your machine to a workspace | -| [ssh](./ssh.md) | Start a shell into a workspace or run a command | -| [start](./start.md) | Start a workspace | -| [stat](./stat.md) | Show resource usage for the current workspace. | -| [stop](./stop.md) | Stop a workspace | -| [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | -| [update](./update.md) | Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. | -| [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | -| [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | -| [server](./server.md) | Start a Coder server | -| [features](./features.md) | List Enterprise features | -| [licenses](./licenses.md) | Add, delete, and list licenses | -| [groups](./groups.md) | Manage groups | -| [prebuilds](./prebuilds.md) | Manage Coder prebuilds | -| [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | +| Name | Purpose | +|--------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------| +| [completion](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. | +| [dotfiles](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | +| [external-auth](./external-auth.md) | Manage external authentication | +| [login](./login.md) | Authenticate with Coder deployment | +| [logout](./logout.md) | Unauthenticate your local session | +| [netcheck](./netcheck.md) | Print network debug information for DERP and STUN | +| [notifications](./notifications.md) | Manage Coder notifications | +| [organizations](./organizations.md) | Organization related commands | +| [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | +| [publickey](./publickey.md) | Output your Coder public key used for Git operations | +| [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | +| [state](./state.md) | Manually manage Terraform state to fix broken workspaces | +| [templates](./templates.md) | Manage templates | +| [tokens](./tokens.md) | Manage personal access tokens | +| [users](./users.md) | Manage users | +| [version](./version.md) | Show coder version | +| [autoupdate](./autoupdate.md) | Toggle auto-update policy for a workspace | +| [config-ssh](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" | +| [create](./create.md) | Create a workspace | +| [delete](./delete.md) | Delete a workspace | +| [favorite](./favorite.md) | Add a workspace to your favorites | +| [list](./list.md) | List workspaces | +| [open](./open.md) | Open a workspace | +| [ping](./ping.md) | Ping a workspace | +| [rename](./rename.md) | Rename a workspace | +| [restart](./restart.md) | Restart a workspace | +| [schedule](./schedule.md) | Schedule automated start and stop times for workspaces | +| [show](./show.md) | Display details of a workspace's resources and agents | +| [speedtest](./speedtest.md) | Run upload and download tests from your machine to a workspace | +| [ssh](./ssh.md) | Start a shell into a workspace or run a command | +| [start](./start.md) | Start a workspace | +| [stat](./stat.md) | Show resource usage for the current workspace. | +| [stop](./stop.md) | Stop a workspace | +| [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | +| [update](./update.md) | Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. | +| [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | +| [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | +| [server](./server.md) | Start a Coder server | +| [features](./features.md) | List Enterprise features | +| [licenses](./licenses.md) | Add, delete, and list licenses | +| [groups](./groups.md) | Manage groups | +| [prebuilds](./prebuilds.md) | Manage Coder prebuilds | +| [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | +| [external-workspaces](./external-workspaces.md) | Create or manage external workspaces | ## Options diff --git a/docs/reference/cli/provisioner_list.md b/docs/reference/cli/provisioner_list.md index 128d76caf4c7e..aa67dcd815f67 100644 --- a/docs/reference/cli/provisioner_list.md +++ b/docs/reference/cli/provisioner_list.md @@ -25,6 +25,33 @@ coder provisioner list [flags] Limit the number of provisioners returned. +### -f, --show-offline + +| | | +|-------------|----------------------------------------------| +| Type | bool | +| Environment | $CODER_PROVISIONER_SHOW_OFFLINE | + +Show offline provisioners. + +### -s, --status + +| | | +|-------------|---------------------------------------------| +| Type | [offline\|idle\|busy] | +| Environment | $CODER_PROVISIONER_LIST_STATUS | + +Filter by provisioner status. + +### -m, --max-age + +| | | +|-------------|----------------------------------------------| +| Type | duration | +| Environment | $CODER_PROVISIONER_LIST_MAX_AGE | + +Filter provisioners by maximum age. + ### -O, --org | | | diff --git a/docs/tutorials/testing-templates.md b/docs/tutorials/testing-templates.md index bcfa33a74e16f..025c0d6ace26f 100644 --- a/docs/tutorials/testing-templates.md +++ b/docs/tutorials/testing-templates.md @@ -86,7 +86,7 @@ jobs: - name: Get short commit SHA to use as template version name id: name - run: echo "version_name=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + run: echo "version_name=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - name: Get latest commit title to use as template version description id: message diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf index e4ed874fd410f..a464972cb05b6 100644 --- a/dogfood/coder/main.tf +++ b/dogfood/coder/main.tf @@ -175,7 +175,6 @@ locals { ], ["us-pittsburgh"])[0] } - data "coder_parameter" "region" { type = "string" name = "Region" @@ -255,7 +254,7 @@ data "coder_parameter" "ai_prompt" { name = "AI Prompt" default = "" description = "Prompt for Claude Code" - mutable = false + mutable = true // Workaround for issue with claiming a prebuild from a preset that does not include this parameter. } provider "docker" { @@ -277,6 +276,74 @@ data "coder_workspace_tags" "tags" { } } +data "coder_parameter" "ide_choices" { + type = "list(string)" + name = "Select IDEs" + form_type = "multi-select" + mutable = true + description = "Choose one or more IDEs to enable in your workspace" + default = jsonencode(["vscode", "code-server", "cursor"]) + option { + name = "VS Code Desktop" + value = "vscode" + icon = "/icon/code.svg" + } + option { + name = "code-server" + value = "code-server" + icon = "/icon/code.svg" + } + option { + name = "VS Code Web" + value = "vscode-web" + icon = "/icon/code.svg" + } + option { + name = "JetBrains IDEs" + value = "jetbrains" + icon = "/icon/jetbrains.svg" + } + option { + name = "JetBrains Fleet" + value = "fleet" + icon = "/icon/fleet.svg" + } + option { + name = "Cursor" + value = "cursor" + icon = "/icon/cursor.svg" + } + option { + name = "Windsurf" + value = "windsurf" + icon = "/icon/windsurf.svg" + } + option { + name = "Zed" + value = "zed" + icon = "/icon/zed.svg" + } +} + +data "coder_parameter" "vscode_channel" { + count = contains(jsondecode(data.coder_parameter.ide_choices.value), "vscode") ? 1 : 0 + type = "string" + name = "VS Code Desktop channel" + description = "Choose the VS Code Desktop channel" + mutable = true + default = "stable" + option { + value = "stable" + name = "Stable" + icon = "/icon/code.svg" + } + option { + value = "insiders" + name = "Insiders" + icon = "/icon/code-insiders.svg" + } +} + module "slackme" { count = data.coder_workspace.me.start_count source = "dev.registry.coder.com/coder/slackme/coder" @@ -292,6 +359,13 @@ module "dotfiles" { agent_id = coder_agent.dev.id } +module "git-config" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/coder/git-config/coder" + version = "1.0.31" + agent_id = coder_agent.dev.id +} + module "git-clone" { count = data.coder_workspace.me.start_count source = "dev.registry.coder.com/coder/git-clone/coder" @@ -309,7 +383,7 @@ module "personalize" { } module "code-server" { - count = data.coder_workspace.me.start_count + count = contains(jsondecode(data.coder_parameter.ide_choices.value), "code-server") ? data.coder_workspace.me.start_count : 0 source = "dev.registry.coder.com/coder/code-server/coder" version = "1.3.1" agent_id = coder_agent.dev.id @@ -319,7 +393,7 @@ module "code-server" { } module "vscode-web" { - count = data.coder_workspace.me.start_count + count = contains(jsondecode(data.coder_parameter.ide_choices.value), "vscode-web") ? data.coder_workspace.me.start_count : 0 source = "dev.registry.coder.com/coder/vscode-web/coder" version = "1.3.1" agent_id = coder_agent.dev.id @@ -331,7 +405,7 @@ module "vscode-web" { } module "jetbrains" { - count = data.coder_workspace.me.start_count + count = contains(jsondecode(data.coder_parameter.ide_choices.value), "jetbrains") ? data.coder_workspace.me.start_count : 0 source = "dev.registry.coder.com/coder/jetbrains/coder" version = "1.0.3" agent_id = coder_agent.dev.id @@ -356,7 +430,7 @@ module "coder-login" { } module "cursor" { - count = data.coder_workspace.me.start_count + count = contains(jsondecode(data.coder_parameter.ide_choices.value), "cursor") ? data.coder_workspace.me.start_count : 0 source = "dev.registry.coder.com/coder/cursor/coder" version = "1.3.0" agent_id = coder_agent.dev.id @@ -364,7 +438,7 @@ module "cursor" { } module "windsurf" { - count = data.coder_workspace.me.start_count + count = contains(jsondecode(data.coder_parameter.ide_choices.value), "windsurf") ? data.coder_workspace.me.start_count : 0 source = "dev.registry.coder.com/coder/windsurf/coder" version = "1.1.1" agent_id = coder_agent.dev.id @@ -372,7 +446,7 @@ module "windsurf" { } module "zed" { - count = data.coder_workspace.me.start_count + count = contains(jsondecode(data.coder_parameter.ide_choices.value), "zed") ? data.coder_workspace.me.start_count : 0 source = "dev.registry.coder.com/coder/zed/coder" version = "1.1.0" agent_id = coder_agent.dev.id @@ -381,7 +455,7 @@ module "zed" { } module "jetbrains-fleet" { - count = data.coder_workspace.me.start_count + count = contains(jsondecode(data.coder_parameter.ide_choices.value), "fleet") ? data.coder_workspace.me.start_count : 0 source = "registry.coder.com/coder/jetbrains-fleet/coder" version = "1.0.1" agent_id = coder_agent.dev.id @@ -399,7 +473,7 @@ module "devcontainers-cli" { module "claude-code" { count = local.has_ai_prompt ? data.coder_workspace.me.start_count : 0 source = "dev.registry.coder.com/coder/claude-code/coder" - version = "~>2.0" + version = "2.0.7" agent_id = coder_agent.dev.id folder = local.repo_dir install_claude_code = true @@ -423,6 +497,11 @@ resource "coder_agent" "dev" { } startup_script_behavior = "blocking" + display_apps { + vscode = contains(jsondecode(data.coder_parameter.ide_choices.value), "vscode") && try(data.coder_parameter.vscode_channel[0].value, "stable") == "stable" + vscode_insiders = contains(jsondecode(data.coder_parameter.ide_choices.value), "vscode") && try(data.coder_parameter.vscode_channel[0].value, "stable") == "insiders" + } + # 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. @@ -810,10 +889,11 @@ resource "coder_env" "claude_task_prompt" { value = data.coder_parameter.ai_prompt.value } -resource "coder_env" "anthropic_api_key" { +# coder exp mcp configure claude-code reads from CLAUDE_API_KEY +resource "coder_env" "claude_api_key" { count = local.has_ai_prompt ? data.coder_workspace.me.start_count : 0 agent_id = coder_agent.dev.id - name = "ANTHROPIC_API_KEY" + name = "CLAUDE_API_KEY" value = var.anthropic_api_key } diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 1ad76a1e44ca9..0519efd72f31b 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -135,6 +135,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "archived": ActionTrack, "source_example_id": ActionIgnore, // Never changes. "has_ai_task": ActionIgnore, // Never changes. + "has_external_agent": ActionIgnore, // Never changes. }, &database.User{}: { "id": ActionTrack, @@ -197,6 +198,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "template_version_preset_id": ActionIgnore, // Never changes. "has_ai_task": ActionIgnore, // Never changes. "ai_task_sidebar_app_id": ActionIgnore, // Never changes. + "has_external_agent": ActionIgnore, // Never changes. }, &database.AuditableGroup{}: { "id": ActionTrack, diff --git a/enterprise/cli/externalworkspaces.go b/enterprise/cli/externalworkspaces.go new file mode 100644 index 0000000000000..081cbb765e170 --- /dev/null +++ b/enterprise/cli/externalworkspaces.go @@ -0,0 +1,261 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + agpl "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" + "github.com/coder/serpent" +) + +type externalAgent struct { + WorkspaceName string `json:"workspace_name"` + AgentName string `json:"agent_name"` + AuthType string `json:"auth_type"` + AuthToken string `json:"auth_token"` + InitScript string `json:"init_script"` +} + +func (r *RootCmd) externalWorkspaces() *serpent.Command { + orgContext := agpl.NewOrganizationContext() + + cmd := &serpent.Command{ + Use: "external-workspaces [subcommand]", + Short: "Create or manage external workspaces", + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Children: []*serpent.Command{ + r.externalWorkspaceCreate(), + r.externalWorkspaceAgentInstructions(), + r.externalWorkspaceList(), + }, + } + + orgContext.AttachOptions(cmd) + return cmd +} + +// externalWorkspaceCreate extends `coder create` to create an external workspace. +func (r *RootCmd) externalWorkspaceCreate() *serpent.Command { + opts := agpl.CreateOptions{ + BeforeCreate: func(ctx context.Context, client *codersdk.Client, _ codersdk.Template, templateVersionID uuid.UUID) error { + version, err := client.TemplateVersion(ctx, templateVersionID) + if err != nil { + return xerrors.Errorf("get template version: %w", err) + } + if !version.HasExternalAgent { + return xerrors.Errorf("template version %q does not have an external agent. Only templates with external agents can be used for external workspace creation", templateVersionID) + } + + return nil + }, + AfterCreate: func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error { + workspace, err := client.WorkspaceByOwnerAndName(ctx, codersdk.Me, workspace.Name, codersdk.WorkspaceOptions{}) + if err != nil { + return xerrors.Errorf("get workspace by name: %w", err) + } + + externalAgents, err := fetchExternalAgents(inv, client, workspace, workspace.LatestBuild.Resources) + if err != nil { + return xerrors.Errorf("fetch external agents: %w", err) + } + + formatted := formatExternalAgent(workspace.Name, externalAgents) + _, err = fmt.Fprintln(inv.Stdout, formatted) + return err + }, + } + + cmd := r.Create(opts) + cmd.Use = "create [workspace]" + cmd.Short = "Create a new external workspace" + cmd.Middleware = serpent.Chain( + cmd.Middleware, + serpent.RequireNArgs(1), + ) + + for i := range cmd.Options { + if cmd.Options[i].Flag == "template" { + cmd.Options[i].Required = true + } + } + + return cmd +} + +// externalWorkspaceAgentInstructions prints the instructions for an external agent. +func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command { + client := new(codersdk.Client) + formatter := cliui.NewOutputFormatter( + cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) { + agent, ok := data.(externalAgent) + if !ok { + return "", xerrors.Errorf("expected externalAgent, got %T", data) + } + + return formatExternalAgent(agent.WorkspaceName, []externalAgent{agent}), nil + }), + cliui.JSONFormat(), + ) + + cmd := &serpent.Command{ + Use: "agent-instructions [user/]workspace[.agent]", + Short: "Get the instructions for an external agent", + Middleware: serpent.Chain(r.InitClient(client), serpent.RequireNArgs(1)), + Handler: func(inv *serpent.Invocation) error { + workspace, workspaceAgent, _, err := agpl.GetWorkspaceAndAgent(inv.Context(), inv, client, false, inv.Args[0]) + if err != nil { + return xerrors.Errorf("find workspace and agent: %w", err) + } + + credentials, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, workspaceAgent.Name) + if err != nil { + return xerrors.Errorf("get external agent token for agent %q: %w", workspaceAgent.Name, err) + } + + agentInfo := externalAgent{ + WorkspaceName: workspace.Name, + AgentName: workspaceAgent.Name, + AuthType: "token", + AuthToken: credentials.AgentToken, + InitScript: credentials.Command, + } + + out, err := formatter.Format(inv.Context(), agentInfo) + if err != nil { + return err + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} + +func (r *RootCmd) externalWorkspaceList() *serpent.Command { + var ( + filter cliui.WorkspaceFilter + formatter = cliui.NewOutputFormatter( + cliui.TableFormat( + []agpl.WorkspaceListRow{}, + []string{ + "workspace", + "template", + "status", + "healthy", + "last built", + "current version", + "outdated", + }, + ), + cliui.JSONFormat(), + ) + ) + client := new(codersdk.Client) + cmd := &serpent.Command{ + Annotations: map[string]string{ + "workspaces": "", + }, + Use: "list", + Short: "List external workspaces", + Aliases: []string{"ls"}, + Middleware: serpent.Chain( + serpent.RequireNArgs(0), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + baseFilter := filter.Filter() + + if baseFilter.FilterQuery == "" { + baseFilter.FilterQuery = "has_external_agent:true" + } else { + baseFilter.FilterQuery += " has_external_agent:true" + } + + res, err := agpl.QueryConvertWorkspaces(inv.Context(), client, baseFilter, agpl.WorkspaceListRowFromWorkspace) + if err != nil { + return err + } + + if len(res) == 0 && formatter.FormatID() != cliui.JSONFormat().ID() { + pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n") + _, _ = fmt.Fprintln(inv.Stderr) + _, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder external-workspaces create ")) + _, _ = fmt.Fprintln(inv.Stderr) + return nil + } + + out, err := formatter.Format(inv.Context(), res) + if err != nil { + return err + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + filter.AttachOptions(&cmd.Options) + formatter.AttachOptions(&cmd.Options) + return cmd +} + +// fetchExternalAgents fetches the external agents for a workspace. +func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, resources []codersdk.WorkspaceResource) ([]externalAgent, error) { + if len(resources) == 0 { + return nil, xerrors.Errorf("no resources found for workspace") + } + + var externalAgents []externalAgent + + for _, resource := range resources { + if resource.Type != "coder_external_agent" || len(resource.Agents) == 0 { + continue + } + + agent := resource.Agents[0] + credentials, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, agent.Name) + if err != nil { + return nil, xerrors.Errorf("get external agent token for agent %q: %w", agent.Name, err) + } + + externalAgents = append(externalAgents, externalAgent{ + AgentName: agent.Name, + AuthType: "token", + AuthToken: credentials.AgentToken, + InitScript: credentials.Command, + }) + } + + return externalAgents, nil +} + +// formatExternalAgent formats the instructions for an external agent. +func formatExternalAgent(workspaceName string, externalAgents []externalAgent) string { + var output strings.Builder + _, _ = output.WriteString(fmt.Sprintf("\nPlease run the following command to attach external agent to the workspace %s:\n\n", cliui.Keyword(workspaceName))) + + for i, agent := range externalAgents { + if len(externalAgents) > 1 { + _, _ = output.WriteString(fmt.Sprintf("For agent %s:\n", cliui.Keyword(agent.AgentName))) + } + + _, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, agent.InitScript))) + + if i < len(externalAgents)-1 { + _, _ = output.WriteString("\n") + } + } + + return output.String() +} diff --git a/enterprise/cli/externalworkspaces_test.go b/enterprise/cli/externalworkspaces_test.go new file mode 100644 index 0000000000000..9ce39c7c28afb --- /dev/null +++ b/enterprise/cli/externalworkspaces_test.go @@ -0,0 +1,560 @@ +package cli_test + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +// completeWithExternalAgent creates a template version with an external agent resource +func completeWithExternalAgent() *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{ + { + Type: "coder_external_agent", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "external-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + HasExternalAgents: true, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ + { + Type: "coder_external_agent", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "external-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// completeWithRegularAgent creates a template version with a regular agent (no external agent) +func completeWithRegularAgent() *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{ + { + Type: "compute", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "regular-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ + { + Type: "compute", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "regular-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestExternalWorkspaces(t *testing.T) { + t.Parallel() + + t.Run("Create", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + "--template", template.Name, + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + // Expect the workspace creation confirmation + pty.ExpectMatch("coder_external_agent.main") + pty.ExpectMatch("external-agent (linux, amd64)") + pty.ExpectMatch("Confirm create") + pty.WriteLine("yes") + + // Expect the external agent instructions + pty.ExpectMatch("Please run the following command to attach external agent") + pty.ExpectRegexMatch("curl -fsSL .* | CODER_AGENT_TOKEN=.* sh") + + ctx := testutil.Context(t, testutil.WaitLong) + testutil.TryReceive(ctx, t, doneChan) + + // Verify the workspace was created + ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-external-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err) + assert.Equal(t, template.Name, ws.TemplateName) + }) + + t.Run("CreateWithoutTemplate", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "Missing values for the required flags: template") + }) + + t.Run("CreateWithRegularTemplate", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithRegularAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + "--template", template.Name, + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "does not have an external agent") + }) + + t.Run("List", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "list", + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + done := make(chan any) + go func() { + errC := inv.WithContext(ctx).Run() + assert.NoError(t, errC) + close(done) + }() + pty.ExpectMatch(ws.Name) + pty.ExpectMatch(template.Name) + cancelFunc() + <-done + }) + + t.Run("ListJSON", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "list", + "--output=json", + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, member, root) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var workspaces []codersdk.Workspace + require.NoError(t, json.Unmarshal(out.Bytes(), &workspaces)) + require.Len(t, workspaces, 1) + assert.Equal(t, ws.Name, workspaces[0].Name) + }) + + t.Run("ListNoWorkspaces", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + args := []string{ + "external-workspaces", + "list", + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + done := make(chan any) + go func() { + errC := inv.WithContext(ctx).Run() + assert.NoError(t, errC) + close(done) + }() + pty.ExpectMatch("No workspaces found!") + pty.ExpectMatch("coder external-workspaces create") + cancelFunc() + <-done + }) + + t.Run("AgentInstructions", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "agent-instructions", + ws.Name, + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + done := make(chan any) + go func() { + errC := inv.WithContext(ctx).Run() + assert.NoError(t, errC) + close(done) + }() + pty.ExpectMatch("Please run the following command to attach external agent to the workspace") + pty.ExpectRegexMatch("curl -fsSL .* | CODER_AGENT_TOKEN=.* sh") + cancelFunc() + + ctx = testutil.Context(t, testutil.WaitLong) + testutil.TryReceive(ctx, t, done) + }) + + t.Run("AgentInstructionsJSON", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "agent-instructions", + ws.Name, + "--output=json", + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, member, root) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var agentInfo map[string]interface{} + require.NoError(t, json.Unmarshal(out.Bytes(), &agentInfo)) + assert.Equal(t, "token", agentInfo["auth_type"]) + assert.NotEmpty(t, agentInfo["auth_token"]) + assert.NotEmpty(t, agentInfo["init_script"]) + }) + + t.Run("AgentInstructionsNonExistentWorkspace", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + args := []string{ + "external-workspaces", + "agent-instructions", + "non-existent-workspace", + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "Resource not found") + }) + + t.Run("AgentInstructionsNonExistentAgent", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "agent-instructions", + ws.Name + ".non-existent-agent", + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "agent not found by name") + }) + + t.Run("CreateWithTemplateVersion", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + "--template", template.Name, + "--template-version", version.Name, + "-y", + } + inv, root := newCLI(t, args...) + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + // Expect the workspace creation confirmation + pty.ExpectMatch("coder_external_agent.main") + pty.ExpectMatch("external-agent (linux, amd64)") + + // Expect the external agent instructions + pty.ExpectMatch("Please run the following command to attach external agent") + pty.ExpectRegexMatch("curl -fsSL .* | CODER_AGENT_TOKEN=.* sh") + + ctx := testutil.Context(t, testutil.WaitLong) + testutil.TryReceive(ctx, t, doneChan) + + // Verify the workspace was created + ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-external-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err) + assert.Equal(t, template.Name, ws.TemplateName) + }) +} diff --git a/enterprise/cli/prebuilds_test.go b/enterprise/cli/prebuilds_test.go index 76d11a41d67f0..cf0c74105020c 100644 --- a/enterprise/cli/prebuilds_test.go +++ b/enterprise/cli/prebuilds_test.go @@ -434,7 +434,6 @@ func TestSchedulePrebuilds(t *testing.T) { }).Do() // Mark the prebuilt workspace's agent as ready so the prebuild can be claimed - // nolint:gocritic ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong)) agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, uuid.MustParse(workspaceBuild.AgentToken)) require.NoError(t, err) diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index 5b101fdbbb4b8..ed54a76f90487 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -19,6 +19,7 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command { r.prebuilds(), r.provisionerDaemons(), r.provisionerd(), + r.externalWorkspaces(), } } diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 3b1fd63ab1c4c..f58ec86b58a43 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -20,6 +20,7 @@ import ( "github.com/coder/coder/v2/enterprise/audit/backends" "github.com/coder/coder/v2/enterprise/coderd" "github.com/coder/coder/v2/enterprise/coderd/dormancy" + "github.com/coder/coder/v2/enterprise/coderd/usage" "github.com/coder/coder/v2/enterprise/dbcrypt" "github.com/coder/coder/v2/enterprise/trialer" "github.com/coder/coder/v2/tailnet" @@ -116,11 +117,33 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { o.ExternalTokenEncryption = cs } + if o.LicenseKeys == nil { + o.LicenseKeys = coderd.Keys + } + + closers := &multiCloser{} + + // Create the enterprise API. api, err := coderd.New(ctx, o) if err != nil { return nil, nil, err } - return api.AGPL, api, nil + closers.Add(api) + + // Start the enterprise usage publisher routine. This won't do anything + // unless the deployment is licensed and one of the licenses has usage + // publishing enabled. + publisher := usage.NewTallymanPublisher(ctx, options.Logger, options.Database, o.LicenseKeys, + usage.PublisherWithHTTPClient(api.HTTPClient), + ) + err = publisher.Start() + if err != nil { + _ = closers.Close() + return nil, nil, xerrors.Errorf("start usage publisher: %w", err) + } + closers.Add(publisher) + + return api.AGPL, closers, nil }) cmd.AddSubcommands( @@ -128,3 +151,23 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { ) return cmd } + +type multiCloser struct { + closers []io.Closer +} + +var _ io.Closer = &multiCloser{} + +func (m *multiCloser) Add(closer io.Closer) { + m.closers = append(m.closers, closer) +} + +func (m *multiCloser) Close() error { + var errs []error + for _, closer := range m.closers { + if err := closer.Close(); err != nil { + errs = append(errs, xerrors.Errorf("close %T: %w", closer, err)) + } + } + return errors.Join(errs...) +} diff --git a/enterprise/cli/testdata/coder_--help.golden b/enterprise/cli/testdata/coder_--help.golden index fc16bb29b9010..ddb44f78ae524 100644 --- a/enterprise/cli/testdata/coder_--help.golden +++ b/enterprise/cli/testdata/coder_--help.golden @@ -14,12 +14,13 @@ USAGE: $ coder templates init SUBCOMMANDS: - features List Enterprise features - groups Manage groups - licenses Add, delete, and list licenses - prebuilds Manage Coder prebuilds - provisioner View and manage provisioner daemons and jobs - server Start a Coder server + external-workspaces Create or manage external workspaces + features List Enterprise features + groups Manage groups + licenses Add, delete, and list licenses + prebuilds Manage Coder prebuilds + provisioner View and manage provisioner daemons and jobs + server Start a Coder server GLOBAL OPTIONS: Global options are applied to all commands. They can be set using environment diff --git a/enterprise/cli/testdata/coder_external-workspaces_--help.golden b/enterprise/cli/testdata/coder_external-workspaces_--help.golden new file mode 100644 index 0000000000000..d8b1ca8363f66 --- /dev/null +++ b/enterprise/cli/testdata/coder_external-workspaces_--help.golden @@ -0,0 +1,18 @@ +coder v0.0.0-devel + +USAGE: + coder external-workspaces [flags] [subcommand] + + Create or manage external workspaces + +SUBCOMMANDS: + agent-instructions Get the instructions for an external agent + create Create a new external workspace + list List external workspaces + +OPTIONS: + -O, --org string, $CODER_ORGANIZATION + Select which organization (uuid or name) to use. + +——— +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden b/enterprise/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden new file mode 100644 index 0000000000000..150a21313ed8c --- /dev/null +++ b/enterprise/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden @@ -0,0 +1,13 @@ +coder v0.0.0-devel + +USAGE: + coder external-workspaces agent-instructions [flags] [user/]workspace[.agent] + + Get the instructions for an external agent + +OPTIONS: + -o, --output text|json (default: text) + Output format. + +——— +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_external-workspaces_create_--help.golden b/enterprise/cli/testdata/coder_external-workspaces_create_--help.golden new file mode 100644 index 0000000000000..208d2cc2296d7 --- /dev/null +++ b/enterprise/cli/testdata/coder_external-workspaces_create_--help.golden @@ -0,0 +1,56 @@ +coder v0.0.0-devel + +USAGE: + coder external-workspaces create [flags] [workspace] + + Create a new external workspace + + - Create a workspace for another user (if you have permission): + + $ coder create / + +OPTIONS: + -O, --org string, $CODER_ORGANIZATION + Select which organization (uuid or name) to use. + + --automatic-updates string, $CODER_WORKSPACE_AUTOMATIC_UPDATES (default: never) + Specify automatic updates setting for the workspace (accepts 'always' + or 'never'). + + --copy-parameters-from string, $CODER_WORKSPACE_COPY_PARAMETERS_FROM + Specify the source workspace name to copy parameters from. + + --parameter string-array, $CODER_RICH_PARAMETER + Rich parameter value in the format "name=value". + + --parameter-default string-array, $CODER_RICH_PARAMETER_DEFAULT + Rich parameter default values in the format "name=value". + + --preset string, $CODER_PRESET_NAME + Specify the name of a template version preset. Use 'none' to + explicitly indicate that no preset should be used. + + --rich-parameter-file string, $CODER_RICH_PARAMETER_FILE + Specify a file path with values for rich parameters defined in the + template. The file should be in YAML format, containing key-value + pairs for the parameters. + + --start-at string, $CODER_WORKSPACE_START_AT + Specify the workspace autostart schedule. Check coder schedule start + --help for the syntax. + + --stop-after duration, $CODER_WORKSPACE_STOP_AFTER + Specify a duration after which the workspace should shut down (e.g. + 8h). + + -t, --template string, $CODER_TEMPLATE_NAME + Specify a template name. + + --template-version string, $CODER_TEMPLATE_VERSION + Specify a template version name. + + -y, --yes bool + Bypass prompts. + +——— +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_external-workspaces_list_--help.golden b/enterprise/cli/testdata/coder_external-workspaces_list_--help.golden new file mode 100644 index 0000000000000..1210bea5aa186 --- /dev/null +++ b/enterprise/cli/testdata/coder_external-workspaces_list_--help.golden @@ -0,0 +1,24 @@ +coder v0.0.0-devel + +USAGE: + coder external-workspaces list [flags] + + List external workspaces + + Aliases: ls + +OPTIONS: + -a, --all bool + Specifies whether all workspaces will be listed or not. + + -c, --column [favorite|workspace|organization id|organization name|template|status|healthy|last built|current version|outdated|starts at|starts next|stops after|stops next|daily cost] (default: workspace,template,status,healthy,last built,current version,outdated) + Columns to display in table output. + + -o, --output table|json (default: table) + Output format. + + --search string (default: owner:me) + Search for a workspace with a query. + +——— +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_provisioner_list_--help.golden b/enterprise/cli/testdata/coder_provisioner_list_--help.golden index 7a1807bb012f5..ce6d0754073a4 100644 --- a/enterprise/cli/testdata/coder_provisioner_list_--help.golden +++ b/enterprise/cli/testdata/coder_provisioner_list_--help.golden @@ -17,8 +17,17 @@ OPTIONS: -l, --limit int, $CODER_PROVISIONER_LIST_LIMIT (default: 50) Limit the number of provisioners returned. + -m, --max-age duration, $CODER_PROVISIONER_LIST_MAX_AGE + Filter provisioners by maximum age. + -o, --output table|json (default: table) Output format. + -f, --show-offline bool, $CODER_PROVISIONER_SHOW_OFFLINE + Show offline provisioners. + + -s, --status [offline|idle|busy], $CODER_PROVISIONER_LIST_STATUS + Filter by provisioner status. + ——— Run `coder --help` for a list of global options. diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 40569ead70658..a81e16585473b 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/coder/coder/v2/buildinfo" @@ -21,10 +22,12 @@ import ( "github.com/coder/coder/v2/coderd/pproflabel" agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/rbac/policy" + agplusage "github.com/coder/coder/v2/coderd/usage" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/enterprise/coderd/connectionlog" "github.com/coder/coder/v2/enterprise/coderd/enidpsync" "github.com/coder/coder/v2/enterprise/coderd/portsharing" + "github.com/coder/coder/v2/enterprise/coderd/usage" "github.com/coder/quartz" "golang.org/x/xerrors" @@ -90,6 +93,13 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { if options.Entitlements == nil { options.Entitlements = entitlements.New() } + if options.Options.UsageInserter == nil { + options.Options.UsageInserter = &atomic.Pointer[agplusage.Inserter]{} + } + if options.Options.UsageInserter.Load() == nil { + collector := usage.NewDBInserter() + options.Options.UsageInserter.Store(&collector) + } ctx, cancelFunc := context.WithCancel(ctx) @@ -506,6 +516,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { apiKeyMiddleware, httpmw.ExtractNotificationTemplateParam(options.Database), ).Put("/notifications/templates/{notification_template}/method", api.updateNotificationTemplateMethod) + + r.Route("/workspaces/{workspace}/external-agent", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + httpmw.ExtractWorkspaceParam(options.Database), + api.RequireFeatureMW(codersdk.FeatureWorkspaceExternalAgent), + ) + r.Get("/{agent}/credentials", api.workspaceExternalAgentCredentials) + }) }) if len(options.SCIMAPIKey) != 0 { @@ -920,17 +939,9 @@ func (api *API) updateEntitlements(ctx context.Context) error { } reloadedEntitlements.Features[codersdk.FeatureExternalTokenEncryption] = featureExternalTokenEncryption - // If there's a license installed, we will use the enterprise build - // limit checker. - // This checker currently only enforces the managed agent limit. - if reloadedEntitlements.HasLicense { - var checker wsbuilder.UsageChecker = api - api.AGPL.BuildUsageChecker.Store(&checker) - } else { - // Don't check any usage, just like AGPL. - var checker wsbuilder.UsageChecker = wsbuilder.NoopUsageChecker{} - api.AGPL.BuildUsageChecker.Store(&checker) - } + // Always use the enterprise usage checker + var checker wsbuilder.UsageChecker = api + api.AGPL.BuildUsageChecker.Store(&checker) return reloadedEntitlements, nil }) @@ -939,9 +950,17 @@ func (api *API) updateEntitlements(ctx context.Context) error { var _ wsbuilder.UsageChecker = &API{} func (api *API) CheckBuildUsage(ctx context.Context, store database.Store, templateVersion *database.TemplateVersion) (wsbuilder.UsageCheckResponse, error) { - // We assume that if this function is called, a valid license is installed. - // When there are no licenses installed, a noop usage checker is used - // instead. + // If the template version has an external agent, we need to check that the + // license is entitled to this feature. + if templateVersion.HasExternalAgent.Valid && templateVersion.HasExternalAgent.Bool { + feature, ok := api.Entitlements.Feature(codersdk.FeatureWorkspaceExternalAgent) + if !ok || !feature.Enabled { + return wsbuilder.UsageCheckResponse{ + Permitted: false, + Message: "You have a template which uses external agents but your license is not entitled to this feature. You will be unable to create new workspaces from these templates.", + }, nil + } + } // If the template version doesn't have an AI task, we don't need to check // usage. @@ -951,32 +970,35 @@ func (api *API) CheckBuildUsage(ctx context.Context, store database.Store, templ }, nil } - // Otherwise, we need to check that we haven't breached the managed agent + // When unlicensed, we need to check that we haven't breached the managed agent // limit. - managedAgentLimit, ok := api.Entitlements.Feature(codersdk.FeatureManagedAgentLimit) - if !ok || !managedAgentLimit.Enabled || managedAgentLimit.Limit == nil || managedAgentLimit.UsagePeriod == nil { - return wsbuilder.UsageCheckResponse{ - Permitted: false, - Message: "Your license is not entitled to managed agents. Please contact sales to continue using managed agents.", - }, nil - } + // Unlicensed deployments are allowed to use unlimited managed agents. + if api.Entitlements.HasLicense() { + managedAgentLimit, ok := api.Entitlements.Feature(codersdk.FeatureManagedAgentLimit) + if !ok || !managedAgentLimit.Enabled || managedAgentLimit.Limit == nil || managedAgentLimit.UsagePeriod == nil { + return wsbuilder.UsageCheckResponse{ + Permitted: false, + Message: "Your license is not entitled to managed agents. Please contact sales to continue using managed agents.", + }, nil + } - // This check is intentionally not committed to the database. It's fine if - // it's not 100% accurate or allows for minor breaches due to build races. - // nolint:gocritic // Requires permission to read all workspaces to read managed agent count. - managedAgentCount, err := store.GetManagedAgentCount(agpldbauthz.AsSystemRestricted(ctx), database.GetManagedAgentCountParams{ - StartTime: managedAgentLimit.UsagePeriod.Start, - EndTime: managedAgentLimit.UsagePeriod.End, - }) - if err != nil { - return wsbuilder.UsageCheckResponse{}, xerrors.Errorf("get managed agent count: %w", err) - } + // This check is intentionally not committed to the database. It's fine if + // it's not 100% accurate or allows for minor breaches due to build races. + // nolint:gocritic // Requires permission to read all workspaces to read managed agent count. + managedAgentCount, err := store.GetManagedAgentCount(agpldbauthz.AsSystemRestricted(ctx), database.GetManagedAgentCountParams{ + StartTime: managedAgentLimit.UsagePeriod.Start, + EndTime: managedAgentLimit.UsagePeriod.End, + }) + if err != nil { + return wsbuilder.UsageCheckResponse{}, xerrors.Errorf("get managed agent count: %w", err) + } - if managedAgentCount >= *managedAgentLimit.Limit { - return wsbuilder.UsageCheckResponse{ - Permitted: false, - Message: "You have breached the managed agent limit in your license. Please contact sales to continue using managed agents.", - }, nil + if managedAgentCount >= *managedAgentLimit.Limit { + return wsbuilder.UsageCheckResponse{ + Permitted: false, + Message: "You have breached the managed agent limit in your license. Please contact sales to continue using managed agents.", + }, nil + } } return wsbuilder.UsageCheckResponse{ diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 94d9e4fda20df..302b367c304cd 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -154,7 +154,6 @@ func TestEntitlements(t *testing.T) { entitlements, err := anotherClient.Entitlements(context.Background()) require.NoError(t, err) require.False(t, entitlements.HasLicense) - //nolint:gocritic // unit test ctx := testDBAuthzRole(context.Background()) _, err = api.Database.InsertLicense(ctx, database.InsertLicenseParams{ UploadedAt: dbtime.Now(), @@ -186,7 +185,6 @@ func TestEntitlements(t *testing.T) { require.False(t, entitlements.HasLicense) // Valid ctx := context.Background() - //nolint:gocritic // unit test _, err = api.Database.InsertLicense(testDBAuthzRole(ctx), database.InsertLicenseParams{ UploadedAt: dbtime.Now(), Exp: dbtime.Now().AddDate(1, 0, 0), @@ -198,7 +196,6 @@ func TestEntitlements(t *testing.T) { }) require.NoError(t, err) // Expired - //nolint:gocritic // unit test _, err = api.Database.InsertLicense(testDBAuthzRole(ctx), database.InsertLicenseParams{ UploadedAt: dbtime.Now(), Exp: dbtime.Now().AddDate(-1, 0, 0), @@ -208,7 +205,6 @@ func TestEntitlements(t *testing.T) { }) require.NoError(t, err) // Invalid - //nolint:gocritic // unit test _, err = api.Database.InsertLicense(testDBAuthzRole(ctx), database.InsertLicenseParams{ UploadedAt: dbtime.Now(), Exp: dbtime.Now().AddDate(1, 0, 0), diff --git a/enterprise/coderd/dormancy/dormantusersjob.go b/enterprise/coderd/dormancy/dormantusersjob.go index cae442ce07507..d331001a560ff 100644 --- a/enterprise/coderd/dormancy/dormantusersjob.go +++ b/enterprise/coderd/dormancy/dormantusersjob.go @@ -37,12 +37,13 @@ func CheckInactiveUsersWithOptions(ctx context.Context, logger slog.Logger, clk ctx, cancelFunc := context.WithCancel(ctx) tf := clk.TickerFunc(ctx, checkInterval, func() error { startTime := time.Now() - lastSeenAfter := dbtime.Now().Add(-dormancyPeriod) + now := dbtime.Time(clk.Now()).UTC() + lastSeenAfter := now.Add(-dormancyPeriod) logger.Debug(ctx, "check inactive user accounts", slog.F("dormancy_period", dormancyPeriod), slog.F("last_seen_after", lastSeenAfter)) updatedUsers, err := db.UpdateInactiveUsersToDormant(ctx, database.UpdateInactiveUsersToDormantParams{ LastSeenAfter: lastSeenAfter, - UpdatedAt: dbtime.Now(), + UpdatedAt: now, }) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { logger.Error(ctx, "can't mark inactive users as dormant", slog.Error(err)) diff --git a/enterprise/coderd/dormancy/dormantusersjob_test.go b/enterprise/coderd/dormancy/dormantusersjob_test.go index e5e5276fe67a9..885a112c6141a 100644 --- a/enterprise/coderd/dormancy/dormantusersjob_test.go +++ b/enterprise/coderd/dormancy/dormantusersjob_test.go @@ -31,20 +31,28 @@ func TestCheckInactiveUsers(t *testing.T) { ctx, cancelFunc := context.WithCancel(context.Background()) t.Cleanup(cancelFunc) - inactiveUser1 := setupUser(ctx, t, db, "dormant-user-1@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-time.Minute)) - inactiveUser2 := setupUser(ctx, t, db, "dormant-user-2@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-time.Hour)) - inactiveUser3 := setupUser(ctx, t, db, "dormant-user-3@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-6*time.Hour)) + // Use a fixed base time to avoid timing races + baseTime := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + dormancyThreshold := baseTime.Add(-dormancyPeriod) - activeUser1 := setupUser(ctx, t, db, "active-user-1@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(time.Minute)) - activeUser2 := setupUser(ctx, t, db, "active-user-2@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(time.Hour)) - activeUser3 := setupUser(ctx, t, db, "active-user-3@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(6*time.Hour)) + // Create inactive users (last seen BEFORE dormancy threshold) + inactiveUser1 := setupUser(ctx, t, db, "dormant-user-1@coder.com", database.UserStatusActive, dormancyThreshold.Add(-time.Minute)) + inactiveUser2 := setupUser(ctx, t, db, "dormant-user-2@coder.com", database.UserStatusActive, dormancyThreshold.Add(-time.Hour)) + inactiveUser3 := setupUser(ctx, t, db, "dormant-user-3@coder.com", database.UserStatusActive, dormancyThreshold.Add(-6*time.Hour)) - suspendedUser1 := setupUser(ctx, t, db, "suspended-user-1@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-time.Minute)) - suspendedUser2 := setupUser(ctx, t, db, "suspended-user-2@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-time.Hour)) - suspendedUser3 := setupUser(ctx, t, db, "suspended-user-3@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-6*time.Hour)) + // Create active users (last seen AFTER dormancy threshold) + activeUser1 := setupUser(ctx, t, db, "active-user-1@coder.com", database.UserStatusActive, baseTime.Add(-time.Minute)) + activeUser2 := setupUser(ctx, t, db, "active-user-2@coder.com", database.UserStatusActive, baseTime.Add(-time.Hour)) + activeUser3 := setupUser(ctx, t, db, "active-user-3@coder.com", database.UserStatusActive, baseTime.Add(-6*time.Hour)) + + suspendedUser1 := setupUser(ctx, t, db, "suspended-user-1@coder.com", database.UserStatusSuspended, dormancyThreshold.Add(-time.Minute)) + suspendedUser2 := setupUser(ctx, t, db, "suspended-user-2@coder.com", database.UserStatusSuspended, dormancyThreshold.Add(-time.Hour)) + suspendedUser3 := setupUser(ctx, t, db, "suspended-user-3@coder.com", database.UserStatusSuspended, dormancyThreshold.Add(-6*time.Hour)) mAudit := audit.NewMock() mClock := quartz.NewMock(t) + // Set the mock clock to the base time to ensure consistent behavior + mClock.Set(baseTime) // Run the periodic job closeFunc := dormancy.CheckInactiveUsersWithOptions(ctx, logger, mClock, db, mAudit, interval, dormancyPeriod) t.Cleanup(closeFunc) diff --git a/enterprise/coderd/enidpsync/groups.go b/enterprise/coderd/enidpsync/groups.go index 7cabce412a1ea..c67d8d53f0501 100644 --- a/enterprise/coderd/enidpsync/groups.go +++ b/enterprise/coderd/enidpsync/groups.go @@ -2,7 +2,6 @@ package enidpsync import ( "context" - "net/http" "github.com/golang-jwt/jwt/v4" @@ -20,51 +19,12 @@ func (e EnterpriseIDPSync) GroupSyncEntitled() bool { // GroupAllowList is implemented here to prevent login by unauthorized users. // TODO: GroupAllowList overlaps with the default organization group sync settings. func (e EnterpriseIDPSync) ParseGroupClaims(ctx context.Context, mergedClaims jwt.MapClaims) (idpsync.GroupParams, *idpsync.HTTPError) { - if !e.GroupSyncEntitled() { - return e.AGPLIDPSync.ParseGroupClaims(ctx, mergedClaims) + resp, err := e.AGPLIDPSync.ParseGroupClaims(ctx, mergedClaims) + if err != nil { + return idpsync.GroupParams{}, err } - - if e.GroupField != "" && len(e.GroupAllowList) > 0 { - groupsRaw, ok := mergedClaims[e.GroupField] - if !ok { - return idpsync.GroupParams{}, &idpsync.HTTPError{ - Code: http.StatusForbidden, - Msg: "Not a member of an allowed group", - Detail: "You have no groups in your claims!", - RenderStaticPage: true, - } - } - parsedGroups, err := idpsync.ParseStringSliceClaim(groupsRaw) - if err != nil { - return idpsync.GroupParams{}, &idpsync.HTTPError{ - Code: http.StatusBadRequest, - Msg: "Failed read groups from claims for allow list check. Ask an administrator for help.", - Detail: err.Error(), - RenderStaticPage: true, - } - } - - inAllowList := false - AllowListCheckLoop: - for _, group := range parsedGroups { - if _, ok := e.GroupAllowList[group]; ok { - inAllowList = true - break AllowListCheckLoop - } - } - - if !inAllowList { - return idpsync.GroupParams{}, &idpsync.HTTPError{ - Code: http.StatusForbidden, - Msg: "Not a member of an allowed group", - Detail: "Ask an administrator to add one of your groups to the allow list.", - RenderStaticPage: true, - } - } - } - return idpsync.GroupParams{ - SyncEntitled: true, - MergedClaims: mergedClaims, + SyncEntitled: e.GroupSyncEntitled(), + MergedClaims: resp.MergedClaims, }, nil } diff --git a/enterprise/coderd/enidpsync/organizations_test.go b/enterprise/coderd/enidpsync/organizations_test.go index 13a9bd69ed8fd..c3bae7cd1d848 100644 --- a/enterprise/coderd/enidpsync/organizations_test.go +++ b/enterprise/coderd/enidpsync/organizations_test.go @@ -56,7 +56,6 @@ func TestOrganizationSync(t *testing.T) { requireUserOrgs := func(t *testing.T, db database.Store, user database.User, expected []uuid.UUID) { t.Helper() - // nolint:gocritic // in testing members, err := db.OrganizationMembers(dbauthz.AsSystemRestricted(context.Background()), database.OrganizationMembersParams{ UserID: user.ID, }) diff --git a/enterprise/coderd/idpsync_test.go b/enterprise/coderd/idpsync_test.go index d34701c3f6936..49d83a62688ba 100644 --- a/enterprise/coderd/idpsync_test.go +++ b/enterprise/coderd/idpsync_test.go @@ -39,7 +39,6 @@ func TestGetGroupSyncSettings(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) dbresv := runtimeconfig.OrganizationResolver(user.OrganizationID, runtimeconfig.NewStoreResolver(db)) entry := runtimeconfig.MustNew[*idpsync.GroupSyncSettings]("group-sync-settings") - //nolint:gocritic // Requires system context to set runtime config err := entry.SetRuntimeValue(dbauthz.AsSystemRestricted(ctx), dbresv, &idpsync.GroupSyncSettings{Field: "august"}) require.NoError(t, err) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 687a4aaf66746..504c9a04caea0 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -3,6 +3,7 @@ package license import ( "context" "crypto/ed25519" + "database/sql" "fmt" "math" "time" @@ -94,10 +95,34 @@ func Entitlements( return codersdk.Entitlements{}, xerrors.Errorf("query active user count: %w", err) } + // nolint:gocritic // Getting external workspaces is a system function. + externalWorkspaces, err := db.GetWorkspaces(dbauthz.AsSystemRestricted(ctx), database.GetWorkspacesParams{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }) + if err != nil { + return codersdk.Entitlements{}, xerrors.Errorf("query external workspaces: %w", err) + } + + // nolint:gocritic // Getting external templates is a system function. + externalTemplates, err := db.GetTemplatesWithFilter(dbauthz.AsSystemRestricted(ctx), database.GetTemplatesWithFilterParams{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }) + if err != nil { + return codersdk.Entitlements{}, xerrors.Errorf("query external templates: %w", err) + } + entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{ - ActiveUserCount: activeUserCount, - ReplicaCount: replicaCount, - ExternalAuthCount: externalAuthCount, + ActiveUserCount: activeUserCount, + ReplicaCount: replicaCount, + ExternalAuthCount: externalAuthCount, + ExternalWorkspaceCount: int64(len(externalWorkspaces)), + ExternalTemplateCount: int64(len(externalTemplates)), ManagedAgentCountFn: func(ctx context.Context, startTime time.Time, endTime time.Time) (int64, error) { // nolint:gocritic // Requires permission to read all workspaces to read managed agent count. return db.GetManagedAgentCount(dbauthz.AsSystemRestricted(ctx), database.GetManagedAgentCountParams{ @@ -114,9 +139,11 @@ func Entitlements( } type FeatureArguments struct { - ActiveUserCount int64 - ReplicaCount int - ExternalAuthCount int + ActiveUserCount int64 + ReplicaCount int + ExternalAuthCount int + ExternalWorkspaceCount int64 + ExternalTemplateCount int64 // Unfortunately, managed agent count is not a simple count of the current // state of the world, but a count between two points in time determined by // the licenses. @@ -418,6 +445,30 @@ func LicensesEntitlements( } } + if featureArguments.ExternalWorkspaceCount > 0 { + feature := entitlements.Features[codersdk.FeatureWorkspaceExternalAgent] + switch feature.Entitlement { + case codersdk.EntitlementNotEntitled: + entitlements.Errors = append(entitlements.Errors, + "You have external workspaces but your license is not entitled to this feature.") + case codersdk.EntitlementGracePeriod: + entitlements.Warnings = append(entitlements.Warnings, + "You have external workspaces but your license is expired.") + } + } + + if featureArguments.ExternalTemplateCount > 0 { + feature := entitlements.Features[codersdk.FeatureWorkspaceExternalAgent] + switch feature.Entitlement { + case codersdk.EntitlementNotEntitled: + entitlements.Errors = append(entitlements.Errors, + "You have templates which use external agents but your license is not entitled to this feature.") + case codersdk.EntitlementGracePeriod: + entitlements.Warnings = append(entitlements.Warnings, + "You have templates which use external agents but your license is expired.") + } + } + // Managed agent warnings are applied based on usage period. We only // generate a warning if the license actually has managed agents. // Note that agents are free when unlicensed. diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index d8203117039cb..0ca7d2287ad63 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -723,6 +723,12 @@ func TestEntitlements(t *testing.T) { return true })). Return(int64(175), nil) + mDB.EXPECT(). + GetWorkspaces(gomock.Any(), gomock.Any()). + Return([]database.GetWorkspacesRow{}, nil) + mDB.EXPECT(). + GetTemplatesWithFilter(gomock.Any(), gomock.Any()). + Return([]database.Template{}, nil) entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all) require.NoError(t, err) @@ -766,6 +772,7 @@ func TestLicenseEntitlements(t *testing.T) { codersdk.FeatureUserRoleManagement: true, codersdk.FeatureAccessControl: true, codersdk.FeatureControlSharedPorts: true, + codersdk.FeatureWorkspaceExternalAgent: true, } legacyLicense := func() *coderdenttest.LicenseOptions { @@ -1109,6 +1116,32 @@ func TestLicenseEntitlements(t *testing.T) { assert.Equal(t, int64(200), *feature.Actual) }, }, + { + Name: "ExternalWorkspace", + Licenses: []*coderdenttest.LicenseOptions{ + enterpriseLicense().UserLimit(100), + }, + Arguments: license.FeatureArguments{ + ExternalWorkspaceCount: 1, + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assert.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[codersdk.FeatureWorkspaceExternalAgent].Entitlement) + assert.True(t, entitlements.Features[codersdk.FeatureWorkspaceExternalAgent].Enabled) + }, + }, + { + Name: "ExternalTemplate", + Licenses: []*coderdenttest.LicenseOptions{ + enterpriseLicense().UserLimit(100), + }, + Arguments: license.FeatureArguments{ + ExternalTemplateCount: 1, + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assert.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[codersdk.FeatureWorkspaceExternalAgent].Entitlement) + assert.True(t, entitlements.Features[codersdk.FeatureWorkspaceExternalAgent].Enabled) + }, + }, } for _, tc := range testCases { diff --git a/enterprise/coderd/prebuilds/metricscollector_test.go b/enterprise/coderd/prebuilds/metricscollector_test.go index 1e9f3f5082806..b852079beb2af 100644 --- a/enterprise/coderd/prebuilds/metricscollector_test.go +++ b/enterprise/coderd/prebuilds/metricscollector_test.go @@ -231,7 +231,6 @@ func TestMetricsCollector(t *testing.T) { } // Force an update to the metrics state to allow the collector to collect fresh metrics. - // nolint:gocritic // Authz context needed to retrieve state. require.NoError(t, collector.UpdateState(dbauthz.AsPrebuildsOrchestrator(ctx), testutil.WaitLong)) metricsFamilies, err := registry.Gather() @@ -367,7 +366,6 @@ func TestMetricsCollector_DuplicateTemplateNames(t *testing.T) { "organization_name": defaultOrg.Name, } - // nolint:gocritic // Authz context needed to retrieve state. ctx = dbauthz.AsPrebuildsOrchestrator(ctx) // Then: metrics collect successfully. diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index c8304952781d1..65b03a7d6b864 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -352,6 +352,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) &api.AGPL.Auditor, api.AGPL.TemplateScheduleStore, api.AGPL.UserQuietHoursScheduleStore, + api.AGPL.UsageInserter, api.DeploymentValues, provisionerdserver.Options{ ExternalAuthConfigs: api.ExternalAuthConfigs, diff --git a/enterprise/coderd/provisionerdaemons_test.go b/enterprise/coderd/provisionerdaemons_test.go index a94a60ffff3c2..5797e978fa34c 100644 --- a/enterprise/coderd/provisionerdaemons_test.go +++ b/enterprise/coderd/provisionerdaemons_test.go @@ -682,7 +682,6 @@ func TestProvisionerDaemonServe(t *testing.T) { if tc.insertParams.Name != "" { tc.insertParams.OrganizationID = user.OrganizationID - // nolint:gocritic // test _, err := db.InsertProvisionerKey(dbauthz.AsSystemRestricted(ctx), tc.insertParams) require.NoError(t, err) } @@ -945,7 +944,6 @@ func TestGetProvisionerDaemons(t *testing.T) { daemonCreatedAt := time.Now() - //nolint:gocritic // We're not testing auth on the following in this test provisionerKey, err := db.InsertProvisionerKey(dbauthz.AsSystemRestricted(ctx), database.InsertProvisionerKeyParams{ Name: "Test Provisioner Key", ID: uuid.New(), @@ -956,7 +954,6 @@ func TestGetProvisionerDaemons(t *testing.T) { }) require.NoError(t, err, "should be able to create a provisioner key") - //nolint:gocritic // We're not testing auth on the following in this test pd, err := db.UpsertProvisionerDaemon(dbauthz.AsSystemRestricted(ctx), database.UpsertProvisionerDaemonParams{ CreatedAt: daemonCreatedAt, Name: "Test Provisioner Daemon", diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 203de46db4168..ed21b8160e2c3 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -242,6 +242,10 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S nextStartAts := []time.Time{} for _, workspace := range workspaces { + // Skip prebuilt workspaces + if workspace.IsPrebuild() { + continue + } nextStartAt := time.Time{} if workspace.AutostartSchedule.Valid { next, err := agpl.NextAllowedAutostart(s.now(), workspace.AutostartSchedule.String, templateSchedule) @@ -254,7 +258,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S nextStartAts = append(nextStartAts, nextStartAt) } - //nolint:gocritic // We need to be able to update information about all workspaces. + //nolint:gocritic // We need to be able to update information about regular user workspaces. if err := db.BatchUpdateWorkspaceNextStartAt(dbauthz.AsSystemRestricted(ctx), database.BatchUpdateWorkspaceNextStartAtParams{ IDs: workspaceIDs, NextStartAts: nextStartAts, @@ -334,6 +338,11 @@ func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuild(ctx context.Conte return xerrors.Errorf("get workspace %q: %w", build.WorkspaceID, err) } + // Skip lifecycle updates for prebuilt workspaces + if workspace.IsPrebuild() { + return nil + } + job, err := db.GetProvisionerJobByID(ctx, build.JobID) if err != nil { return xerrors.Errorf("get provisioner job %q: %w", build.JobID, err) diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go index 2eb13b4eb3554..e764826f76922 100644 --- a/enterprise/coderd/schedule/template_test.go +++ b/enterprise/coderd/schedule/template_test.go @@ -1,6 +1,7 @@ package schedule_test import ( + "context" "database/sql" "encoding/json" "fmt" @@ -17,14 +18,18 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" agplschedule "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/schedule/cron" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/coderd/schedule" + "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" ) @@ -714,7 +719,6 @@ func TestNotifications(t *testing.T) { // Lower the dormancy TTL to ensure the schedule recalculates deadlines and // triggers notifications. - // nolint:gocritic // Need an actor in the context. _, err = templateScheduleStore.Set(dbauthz.AsNotifier(ctx), db, template, agplschedule.TemplateScheduleOptions{ TimeTilDormant: timeTilDormant / 2, TimeTilDormantAutoDelete: timeTilDormant / 2, @@ -979,6 +983,252 @@ func TestTemplateTTL(t *testing.T) { }) } +func TestTemplateUpdatePrebuilds(t *testing.T) { + t.Parallel() + + // Dormant auto-delete configured to 10 hours + dormantAutoDelete := 10 * time.Hour + + // TTL configured to 8 hours + ttl := 8 * time.Hour + + // Autostop configuration set to everyday at midnight + autostopWeekdays, err := codersdk.WeekdaysToBitmap(codersdk.AllDaysOfWeek) + require.NoError(t, err) + + // Autostart configuration set to everyday at midnight + autostartSchedule, err := cron.Weekly("CRON_TZ=UTC 0 0 * * *") + require.NoError(t, err) + autostartWeekdays, err := codersdk.WeekdaysToBitmap(codersdk.AllDaysOfWeek) + require.NoError(t, err) + + cases := []struct { + name string + templateSchedule agplschedule.TemplateScheduleOptions + workspaceUpdate func(*testing.T, context.Context, database.Store, time.Time, database.ClaimPrebuiltWorkspaceRow) + assertWorkspace func(*testing.T, context.Context, database.Store, time.Time, bool, database.Workspace) + }{ + { + name: "TemplateDormantAutoDeleteUpdatePrebuildAfterClaim", + templateSchedule: agplschedule.TemplateScheduleOptions{ + // Template level TimeTilDormantAutodelete set to 10 hours + TimeTilDormantAutoDelete: dormantAutoDelete, + }, + workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, + workspace database.ClaimPrebuiltWorkspaceRow, + ) { + // When: the workspace is marked dormant + dormantWorkspace, err := db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{ + ID: workspace.ID, + DormantAt: sql.NullTime{ + Time: now, + Valid: true, + }, + }) + require.NoError(t, err) + require.NotNil(t, dormantWorkspace.DormantAt) + }, + assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, + isPrebuild bool, workspace database.Workspace, + ) { + if isPrebuild { + // The unclaimed prebuild should have an empty DormantAt and DeletingAt + require.True(t, workspace.DormantAt.Time.IsZero()) + require.True(t, workspace.DeletingAt.Time.IsZero()) + } else { + // The claimed workspace should have its DormantAt and DeletingAt updated + require.False(t, workspace.DormantAt.Time.IsZero()) + require.False(t, workspace.DeletingAt.Time.IsZero()) + require.WithinDuration(t, now.UTC(), workspace.DormantAt.Time.UTC(), time.Second) + require.WithinDuration(t, now.Add(dormantAutoDelete).UTC(), workspace.DeletingAt.Time.UTC(), time.Second) + } + }, + }, + { + name: "TemplateTTLUpdatePrebuildAfterClaim", + templateSchedule: agplschedule.TemplateScheduleOptions{ + // Template level TTL can only be set if autostop is disabled for users + DefaultTTL: ttl, + UserAutostopEnabled: false, + }, + workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, + workspace database.ClaimPrebuiltWorkspaceRow) { + }, + assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, + isPrebuild bool, workspace database.Workspace, + ) { + if isPrebuild { + // The unclaimed prebuild should have an empty TTL + require.Equal(t, sql.NullInt64{}, workspace.Ttl) + } else { + // The claimed workspace should have its TTL updated + require.Equal(t, sql.NullInt64{Int64: int64(ttl), Valid: true}, workspace.Ttl) + } + }, + }, + { + name: "TemplateAutostopUpdatePrebuildAfterClaim", + templateSchedule: agplschedule.TemplateScheduleOptions{ + // Template level Autostop set for everyday + AutostopRequirement: agplschedule.TemplateAutostopRequirement{ + DaysOfWeek: autostopWeekdays, + Weeks: 0, + }, + }, + workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, + workspace database.ClaimPrebuiltWorkspaceRow) { + }, + assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, isPrebuild bool, workspace database.Workspace) { + if isPrebuild { + // The unclaimed prebuild should have an empty MaxDeadline + prebuildBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + require.NoError(t, err) + require.True(t, prebuildBuild.MaxDeadline.IsZero()) + } else { + // The claimed workspace should have its MaxDeadline updated + workspaceBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + require.NoError(t, err) + require.False(t, workspaceBuild.MaxDeadline.IsZero()) + } + }, + }, + { + name: "TemplateAutostartUpdatePrebuildAfterClaim", + templateSchedule: agplschedule.TemplateScheduleOptions{ + // Template level Autostart set for everyday + UserAutostartEnabled: true, + AutostartRequirement: agplschedule.TemplateAutostartRequirement{ + DaysOfWeek: autostartWeekdays, + }, + }, + workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, workspace database.ClaimPrebuiltWorkspaceRow) { + // To compute NextStartAt, the workspace must have a valid autostart schedule + err = db.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{ + ID: workspace.ID, + AutostartSchedule: sql.NullString{ + String: autostartSchedule.String(), + Valid: true, + }, + }) + require.NoError(t, err) + }, + assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, isPrebuild bool, workspace database.Workspace) { + if isPrebuild { + // The unclaimed prebuild should have an empty NextStartAt + require.True(t, workspace.NextStartAt.Time.IsZero()) + } else { + // The claimed workspace should have its NextStartAt updated + require.False(t, workspace.NextStartAt.Time.IsZero()) + } + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + clock := quartz.NewMock(t) + clock.Set(dbtime.Now()) + + // Setup + var ( + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + db, _ = dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + ctx = testutil.Context(t, testutil.WaitLong) + user = dbgen.User(t, db, database.User{}) + ) + + // Setup the template schedule store + notifyEnq := notifications.NewNoopEnqueuer() + const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true) + require.NoError(t, err) + userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} + userQuietHoursStorePtr.Store(&userQuietHoursStore) + templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, clock) + + // Given: a template and a template version with preset and a prebuilt workspace + presetID := uuid.New() + org := dbfake.Organization(t, db).Do() + tv := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{ + OrganizationID: org.Org.ID, + CreatedBy: user.ID, + }).Preset(database.TemplateVersionPreset{ + ID: presetID, + DesiredInstances: sql.NullInt32{ + Int32: 1, + Valid: true, + }, + }).Do() + workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: database.PrebuildsSystemUserID, + TemplateID: tv.Template.ID, + OrganizationID: tv.Template.OrganizationID, + }).Seed(database.WorkspaceBuild{ + TemplateVersionID: tv.TemplateVersion.ID, + TemplateVersionPresetID: uuid.NullUUID{ + UUID: presetID, + Valid: true, + }, + }).WithAgent(func(agent []*proto.Agent) []*proto.Agent { + return agent + }).Do() + + // Mark the prebuilt workspace's agent as ready so the prebuild can be claimed + // nolint:gocritic + agentCtx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong)) + agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(agentCtx, uuid.MustParse(workspaceBuild.AgentToken)) + require.NoError(t, err) + err = db.UpdateWorkspaceAgentLifecycleStateByID(agentCtx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: agent.WorkspaceAgent.ID, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + }) + require.NoError(t, err) + + // Given: a prebuilt workspace + prebuild, err := db.GetWorkspaceByID(ctx, workspaceBuild.Workspace.ID) + require.NoError(t, err) + tc.assertWorkspace(t, ctx, db, clock.Now(), true, prebuild) + + // When: the template schedule is updated + _, err = templateScheduleStore.Set(ctx, db, tv.Template, tc.templateSchedule) + require.NoError(t, err) + + // Then: lifecycle parameters must remain unset while the prebuild is unclaimed + prebuild, err = db.GetWorkspaceByID(ctx, workspaceBuild.Workspace.ID) + require.NoError(t, err) + tc.assertWorkspace(t, ctx, db, clock.Now(), true, prebuild) + + // Given: the prebuilt workspace is claimed by a user + claimedWorkspace := dbgen.ClaimPrebuild( + t, db, + clock.Now(), + user.ID, + "claimedWorkspace-autostop", + presetID, + sql.NullString{}, + sql.NullTime{}, + sql.NullInt64{}) + require.Equal(t, prebuild.ID, claimedWorkspace.ID) + + // Given: the workspace level configurations are properly set in order to ensure the + // lifecycle parameters are updated + tc.workspaceUpdate(t, ctx, db, clock.Now(), claimedWorkspace) + + // When: the template schedule is updated + _, err = templateScheduleStore.Set(ctx, db, tv.Template, tc.templateSchedule) + require.NoError(t, err) + + // Then: the workspace should have its lifecycle parameters updated + workspace, err := db.GetWorkspaceByID(ctx, claimedWorkspace.ID) + require.NoError(t, err) + tc.assertWorkspace(t, ctx, db, clock.Now(), false, workspace) + }) + } +} + func must[V any](v V, err error) V { if err != nil { panic(err) diff --git a/enterprise/coderd/usage/inserter.go b/enterprise/coderd/usage/inserter.go index 3320c25d454ce..f3566595a181f 100644 --- a/enterprise/coderd/usage/inserter.go +++ b/enterprise/coderd/usage/inserter.go @@ -10,19 +10,21 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" agplusage "github.com/coder/coder/v2/coderd/usage" + "github.com/coder/coder/v2/coderd/usage/usagetypes" "github.com/coder/quartz" ) -// Inserter accepts usage events and stores them in the database for publishing. -type Inserter struct { +// dbInserter collects usage events and stores them in the database for +// publishing. +type dbInserter struct { clock quartz.Clock } -var _ agplusage.Inserter = &Inserter{} +var _ agplusage.Inserter = &dbInserter{} -// NewInserter creates a new database-backed usage event inserter. -func NewInserter(opts ...InserterOptions) *Inserter { - c := &Inserter{ +// NewDBInserter creates a new database-backed usage event inserter. +func NewDBInserter(opts ...InserterOption) agplusage.Inserter { + c := &dbInserter{ clock: quartz.NewReal(), } for _, opt := range opts { @@ -31,17 +33,17 @@ func NewInserter(opts ...InserterOptions) *Inserter { return c } -type InserterOptions func(*Inserter) +type InserterOption func(*dbInserter) // InserterWithClock sets the quartz clock to use for the inserter. -func InserterWithClock(clock quartz.Clock) InserterOptions { - return func(c *Inserter) { +func InserterWithClock(clock quartz.Clock) InserterOption { + return func(c *dbInserter) { c.clock = clock } } // InsertDiscreteUsageEvent implements agplusage.Inserter. -func (c *Inserter) InsertDiscreteUsageEvent(ctx context.Context, tx database.Store, event agplusage.DiscreteEvent) error { +func (i *dbInserter) InsertDiscreteUsageEvent(ctx context.Context, tx database.Store, event usagetypes.DiscreteEvent) error { if !event.EventType().IsDiscrete() { return xerrors.Errorf("event type %q is not a discrete event", event.EventType()) } @@ -61,6 +63,6 @@ func (c *Inserter) InsertDiscreteUsageEvent(ctx context.Context, tx database.Sto ID: uuid.New().String(), EventType: string(event.EventType()), EventData: jsonData, - CreatedAt: dbtime.Time(c.clock.Now()), + CreatedAt: dbtime.Time(i.clock.Now()), }) } diff --git a/enterprise/coderd/usage/inserter_test.go b/enterprise/coderd/usage/inserter_test.go index c5abd931cfaba..7ac915be7a5a8 100644 --- a/enterprise/coderd/usage/inserter_test.go +++ b/enterprise/coderd/usage/inserter_test.go @@ -12,7 +12,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtime" - agplusage "github.com/coder/coder/v2/coderd/usage" + "github.com/coder/coder/v2/coderd/usage/usagetypes" "github.com/coder/coder/v2/enterprise/coderd/usage" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" @@ -28,42 +28,42 @@ func TestInserter(t *testing.T) { ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) clock := quartz.NewMock(t) - inserter := usage.NewInserter(usage.InserterWithClock(clock)) + inserter := usage.NewDBInserter(usage.InserterWithClock(clock)) now := dbtime.Now() events := []struct { time time.Time - event agplusage.DiscreteEvent + event usagetypes.DiscreteEvent }{ { time: now, - event: agplusage.DCManagedAgentsV1{ + event: usagetypes.DCManagedAgentsV1{ Count: 1, }, }, { time: now.Add(1 * time.Minute), - event: agplusage.DCManagedAgentsV1{ + event: usagetypes.DCManagedAgentsV1{ Count: 2, }, }, } - for _, event := range events { - eventJSON := jsoninate(t, event.event) - db.EXPECT().InsertUsageEvent(ctx, gomock.Any()).DoAndReturn( + for _, e := range events { + eventJSON := jsoninate(t, e.event) + db.EXPECT().InsertUsageEvent(gomock.Any(), gomock.Any()).DoAndReturn( func(ctx interface{}, params database.InsertUsageEventParams) error { _, err := uuid.Parse(params.ID) assert.NoError(t, err) - assert.Equal(t, string(event.event.EventType()), params.EventType) + assert.Equal(t, e.event.EventType(), usagetypes.UsageEventType(params.EventType)) assert.JSONEq(t, eventJSON, string(params.EventData)) - assert.Equal(t, event.time, params.CreatedAt) + assert.Equal(t, e.time, params.CreatedAt) return nil }, ).Times(1) - clock.Set(event.time) - err := inserter.InsertDiscreteUsageEvent(ctx, db, event.event) + clock.Set(e.time) + err := inserter.InsertDiscreteUsageEvent(ctx, db, e.event) require.NoError(t, err) } }) @@ -76,8 +76,8 @@ func TestInserter(t *testing.T) { db := dbmock.NewMockStore(ctrl) // We should get an error if the event is invalid. - inserter := usage.NewInserter() - err := inserter.InsertDiscreteUsageEvent(ctx, db, agplusage.DCManagedAgentsV1{ + inserter := usage.NewDBInserter() + err := inserter.InsertDiscreteUsageEvent(ctx, db, usagetypes.DCManagedAgentsV1{ Count: 0, // invalid }) assert.ErrorContains(t, err, `invalid "dc_managed_agents_v1" event: count must be greater than 0`) diff --git a/enterprise/coderd/usage/publisher.go b/enterprise/coderd/usage/publisher.go index e8722841160fb..ce38f9a24a925 100644 --- a/enterprise/coderd/usage/publisher.go +++ b/enterprise/coderd/usage/publisher.go @@ -14,19 +14,18 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/pproflabel" - agplusage "github.com/coder/coder/v2/coderd/usage" + "github.com/coder/coder/v2/coderd/usage/usagetypes" "github.com/coder/coder/v2/cryptorand" - "github.com/coder/coder/v2/enterprise/coderd" "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/quartz" ) const ( - CoderLicenseJWTHeader = "Coder-License-JWT" - tallymanURL = "https://tallyman-prod.coder.com" tallymanIngestURLV1 = tallymanURL + "/api/v1/events/ingest" @@ -49,17 +48,17 @@ type Publisher interface { } type tallymanPublisher struct { - ctx context.Context - ctxCancel context.CancelFunc - log slog.Logger - db database.Store - done chan struct{} + ctx context.Context + ctxCancel context.CancelFunc + log slog.Logger + db database.Store + licenseKeys map[string]ed25519.PublicKey + done chan struct{} // Configured with options: ingestURL string httpClient *http.Client clock quartz.Clock - licenseKeys map[string]ed25519.PublicKey initialDelay time.Duration } @@ -67,19 +66,21 @@ var _ Publisher = &tallymanPublisher{} // NewTallymanPublisher creates a Publisher that publishes usage events to // Coder's Tallyman service. -func NewTallymanPublisher(ctx context.Context, log slog.Logger, db database.Store, opts ...TallymanPublisherOption) Publisher { +func NewTallymanPublisher(ctx context.Context, log slog.Logger, db database.Store, keys map[string]ed25519.PublicKey, opts ...TallymanPublisherOption) Publisher { ctx, cancel := context.WithCancel(ctx) + ctx = dbauthz.AsUsagePublisher(ctx) //nolint:gocritic // we intentionally want to be able to process usage events + publisher := &tallymanPublisher{ - ctx: ctx, - ctxCancel: cancel, - log: log, - db: db, - done: make(chan struct{}), + ctx: ctx, + ctxCancel: cancel, + log: log, + db: db, + licenseKeys: keys, + done: make(chan struct{}), - ingestURL: tallymanIngestURLV1, - httpClient: http.DefaultClient, - clock: quartz.NewReal(), - licenseKeys: coderd.Keys, + ingestURL: tallymanIngestURLV1, + httpClient: http.DefaultClient, + clock: quartz.NewReal(), } for _, opt := range opts { opt(publisher) @@ -92,6 +93,9 @@ type TallymanPublisherOption func(*tallymanPublisher) // PublisherWithHTTPClient sets the HTTP client to use for publishing usage events. func PublisherWithHTTPClient(httpClient *http.Client) TallymanPublisherOption { return func(p *tallymanPublisher) { + if httpClient == nil { + httpClient = http.DefaultClient + } p.httpClient = httpClient } } @@ -103,14 +107,6 @@ func PublisherWithClock(clock quartz.Clock) TallymanPublisherOption { } } -// PublisherWithLicenseKeys sets the license public keys to use for license -// validation. -func PublisherWithLicenseKeys(keys map[string]ed25519.PublicKey) TallymanPublisherOption { - return func(p *tallymanPublisher) { - p.licenseKeys = keys - } -} - // PublisherWithIngestURL sets the ingest URL to use for publishing usage // events. func PublisherWithIngestURL(ingestURL string) TallymanPublisherOption { @@ -141,14 +137,18 @@ func (p *tallymanPublisher) Start() error { if p.initialDelay <= 0 { // Pick a random time between tallymanPublishInitialMinimumDelay and // tallymanPublishInterval. - maxPlusDelay := int(tallymanPublishInterval - tallymanPublishInitialMinimumDelay) - plusDelay, err := cryptorand.Intn(maxPlusDelay) + maxPlusDelay := tallymanPublishInterval - tallymanPublishInitialMinimumDelay + plusDelay, err := cryptorand.Int63n(int64(maxPlusDelay)) if err != nil { return xerrors.Errorf("could not generate random start delay: %w", err) } p.initialDelay = tallymanPublishInitialMinimumDelay + time.Duration(plusDelay) } + if len(p.licenseKeys) == 0 { + return xerrors.New("no license keys provided") + } + pproflabel.Go(ctx, pproflabel.Service(pproflabel.ServiceTallymanPublisher), func(ctx context.Context) { p.publishLoop(ctx, deploymentUUID) }) @@ -216,20 +216,19 @@ func (p *tallymanPublisher) publishOnce(ctx context.Context, deploymentID uuid.U var ( eventIDs = make(map[string]struct{}) - tallymanReq = TallymanIngestRequestV1{ - DeploymentID: deploymentID, - Events: make([]TallymanIngestEventV1, 0, len(events)), + tallymanReq = usagetypes.TallymanV1IngestRequest{ + Events: make([]usagetypes.TallymanV1IngestEvent, 0, len(events)), } ) for _, event := range events { eventIDs[event.ID] = struct{}{} - eventType := agplusage.EventType(event.EventType) + eventType := usagetypes.UsageEventType(event.EventType) if !eventType.Valid() { // This should never happen due to the check constraint in the // database. return 0, xerrors.Errorf("event %q has an invalid event type %q", event.ID, event.EventType) } - tallymanReq.Events = append(tallymanReq.Events, TallymanIngestEventV1{ + tallymanReq.Events = append(tallymanReq.Events, usagetypes.TallymanV1IngestEvent{ ID: event.ID, EventType: eventType, EventData: event.EventData, @@ -242,17 +241,17 @@ func (p *tallymanPublisher) publishOnce(ctx context.Context, deploymentID uuid.U return 0, xerrors.Errorf("duplicate event IDs found in events for publishing") } - resp, err := p.sendPublishRequest(ctx, licenseJwt, tallymanReq) + resp, err := p.sendPublishRequest(ctx, deploymentID, licenseJwt, tallymanReq) allFailed := err != nil if err != nil { p.log.Warn(ctx, "failed to send publish request to tallyman", slog.F("count", len(events)), slog.Error(err)) // Fake a response with all events temporarily rejected. - resp = TallymanIngestResponseV1{ - AcceptedEvents: []TallymanIngestAcceptedEventV1{}, - RejectedEvents: make([]TallymanIngestRejectedEventV1, len(events)), + resp = usagetypes.TallymanV1IngestResponse{ + AcceptedEvents: []usagetypes.TallymanV1IngestAcceptedEvent{}, + RejectedEvents: make([]usagetypes.TallymanV1IngestRejectedEvent, len(events)), } for i, event := range events { - resp.RejectedEvents[i] = TallymanIngestRejectedEventV1{ + resp.RejectedEvents[i] = usagetypes.TallymanV1IngestRejectedEvent{ ID: event.ID, Message: fmt.Sprintf("failed to publish to tallyman: %v", err), Permanent: false, @@ -266,8 +265,8 @@ func (p *tallymanPublisher) publishOnce(ctx context.Context, deploymentID uuid.U p.log.Warn(ctx, "tallyman returned a different number of events than we sent", slog.F("sent", len(events)), slog.F("accepted", len(resp.AcceptedEvents)), slog.F("rejected", len(resp.RejectedEvents))) } - acceptedEvents := make(map[string]*TallymanIngestAcceptedEventV1) - rejectedEvents := make(map[string]*TallymanIngestRejectedEventV1) + acceptedEvents := make(map[string]*usagetypes.TallymanV1IngestAcceptedEvent) + rejectedEvents := make(map[string]*usagetypes.TallymanV1IngestRejectedEvent) for _, event := range resp.AcceptedEvents { acceptedEvents[event.ID] = &event } @@ -388,37 +387,39 @@ func (p *tallymanPublisher) getBestLicenseJWT(ctx context.Context) (string, erro return bestLicense.Raw, nil } -func (p *tallymanPublisher) sendPublishRequest(ctx context.Context, licenseJwt string, req TallymanIngestRequestV1) (TallymanIngestResponseV1, error) { +func (p *tallymanPublisher) sendPublishRequest(ctx context.Context, deploymentID uuid.UUID, licenseJwt string, req usagetypes.TallymanV1IngestRequest) (usagetypes.TallymanV1IngestResponse, error) { body, err := json.Marshal(req) if err != nil { - return TallymanIngestResponseV1{}, err + return usagetypes.TallymanV1IngestResponse{}, err } r, err := http.NewRequestWithContext(ctx, http.MethodPost, p.ingestURL, bytes.NewReader(body)) if err != nil { - return TallymanIngestResponseV1{}, err + return usagetypes.TallymanV1IngestResponse{}, err } - r.Header.Set(CoderLicenseJWTHeader, licenseJwt) + r.Header.Set("User-Agent", "coderd/"+buildinfo.Version()) + r.Header.Set(usagetypes.TallymanCoderLicenseKeyHeader, licenseJwt) + r.Header.Set(usagetypes.TallymanCoderDeploymentIDHeader, deploymentID.String()) resp, err := p.httpClient.Do(r) if err != nil { - return TallymanIngestResponseV1{}, err + return usagetypes.TallymanV1IngestResponse{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - var errBody TallymanErrorV1 + var errBody usagetypes.TallymanV1Response if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil { - errBody = TallymanErrorV1{ + errBody = usagetypes.TallymanV1Response{ Message: fmt.Sprintf("could not decode error response body: %v", err), } } - return TallymanIngestResponseV1{}, xerrors.Errorf("unexpected status code %v, error: %s", resp.StatusCode, errBody.Message) + return usagetypes.TallymanV1IngestResponse{}, xerrors.Errorf("unexpected status code %v, error: %s", resp.StatusCode, errBody.Message) } - var respBody TallymanIngestResponseV1 + var respBody usagetypes.TallymanV1IngestResponse if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil { - return TallymanIngestResponseV1{}, xerrors.Errorf("decode response body: %w", err) + return usagetypes.TallymanV1IngestResponse{}, xerrors.Errorf("decode response body: %w", err) } return respBody, nil @@ -430,34 +431,3 @@ func (p *tallymanPublisher) Close() error { <-p.done return nil } - -type TallymanErrorV1 struct { - Message string `json:"message"` -} - -type TallymanIngestRequestV1 struct { - DeploymentID uuid.UUID `json:"deployment_id"` - Events []TallymanIngestEventV1 `json:"events"` -} - -type TallymanIngestEventV1 struct { - ID string `json:"id"` - EventType agplusage.EventType `json:"event_type"` - EventData json.RawMessage `json:"event_data"` - CreatedAt time.Time `json:"created_at"` -} - -type TallymanIngestResponseV1 struct { - AcceptedEvents []TallymanIngestAcceptedEventV1 `json:"accepted_events"` - RejectedEvents []TallymanIngestRejectedEventV1 `json:"rejected_events"` -} - -type TallymanIngestAcceptedEventV1 struct { - ID string `json:"id"` -} - -type TallymanIngestRejectedEventV1 struct { - ID string `json:"id"` - Message string `json:"message"` - Permanent bool `json:"permanent"` -} diff --git a/enterprise/coderd/usage/publisher_test.go b/enterprise/coderd/usage/publisher_test.go index a2a997b032ac0..c104c9712e499 100644 --- a/enterprise/coderd/usage/publisher_test.go +++ b/enterprise/coderd/usage/publisher_test.go @@ -10,17 +10,21 @@ import ( "time" "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" "go.uber.org/mock/gomock" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" - agplusage "github.com/coder/coder/v2/coderd/usage" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/usage/usagetypes" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/usage" "github.com/coder/coder/v2/testutil" @@ -40,32 +44,32 @@ func TestIntegration(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) log := slogtest.Make(t, nil) db, _ := dbtestutil.NewDB(t) + clock := quartz.NewMock(t) deploymentID, licenseJWT := configureDeployment(ctx, t, db) now := time.Now() var ( calls int - handler func(req usage.TallymanIngestRequestV1) any + handler func(req usagetypes.TallymanV1IngestRequest) any ) - ingestURL := fakeServer(t, tallymanHandler(t, licenseJWT, func(req usage.TallymanIngestRequestV1) any { + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any { calls++ t.Logf("tallyman backend received call %d", calls) - assert.Equal(t, deploymentID, req.DeploymentID) if handler == nil { t.Errorf("handler is nil") - return usage.TallymanIngestResponseV1{} + return usagetypes.TallymanV1IngestResponse{} } return handler(req) })) - inserter := usage.NewInserter( + inserter := usage.NewDBInserter( usage.InserterWithClock(clock), ) // Insert an old event that should never be published. clock.Set(now.Add(-31 * 24 * time.Hour)) - err := inserter.InsertDiscreteUsageEvent(ctx, db, agplusage.DCManagedAgentsV1{ + err := inserter.InsertDiscreteUsageEvent(ctx, db, usagetypes.DCManagedAgentsV1{ Count: 31, }) require.NoError(t, err) @@ -74,16 +78,18 @@ func TestIntegration(t *testing.T) { clock.Set(now.Add(1 * time.Second)) for i := 0; i < eventCount; i++ { clock.Advance(time.Second) - err := inserter.InsertDiscreteUsageEvent(ctx, db, agplusage.DCManagedAgentsV1{ + err := inserter.InsertDiscreteUsageEvent(ctx, db, usagetypes.DCManagedAgentsV1{ Count: uint64(i + 1), // nolint:gosec // these numbers are tiny and will not overflow }) require.NoErrorf(t, err, "collecting event %d", i) } - publisher := usage.NewTallymanPublisher(ctx, log, db, + // Wrap the publisher's DB in a dbauthz to ensure that the publisher has + // enough permissions. + authzDB := dbauthz.New(db, rbac.NewAuthorizer(prometheus.NewRegistry()), log, coderdtest.AccessControlStorePointer()) + publisher := usage.NewTallymanPublisher(ctx, log, authzDB, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), ) defer publisher.Close() @@ -110,33 +116,33 @@ func TestIntegration(t *testing.T) { // first event, temporarily reject the second, and permanently reject the // third. var temporarilyRejectedEventID string - handler = func(req usage.TallymanIngestRequestV1) any { + handler = func(req usagetypes.TallymanV1IngestRequest) any { // On the first call, accept the first event, temporarily reject the // second, and permanently reject the third. - acceptedEvents := make([]usage.TallymanIngestAcceptedEventV1, 1) - rejectedEvents := make([]usage.TallymanIngestRejectedEventV1, 2) + acceptedEvents := make([]usagetypes.TallymanV1IngestAcceptedEvent, 1) + rejectedEvents := make([]usagetypes.TallymanV1IngestRejectedEvent, 2) if assert.Len(t, req.Events, eventCount) { - assert.JSONEqf(t, jsoninate(t, agplusage.DCManagedAgentsV1{ + assert.JSONEqf(t, jsoninate(t, usagetypes.DCManagedAgentsV1{ Count: 1, }), string(req.Events[0].EventData), "event data did not match for event %d", 0) acceptedEvents[0].ID = req.Events[0].ID temporarilyRejectedEventID = req.Events[1].ID - assert.JSONEqf(t, jsoninate(t, agplusage.DCManagedAgentsV1{ + assert.JSONEqf(t, jsoninate(t, usagetypes.DCManagedAgentsV1{ Count: 2, }), string(req.Events[1].EventData), "event data did not match for event %d", 1) rejectedEvents[0].ID = req.Events[1].ID rejectedEvents[0].Message = "temporarily rejected" rejectedEvents[0].Permanent = false - assert.JSONEqf(t, jsoninate(t, agplusage.DCManagedAgentsV1{ + assert.JSONEqf(t, jsoninate(t, usagetypes.DCManagedAgentsV1{ Count: 3, }), string(req.Events[2].EventData), "event data did not match for event %d", 2) rejectedEvents[1].ID = req.Events[2].ID rejectedEvents[1].Message = "permanently rejected" rejectedEvents[1].Permanent = true } - return usage.TallymanIngestResponseV1{ + return usagetypes.TallymanV1IngestResponse{ AcceptedEvents: acceptedEvents, RejectedEvents: rejectedEvents, } @@ -155,16 +161,16 @@ func TestIntegration(t *testing.T) { // Set the handler for the next publish call. This call should only include // the temporarily rejected event from earlier. This time we'll accept it. - handler = func(req usage.TallymanIngestRequestV1) any { + handler = func(req usagetypes.TallymanV1IngestRequest) any { assert.Len(t, req.Events, 1) - acceptedEvents := make([]usage.TallymanIngestAcceptedEventV1, len(req.Events)) + acceptedEvents := make([]usagetypes.TallymanV1IngestAcceptedEvent, len(req.Events)) for i, event := range req.Events { assert.Equal(t, temporarilyRejectedEventID, event.ID) acceptedEvents[i].ID = event.ID } - return usage.TallymanIngestResponseV1{ + return usagetypes.TallymanV1IngestResponse{ AcceptedEvents: acceptedEvents, - RejectedEvents: []usage.TallymanIngestRejectedEventV1{}, + RejectedEvents: []usagetypes.TallymanV1IngestRejectedEvent{}, } } @@ -204,18 +210,17 @@ func TestPublisherNoEligibleLicenses(t *testing.T) { db.EXPECT().GetDeploymentID(gomock.Any()).Return(deploymentID.String(), nil).Times(1) var calls int - ingestURL := fakeServer(t, tallymanHandler(t, "", func(req usage.TallymanIngestRequestV1) any { + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), "", func(req usagetypes.TallymanV1IngestRequest) any { calls++ - return usage.TallymanIngestResponseV1{ - AcceptedEvents: []usage.TallymanIngestAcceptedEventV1{}, - RejectedEvents: []usage.TallymanIngestRejectedEventV1{}, + return usagetypes.TallymanV1IngestResponse{ + AcceptedEvents: []usagetypes.TallymanV1IngestAcceptedEvent{}, + RejectedEvents: []usagetypes.TallymanV1IngestRejectedEvent{}, } })) - publisher := usage.NewTallymanPublisher(ctx, log, db, + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), ) defer publisher.Close() @@ -274,23 +279,22 @@ func TestPublisherClaimExpiry(t *testing.T) { log := slogtest.Make(t, nil) db, _ := dbtestutil.NewDB(t) clock := quartz.NewMock(t) - _, licenseJWT := configureDeployment(ctx, t, db) + deploymentID, licenseJWT := configureDeployment(ctx, t, db) now := time.Now() var calls int - ingestURL := fakeServer(t, tallymanHandler(t, licenseJWT, func(req usage.TallymanIngestRequestV1) any { + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any { calls++ return tallymanAcceptAllHandler(req) })) - inserter := usage.NewInserter( + inserter := usage.NewDBInserter( usage.InserterWithClock(clock), ) - publisher := usage.NewTallymanPublisher(ctx, log, db, + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), usage.PublisherWithInitialDelay(17*time.Minute), ) defer publisher.Close() @@ -298,7 +302,7 @@ func TestPublisherClaimExpiry(t *testing.T) { // Create an event that was claimed 1h-18m ago. The ticker has a forced // delay of 17m in this test. clock.Set(now) - err := inserter.InsertDiscreteUsageEvent(ctx, db, agplusage.DCManagedAgentsV1{ + err := inserter.InsertDiscreteUsageEvent(ctx, db, usagetypes.DCManagedAgentsV1{ Count: 1, }) require.NoError(t, err) @@ -353,24 +357,23 @@ func TestPublisherMissingEvents(t *testing.T) { log := slogtest.Make(t, nil) ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) - _, licenseJWT := configureMockDeployment(t, db) + deploymentID, licenseJWT := configureMockDeployment(t, db) clock := quartz.NewMock(t) now := time.Now() clock.Set(now) var calls int - ingestURL := fakeServer(t, tallymanHandler(t, licenseJWT, func(req usage.TallymanIngestRequestV1) any { + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any { calls++ - return usage.TallymanIngestResponseV1{ - AcceptedEvents: []usage.TallymanIngestAcceptedEventV1{}, - RejectedEvents: []usage.TallymanIngestRejectedEventV1{}, + return usagetypes.TallymanV1IngestResponse{ + AcceptedEvents: []usagetypes.TallymanV1IngestAcceptedEvent{}, + RejectedEvents: []usagetypes.TallymanV1IngestRejectedEvent{}, } })) - publisher := usage.NewTallymanPublisher(ctx, log, db, + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), ) // Expect the publisher to call SelectUsageEventsForPublishing, followed by @@ -378,8 +381,8 @@ func TestPublisherMissingEvents(t *testing.T) { events := []database.UsageEvent{ { ID: uuid.New().String(), - EventType: string(agplusage.UsageEventTypeDCManagedAgentsV1), - EventData: []byte(jsoninate(t, agplusage.DCManagedAgentsV1{ + EventType: string(usagetypes.UsageEventTypeDCManagedAgentsV1), + EventData: []byte(jsoninate(t, usagetypes.DCManagedAgentsV1{ Count: 1, })), CreatedAt: now, @@ -504,16 +507,14 @@ func TestPublisherLicenseSelection(t *testing.T) { }, nil) called := false - ingestURL := fakeServer(t, tallymanHandler(t, expectedLicense, func(req usage.TallymanIngestRequestV1) any { + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), expectedLicense, func(req usagetypes.TallymanV1IngestRequest) any { called = true - assert.Equal(t, deploymentID, req.DeploymentID) return tallymanAcceptAllHandler(req) })) - publisher := usage.NewTallymanPublisher(ctx, log, db, + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), ) defer publisher.Close() @@ -533,8 +534,8 @@ func TestPublisherLicenseSelection(t *testing.T) { events := []database.UsageEvent{ { ID: uuid.New().String(), - EventType: string(agplusage.UsageEventTypeDCManagedAgentsV1), - EventData: []byte(jsoninate(t, agplusage.DCManagedAgentsV1{ + EventType: string(usagetypes.UsageEventTypeDCManagedAgentsV1), + EventData: []byte(jsoninate(t, usagetypes.DCManagedAgentsV1{ Count: 1, })), }, @@ -569,20 +570,19 @@ func TestPublisherTallymanError(t *testing.T) { now := time.Now() clock.Set(now) - _, licenseJWT := configureMockDeployment(t, db) + deploymentID, licenseJWT := configureMockDeployment(t, db) const errorMessage = "tallyman error" var calls int - ingestURL := fakeServer(t, tallymanHandler(t, licenseJWT, func(req usage.TallymanIngestRequestV1) any { + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any { calls++ - return usage.TallymanErrorV1{ + return usagetypes.TallymanV1Response{ Message: errorMessage, } })) - publisher := usage.NewTallymanPublisher(ctx, log, db, + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), ) defer publisher.Close() @@ -602,8 +602,8 @@ func TestPublisherTallymanError(t *testing.T) { events := []database.UsageEvent{ { ID: uuid.New().String(), - EventType: string(agplusage.UsageEventTypeDCManagedAgentsV1), - EventData: []byte(jsoninate(t, agplusage.DCManagedAgentsV1{ + EventType: string(usagetypes.UsageEventTypeDCManagedAgentsV1), + EventData: []byte(jsoninate(t, usagetypes.DCManagedAgentsV1{ Count: 1, })), }, @@ -630,7 +630,7 @@ func TestPublisherTallymanError(t *testing.T) { func jsoninate(t *testing.T, v any) string { t.Helper() - if e, ok := v.(agplusage.Event); ok { + if e, ok := v.(usagetypes.Event); ok { v = e.Fields() } buf, err := json.Marshal(v) @@ -686,44 +686,61 @@ func fakeServer(t *testing.T, handler http.Handler) string { return server.URL } -func tallymanHandler(t *testing.T, expectLicenseJWT string, handler func(req usage.TallymanIngestRequestV1) any) http.Handler { +func tallymanHandler(t *testing.T, expectDeploymentID string, expectLicenseJWT string, handler func(req usagetypes.TallymanV1IngestRequest) any) http.Handler { t.Helper() return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { t.Helper() - licenseJWT := r.Header.Get(usage.CoderLicenseJWTHeader) + licenseJWT := r.Header.Get(usagetypes.TallymanCoderLicenseKeyHeader) if expectLicenseJWT != "" && !assert.Equal(t, expectLicenseJWT, licenseJWT, "license JWT in request did not match") { rw.WriteHeader(http.StatusUnauthorized) - err := json.NewEncoder(rw).Encode(usage.TallymanErrorV1{ + _ = json.NewEncoder(rw).Encode(usagetypes.TallymanV1Response{ Message: "license JWT in request did not match", }) - require.NoError(t, err) return } - var req usage.TallymanIngestRequestV1 + deploymentID := r.Header.Get(usagetypes.TallymanCoderDeploymentIDHeader) + if expectDeploymentID != "" && !assert.Equal(t, expectDeploymentID, deploymentID, "deployment ID in request did not match") { + rw.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(rw).Encode(usagetypes.TallymanV1Response{ + Message: "deployment ID in request did not match", + }) + return + } + + var req usagetypes.TallymanV1IngestRequest err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) + if !assert.NoError(t, err, "could not decode request body") { + rw.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(rw).Encode(usagetypes.TallymanV1Response{ + Message: "could not decode request body", + }) + return + } resp := handler(req) switch resp.(type) { - case usage.TallymanErrorV1: + case usagetypes.TallymanV1Response: rw.WriteHeader(http.StatusInternalServerError) default: rw.WriteHeader(http.StatusOK) } err = json.NewEncoder(rw).Encode(resp) - require.NoError(t, err) + if !assert.NoError(t, err, "could not encode response body") { + rw.WriteHeader(http.StatusInternalServerError) + return + } }) } -func tallymanAcceptAllHandler(req usage.TallymanIngestRequestV1) usage.TallymanIngestResponseV1 { - acceptedEvents := make([]usage.TallymanIngestAcceptedEventV1, len(req.Events)) +func tallymanAcceptAllHandler(req usagetypes.TallymanV1IngestRequest) usagetypes.TallymanV1IngestResponse { + acceptedEvents := make([]usagetypes.TallymanV1IngestAcceptedEvent, len(req.Events)) for i, event := range req.Events { acceptedEvents[i].ID = event.ID } - return usage.TallymanIngestResponseV1{ + return usagetypes.TallymanV1IngestResponse{ AcceptedEvents: acceptedEvents, - RejectedEvents: []usage.TallymanIngestRejectedEventV1{}, + RejectedEvents: []usagetypes.TallymanV1IngestRejectedEvent{}, } } diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index 46207f319dbe1..fd4706a25e511 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -941,7 +941,6 @@ func TestGroupSync(t *testing.T) { require.NoError(t, err) } - // nolint:gocritic _, err := runner.API.Database.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{ NewLoginType: database.LoginTypeOIDC, UserID: user.ID, diff --git a/enterprise/coderd/workspaceagents.go b/enterprise/coderd/workspaceagents.go index 3223151425630..739aba6d628c2 100644 --- a/enterprise/coderd/workspaceagents.go +++ b/enterprise/coderd/workspaceagents.go @@ -2,9 +2,14 @@ package coderd import ( "context" + "fmt" "net/http" + "github.com/go-chi/chi/v5" + + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/codersdk" ) @@ -17,3 +22,77 @@ func (api *API) shouldBlockNonBrowserConnections(rw http.ResponseWriter) bool { } return false } + +// @Summary Get workspace external agent credentials +// @ID get-workspace-external-agent-credentials +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param workspace path string true "Workspace ID" format(uuid) +// @Param agent path string true "Agent name" +// @Success 200 {object} codersdk.ExternalAgentCredentials +// @Router /workspaces/{workspace}/external-agent/{agent}/credentials [get] +func (api *API) workspaceExternalAgentCredentials(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspace := httpmw.WorkspaceParam(r) + agentName := chi.URLParam(r, "agent") + + build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get latest workspace build.", + Detail: err.Error(), + }) + return + } + if !build.HasExternalAgent.Bool { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Workspace does not have an external agent.", + }) + return + } + + agents, err := api.Database.GetWorkspaceAgentsByWorkspaceAndBuildNumber(ctx, database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ + WorkspaceID: workspace.ID, + BuildNumber: build.BuildNumber, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get workspace agents.", + Detail: err.Error(), + }) + return + } + + var agent *database.WorkspaceAgent + for i := range agents { + if agents[i].Name == agentName { + agent = &agents[i] + break + } + } + if agent == nil { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: fmt.Sprintf("External agent '%s' not found in workspace.", agentName), + }) + return + } + + if agent.AuthInstanceID.Valid { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "External agent is authenticated with an instance ID.", + }) + return + } + + initScriptURL := fmt.Sprintf("%s/api/v2/init-script/%s/%s", api.AccessURL.String(), agent.OperatingSystem, agent.Architecture) + command := fmt.Sprintf("curl -fsSL %q | CODER_AGENT_TOKEN=%q sh", initScriptURL, agent.AuthToken.String()) + if agent.OperatingSystem == "windows" { + command = fmt.Sprintf("$env:CODER_AGENT_TOKEN=%q; iwr -useb %q | iex", agent.AuthToken.String(), initScriptURL) + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ExternalAgentCredentials{ + AgentToken: agent.AuthToken.String(), + Command: command, + }) +} diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index f4f0670cd150e..c9d44e667c212 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -3,6 +3,7 @@ package coderd_test import ( "context" "crypto/tls" + "database/sql" "fmt" "net/http" "os" @@ -12,6 +13,7 @@ import ( "time" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/provisionersdk" @@ -344,3 +346,123 @@ func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.Cr return setupResp{workspace, sdkAgent, agnt} } + +func TestWorkspaceExternalAgentCredentials(t *testing.T) { + t.Parallel() + + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + + t.Run("Success - linux", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).Seed(database.WorkspaceBuild{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }).Resource(&proto.Resource{ + Name: "test-agent", + Type: "coder_external_agent", + }).WithAgent(func(a []*proto.Agent) []*proto.Agent { + a[0].Name = "test-agent" + a[0].OperatingSystem = "linux" + a[0].Architecture = "amd64" + return a + }).Do() + + credentials, err := client.WorkspaceExternalAgentCredentials( + ctx, r.Workspace.ID, "test-agent") + require.NoError(t, err) + + require.Equal(t, r.AgentToken, credentials.AgentToken) + expectedCommand := fmt.Sprintf("curl -fsSL \"%s/api/v2/init-script/linux/amd64\" | CODER_AGENT_TOKEN=%q sh", client.URL, r.AgentToken) + require.Equal(t, expectedCommand, credentials.Command) + }) + + t.Run("Success - windows", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).Resource(&proto.Resource{ + Name: "test-agent", + Type: "coder_external_agent", + }).Seed(database.WorkspaceBuild{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }).WithAgent(func(a []*proto.Agent) []*proto.Agent { + a[0].Name = "test-agent" + a[0].OperatingSystem = "windows" + a[0].Architecture = "amd64" + return a + }).Do() + + credentials, err := client.WorkspaceExternalAgentCredentials( + ctx, r.Workspace.ID, "test-agent") + require.NoError(t, err) + + require.Equal(t, r.AgentToken, credentials.AgentToken) + expectedCommand := fmt.Sprintf("$env:CODER_AGENT_TOKEN=%q; iwr -useb \"%s/api/v2/init-script/windows/amd64\" | iex", r.AgentToken, client.URL) + require.Equal(t, expectedCommand, credentials.Command) + }) + + t.Run("WithInstanceID - should return 404", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).Seed(database.WorkspaceBuild{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }).Resource(&proto.Resource{ + Name: "test-agent", + Type: "coder_external_agent", + }).WithAgent(func(a []*proto.Agent) []*proto.Agent { + a[0].Name = "test-agent" + a[0].Auth = &proto.Agent_InstanceId{ + InstanceId: uuid.New().String(), + } + return a + }).Do() + + _, err := client.WorkspaceExternalAgentCredentials(ctx, r.Workspace.ID, "test-agent") + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, "External agent is authenticated with an instance ID.", apiErr.Message) + }) + + t.Run("No external agent - should return 404", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).Do() + + _, err := client.WorkspaceExternalAgentCredentials(ctx, r.Workspace.ID, "test-agent") + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, "Workspace does not have an external agent.", apiErr.Message) + }) +} diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go index f49e135ad55b3..f39b090ca21b1 100644 --- a/enterprise/coderd/workspacequota_test.go +++ b/enterprise/coderd/workspacequota_test.go @@ -462,7 +462,6 @@ func TestWorkspaceSerialization(t *testing.T) { // +------------------------------+------------------+ // pq: could not serialize access due to concurrent update ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // testing ctx = dbauthz.AsSystemRestricted(ctx) myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ @@ -520,7 +519,6 @@ func TestWorkspaceSerialization(t *testing.T) { // +------------------------------+------------------+ // Works! ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // testing ctx = dbauthz.AsSystemRestricted(ctx) myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ @@ -589,7 +587,6 @@ func TestWorkspaceSerialization(t *testing.T) { // +---------------------+----------------------------------+ // pq: could not serialize access due to concurrent update ctx := testutil.Context(t, testutil.WaitShort) - //nolint:gocritic // testing ctx = dbauthz.AsSystemRestricted(ctx) myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ @@ -642,7 +639,6 @@ func TestWorkspaceSerialization(t *testing.T) { // | CommitTx() | | // +---------------------+----------------------------------+ ctx := testutil.Context(t, testutil.WaitShort) - //nolint:gocritic // testing ctx = dbauthz.AsSystemRestricted(ctx) myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ @@ -686,7 +682,6 @@ func TestWorkspaceSerialization(t *testing.T) { // +---------------------+----------------------------------+ // Works! ctx := testutil.Context(t, testutil.WaitShort) - //nolint:gocritic // testing ctx = dbauthz.AsSystemRestricted(ctx) var err error @@ -741,7 +736,6 @@ func TestWorkspaceSerialization(t *testing.T) { // | | CommitTx() | // +---------------------+---------------------+ ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // testing ctx = dbauthz.AsSystemRestricted(ctx) myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ @@ -799,7 +793,6 @@ func TestWorkspaceSerialization(t *testing.T) { // | | CommitTx() | // +---------------------+---------------------+ ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // testing ctx = dbauthz.AsSystemRestricted(ctx) myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ @@ -860,7 +853,6 @@ func TestWorkspaceSerialization(t *testing.T) { // +---------------------+---------------------+ // pq: could not serialize access due to read/write dependencies among transactions ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // testing ctx = dbauthz.AsSystemRestricted(ctx) myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index dad24460068cd..1cdcd9fb43144 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -42,6 +42,7 @@ import ( agplschedule "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule/cron" "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/codersdk" entaudit "github.com/coder/coder/v2/enterprise/audit" "github.com/coder/coder/v2/enterprise/audit/backends" @@ -569,7 +570,6 @@ func TestCreateUserWorkspace(t *testing.T) { return a }).Do() - // nolint:gocritic // this is a test ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong)) agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, uuid.MustParse(r.AgentToken)) require.NoError(t, err) @@ -1122,7 +1122,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // Simulate the workspace being dormant beyond the threshold. tickTime2 := ws.DormantAt.Add(2 * transitionTTL) - coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime2) ticker <- tickTime2 stats = <-statCh require.Len(t, stats.Transitions, 1) @@ -1277,7 +1277,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // We should see the workspace get stopped now. tickTime2 := ws.LastUsedAt.Add(inactiveTTL * 2) - coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime2) tickCh <- tickTime2 stats = <-statsCh require.Len(t, stats.Errors, 0) @@ -1481,7 +1481,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // Force an autostart transition again. tickTime2 := sched.Next(firstBuild.CreatedAt) - coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime2) tickCh <- tickTime2 stats = <-statsCh require.Len(t, stats.Errors, 0) @@ -1707,7 +1707,6 @@ func TestWorkspaceAutobuild(t *testing.T) { // We want to test the database nullifies the NextStartAt so we // make a raw DB call here. We pass in NextStartAt here so we // can test the database will nullify it and not us. - //nolint: gocritic // We need system context to modify this. err = db.UpdateWorkspaceAutostart(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAutostartParams{ ID: ws.ID, AutostartSchedule: sql.NullString{Valid: true, String: sched.String()}, @@ -2719,7 +2718,6 @@ func TestPrebuildUpdateLifecycleParams(t *testing.T) { }).Do() // Mark the prebuilt workspace's agent as ready so the prebuild can be claimed - // nolint:gocritic ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong)) agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, uuid.MustParse(workspaceBuild.AgentToken)) require.NoError(t, err) @@ -2767,6 +2765,114 @@ func TestPrebuildUpdateLifecycleParams(t *testing.T) { } } +func TestPrebuildActivityBump(t *testing.T) { + t.Parallel() + + clock := quartz.NewMock(t) + clock.Set(dbtime.Now()) + + // Setup + log := testutil.Logger(t) + client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + Clock: clock, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspacePrebuilds: 1, + }, + }, + }) + + // Given: a template and a template version with preset and a prebuilt workspace + presetID := uuid.New() + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + // Configure activity bump on the template + activityBump := time.Hour + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.ActivityBumpMillis = ptr.Ref[int64](activityBump.Milliseconds()) + }) + dbgen.Preset(t, db, database.InsertPresetParams{ + ID: presetID, + TemplateVersionID: version.ID, + DesiredInstances: sql.NullInt32{Int32: 1, Valid: true}, + }) + // Given: a prebuild with an expired Deadline + deadline := clock.Now().Add(-30 * time.Minute) + wb := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: database.PrebuildsSystemUserID, + TemplateID: template.ID, + }).Seed(database.WorkspaceBuild{ + TemplateVersionID: version.ID, + TemplateVersionPresetID: uuid.NullUUID{ + UUID: presetID, + Valid: true, + }, + Deadline: deadline, + }).WithAgent(func(agent []*proto.Agent) []*proto.Agent { + return agent + }).Do() + + // Mark the prebuilt workspace's agent as ready so the prebuild can be claimed + // nolint:gocritic + ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong)) + agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, uuid.MustParse(wb.AgentToken)) + require.NoError(t, err) + err = db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: agent.WorkspaceAgent.ID, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + }) + require.NoError(t, err) + + // Given: a prebuilt workspace with a Deadline and an empty MaxDeadline + prebuild := coderdtest.MustWorkspace(t, client, wb.Workspace.ID) + require.Equal(t, deadline.UTC(), prebuild.LatestBuild.Deadline.Time.UTC()) + require.Zero(t, prebuild.LatestBuild.MaxDeadline) + + // When: activity bump is applied to an unclaimed prebuild + workspacestats.ActivityBumpWorkspace(ctx, log, db, prebuild.ID, clock.Now().Add(10*time.Hour)) + + // Then: prebuild Deadline/MaxDeadline remain unchanged + prebuild = coderdtest.MustWorkspace(t, client, wb.Workspace.ID) + require.Equal(t, deadline.UTC(), prebuild.LatestBuild.Deadline.Time.UTC()) + require.Zero(t, prebuild.LatestBuild.MaxDeadline) + + // Given: the prebuilt workspace is claimed by a user + user, err := client.User(ctx, "testUser") + require.NoError(t, err) + claimedWorkspace, err := client.CreateUserWorkspace(ctx, user.ID.String(), codersdk.CreateWorkspaceRequest{ + TemplateVersionID: version.ID, + TemplateVersionPresetID: presetID, + Name: coderdtest.RandomUsername(t), + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, claimedWorkspace.LatestBuild.ID) + workspace := coderdtest.MustWorkspace(t, client, claimedWorkspace.ID) + require.Equal(t, prebuild.ID, workspace.ID) + // Claimed workspaces have an empty Deadline and MaxDeadline + require.Zero(t, workspace.LatestBuild.Deadline) + require.Zero(t, workspace.LatestBuild.MaxDeadline) + + // Given: the claimed workspace has an expired Deadline + err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ + ID: workspace.LatestBuild.ID, + Deadline: deadline, + UpdatedAt: clock.Now(), + }) + require.NoError(t, err) + workspace = coderdtest.MustWorkspace(t, client, claimedWorkspace.ID) + + // When: activity bump is applied to a claimed prebuild + workspacestats.ActivityBumpWorkspace(ctx, log, db, workspace.ID, clock.Now().Add(10*time.Hour)) + + // Then: Deadline is extended by the activity bump, MaxDeadline remains unset + workspace = coderdtest.MustWorkspace(t, client, claimedWorkspace.ID) + require.WithinDuration(t, clock.Now().Add(activityBump).UTC(), workspace.LatestBuild.Deadline.Time.UTC(), testutil.WaitMedium) + require.Zero(t, workspace.LatestBuild.MaxDeadline) +} + // TestWorkspaceTemplateParamsChange tests a workspace with a parameter that // validation changes on apply. The params used in create workspace are invalid // according to the static params on import. @@ -3613,7 +3719,6 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { require.Equal(t, ws.LatestBuild.MatchedProvisioners.Available, 0) // Verify that the provisioner daemon is registered in the database - //nolint:gocritic // unit testing daemons, err := db.GetProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) require.NoError(t, err) require.Equal(t, 1, len(daemons)) @@ -3649,7 +3754,6 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { ctx = testutil.Context(t, testutil.WaitLong) // Reset the context to avoid timeouts. - // nolint:gocritic // unit testing daemons, err := db.GetProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) require.NoError(t, err) require.Equal(t, len(daemons), 1) @@ -3659,8 +3763,6 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { require.NoError(t, err) // Simulate it's subsequent deletion from the database: - - // nolint:gocritic // unit testing _, err = db.UpsertProvisionerDaemon(dbauthz.AsSystemRestricted(ctx), database.UpsertProvisionerDaemonParams{ Name: daemons[0].Name, OrganizationID: daemons[0].OrganizationID, @@ -3678,7 +3780,6 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { }, }) require.NoError(t, err) - // nolint:gocritic // unit testing err = db.DeleteOldProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) require.NoError(t, err) @@ -3689,7 +3790,6 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Count, 0) require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Available, 0) - // nolint:gocritic // unit testing _, err = client.WorkspaceByOwnerAndName(dbauthz.As(ctx, userSubject), username, workspace.Name, codersdk.WorkspaceOptions{}) require.NoError(t, err) require.Equal(t, workspace.LatestBuild.Status, codersdk.WorkspaceStatusPending) @@ -3726,7 +3826,6 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { ctx = testutil.Context(t, testutil.WaitLong) // Reset the context to avoid timeouts. - // nolint:gocritic // unit testing daemons, err := db.GetProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) require.NoError(t, err) require.Equal(t, len(daemons), 1) @@ -3735,7 +3834,6 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { err = closer.Close() require.NoError(t, err) - // nolint:gocritic // unit testing _, err = db.UpsertProvisionerDaemon(dbauthz.AsSystemRestricted(ctx), database.UpsertProvisionerDaemonParams{ Name: daemons[0].Name, OrganizationID: daemons[0].OrganizationID, diff --git a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go index b0051551a0f3d..72f5a4291c40e 100644 --- a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go +++ b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go @@ -75,7 +75,7 @@ func (c *Client) RequestIgnoreRedirects(ctx context.Context, method, path string // DialWorkspaceAgent calls the underlying codersdk.Client's DialWorkspaceAgent // method. -func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, options *workspacesdk.DialAgentOptions) (agentConn *workspacesdk.AgentConn, err error) { +func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, options *workspacesdk.DialAgentOptions) (agentConn workspacesdk.AgentConn, err error) { return workspacesdk.New(c.SDKClient).DialAgent(ctx, agentID, options) } diff --git a/go.mod b/go.mod index e10c7a248db7e..3f9d92aa54c0e 100644 --- a/go.mod +++ b/go.mod @@ -309,7 +309,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-test/deep v1.1.0 // indirect - github.com/go-viper/mapstructure/v2 v2.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect @@ -477,6 +477,7 @@ require ( ) require ( + github.com/anthropics/anthropic-sdk-go v1.4.0 github.com/brianvoe/gofakeit/v7 v7.3.0 github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 github.com/coder/aisdk-go v0.0.9 @@ -500,7 +501,6 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect - github.com/anthropics/anthropic-sdk-go v1.4.0 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/aquasecurity/trivy v0.58.2 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect @@ -516,7 +516,7 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect - github.com/hashicorp/go-getter v1.7.8 // indirect + github.com/hashicorp/go-getter v1.7.9 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackmordaunt/icns/v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 3575f35177154..4bc0e0336ab06 100644 --- a/go.sum +++ b/go.sum @@ -1154,8 +1154,8 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= -github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -1353,8 +1353,8 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= -github.com/hashicorp/go-getter v1.7.8 h1:mshVHx1Fto0/MydBekWan5zUipGq7jO0novchgMmSiY= -github.com/hashicorp/go-getter v1.7.8/go.mod h1:2c6CboOEb9jG6YvmC9xdD+tyAFsrUaJPedwXDGr0TM4= +github.com/hashicorp/go-getter v1.7.9 h1:G9gcjrDixz7glqJ+ll5IWvggSBR+R0B54DSRt4qfdC4= +github.com/hashicorp/go-getter v1.7.9/go.mod h1:dyFCmT1AQkDfOIt9NH8pw9XBDqNrIKJT5ylbpi7zPNE= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= diff --git a/provisioner/terraform/executor.go b/provisioner/terraform/executor.go index ea63f8c59877e..8940a1708bf19 100644 --- a/provisioner/terraform/executor.go +++ b/provisioner/terraform/executor.go @@ -363,6 +363,7 @@ func (e *executor) plan(ctx, killCtx context.Context, env, vars []string, logr l ModuleFiles: moduleFiles, HasAiTasks: state.HasAITasks, AiTasks: state.AITasks, + HasExternalAgents: state.HasExternalAgents, } return msg, nil diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index d067965997308..90a34e6d03a8c 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -1135,6 +1135,31 @@ func TestProvision(t *testing.T) { HasAiTasks: true, }, }, + { + Name: "external-agent", + Files: map[string]string{ + "main.tf": `terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7.0" + } + } + } + resource "coder_external_agent" "example" { + agent_id = "123" + } + `, + }, + Response: &proto.PlanComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "coder_external_agent", + }}, + HasExternalAgents: true, + }, + SkipCacheProviders: true, + }, } // Remove unused cache dirs before running tests. @@ -1237,6 +1262,7 @@ func TestProvision(t *testing.T) { require.Equal(t, string(modulesWant), string(modulesGot)) require.Equal(t, planComplete.HasAiTasks, testCase.Response.HasAiTasks) + require.Equal(t, planComplete.HasExternalAgents, testCase.Response.HasExternalAgents) } if testCase.Apply { diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index 9642751e7466a..3dcead074c22a 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -165,6 +165,7 @@ type State struct { ExternalAuthProviders []*proto.ExternalAuthProviderResource AITasks []*proto.AITask HasAITasks bool + HasExternalAgents bool } var ErrInvalidTerraformAddr = xerrors.New("invalid terraform address") @@ -188,6 +189,20 @@ func hasAITaskResources(graph *gographviz.Graph) bool { return false } +func hasExternalAgentResources(graph *gographviz.Graph) bool { + for _, node := range graph.Nodes.Lookup { + if label, exists := node.Attrs["label"]; exists { + labelValue := strings.Trim(label, `"`) + // The first condition is for the case where the resource is in the root module. + // The second condition is for the case where the resource is in a child module. + if strings.HasPrefix(labelValue, "coder_external_agent.") || strings.Contains(labelValue, ".coder_external_agent.") { + return true + } + } + } + return false +} + // ConvertState consumes Terraform state and a GraphViz representation // produced by `terraform graph` to produce resources consumable by Coder. // nolint:gocognit // This function makes more sense being large for now, until refactored. @@ -1065,6 +1080,7 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s ExternalAuthProviders: externalAuthProviders, HasAITasks: hasAITasks, AITasks: aiTasks, + HasExternalAgents: hasExternalAgentResources(graph), }, nil } @@ -1252,7 +1268,8 @@ func findResourcesInGraph(graph *gographviz.Graph, tfResourcesByLabel map[string continue } // Don't associate Coder resources with other Coder resources! - if strings.HasPrefix(resource.Type, "coder_") { + // Except for coder_external_agent, which is a special case. + if strings.HasPrefix(resource.Type, "coder_") && resource.Type != "coder_external_agent" { continue } graphResources = append(graphResources, &graphResource{ diff --git a/provisioner/terraform/resources_test.go b/provisioner/terraform/resources_test.go index 1575c6c9c159e..715055c00cad9 100644 --- a/provisioner/terraform/resources_test.go +++ b/provisioner/terraform/resources_test.go @@ -1573,6 +1573,35 @@ func TestAITasks(t *testing.T) { }) } +func TestExternalAgents(t *testing.T) { + t.Parallel() + ctx, logger := ctxAndLogger(t) + + t.Run("External agents can be defined", func(t *testing.T) { + t.Parallel() + + // nolint:dogsled + _, filename, _, _ := runtime.Caller(0) + + dir := filepath.Join(filepath.Dir(filename), "testdata", "resources", "external-agents") + tfPlanRaw, err := os.ReadFile(filepath.Join(dir, "external-agents.tfplan.json")) + require.NoError(t, err) + var tfPlan tfjson.Plan + err = json.Unmarshal(tfPlanRaw, &tfPlan) + require.NoError(t, err) + tfPlanGraph, err := os.ReadFile(filepath.Join(dir, "external-agents.tfplan.dot")) + require.NoError(t, err) + + state, err := terraform.ConvertState(ctx, []*tfjson.StateModule{tfPlan.PlannedValues.RootModule, tfPlan.PriorState.Values.RootModule}, string(tfPlanGraph), logger) + require.NotNil(t, state) + require.NoError(t, err) + require.True(t, state.HasExternalAgents) + require.Len(t, state.Resources, 1) + require.Len(t, state.Resources[0].Agents, 1) + require.Equal(t, "dev1", state.Resources[0].Agents[0].Name) + }) +} + // sortResource ensures resources appear in a consistent ordering // to prevent tests from flaking. func sortResources(resources []*proto.Resource) { diff --git a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.dot b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.dot new file mode 100644 index 0000000000000..d2db86a89e488 --- /dev/null +++ b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.dot @@ -0,0 +1,22 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_agent.dev1 (expand)" [label = "coder_agent.dev1", shape = "box"] + "[root] coder_external_agent.dev1 (expand)" [label = "coder_external_agent.dev1", shape = "box"] + "[root] data.coder_provisioner.me (expand)" [label = "data.coder_provisioner.me", shape = "box"] + "[root] data.coder_workspace.me (expand)" [label = "data.coder_workspace.me", shape = "box"] + "[root] data.coder_workspace_owner.me (expand)" [label = "data.coder_workspace_owner.me", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] coder_agent.dev1 (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] coder_external_agent.dev1 (expand)" -> "[root] coder_agent.dev1 (expand)" + "[root] data.coder_provisioner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_owner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_external_agent.dev1 (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_provisioner.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_owner.me (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.json b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.json new file mode 100644 index 0000000000000..3d085a535b2bf --- /dev/null +++ b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.json @@ -0,0 +1,277 @@ +{ + "format_version": "1.2", + "terraform_version": "1.12.2", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "coder_agent.dev1", + "mode": "managed", + "type": "coder_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "env": null, + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "troubleshooting_url": null + }, + "sensitive_values": { + "display_apps": [], + "metadata": [], + "resources_monitoring": [], + "token": true + } + }, + { + "address": "coder_external_agent.dev1", + "mode": "managed", + "type": "coder_external_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "sensitive_values": { + "agent_id": true + } + } + ] + } + }, + "resource_changes": [ + { + "address": "coder_agent.dev1", + "mode": "managed", + "type": "coder_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "env": null, + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "troubleshooting_url": null + }, + "after_unknown": { + "display_apps": true, + "id": true, + "init_script": true, + "metadata": [], + "resources_monitoring": [], + "token": true + }, + "before_sensitive": false, + "after_sensitive": { + "display_apps": [], + "metadata": [], + "resources_monitoring": [], + "token": true + } + } + }, + { + "address": "coder_external_agent.dev1", + "mode": "managed", + "type": "coder_external_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": {}, + "after_unknown": { + "agent_id": true, + "id": true + }, + "before_sensitive": false, + "after_sensitive": { + "agent_id": true + } + } + } + ], + "prior_state": { + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "arch": "amd64", + "id": "d607be41-7697-475f-8257-2f6e24adbede", + "os": "linux" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "access_port": 443, + "access_url": "https://dev.coder.com/", + "id": "0b7fc772-5e27-4096-b8a3-9e6a8b914ebe", + "is_prebuild": false, + "is_prebuild_claim": false, + "name": "kacper", + "prebuild_count": 0, + "start_count": 1, + "template_id": "", + "template_name": "", + "template_version": "", + "transition": "start" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 0, + "values": { + "email": "default@example.com", + "full_name": "kacpersaw", + "groups": [], + "id": "1ebd1795-7cf2-47c5-8024-5d56e68f1681", + "login_type": null, + "name": "default", + "oidc_access_token": "", + "rbac_roles": [], + "session_token": "", + "ssh_private_key": "", + "ssh_public_key": "" + }, + "sensitive_values": { + "groups": [], + "oidc_access_token": true, + "rbac_roles": [], + "session_token": true, + "ssh_private_key": true + } + } + ] + } + } + }, + "configuration": { + "provider_config": { + "coder": { + "name": "coder", + "full_name": "registry.terraform.io/coder/coder", + "version_constraint": ">= 2.0.0" + } + }, + "root_module": { + "resources": [ + { + "address": "coder_agent.dev1", + "mode": "managed", + "type": "coder_agent", + "name": "dev1", + "provider_config_key": "coder", + "expressions": { + "arch": { + "constant_value": "amd64" + }, + "os": { + "constant_value": "linux" + } + }, + "schema_version": 1 + }, + { + "address": "coder_external_agent.dev1", + "mode": "managed", + "type": "coder_external_agent", + "name": "dev1", + "provider_config_key": "coder", + "expressions": { + "agent_id": { + "references": [ + "coder_agent.dev1.token", + "coder_agent.dev1" + ] + } + }, + "schema_version": 1 + }, + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_config_key": "coder", + "schema_version": 1 + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_config_key": "coder", + "schema_version": 1 + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_config_key": "coder", + "schema_version": 0 + } + ] + } + }, + "relevant_attributes": [ + { + "resource": "coder_agent.dev1", + "attribute": [ + "token" + ] + } + ], + "timestamp": "2025-07-31T11:08:54Z", + "applyable": true, + "complete": true, + "errored": false +} diff --git a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.dot b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.dot new file mode 100644 index 0000000000000..d2db86a89e488 --- /dev/null +++ b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.dot @@ -0,0 +1,22 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_agent.dev1 (expand)" [label = "coder_agent.dev1", shape = "box"] + "[root] coder_external_agent.dev1 (expand)" [label = "coder_external_agent.dev1", shape = "box"] + "[root] data.coder_provisioner.me (expand)" [label = "data.coder_provisioner.me", shape = "box"] + "[root] data.coder_workspace.me (expand)" [label = "data.coder_workspace.me", shape = "box"] + "[root] data.coder_workspace_owner.me (expand)" [label = "data.coder_workspace_owner.me", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] coder_agent.dev1 (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] coder_external_agent.dev1 (expand)" -> "[root] coder_agent.dev1 (expand)" + "[root] data.coder_provisioner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_owner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_external_agent.dev1 (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_provisioner.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_owner.me (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.json b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.json new file mode 100644 index 0000000000000..af884a315ec9d --- /dev/null +++ b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.json @@ -0,0 +1,138 @@ +{ + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "arch": "amd64", + "id": "0ce4713c-28d6-4999-9381-52b8a603b672", + "os": "linux" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "access_port": 443, + "access_url": "https://dev.coder.com/", + "id": "dfa1dbe8-ad31-410b-b201-a4ed4d884938", + "is_prebuild": false, + "is_prebuild_claim": false, + "name": "kacper", + "prebuild_count": 0, + "start_count": 1, + "template_id": "", + "template_name": "", + "template_version": "", + "transition": "start" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 0, + "values": { + "email": "default@example.com", + "full_name": "kacpersaw", + "groups": [], + "id": "f5e82b90-ea22-4288-8286-9cf7af651143", + "login_type": null, + "name": "default", + "oidc_access_token": "", + "rbac_roles": [], + "session_token": "", + "ssh_private_key": "", + "ssh_public_key": "" + }, + "sensitive_values": { + "groups": [], + "oidc_access_token": true, + "rbac_roles": [], + "session_token": true, + "ssh_private_key": true + } + }, + { + "address": "coder_agent.dev1", + "mode": "managed", + "type": "coder_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "display_apps": [ + { + "port_forwarding_helper": true, + "ssh_helper": true, + "vscode": true, + "vscode_insiders": false, + "web_terminal": true + } + ], + "env": null, + "id": "15a35370-3b2e-4ee7-8b28-81cef0152d8b", + "init_script": "", + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "token": "d054c66b-cc5c-41ae-aa0c-2098a1075272", + "troubleshooting_url": null + }, + "sensitive_values": { + "display_apps": [ + {} + ], + "metadata": [], + "resources_monitoring": [], + "token": true + } + }, + { + "address": "coder_external_agent.dev1", + "mode": "managed", + "type": "coder_external_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "agent_id": "d054c66b-cc5c-41ae-aa0c-2098a1075272", + "id": "4d87dd70-879c-4347-b0c1-b8f3587d1021" + }, + "sensitive_values": { + "agent_id": true + }, + "depends_on": [ + "coder_agent.dev1" + ] + } + ] + } + } +} diff --git a/provisioner/terraform/testdata/resources/external-agents/main.tf b/provisioner/terraform/testdata/resources/external-agents/main.tf new file mode 100644 index 0000000000000..282b77e1474a9 --- /dev/null +++ b/provisioner/terraform/testdata/resources/external-agents/main.tf @@ -0,0 +1,21 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">=2.0.0" + } + } +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "dev1" { + os = "linux" + arch = "amd64" +} + +resource "coder_external_agent" "dev1" { + agent_id = coder_agent.dev1.token +} diff --git a/provisioner/terraform/testdata/resources/version.txt b/provisioner/terraform/testdata/resources/version.txt index 3d0e62313ced1..6b89d58f861a7 100644 --- a/provisioner/terraform/testdata/resources/version.txt +++ b/provisioner/terraform/testdata/resources/version.txt @@ -1 +1 @@ -1.11.4 +1.12.2 diff --git a/provisionerd/proto/provisionerd.pb.go b/provisionerd/proto/provisionerd.pb.go index 9960105c78962..818719f1b3995 100644 --- a/provisionerd/proto/provisionerd.pb.go +++ b/provisionerd/proto/provisionerd.pb.go @@ -1403,6 +1403,7 @@ type CompletedJob_TemplateImport struct { ModuleFiles []byte `protobuf:"bytes,10,opt,name=module_files,json=moduleFiles,proto3" json:"module_files,omitempty"` ModuleFilesHash []byte `protobuf:"bytes,11,opt,name=module_files_hash,json=moduleFilesHash,proto3" json:"module_files_hash,omitempty"` HasAiTasks bool `protobuf:"varint,12,opt,name=has_ai_tasks,json=hasAiTasks,proto3" json:"has_ai_tasks,omitempty"` + HasExternalAgents bool `protobuf:"varint,13,opt,name=has_external_agents,json=hasExternalAgents,proto3" json:"has_external_agents,omitempty"` } func (x *CompletedJob_TemplateImport) Reset() { @@ -1521,6 +1522,13 @@ func (x *CompletedJob_TemplateImport) GetHasAiTasks() bool { return false } +func (x *CompletedJob_TemplateImport) GetHasExternalAgents() bool { + if x != nil { + return x.HasExternalAgents + } + return false +} + type CompletedJob_TemplateDryRun struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1710,7 +1718,7 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x6d, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x73, 0x1a, 0x10, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x1a, 0x10, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, - 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0x8b, 0x0b, 0x0a, 0x0c, 0x43, 0x6f, + 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xbb, 0x0b, 0x0a, 0x0c, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x54, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x62, @@ -1749,7 +1757,7 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x69, 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x49, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x07, - 0x61, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x1a, 0x9f, 0x05, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, + 0x61, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x1a, 0xcf, 0x05, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x3e, 0x0a, 0x0f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, @@ -1791,7 +1799,10 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x48, 0x61, 0x73, 0x68, 0x12, 0x20, 0x0a, 0x0c, 0x68, 0x61, 0x73, 0x5f, 0x61, 0x69, 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x68, - 0x61, 0x73, 0x41, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x1a, 0x74, 0x0a, 0x0e, 0x54, 0x65, 0x6d, + 0x61, 0x73, 0x41, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x68, 0x61, 0x73, + 0x5f, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x68, 0x61, 0x73, 0x45, 0x78, 0x74, 0x65, 0x72, + 0x6e, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x1a, 0x74, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, diff --git a/provisionerd/proto/provisionerd.proto b/provisionerd/proto/provisionerd.proto index eeeb5f02da0fb..b008da33ea87e 100644 --- a/provisionerd/proto/provisionerd.proto +++ b/provisionerd/proto/provisionerd.proto @@ -95,6 +95,7 @@ message CompletedJob { bytes module_files = 10; bytes module_files_hash = 11; bool has_ai_tasks = 12; + bool has_external_agents = 13; } message TemplateDryRun { repeated provisioner.Resource resources = 1; diff --git a/provisionerd/proto/version.go b/provisionerd/proto/version.go index 10e73a3be176c..3ae1bbae04454 100644 --- a/provisionerd/proto/version.go +++ b/provisionerd/proto/version.go @@ -47,9 +47,12 @@ import "github.com/coder/coder/v2/apiversion" // // API v1.8: // - Add new fields `description` and `icon` to `Preset`. +// +// API v1.9: +// - Added new field named 'has_external_agent' in 'CompleteJob.TemplateImport' const ( CurrentMajor = 1 - CurrentMinor = 8 + CurrentMinor = 9 ) // CurrentVersion is the current provisionerd API version. diff --git a/provisionerd/runner/runner.go b/provisionerd/runner/runner.go index b80cf9060b358..924f0628820ce 100644 --- a/provisionerd/runner/runner.go +++ b/provisionerd/runner/runner.go @@ -600,8 +600,9 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p // ModuleFiles are not on the stopProvision. So grab from the startProvision. ModuleFiles: startProvision.ModuleFiles, // ModuleFileHash will be populated if the file is uploaded async - ModuleFilesHash: []byte{}, - HasAiTasks: startProvision.HasAITasks, + ModuleFilesHash: []byte{}, + HasAiTasks: startProvision.HasAITasks, + HasExternalAgents: startProvision.HasExternalAgents, }, }, }, nil @@ -666,6 +667,7 @@ type templateImportProvision struct { Plan json.RawMessage ModuleFiles []byte HasAITasks bool + HasExternalAgents bool } // Performs a dry-run provision when importing a template. @@ -807,6 +809,7 @@ func (r *Runner) runTemplateImportProvisionWithRichParameters( Plan: c.Plan, ModuleFiles: moduleFilesData, HasAITasks: c.HasAiTasks, + HasExternalAgents: c.HasExternalAgents, }, nil default: return nil, xerrors.Errorf("invalid message type %q received from provisioner", diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index 52d40ef87dd4d..c96878fba5fea 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -3401,8 +3401,9 @@ type PlanComplete struct { // still need to know that such resources are defined. // // See `hasAITaskResources` in provisioner/terraform/resources.go for more details. - HasAiTasks bool `protobuf:"varint,13,opt,name=has_ai_tasks,json=hasAiTasks,proto3" json:"has_ai_tasks,omitempty"` - AiTasks []*AITask `protobuf:"bytes,14,rep,name=ai_tasks,json=aiTasks,proto3" json:"ai_tasks,omitempty"` + HasAiTasks bool `protobuf:"varint,13,opt,name=has_ai_tasks,json=hasAiTasks,proto3" json:"has_ai_tasks,omitempty"` + AiTasks []*AITask `protobuf:"bytes,14,rep,name=ai_tasks,json=aiTasks,proto3" json:"ai_tasks,omitempty"` + HasExternalAgents bool `protobuf:"varint,15,opt,name=has_external_agents,json=hasExternalAgents,proto3" json:"has_external_agents,omitempty"` } func (x *PlanComplete) Reset() { @@ -3528,6 +3529,13 @@ func (x *PlanComplete) GetAiTasks() []*AITask { return nil } +func (x *PlanComplete) GetHasExternalAgents() bool { + if x != nil { + return x.HasExternalAgents + } + return false +} + // ApplyRequest asks the provisioner to apply the changes. Apply MUST be preceded by a successful plan request/response // in the same Session. The plan data is not transmitted over the wire and is cached by the provisioner in the Session. type ApplyRequest struct { @@ -4855,7 +4863,7 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x6f, 0x6d, 0x69, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x6f, 0x6d, 0x69, 0x74, 0x4d, 0x6f, 0x64, - 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0x91, 0x05, 0x0a, 0x0c, 0x50, 0x6c, 0x61, + 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0xc1, 0x05, 0x0a, 0x0c, 0x50, 0x6c, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, @@ -4896,7 +4904,10 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x52, 0x0a, 0x68, 0x61, 0x73, 0x41, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x69, 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x49, 0x54, - 0x61, 0x73, 0x6b, 0x52, 0x07, 0x61, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x22, 0x41, 0x0a, 0x0c, + 0x61, 0x73, 0x6b, 0x52, 0x07, 0x61, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x2e, 0x0a, 0x13, + 0x68, 0x61, 0x73, 0x5f, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x73, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x68, 0x61, 0x73, 0x45, 0x78, + 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x41, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index 2d53d8598e7a6..b120ba1c0e607 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -419,6 +419,7 @@ message PlanComplete { // See `hasAITaskResources` in provisioner/terraform/resources.go for more details. bool has_ai_tasks = 13; repeated provisioner.AITask ai_tasks = 14; + bool has_external_agents = 15; } // ApplyRequest asks the provisioner to apply the changes. Apply MUST be preceded by a successful plan request/response diff --git a/scaletest/agentconn/run.go b/scaletest/agentconn/run.go index dba21cc24e3a0..b0990d9cb11a6 100644 --- a/scaletest/agentconn/run.go +++ b/scaletest/agentconn/run.go @@ -89,7 +89,7 @@ func (r *Runner) Run(ctx context.Context, _ string, w io.Writer) error { // Ensure DERP for completeness. if r.cfg.ConnectionMode == ConnectionModeDerp { - status := conn.Status() + status := conn.TailnetConn().Status() if len(status.Peers()) != 1 { return xerrors.Errorf("check connection mode: expected 1 peer, got %d", len(status.Peers())) } @@ -133,7 +133,7 @@ func (r *Runner) Run(ctx context.Context, _ string, w io.Writer) error { return nil } -func waitForDisco(ctx context.Context, logs io.Writer, conn *workspacesdk.AgentConn) error { +func waitForDisco(ctx context.Context, logs io.Writer, conn workspacesdk.AgentConn) error { const pingAttempts = 10 const pingDelay = 1 * time.Second @@ -165,7 +165,7 @@ func waitForDisco(ctx context.Context, logs io.Writer, conn *workspacesdk.AgentC return nil } -func waitForDirectConnection(ctx context.Context, logs io.Writer, conn *workspacesdk.AgentConn) error { +func waitForDirectConnection(ctx context.Context, logs io.Writer, conn workspacesdk.AgentConn) error { const directConnectionAttempts = 30 const directConnectionDelay = 1 * time.Second @@ -174,7 +174,7 @@ func waitForDirectConnection(ctx context.Context, logs io.Writer, conn *workspac for i := 0; i < directConnectionAttempts; i++ { _, _ = fmt.Fprintf(logs, "\tDirect connection check %d/%d...\n", i+1, directConnectionAttempts) - status := conn.Status() + status := conn.TailnetConn().Status() var err error if len(status.Peers()) != 1 { @@ -207,7 +207,7 @@ func waitForDirectConnection(ctx context.Context, logs io.Writer, conn *workspac return nil } -func verifyConnection(ctx context.Context, logs io.Writer, conn *workspacesdk.AgentConn) error { +func verifyConnection(ctx context.Context, logs io.Writer, conn workspacesdk.AgentConn) error { const verifyConnectionAttempts = 30 const verifyConnectionDelay = 1 * time.Second @@ -249,7 +249,7 @@ func verifyConnection(ctx context.Context, logs io.Writer, conn *workspacesdk.Ag return nil } -func performInitialConnections(ctx context.Context, logs io.Writer, conn *workspacesdk.AgentConn, specs []Connection) error { +func performInitialConnections(ctx context.Context, logs io.Writer, conn workspacesdk.AgentConn, specs []Connection) error { if len(specs) == 0 { return nil } @@ -287,7 +287,7 @@ func performInitialConnections(ctx context.Context, logs io.Writer, conn *worksp return nil } -func holdConnection(ctx context.Context, logs io.Writer, conn *workspacesdk.AgentConn, holdDur time.Duration, specs []Connection) error { +func holdConnection(ctx context.Context, logs io.Writer, conn workspacesdk.AgentConn, holdDur time.Duration, specs []Connection) error { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -364,7 +364,7 @@ func holdConnection(ctx context.Context, logs io.Writer, conn *workspacesdk.Agen return nil } -func agentHTTPClient(conn *workspacesdk.AgentConn) *http.Client { +func agentHTTPClient(conn workspacesdk.AgentConn) *http.Client { return &http.Client{ Transport: &http.Transport{ DisableKeepAlives: true, diff --git a/scaletest/terraform/action/cf_dns.tf b/scaletest/terraform/action/cf_dns.tf index eaaff28ce03a0..664b909ae90b2 100644 --- a/scaletest/terraform/action/cf_dns.tf +++ b/scaletest/terraform/action/cf_dns.tf @@ -1,6 +1,10 @@ +data "cloudflare_zone" "domain" { + name = var.cloudflare_domain +} + resource "cloudflare_record" "coder" { for_each = local.deployments - zone_id = var.cloudflare_zone_id + zone_id = data.cloudflare_zone.domain.zone_id name = each.value.subdomain content = google_compute_address.coder[each.key].address type = "A" diff --git a/scaletest/terraform/action/main.tf b/scaletest/terraform/action/main.tf index c5e22ff9f03ad..cd26c7ec1ccd2 100644 --- a/scaletest/terraform/action/main.tf +++ b/scaletest/terraform/action/main.tf @@ -46,8 +46,13 @@ terraform { provider "google" { } +data "google_secret_manager_secret_version_access" "cloudflare_api_token_dns" { + secret = "cloudflare-api-token-dns" + project = var.project_id +} + provider "cloudflare" { - api_token = var.cloudflare_api_token + api_token = coalesce(var.cloudflare_api_token, data.google_secret_manager_secret_version_access.cloudflare_api_token_dns.secret_data) } provider "kubernetes" { diff --git a/scaletest/terraform/action/vars.tf b/scaletest/terraform/action/vars.tf index 6788e843d8b6f..3952baab82b80 100644 --- a/scaletest/terraform/action/vars.tf +++ b/scaletest/terraform/action/vars.tf @@ -13,6 +13,7 @@ variable "scenario" { // GCP variable "project_id" { description = "The project in which to provision resources" + default = "coder-scaletest" } variable "k8s_version" { @@ -24,19 +25,14 @@ variable "k8s_version" { variable "cloudflare_api_token" { description = "Cloudflare API token." sensitive = true -} - -variable "cloudflare_email" { - description = "Cloudflare email address." - sensitive = true + # only override if you want to change the cloudflare_domain; pulls the token for scaletest.dev from Google Secrets + # Manager if null. + default = null } variable "cloudflare_domain" { description = "Cloudflare coder domain." -} - -variable "cloudflare_zone_id" { - description = "Cloudflare zone ID." + default = "scaletest.dev" } // Coder diff --git a/scripts/check_unstaged.sh b/scripts/check_unstaged.sh index 90d4cad87e4fc..715c84c374acf 100755 --- a/scripts/check_unstaged.sh +++ b/scripts/check_unstaged.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -euo pipefail # shellcheck source=scripts/lib.sh diff --git a/scripts/fixtures.sh b/scripts/fixtures.sh new file mode 100755 index 0000000000000..377cecde71f64 --- /dev/null +++ b/scripts/fixtures.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") +# shellcheck source=scripts/lib.sh +source "${SCRIPT_DIR}/lib.sh" + +CODER_DEV_SHIM="${PROJECT_ROOT}/scripts/coder-dev.sh" + +add_license() { + CODER_DEV_LICENSE="${CODER_DEV_LICENSE:-}" + if [[ -z "${CODER_DEV_LICENSE}" ]]; then + echo "No license provided. Please set CODER_DEV_LICENSE environment variable." + exit 1 + fi + + if [[ "${CODER_BUILD_AGPL:-0}" -gt "0" ]]; then + echo "Not adding a license in AGPL build mode." + exit 0 + fi + + NUM_LICENSES=$("${CODER_DEV_SHIM}" licenses list -o json | jq -r '. | length') + if [[ "${NUM_LICENSES}" -gt "0" ]]; then + echo "License already exists. Skipping addition." + exit 0 + fi + + echo -n "${CODER_DEV_LICENSE}" | "${CODER_DEV_SHIM}" licenses add -f - || { + echo "ERROR: failed to add license. Try adding one manually." + exit 1 + } + + exit 0 +} + +main() { + if [[ $# -eq 0 ]]; then + echo "Available fixtures:" + echo " license: adds the license from CODER_DEV_LICENSE" + exit 0 + fi + + [[ -n "${VERBOSE:-}" ]] && set -x + set -euo pipefail + + case "$1" in + "license") + add_license + ;; + *) + echo "Unknown fixture: $1" + exit 1 + ;; + esac +} + +main "$@" diff --git a/scripts/rules.go b/scripts/rules.go index f15582d12a4bb..dce029a102d01 100644 --- a/scripts/rules.go +++ b/scripts/rules.go @@ -37,7 +37,9 @@ func dbauthzAuthorizationContext(m dsl.Matcher) { Where( m["c"].Type.Implements("context.Context") && // Only report on functions that start with "As". - m["f"].Text.Matches("^As"), + m["f"].Text.Matches("^As") && + // Ignore test usages of dbauthz contexts. + !m.File().Name.Matches(`_test\.go$`), ). // Instructions for fixing the lint error should be included on the dangerous function. Report("Using '$f' is dangerous and should be accompanied by a comment explaining why it's ok and a nolint.") diff --git a/scripts/testidp/main.go b/scripts/testidp/main.go index a6188ace2ce9b..64f2ddb30f2d3 100644 --- a/scripts/testidp/main.go +++ b/scripts/testidp/main.go @@ -96,7 +96,9 @@ func RunIDP() func(t *testing.T) { "groups": []string{"testidp", "qa", "engineering"}, "roles": []string{"testidp", "admin", "higher_power"}, }), - oidctest.WithDefaultIDClaims(jwt.MapClaims{}), + oidctest.WithDefaultIDClaims(jwt.MapClaims{ + "sub": uuid.MustParse("26c6a19c-b9b8-493b-a991-88a4c3310314"), + }), oidctest.WithDefaultExpire(*expiry), oidctest.WithStaticCredentials(*clientID, *clientSecret), oidctest.WithIssuer("http://localhost:4500"), diff --git a/scripts/zizmor.sh b/scripts/zizmor.sh new file mode 100755 index 0000000000000..a9326e2ee0868 --- /dev/null +++ b/scripts/zizmor.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +# Usage: ./zizmor.sh [args...] +# +# This script is a wrapper around the zizmor Docker image. Zizmor lints GitHub +# actions workflows. +# +# We use Docker to run zizmor since it's written in Rust and is difficult to +# install on Ubuntu runners without building it with a Rust toolchain, which +# takes a long time. +# +# The repo is mounted at /repo and the working directory is set to /repo. + +set -euo pipefail +# shellcheck source=scripts/lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +cdroot + +image_tag="ghcr.io/zizmorcore/zizmor:1.11.0" +docker_args=( + "--rm" + "--volume" "$(pwd):/repo" + "--workdir" "/repo" + "--network" "host" +) + +if [[ -t 0 ]]; then + docker_args+=("-it") +fi + +# If no GH_TOKEN is set, try to get one from `gh auth token`. +if [[ "${GH_TOKEN:-}" == "" ]] && command -v gh &>/dev/null; then + set +e + GH_TOKEN="$(gh auth token)" + export GH_TOKEN + set -e +fi + +# Pass through the GitHub token if it's set, which allows zizmor to scan +# imported workflows too. +if [[ "${GH_TOKEN:-}" != "" ]]; then + docker_args+=("--env" "GH_TOKEN") +fi + +logrun exec docker run "${docker_args[@]}" "$image_tag" "$@" diff --git a/site/e2e/provisionerGenerated.ts b/site/e2e/provisionerGenerated.ts index 78a010f6c736f..00b2050d94d98 100644 --- a/site/e2e/provisionerGenerated.ts +++ b/site/e2e/provisionerGenerated.ts @@ -462,6 +462,7 @@ export interface PlanComplete { */ hasAiTasks: boolean; aiTasks: AITask[]; + hasExternalAgents: boolean; } /** @@ -1395,6 +1396,9 @@ export const PlanComplete = { for (const v of message.aiTasks) { AITask.encode(v!, writer.uint32(114).fork()).ldelim(); } + if (message.hasExternalAgents === true) { + writer.uint32(120).bool(message.hasExternalAgents); + } return writer; }, }; diff --git a/site/package.json b/site/package.json index bb061511e1619..5693fc5d55220 100644 --- a/site/package.json +++ b/site/package.json @@ -47,7 +47,7 @@ "@fontsource/ibm-plex-mono": "5.1.1", "@fontsource/jetbrains-mono": "5.2.5", "@fontsource/source-code-pro": "5.2.5", - "@monaco-editor/react": "4.6.0", + "@monaco-editor/react": "4.7.0", "@mui/icons-material": "5.16.14", "@mui/material": "5.16.14", "@mui/system": "5.16.14", @@ -93,7 +93,7 @@ "jszip": "3.10.1", "lodash": "4.17.21", "lucide-react": "0.474.0", - "monaco-editor": "0.52.0", + "monaco-editor": "0.52.2", "pretty-bytes": "6.1.1", "react": "18.3.1", "react-color": "2.19.3", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 99ef8ac44af6d..31a8857901845 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -54,8 +54,8 @@ importers: specifier: 5.2.5 version: 5.2.5 '@monaco-editor/react': - specifier: 4.6.0 - version: 4.6.0(monaco-editor@0.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 4.7.0 + version: 4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/icons-material': specifier: 5.16.14 version: 5.16.14(@mui/material@5.16.14(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.12)(react@18.3.1) @@ -192,8 +192,8 @@ importers: specifier: 0.474.0 version: 0.474.0(react@18.3.1) monaco-editor: - specifier: 0.52.0 - version: 0.52.0 + specifier: 0.52.2 + version: 0.52.2 pretty-bytes: specifier: 6.1.1 version: 6.1.1 @@ -1260,17 +1260,15 @@ packages: '@mjackson/multipart-parser@0.6.3': resolution: {integrity: sha512-aQhySnM6OpAYMMG+m7LEygYye99hB1md/Cy1AFE0yD5hfNW+X4JDu7oNVY9Gc6IW8PZ45D1rjFLDIUdnkXmwrA==, tarball: https://registry.npmjs.org/@mjackson/multipart-parser/-/multipart-parser-0.6.3.tgz} - '@monaco-editor/loader@1.4.0': - resolution: {integrity: sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==, tarball: https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz} - peerDependencies: - monaco-editor: '>= 0.21.0 < 1' + '@monaco-editor/loader@1.5.0': + resolution: {integrity: sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==, tarball: https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz} - '@monaco-editor/react@4.6.0': - resolution: {integrity: sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==, tarball: https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz} + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==, tarball: https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz} peerDependencies: monaco-editor: '>= 0.25.0 < 1' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@mswjs/interceptors@0.35.9': resolution: {integrity: sha512-SSnyl/4ni/2ViHKkiZb8eajA/eN1DNFaHjhGiLUdZvDz6PKF4COSf/17xqSz64nOo2Ia29SA6B2KNCsyCbVmaQ==, tarball: https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.35.9.tgz} @@ -4759,8 +4757,8 @@ packages: resolution: {integrity: sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==, tarball: https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz} engines: {node: '>= 8'} - monaco-editor@0.52.0: - resolution: {integrity: sha512-OeWhNpABLCeTqubfqLMXGsqf6OmPU6pHM85kF3dhy6kq5hnhuVS1p3VrEW/XhWHc71P2tHyS5JFySD8mgs1crw==, tarball: https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.0.tgz} + monaco-editor@0.52.2: + resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==, tarball: https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz} moo-color@1.0.3: resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==, tarball: https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz} @@ -7240,15 +7238,14 @@ snapshots: dependencies: '@mjackson/headers': 0.5.1 - '@monaco-editor/loader@1.4.0(monaco-editor@0.52.0)': + '@monaco-editor/loader@1.5.0': dependencies: - monaco-editor: 0.52.0 state-local: 1.0.7 - '@monaco-editor/react@4.6.0(monaco-editor@0.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@monaco-editor/loader': 1.4.0(monaco-editor@0.52.0) - monaco-editor: 0.52.0 + '@monaco-editor/loader': 1.5.0 + monaco-editor: 0.52.2 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -11340,7 +11337,7 @@ snapshots: mock-socket@9.3.1: {} - monaco-editor@0.52.0: {} + monaco-editor@0.52.2: {} moo-color@1.0.3: dependencies: diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ea97a5b46a2ef..d95d644ef7678 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -21,6 +21,7 @@ */ import globalAxios, { type AxiosInstance, isAxiosError } from "axios"; import type dayjs from "dayjs"; +import type { Task } from "modules/tasks/tasks"; import userAgentParser from "ua-parser-js"; import { delay } from "../utils/delay"; import { OneWayWebSocket } from "../utils/OneWayWebSocket"; @@ -420,6 +421,12 @@ export type GetProvisionerDaemonsParams = { // Stringified JSON Object tags?: string; limit?: number; + // Include offline provisioner daemons? + offline?: boolean; +}; + +export type TasksFilter = { + username?: string; }; /** @@ -1187,9 +1194,9 @@ class ApiMethods { }; getWorkspaces = async ( - options: TypesGen.WorkspacesRequest, + req: TypesGen.WorkspacesRequest, ): Promise => { - const url = getURLWithSearchParams("/api/v2/workspaces", options); + const url = getURLWithSearchParams("/api/v2/workspaces", req); const response = await this.axios.get(url); return response.data; }; @@ -2022,6 +2029,16 @@ class ApiMethods { return response.data; }; + getWorkspaceAgentCredentials = async ( + workspaceID: string, + agentName: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaces/${workspaceID}/external-agent/${agentName}/credentials`, + ); + return response.data; + }; + upsertWorkspaceAgentSharedPort = async ( workspaceID: string, req: TypesGen.UpsertWorkspaceAgentPortShareRequest, @@ -2677,6 +2694,26 @@ class ExperimentalApiMethods { return response.data; }; + + getTasks = async (filter: TasksFilter): Promise => { + const queryExpressions = ["has-ai-task:true"]; + + if (filter.username) { + queryExpressions.push(`owner:${filter.username}`); + } + + const workspaces = await API.getWorkspaces({ + q: queryExpressions.join(" "), + }); + const prompts = await API.experimental.getAITasksPrompts( + workspaces.workspaces.map((workspace) => workspace.latest_build.id), + ); + + return workspaces.workspaces.map((workspace) => ({ + workspace, + prompt: prompts.prompts[workspace.latest_build.id], + })); + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index c7913f81565f0..31a0302c94653 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -17,6 +17,7 @@ import { } from "hooks/useEmbeddedMetadata"; import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; import type { + MutationOptions, QueryClient, UseMutationOptions, UseQueryOptions, @@ -192,10 +193,15 @@ const loginFn = async ({ }; }; -export const logout = (queryClient: QueryClient) => { +export const logout = (queryClient: QueryClient): MutationOptions => { return { mutationFn: API.logout, - onSuccess: () => { + // We're doing this cleanup in `onSettled` instead of `onSuccess` because in the case where an oAuth refresh token has expired this endpoint will return a 401 instead of 200. + onSettled: (_, error) => { + if (error) { + console.error(error); + } + /** * 2024-05-02 - If we persist any form of user data after the user logs * out, that will continue to seed the React Query cache, creating @@ -210,6 +216,14 @@ export const logout = (queryClient: QueryClient) => { * Deleting the user data will mean that all future requests have to take * a full roundtrip, but this still felt like the best way to ensure that * manually logging out doesn't blow the entire app up. + * + * 2025-08-20 - Since this endpoint is for performing a post logout clean up + * on the backend we should move this local clean up outside of the mutation + * so that it can be explicitly performed even in cases where we don't want + * run the clean up (e.g. when a user is unauthorized). Unfortunately our + * auth logic is too tangled up with some obscured React Query behaviors to + * be able to move right now. After `AuthProvider.tsx` is refactored this + * should be moved. */ defaultMetadataManager.clearMetadataByKey("user"); queryClient.removeQueries(); diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 536925a97390f..1c3e82a8816c2 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -139,15 +139,14 @@ async function findMatchWorkspace(q: string): Promise { } } -function workspacesKey(config: WorkspacesRequest = {}) { - const { q, limit } = config; - return ["workspaces", { q, limit }] as const; +function workspacesKey(req: WorkspacesRequest = {}) { + return ["workspaces", req] as const; } -export function workspaces(config: WorkspacesRequest = {}) { +export function workspaces(req: WorkspacesRequest = {}) { return { - queryKey: workspacesKey(config), - queryFn: () => API.getWorkspaces(config), + queryKey: workspacesKey(req), + queryFn: () => API.getWorkspaces(req), } as const satisfies QueryOptions; } @@ -430,3 +429,13 @@ export const updateWorkspaceACL = (workspaceId: string) => { }, }; }; + +export const workspaceAgentCredentials = ( + workspaceId: string, + agentName: string, +) => { + return { + queryKey: ["workspaces", workspaceId, "agents", agentName, "credentials"], + queryFn: () => API.getWorkspaceAgentCredentials(workspaceId, agentName), + }; +}; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 920409ae4ce05..58167d7d27df0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -478,7 +478,6 @@ export interface CreateProvisionerKeyResponse { // From codersdk/aitasks.go export interface CreateTaskRequest { - readonly name: string; readonly template_version_id: string; readonly template_version_preset_id?: string; readonly prompt: string; @@ -939,6 +938,12 @@ export const Experiments: Experiment[] = [ "workspace-usage", ]; +// From codersdk/workspaces.go +export interface ExternalAgentCredentials { + readonly command: string; + readonly agent_token: string; +} + // From codersdk/externalauth.go export interface ExternalAuth { readonly authenticated: boolean; @@ -1052,6 +1057,7 @@ export type FeatureName = | "user_limit" | "user_role_management" | "workspace_batch_actions" + | "workspace_external_agent" | "workspace_prebuilds" | "workspace_proxy"; @@ -1075,6 +1081,7 @@ export const FeatureNames: FeatureName[] = [ "user_limit", "user_role_management", "workspace_batch_actions", + "workspace_external_agent", "workspace_prebuilds", "workspace_proxy", ]; @@ -1833,6 +1840,9 @@ export interface OrganizationMemberWithUserData extends OrganizationMember { // From codersdk/organizations.go export interface OrganizationProvisionerDaemonsOptions { readonly Limit: number; + readonly Offline: boolean; + readonly Status: readonly ProvisionerDaemonStatus[]; + readonly MaxAge: number; readonly IDs: readonly string[]; readonly Tags: Record; } @@ -2797,6 +2807,44 @@ export interface TailDERPRegion { readonly Nodes: readonly TailDERPNode[]; } +// From codersdk/aitasks.go +export interface Task { + readonly id: string; + readonly organization_id: string; + readonly owner_id: string; + readonly name: string; + readonly template_id: string; + readonly workspace_id: string | null; + readonly initial_prompt: string; + readonly status: WorkspaceStatus; + readonly current_state: TaskStateEntry | null; + readonly created_at: string; + readonly updated_at: string; +} + +// From codersdk/aitasks.go +export type TaskState = "completed" | "failed" | "idle" | "working"; + +// From codersdk/aitasks.go +export interface TaskStateEntry { + readonly timestamp: string; + readonly state: TaskState; + readonly message: string; + readonly uri: string; +} + +export const TaskStates: TaskState[] = [ + "completed", + "failed", + "idle", + "working", +]; + +// From codersdk/aitasks.go +export interface TasksFilter { + readonly owner?: string; +} + // From codersdk/deployment.go export interface TelemetryConfig { readonly enable: boolean; @@ -3000,6 +3048,7 @@ export interface TemplateVersion { readonly archived: boolean; readonly warnings?: readonly TemplateVersionWarning[]; readonly matched_provisioners?: MatchedProvisioners; + readonly has_external_agent: boolean; } // From codersdk/templateversions.go @@ -3876,6 +3925,7 @@ export interface WorkspaceBuild { readonly template_version_preset_id: string | null; readonly has_ai_task?: boolean; readonly ai_task_sidebar_app_id?: string; + readonly has_external_agent?: boolean; } // From codersdk/workspacebuilds.go diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx index a042b5cf7203c..c3d0b27475bf2 100644 --- a/site/src/components/Badge/Badge.tsx +++ b/site/src/components/Badge/Badge.tsx @@ -24,6 +24,7 @@ const badgeVariants = cva( "border border-solid border-border-destructive bg-surface-red text-highlight-red shadow", green: "border border-solid border-surface-green bg-surface-green text-highlight-green shadow", + info: "border border-solid border-surface-sky bg-surface-sky text-highlight-sky shadow", }, size: { xs: "text-2xs font-regular h-5 [&_svg]:hidden rounded px-1.5", diff --git a/site/src/components/CodeExample/CodeExample.stories.tsx b/site/src/components/CodeExample/CodeExample.stories.tsx index 0213762fd31e2..61f129f448a73 100644 --- a/site/src/components/CodeExample/CodeExample.stories.tsx +++ b/site/src/components/CodeExample/CodeExample.stories.tsx @@ -31,3 +31,12 @@ export const LongCode: Story = { code: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnKzATuWwmmt5+CKTPuRGN0R1PBemA+6/SStpLiyX+L", }, }; + +export const Redact: Story = { + args: { + secret: false, + redactPattern: /CODER_AGENT_TOKEN="([^"]+)"/g, + redactReplacement: `CODER_AGENT_TOKEN="********"`, + showRevealButton: true, + }, +}; diff --git a/site/src/components/CodeExample/CodeExample.tsx b/site/src/components/CodeExample/CodeExample.tsx index 474dcb1fac225..b69a220550958 100644 --- a/site/src/components/CodeExample/CodeExample.tsx +++ b/site/src/components/CodeExample/CodeExample.tsx @@ -1,11 +1,26 @@ import type { Interpolation, Theme } from "@emotion/react"; -import type { FC } from "react"; +import { Button } from "components/Button/Button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { EyeIcon, EyeOffIcon } from "lucide-react"; +import { type FC, useState } from "react"; import { MONOSPACE_FONT_FAMILY } from "theme/constants"; import { CopyButton } from "../CopyButton/CopyButton"; interface CodeExampleProps { code: string; + /** Defaulting to true to be on the safe side; you should have to opt out of the secure option, not remember to opt in */ secret?: boolean; + /** Redact parts of the code if the user doesn't want to obfuscate the whole code */ + redactPattern?: RegExp; + /** Replacement text for redacted content */ + redactReplacement?: string; + /** Show a button to reveal the redacted parts of the code */ + showRevealButton?: boolean; className?: string; } @@ -15,11 +30,28 @@ interface CodeExampleProps { export const CodeExample: FC = ({ code, className, - - // Defaulting to true to be on the safe side; you should have to opt out of - // the secure option, not remember to opt in secret = true, + redactPattern, + redactReplacement = "********", + showRevealButton, }) => { + const [showFullValue, setShowFullValue] = useState(false); + + const displayValue = secret + ? obfuscateText(code) + : redactPattern && !showFullValue + ? code.replace(redactPattern, redactReplacement) + : code; + + const showButtonLabel = showFullValue + ? "Hide sensitive data" + : "Show sensitive data"; + const icon = showFullValue ? ( + + ) : ( + + ); + return (
@@ -33,17 +65,36 @@ export const CodeExample: FC = ({ * 2. Even with it turned on and supported, the plaintext is still * readily available in the HTML itself */} - {obfuscateText(code)} + {displayValue} Encrypted text. Please access via the copy button. ) : ( - code + displayValue )} - +
+ {showRevealButton && redactPattern && !secret && ( + + + + + + {showButtonLabel} + + + )} + +
); }; diff --git a/site/src/components/EmptyState/EmptyState.tsx b/site/src/components/EmptyState/EmptyState.tsx index 1371d7e9fa56e..3faede44dd4a2 100644 --- a/site/src/components/EmptyState/EmptyState.tsx +++ b/site/src/components/EmptyState/EmptyState.tsx @@ -1,4 +1,5 @@ import type { FC, HTMLAttributes, ReactNode } from "react"; +import { cn } from "utils/cn"; export interface EmptyStateProps extends HTMLAttributes { /** Text Message to display, placed inside Typography component */ @@ -21,44 +22,25 @@ export const EmptyState: FC = ({ cta, image, isCompact, + className, ...attrs }) => { return (
-
{message}
+
{message}
{description && ( -

({ - marginTop: 16, - fontSize: 16, - lineHeight: "140%", - maxWidth: 480, - color: theme.palette.text.secondary, - })} - > +

{description}

)} - {cta &&
{cta}
} + {cta &&
{cta}
} {image}
); diff --git a/site/src/components/FullPageForm/FullPageForm.tsx b/site/src/components/FullPageForm/FullPageForm.tsx index 571cc56ea9b0f..b4825731bcd43 100644 --- a/site/src/components/FullPageForm/FullPageForm.tsx +++ b/site/src/components/FullPageForm/FullPageForm.tsx @@ -19,7 +19,7 @@ export const FullPageForm: FC = ({ }) => { return ( - + {title} {detail && {detail}} diff --git a/site/src/components/LastSeen/LastSeen.tsx b/site/src/components/LastSeen/LastSeen.tsx index 02c98a09bf1ce..1dd8444df4483 100644 --- a/site/src/components/LastSeen/LastSeen.tsx +++ b/site/src/components/LastSeen/LastSeen.tsx @@ -40,7 +40,7 @@ export const LastSeen: FC = ({ at, className, ...attrs }) => { return ( diff --git a/site/src/components/Latency/Latency.tsx b/site/src/components/Latency/Latency.tsx index de5c5dc2851b8..136d79bb5b8f0 100644 --- a/site/src/components/Latency/Latency.tsx +++ b/site/src/components/Latency/Latency.tsx @@ -25,11 +25,7 @@ export const Latency: FC = ({ if (isLoading) { return ( - + ); } @@ -38,22 +34,20 @@ export const Latency: FC = ({ const notAvailableText = "Latency not available"; return ( - + <> + {notAvailableText} + + + ); } return ( -

+

Latency: {latency.toFixed(0)} ms -

+
); }; diff --git a/site/src/components/PaginationWidget/PaginationWidgetBase.tsx b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx index 2022461a401f6..02b5d39a6b445 100644 --- a/site/src/components/PaginationWidget/PaginationWidgetBase.tsx +++ b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx @@ -39,16 +39,7 @@ export const PaginationWidgetBase: FC = ({ ); return ( -
+
= ({ return (
- -
{message}
+ +
{message}
- {description &&

{description}

} + {description && ( +

{description}

+ )} Read the documentation
-
+
-