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/.devcontainer/filebrowser/install.sh b/.devcontainer/filebrowser/install.sh old mode 100644 new mode 100755 index 48158a38cd782..6e8d58a14bf80 --- a/.devcontainer/filebrowser/install.sh +++ b/.devcontainer/filebrowser/install.sh @@ -8,7 +8,15 @@ printf "%sInstalling filebrowser\n\n" "${BOLD}" # Check if filebrowser is installed. if ! command -v filebrowser &>/dev/null; then - curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash + VERSION="v2.42.1" + EXPECTED_HASH="7d83c0f077df10a8ec9bfd9bf6e745da5d172c3c768a322b0e50583a6bc1d3cc" + + curl -fsSL "https://github.com/filebrowser/filebrowser/releases/download/${VERSION}/linux-amd64-filebrowser.tar.gz" -o /tmp/filebrowser.tar.gz + echo "${EXPECTED_HASH} /tmp/filebrowser.tar.gz" | sha256sum -c + tar -xzf /tmp/filebrowser.tar.gz -C /tmp + sudo mv /tmp/filebrowser /usr/local/bin/ + sudo chmod +x /usr/local/bin/filebrowser + rm /tmp/filebrowser.tar.gz fi # Create entrypoint. diff --git a/.devcontainer/scripts/post_create.sh b/.devcontainer/scripts/post_create.sh index 8799908311431..a1b774f98d2ca 100755 --- a/.devcontainer/scripts/post_create.sh +++ b/.devcontainer/scripts/post_create.sh @@ -1,7 +1,11 @@ #!/bin/sh install_devcontainer_cli() { - npm install -g @devcontainers/cli + set -e + echo "🔧 Installing DevContainer CLI..." + cd "$(dirname "$0")/../tools/devcontainer-cli" + npm ci --omit=dev + ln -sf "$(pwd)/node_modules/.bin/devcontainer" "$(npm config get prefix)/bin/devcontainer" } install_ssh_config() { diff --git a/.devcontainer/tools/devcontainer-cli/package-lock.json b/.devcontainer/tools/devcontainer-cli/package-lock.json new file mode 100644 index 0000000000000..2fee536abeb07 --- /dev/null +++ b/.devcontainer/tools/devcontainer-cli/package-lock.json @@ -0,0 +1,26 @@ +{ + "name": "devcontainer-cli", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "devcontainer-cli", + "version": "1.0.0", + "dependencies": { + "@devcontainers/cli": "^0.80.0" + } + }, + "node_modules/@devcontainers/cli": { + "version": "0.80.0", + "resolved": "https://registry.npmjs.org/@devcontainers/cli/-/cli-0.80.0.tgz", + "integrity": "sha512-w2EaxgjyeVGyzfA/KUEZBhyXqu/5PyWNXcnrXsZOBrt3aN2zyGiHrXoG54TF6K0b5DSCF01Rt5fnIyrCeFzFKw==", + "bin": { + "devcontainer": "devcontainer.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + } + } +} diff --git a/.devcontainer/tools/devcontainer-cli/package.json b/.devcontainer/tools/devcontainer-cli/package.json new file mode 100644 index 0000000000000..b474c8615592d --- /dev/null +++ b/.devcontainer/tools/devcontainer-cli/package.json @@ -0,0 +1,8 @@ +{ + "name": "devcontainer-cli", + "private": true, + "version": "1.0.0", + "dependencies": { + "@devcontainers/cli": "^0.80.0" + } +} diff --git a/.editorconfig b/.editorconfig index 9415469de3c00..554e8a73ffeda 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,7 +7,7 @@ trim_trailing_whitespace = true insert_final_newline = true indent_style = tab -[*.{yaml,yml,tf,tfvars,nix}] +[*.{yaml,yml,tf,tftpl,tfvars,nix}] indent_style = space indent_size = 2 @@ -18,3 +18,11 @@ indent_size = 2 [coderd/database/dump.sql] indent_style = space indent_size = 4 + +[coderd/database/queries/*.sql] +indent_style = tab +indent_size = 4 + +[coderd/database/migrations/*.sql] +indent_style = tab +indent_size = 4 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/setup-go/action.yaml b/.github/actions/setup-go/action.yaml index a8a88621dda18..097a1b6cfd119 100644 --- a/.github/actions/setup-go/action.yaml +++ b/.github/actions/setup-go/action.yaml @@ -4,7 +4,7 @@ description: | inputs: version: description: "The Go version to use." - default: "1.24.4" + default: "1.24.6" use-preinstalled-go: description: "Whether to use preinstalled Go." default: "false" diff --git a/.github/actions/setup-node/action.yaml b/.github/actions/setup-node/action.yaml index 02ffa14312ffe..6ed9985185746 100644 --- a/.github/actions/setup-node/action.yaml +++ b/.github/actions/setup-node/action.yaml @@ -16,7 +16,7 @@ runs: - name: Setup Node uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: - node-version: 20.16.0 + node-version: 20.19.4 # See https://github.com/actions/setup-node#caching-global-packages-data cache: "pnpm" cache-dependency-path: ${{ inputs.directory }}/pnpm-lock.yaml diff --git a/.github/actions/setup-tf/action.yaml b/.github/actions/setup-tf/action.yaml index 0e19b657656be..6f8c8c32cf38c 100644 --- a/.github/actions/setup-tf/action.yaml +++ b/.github/actions/setup-tf/action.yaml @@ -7,5 +7,5 @@ runs: - name: Install Terraform uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 with: - terraform_version: 1.12.2 + terraform_version: 1.13.0 terraform_wrapper: false 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/dependabot.yaml b/.github/dependabot.yaml index 9cdca1f03d72c..67d1f1342dcaf 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -33,6 +33,7 @@ updates: - dependency-name: "*" update-types: - version-update:semver-patch + - dependency-name: "github.com/mark3labs/mcp-go" # Update our Dockerfile. - package-ecosystem: "docker" 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 a55bd137f7994..747f158e28a9e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,10 +39,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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. @@ -121,7 +123,7 @@ jobs: # runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} # steps: # - name: Checkout - # uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + # uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 # with: # fetch-depth: 1 # # See: https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#commits-made-by-this-action-do-not-trigger-new-workflow-runs @@ -159,9 +161,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -171,13 +174,13 @@ 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@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: | ${{ env.LINT_CACHE_DIR }} @@ -187,7 +190,7 @@ jobs: # Check for any typos - name: Check for typos - uses: crate-ci/typos@392b78fe18a52790c53f42456e46124f77346842 # v1.34.0 + uses: crate-ci/typos@52bd719c2c91f9d676e2aa359fc8e0db8925e6d8 # v1.35.3 with: config: .github/workflows/typos.toml @@ -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: | @@ -231,9 +239,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -256,8 +265,8 @@ jobs: pushd /tmp/proto curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip unzip protoc.zip - cp -r ./bin/* /usr/local/bin - cp -r ./include /usr/local/bin/include + sudo cp -r ./bin/* /usr/local/bin + sudo cp -r ./include /usr/local/bin/include popd - name: make gen @@ -286,9 +295,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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,15 @@ 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)")" + + make test - gotestsum --format standard-quiet --packages "$PACKAGES" \ - -- -timeout=20m -v -p $NUM_PARALLEL_PACKAGES -parallel=$NUM_PARALLEL_TESTS $TESTCOUNT + - name: Upload failed test db dumps + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: failed-test-db-dump-${{matrix.os}} + path: "**/*.test.sql" - name: Upload Go Build Cache uses: ./.github/actions/test-cache/upload @@ -537,9 +551,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go @@ -585,9 +600,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go @@ -644,9 +660,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go @@ -670,14 +687,15 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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: @@ -702,9 +720,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -773,12 +792,13 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: # 👇 Ensures Chromatic can read your full git history 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 @@ -788,7 +808,7 @@ jobs: # the check to pass. This is desired in PRs, but not in mainline. - name: Publish to Chromatic (non-mainline) if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder' - uses: chromaui/action@4d8ebd13658d795114f8051e25c28d66f14886c6 # v13.1.2 + uses: chromaui/action@58d9ffb36c90c97a02d061544ecc849cc4a242a9 # v13.1.3 env: NODE_OPTIONS: "--max_old_space_size=4096" STORYBOOK: true @@ -820,7 +840,7 @@ jobs: # infinitely "in progress" in mainline unless we re-review each build. - name: Publish to Chromatic (mainline) if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder' - uses: chromaui/action@4d8ebd13658d795114f8051e25c28d66f14886c6 # v13.1.2 + uses: chromaui/action@58d9ffb36c90c97a02d061544ecc849cc4a242a9 # v13.1.3 env: NODE_OPTIONS: "--max_old_space_size=4096" STORYBOOK: true @@ -853,10 +873,11 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: # 0 is required here for version.sh to work. fetch-depth: 0 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -869,8 +890,8 @@ jobs: pushd /tmp/proto curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip unzip protoc.zip - cp -r ./bin/* /usr/local/bin - cp -r ./include /usr/local/bin/include + sudo cp -r ./bin/* /usr/local/bin + sudo cp -r ./include /usr/local/bin/include popd - name: Setup Go @@ -910,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() @@ -920,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 }}" @@ -930,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. @@ -950,21 +973,24 @@ jobs: steps: # Harden Runner doesn't work on macOS - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 with: - xcode-version: "16.0.0" + xcode-version: "16.1.0" - name: Setup Go uses: ./.github/actions/setup-go @@ -1020,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. @@ -1048,12 +1115,13 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: GHCR Login - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -1121,17 +1189,17 @@ jobs: # Setup GCloud for signing Windows binaries. - name: Authenticate to Google Cloud id: gcloud_auth - uses: google-github-actions/auth@140bb5113ffb6b65a7e9b937a81fa96cf5064462 # v2.1.11 + uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5 # v2.1.12 with: - workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }} - service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }} + workload_identity_provider: ${{ vars.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }} + service_account: ${{ vars.GCP_CODE_SIGNING_SERVICE_ACCOUNT }} token_format: "access_token" - name: Setup GCloud SDK - uses: google-github-actions/setup-gcloud@6a7c903a70c8625ed6700fa299f5ddb4ca6022e9 # v2.1.5 + uses: google-github-actions/setup-gcloud@cb1e50a9932213ecece00a606661ae9ca44f3397 # v2.2.0 - name: Download dylibs - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: dylibs path: ./build @@ -1148,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 \ @@ -1185,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 @@ -1206,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 @@ -1219,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 @@ -1363,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 @@ -1420,18 +1490,19 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: Authenticate to Google Cloud - uses: google-github-actions/auth@140bb5113ffb6b65a7e9b937a81fa96cf5064462 # v2.1.11 + uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5 # v2.1.12 with: - workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github - service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com + workload_identity_provider: ${{ vars.GCP_WORKLOAD_ID_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} - name: Set up Google Cloud SDK - uses: google-github-actions/setup-gcloud@6a7c903a70c8625ed6700fa299f5ddb4ca6022e9 # v2.1.5 + uses: google-github-actions/setup-gcloud@cb1e50a9932213ecece00a606661ae9ca44f3397 # v2.2.0 - name: Set up Flux CLI uses: fluxcd/flux2/action@6bf37f6a560fd84982d67f853162e4b3c2235edb # v2.6.4 @@ -1484,9 +1555,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 @@ -1519,10 +1591,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 @@ -1558,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}"'" } ] }, @@ -1574,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 bb45d4c0a0601..5c8fa142450bb 100644 --- a/.github/workflows/docker-base.yaml +++ b/.github/workflows/docker-base.yaml @@ -43,10 +43,12 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Docker login - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/docs-ci.yaml b/.github/workflows/docs-ci.yaml index 39954783f1ba8..887db40660caf 100644 --- a/.github/workflows/docs-ci.yaml +++ b/.github/workflows/docs-ci.yaml @@ -23,12 +23,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node - - uses: tj-actions/changed-files@055970845dd036d7345da7399b7e89f2e10f2b04 # v45.0.7 + - uses: tj-actions/changed-files@f963b3f3562b00b6d2dd25efc390eb04e51ef6c6 # v45.0.7 id: changed-files with: files: | @@ -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 bafdb5fb19767..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: @@ -32,7 +31,9 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Setup Nix uses: nixbuild/nix-quick-install-action@63ca48f939ee3b8d835f4126562537df0fee5b91 # v32 @@ -62,15 +63,16 @@ jobs: - name: Get branch name id: branch-name - uses: tj-actions/branch-names@dde14ac574a8b9b1cedc59a1cf312788af43d8d8 # v8.2.1 + uses: tj-actions/branch-names@5250492686b253f06fa55861556d1027b067aeb5 # v9.0.2 - 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 @@ -80,7 +82,7 @@ jobs: - name: Login to DockerHub if: github.ref == 'refs/heads/main' - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} @@ -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 @@ -123,16 +130,18 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Setup Terraform uses: ./.github/actions/setup-tf - name: Authenticate to Google Cloud - uses: google-github-actions/auth@140bb5113ffb6b65a7e9b937a81fa96cf5064462 # v2.1.11 + uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5 # v2.1.12 with: - workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github - service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com + workload_identity_provider: ${{ vars.GCP_WORKLOAD_ID_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} - name: Terraform init and validate run: | @@ -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' @@ -169,6 +178,7 @@ jobs: CODER_URL: https://dev.coder.com CODER_SESSION_TOKEN: ${{ secrets.CODER_SESSION_TOKEN }} # Template source & details + TF_VAR_CODER_DOGFOOD_ANTHROPIC_API_KEY: ${{ secrets.CODER_DOGFOOD_ANTHROPIC_API_KEY }} TF_VAR_CODER_TEMPLATE_NAME: ${{ secrets.CODER_TEMPLATE_NAME }} TF_VAR_CODER_TEMPLATE_VERSION: ${{ steps.vars.outputs.sha_short }} TF_VAR_CODER_TEMPLATE_DIR: ./coder diff --git a/.github/workflows/nightly-gauntlet.yaml b/.github/workflows/nightly-gauntlet.yaml index a8e8fc957ee37..5769b3b652c44 100644 --- a/.github/workflows/nightly-gauntlet.yaml +++ b/.github/workflows/nightly-gauntlet.yaml @@ -27,7 +27,7 @@ jobs: - windows-2022 steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -37,6 +37,11 @@ jobs: - name: Disable Spotlight Indexing if: runner.os == 'macOS' run: | + enabled=$(sudo mdutil -a -s | { grep -Fc "Indexing enabled" || true; }) + if [ "$enabled" -eq 0 ]; then + echo "Spotlight indexing is already disabled" + exit 0 + fi sudo mdutil -a -i off sudo mdutil -X / sudo launchctl bootout system /System/Library/LaunchDaemons/com.apple.metadata.mds.plist @@ -45,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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 1 + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go @@ -180,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}"'" } ] }, @@ -196,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 c82861db22094..ccf7511eafc78 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -44,7 +44,9 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 }} @@ -79,9 +81,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 @@ -223,9 +233,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 + persist-credentials: false - name: Setup Node uses: ./.github/actions/setup-node @@ -237,7 +248,7 @@ jobs: uses: ./.github/actions/setup-sqlc - name: GHCR Login - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 @@ -420,7 +434,7 @@ jobs: curl -fsSL "$URL" -o "${DEST}" chmod +x "${DEST}" "${DEST}" version - mv "${DEST}" /usr/local/bin/coder + sudo mv "${DEST}" /usr/local/bin/coder - name: Create first user if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' @@ -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 9feaf72b938ff..ecd2e2ac39be9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -32,15 +32,43 @@ env: CODER_RELEASE_NOTES: ${{ inputs.release_notes }} jobs: + # Only allow maintainers/admins to release. + check-perms: + runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} + steps: + - name: Allow only maintainers/admins + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const {data} = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor + }); + const role = data.role_name || data.user?.role_name || data.permission; + const perms = data.user?.permissions || {}; + core.info(`Actor ${context.actor} permission=${data.permission}, role_name=${role}`); + + const allowed = + role === 'admin' || + role === 'maintain' || + perms.admin === true || + perms.maintain === true; + + if (!allowed) core.setFailed('Denied: requires maintain or admin'); + # build-dylib is a separate job to build the dylib on macOS. build-dylib: runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest' }} + needs: check-perms steps: # Harden Runner doesn't work on macOS. - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 @@ -53,14 +81,16 @@ 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 with: - xcode-version: "16.0.0" + xcode-version: "16.1.0" - name: Setup Go uses: ./.github/actions/setup-go @@ -114,7 +144,7 @@ jobs: release: name: Build and publish - needs: build-dylib + needs: [build-dylib, check-perms] runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} permissions: # Required to publish a release @@ -139,9 +169,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 @@ -156,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. @@ -200,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: | @@ -208,7 +239,7 @@ jobs: cat "$CODER_RELEASE_NOTES_FILE" - name: Docker Login - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -286,17 +317,17 @@ jobs: # Setup GCloud for signing Windows binaries. - name: Authenticate to Google Cloud id: gcloud_auth - uses: google-github-actions/auth@140bb5113ffb6b65a7e9b937a81fa96cf5064462 # v2.1.11 + uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5 # v2.1.12 with: - workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }} - service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }} + workload_identity_provider: ${{ vars.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }} + service_account: ${{ vars.GCP_CODE_SIGNING_SERVICE_ACCOUNT }} token_format: "access_token" - name: Setup GCloud SDK - uses: google-github-actions/setup-gcloud@6a7c903a70c8625ed6700fa299f5ddb4ca6022e9 # v2.1.5 + uses: google-github-actions/setup-gcloud@cb1e50a9932213ecece00a606661ae9ca44f3397 # v2.2.0 - name: Download dylibs - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: dylibs path: ./build @@ -350,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 @@ -387,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 @@ -409,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. @@ -476,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 @@ -484,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 }} @@ -501,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 @@ -572,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 @@ -615,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 @@ -635,27 +670,28 @@ jobs: run: ls -lh build - name: Publish Coder CLI binaries and detached signatures to GCS - if: ${{ !inputs.dry_run && github.ref == 'refs/heads/main' && github.repository_owner == 'coder'}} + if: ${{ !inputs.dry_run }} run: | set -euxo pipefail version="$(./scripts/version.sh)" - binaries=( - "coder-darwin-amd64" - "coder-darwin-arm64" - "coder-linux-amd64" - "coder-linux-arm64" - "coder-linux-armv7" - "coder-windows-amd64.exe" - "coder-windows-arm64.exe" - ) - - for binary in "${binaries[@]}"; do - detached_signature="${binary}.asc" - gcloud storage cp "./site/out/bin/${binary}" "gs://releases.coder.com/coder-cli/${version}/${binary}" - gcloud storage cp "./site/out/bin/${detached_signature}" "gs://releases.coder.com/coder-cli/${version}/${detached_signature}" - done + # Source array of slim binaries + declare -A binaries + binaries["coder-darwin-amd64"]="coder-slim_${version}_darwin_amd64" + binaries["coder-darwin-arm64"]="coder-slim_${version}_darwin_arm64" + binaries["coder-linux-amd64"]="coder-slim_${version}_linux_amd64" + binaries["coder-linux-arm64"]="coder-slim_${version}_linux_arm64" + binaries["coder-linux-armv7"]="coder-slim_${version}_linux_armv7" + binaries["coder-windows-amd64.exe"]="coder-slim_${version}_windows_amd64.exe" + binaries["coder-windows-arm64.exe"]="coder-slim_${version}_windows_arm64.exe" + + for cli_name in "${!binaries[@]}"; do + slim_binary="${binaries[$cli_name]}" + detached_signature="${slim_binary}.asc" + gcloud storage cp "./build/${slim_binary}" "gs://releases.coder.com/coder-cli/${version}/${cli_name}" + gcloud storage cp "./build/${detached_signature}" "gs://releases.coder.com/coder-cli/${version}/${cli_name}.asc" + done - name: Publish release run: | @@ -679,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 @@ -694,15 +730,17 @@ 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@140bb5113ffb6b65a7e9b937a81fa96cf5064462 # v2.1.11 + uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5 # v2.1.12 with: - workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }} - service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} + workload_identity_provider: ${{ vars.GCP_WORKLOAD_ID_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} - name: Setup GCloud SDK - uses: google-github-actions/setup-gcloud@6a7c903a70c8625ed6700fa299f5ddb4ca6022e9 # 2.1.5 + uses: google-github-actions/setup-gcloud@cb1e50a9932213ecece00a606661ae9ca44f3397 # 2.2.0 - name: Publish Helm Chart if: ${{ !inputs.dry_run }} @@ -714,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 }} @@ -770,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 @@ -794,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" @@ -809,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 @@ -828,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: @@ -850,9 +888,10 @@ jobs: GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 @@ -871,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 @@ -903,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 @@ -920,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. @@ -935,9 +976,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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/scorecard.yml b/.github/workflows/scorecard.yml index 1e5104310e085..87e9e6271c6ac 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -25,7 +25,7 @@ jobs: egress-policy: audit - name: "Checkout code" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false @@ -47,6 +47,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@d6bbdef45e766d081b84a2def353b0055f728d3e # v3.29.3 + uses: github/codeql-action/upload-sarif@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.5 with: sarif_file: results.sarif diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index d31595c3a8465..e7fde82bf1dce 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -32,13 +32,15 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false - name: Setup Go uses: ./.github/actions/setup-go - name: Initialize CodeQL - uses: github/codeql-action/init@d6bbdef45e766d081b84a2def353b0055f728d3e # v3.29.3 + uses: github/codeql-action/init@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.5 with: languages: go, javascript @@ -48,7 +50,7 @@ jobs: rm Makefile - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d6bbdef45e766d081b84a2def353b0055f728d3e # v3.29.3 + uses: github/codeql-action/analyze@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.5 - name: Send Slack notification on failure if: ${{ failure() }} @@ -72,9 +74,10 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 @@ -150,7 +154,7 @@ jobs: severity: "CRITICAL,HIGH" - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@d6bbdef45e766d081b84a2def353b0055f728d3e # v3.29.3 + uses: github/codeql-action/upload-sarif@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.5 with: sarif_file: trivy-results.sarif category: "Trivy" diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 00d7eef888833..27ec157fa0f3f 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -101,7 +101,9 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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/typos.toml b/.github/workflows/typos.toml index 6a9b07b475111..6f475668118c9 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -28,6 +28,7 @@ HELO = "HELO" LKE = "LKE" byt = "byt" typ = "typ" +Inferrable = "Inferrable" [files] extend-exclude = [ @@ -47,5 +48,5 @@ extend-exclude = [ "provisioner/terraform/testdata/**", # notifications' golden files confuse the detector because of quoted-printable encoding "coderd/notifications/testdata/**", - "agent/agentcontainers/testdata/devcontainercli/**" + "agent/agentcontainers/testdata/devcontainercli/**", ] diff --git a/.github/workflows/weekly-docs.yaml b/.github/workflows/weekly-docs.yaml index dd83a5629ca83..56f5e799305e8 100644 --- a/.github/workflows/weekly-docs.yaml +++ b/.github/workflows/weekly-docs.yaml @@ -26,7 +26,9 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 f2cf72b7d8ae0..7fef4af975bc2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,7 +49,7 @@ "[javascript][javascriptreact][json][jsonc][typescript][typescriptreact]": { "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { - "quickfix.biome": "explicit" + "source.fixAll.biome": "explicit" // "source.organizeImports.biome": "explicit" } }, diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000000000..681311eb9cf45 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS index e571a160b12b7..fde24a9d874ed 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -7,7 +7,6 @@ tailnet/proto/ @spikecurtis @johnstcn vpn/vpn.proto @spikecurtis @johnstcn vpn/version.go @spikecurtis @johnstcn - # This caching code is particularly tricky, and one must be very careful when # altering it. coderd/files/ @aslilac @@ -19,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. @@ -28,4 +27,15 @@ 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 +# requires elite ball knowledge of most of the scheduling code to make changes +# without inadvertently affecting other parts of the codebase. +coderd/schedule/autostop.go @deansheather @DanielleMaywood + +# Usage tracking code requires intimate knowledge of Tallyman and Metronome, as +# well as guidance from revenue. +coderd/usage/ @deansheather @spikecurtis +enterprise/coderd/usage/ @deansheather @spikecurtis diff --git a/Makefile b/Makefile index bd3f04a4874cd..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: @@ -576,6 +578,7 @@ lint/go: ./scripts/check_codersdk_imports.sh linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2) go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run + go run github.com/coder/paralleltestctx/cmd/paralleltestctx@v0.0.1 -custom-funcs="testutil.Context" ./... .PHONY: lint/go lint/examples: @@ -597,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 := \ @@ -635,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) @@ -685,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 @@ -728,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 \ @@ -935,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 15c653d56ad62..d80f5d1982b74 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -456,8 +456,6 @@ func TestAgent_GitSSH(t *testing.T) { func TestAgent_SessionTTYShell(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - t.Cleanup(cancel) if runtime.GOOS == "windows" { // This might be our implementation, or ConPTY itself. // It's difficult to find extensive tests for it, so @@ -468,6 +466,7 @@ func TestAgent_SessionTTYShell(t *testing.T) { for _, port := range sshPorts { t.Run(fmt.Sprintf("(%d)", port), func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) session := setupSSHSessionOnPort(t, agentsdk.Manifest{}, codersdk.ServiceBannerConfig{}, nil, port) command := "sh" @@ -2669,11 +2668,11 @@ func TestAgent_Dial(t *testing.T) { cases := []struct { name string - setup func(t *testing.T) net.Listener + setup func(t testing.TB) net.Listener }{ { name: "TCP", - setup: func(t *testing.T) net.Listener { + setup: func(t testing.TB) net.Listener { l, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err, "create TCP listener") return l @@ -2681,7 +2680,7 @@ func TestAgent_Dial(t *testing.T) { }, { name: "UDP", - setup: func(t *testing.T) net.Listener { + setup: func(t testing.TB) net.Listener { addr := net.UDPAddr{ IP: net.ParseIP("127.0.0.1"), Port: 0, @@ -2699,57 +2698,69 @@ func TestAgent_Dial(t *testing.T) { // The purpose of this test is to ensure that a client can dial a // listener in the workspace over tailnet. - l := c.setup(t) - done := make(chan struct{}) - defer func() { - l.Close() - <-done - }() - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - go func() { - defer close(done) - for range 2 { - c, err := l.Accept() - if assert.NoError(t, err, "accept connection") { - testAccept(ctx, t, c) - _ = c.Close() + // + // The OS sometimes drops packets if the system can't keep up with + // them. For TCP packets, it's typically fine due to + // retransmissions, but for UDP packets, it can fail this test. + // + // The OS gets involved for the Wireguard traffic (either via DERP + // or direct UDP), and also for the traffic between the agent and + // the listener in the "workspace". + // + // To avoid this, we'll retry this test up to 3 times. + //nolint:gocritic // This test is flaky due to uncontrollable OS packet drops under heavy load. + testutil.RunRetry(t, 3, func(t testing.TB) { + ctx := testutil.Context(t, testutil.WaitLong) + + l := c.setup(t) + done := make(chan struct{}) + defer func() { + l.Close() + <-done + }() + + go func() { + defer close(done) + for range 2 { + c, err := l.Accept() + if assert.NoError(t, err, "accept connection") { + testAccept(ctx, t, c) + _ = c.Close() + } } - } - }() + }() - agentID := uuid.UUID{0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8} - //nolint:dogsled - agentConn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{ - AgentID: agentID, - }, 0) - require.True(t, agentConn.AwaitReachable(ctx)) - conn, err := agentConn.DialContext(ctx, l.Addr().Network(), l.Addr().String()) - require.NoError(t, err) - testDial(ctx, t, conn) - err = conn.Close() - require.NoError(t, err) + agentID := uuid.UUID{0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8} + //nolint:dogsled + agentConn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{ + AgentID: agentID, + }, 0) + require.True(t, agentConn.AwaitReachable(ctx)) + conn, err := agentConn.DialContext(ctx, l.Addr().Network(), l.Addr().String()) + require.NoError(t, err) + testDial(ctx, t, conn) + err = conn.Close() + require.NoError(t, err) - // also connect via the CoderServicePrefix, to test that we can reach the agent on this - // IP. This will be required for CoderVPN. - _, rawPort, _ := net.SplitHostPort(l.Addr().String()) - port, _ := strconv.ParseUint(rawPort, 10, 16) - ipp := netip.AddrPortFrom(tailnet.CoderServicePrefix.AddrFromUUID(agentID), uint16(port)) - - switch l.Addr().Network() { - case "tcp": - conn, err = agentConn.Conn.DialContextTCP(ctx, ipp) - case "udp": - conn, err = agentConn.Conn.DialContextUDP(ctx, ipp) - default: - t.Fatalf("unknown network: %s", l.Addr().Network()) - } - require.NoError(t, err) - testDial(ctx, t, conn) - err = conn.Close() - require.NoError(t, err) + // also connect via the CoderServicePrefix, to test that we can reach the agent on this + // IP. This will be required for CoderVPN. + _, rawPort, _ := net.SplitHostPort(l.Addr().String()) + port, _ := strconv.ParseUint(rawPort, 10, 16) + ipp := netip.AddrPortFrom(tailnet.CoderServicePrefix.AddrFromUUID(agentID), uint16(port)) + + switch l.Addr().Network() { + case "tcp": + conn, err = agentConn.TailnetConn().DialContextTCP(ctx, ipp) + case "udp": + conn, err = agentConn.TailnetConn().DialContextUDP(ctx, ipp) + default: + t.Fatalf("unknown network: %s", l.Addr().Network()) + } + require.NoError(t, err) + testDial(ctx, t, conn) + err = conn.Close() + require.NoError(t, err) + }) }) } } @@ -2800,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, @@ -2880,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() @@ -3252,8 +3263,8 @@ func setupSSHSessionOnPort( return session } -func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Duration, opts ...func(*agenttest.Client, *agent.Options)) ( - *workspacesdk.AgentConn, +func setupAgent(t testing.TB, metadata agentsdk.Manifest, ptyTimeout time.Duration, opts ...func(*agenttest.Client, *agent.Options)) ( + workspacesdk.AgentConn, *agenttest.Client, <-chan *proto.Stats, afero.Fs, @@ -3350,7 +3361,7 @@ func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Durati var dialTestPayload = []byte("dean-was-here123") -func testDial(ctx context.Context, t *testing.T, c net.Conn) { +func testDial(ctx context.Context, t testing.TB, c net.Conn) { t.Helper() if deadline, ok := ctx.Deadline(); ok { @@ -3366,7 +3377,7 @@ func testDial(ctx context.Context, t *testing.T, c net.Conn) { assertReadPayload(t, c, dialTestPayload) } -func testAccept(ctx context.Context, t *testing.T, c net.Conn) { +func testAccept(ctx context.Context, t testing.TB, c net.Conn) { t.Helper() defer c.Close() @@ -3383,7 +3394,7 @@ func testAccept(ctx context.Context, t *testing.T, c net.Conn) { assertWritePayload(t, c, dialTestPayload) } -func assertReadPayload(t *testing.T, r io.Reader, payload []byte) { +func assertReadPayload(t testing.TB, r io.Reader, payload []byte) { t.Helper() b := make([]byte, len(payload)+16) n, err := r.Read(b) @@ -3392,11 +3403,11 @@ func assertReadPayload(t *testing.T, r io.Reader, payload []byte) { assert.Equal(t, payload, b[:n]) } -func assertWritePayload(t *testing.T, w io.Writer, payload []byte) { +func assertWritePayload(t testing.TB, w io.Writer, payload []byte) { t.Helper() n, err := w.Write(payload) assert.NoError(t, err, "write payload") - assert.Equal(t, len(payload), n, "payload length does not match") + assert.Equal(t, len(payload), n, "written payload length does not match") } func testSessionOutput(t *testing.T, session *ssh.Session, expected, unexpected []string, expectedRe *regexp.Regexp) { @@ -3459,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 }) @@ -3513,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", @@ -3524,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.go b/agent/agentcontainers/api.go index 48ade4bb195f4..d77d4209cb245 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -77,7 +77,8 @@ type API struct { subAgentURL string subAgentEnv []string - projectDiscovery bool // If we should perform project discovery or not. + projectDiscovery bool // If we should perform project discovery or not. + discoveryAutostart bool // If we should autostart discovered projects. ownerName string workspaceName string @@ -144,7 +145,8 @@ func WithCommandEnv(ce CommandEnv) Option { strings.HasPrefix(s, "CODER_AGENT_TOKEN=") || strings.HasPrefix(s, "CODER_AGENT_AUTH=") || strings.HasPrefix(s, "CODER_AGENT_DEVCONTAINERS_ENABLE=") || - strings.HasPrefix(s, "CODER_AGENT_DEVCONTAINERS_PROJECT_DISCOVERY_ENABLE=") + strings.HasPrefix(s, "CODER_AGENT_DEVCONTAINERS_PROJECT_DISCOVERY_ENABLE=") || + strings.HasPrefix(s, "CODER_AGENT_DEVCONTAINERS_DISCOVERY_AUTOSTART_ENABLE=") }) return shell, dir, env, nil } @@ -287,6 +289,14 @@ func WithProjectDiscovery(projectDiscovery bool) Option { } } +// WithDiscoveryAutostart sets if the API should attempt to autostart +// projects that have been discovered +func WithDiscoveryAutostart(discoveryAutostart bool) Option { + return func(api *API) { + api.discoveryAutostart = discoveryAutostart + } +} + // ScriptLogger is an interface for sending devcontainer logs to the // controlplane. type ScriptLogger interface { @@ -542,11 +552,13 @@ func (api *API) discoverDevcontainersInProject(projectPath string) error { Container: nil, } - config, err := api.dccli.ReadConfig(api.ctx, workspaceFolder, path, []string{}) - if err != nil { - logger.Error(api.ctx, "read project configuration", slog.Error(err)) - } else if config.Configuration.Customizations.Coder.AutoStart { - dc.Status = codersdk.WorkspaceAgentDevcontainerStatusStarting + if api.discoveryAutostart { + config, err := api.dccli.ReadConfig(api.ctx, workspaceFolder, path, []string{}) + if err != nil { + logger.Error(api.ctx, "read project configuration", slog.Error(err)) + } else if config.Configuration.Customizations.Coder.AutoStart { + dc.Status = codersdk.WorkspaceAgentDevcontainerStatusStarting + } } api.knownDevcontainers[workspaceFolder] = dc @@ -751,7 +763,11 @@ func (api *API) broadcastUpdatesLocked() { func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - conn, err := websocket.Accept(rw, r, nil) + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + // We want `NoContextTakeover` compression to balance improving + // bandwidth cost/latency with minimal memory usage overhead. + CompressionMode: websocket.CompressionNoContextTakeover, + }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to upgrade connection to websocket.", diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 340853a91d360..263f1698a7117 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -71,6 +71,7 @@ func (f *fakeContainerCLI) ExecAs(ctx context.Context, name, user string, args . // fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI // interface for testing. type fakeDevcontainerCLI struct { + up func(workspaceFolder, configPath string) (string, error) upID string upErr error upErrC chan func() error // If set, send to return err, close to return upErr. @@ -79,9 +80,14 @@ type fakeDevcontainerCLI struct { readConfig agentcontainers.DevcontainerConfig readConfigErr error readConfigErrC chan func(envs []string) error + + configMap map[string]agentcontainers.DevcontainerConfig // By config path } -func (f *fakeDevcontainerCLI) Up(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { +func (f *fakeDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { + if f.up != nil { + return f.up(workspaceFolder, configPath) + } if f.upErrC != nil { select { case <-ctx.Done(): @@ -109,7 +115,12 @@ func (f *fakeDevcontainerCLI) Exec(ctx context.Context, _, _ string, cmd string, return f.execErr } -func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, _ string, envs []string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) { +func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, configPath string, envs []string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) { + if f.configMap != nil { + if v, found := f.configMap[configPath]; found { + return v, f.readConfigErr + } + } if f.readConfigErrC != nil { select { case <-ctx.Done(): @@ -1664,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}, @@ -2085,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}, @@ -2128,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. @@ -2138,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), ) @@ -2146,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() @@ -2447,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{ @@ -2541,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{ @@ -2646,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{ @@ -3576,193 +3592,112 @@ func TestDevcontainerDiscovery(t *testing.T) { name string agentDir string fs map[string]string + configMap map[string]agentcontainers.DevcontainerConfig expectDevcontainerCount int - setupMocks func(mDCCLI *acmock.MockDevcontainerCLI) + expectUpCalledCount int }{ { name: "SingleEnabled", agentDir: "/home/coder", expectDevcontainerCount: 1, + expectUpCalledCount: 1, fs: map[string]string{ "/home/coder/.git/HEAD": "", "/home/coder/.devcontainer/devcontainer.json": "", }, - setupMocks: func(mDCCLI *acmock.MockDevcontainerCLI) { - gomock.InOrder( - // Given: This dev container has auto start enabled. - mDCCLI.EXPECT().ReadConfig(gomock.Any(), - "/home/coder", - "/home/coder/.devcontainer/devcontainer.json", - []string{}, - ).Return(agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ - Customizations: agentcontainers.DevcontainerCustomizations{ - Coder: agentcontainers.CoderCustomization{ - AutoStart: true, - }, + configMap: map[string]agentcontainers.DevcontainerConfig{ + "/home/coder/.devcontainer/devcontainer.json": { + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: true, }, }, - }, nil), - - // Then: We expect it to be started. - mDCCLI.EXPECT().Up(gomock.Any(), - "/home/coder", - "/home/coder/.devcontainer/devcontainer.json", - gomock.Any(), - ).Return("", nil), - ) + }, + }, }, }, { name: "SingleDisabled", agentDir: "/home/coder", expectDevcontainerCount: 1, + expectUpCalledCount: 0, fs: map[string]string{ "/home/coder/.git/HEAD": "", "/home/coder/.devcontainer/devcontainer.json": "", }, - setupMocks: func(mDCCLI *acmock.MockDevcontainerCLI) { - gomock.InOrder( - // Given: This dev container has auto start disabled. - mDCCLI.EXPECT().ReadConfig(gomock.Any(), - "/home/coder", - "/home/coder/.devcontainer/devcontainer.json", - []string{}, - ).Return(agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ - Customizations: agentcontainers.DevcontainerCustomizations{ - Coder: agentcontainers.CoderCustomization{ - AutoStart: false, - }, + configMap: map[string]agentcontainers.DevcontainerConfig{ + "/home/coder/.devcontainer/devcontainer.json": { + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: false, }, }, - }, nil), - - // Then: We expect it to _not_ be started. - mDCCLI.EXPECT().Up(gomock.Any(), - "/home/coder", - "/home/coder/.devcontainer/devcontainer.json", - gomock.Any(), - ).Return("", nil).Times(0), - ) + }, + }, }, }, { name: "OneEnabledOneDisabled", agentDir: "/home/coder", expectDevcontainerCount: 2, + expectUpCalledCount: 1, fs: map[string]string{ "/home/coder/.git/HEAD": "", "/home/coder/.devcontainer/devcontainer.json": "", "/home/coder/project/.devcontainer.json": "", }, - setupMocks: func(mDCCLI *acmock.MockDevcontainerCLI) { - gomock.InOrder( - // Given: This dev container has auto start enabled. - mDCCLI.EXPECT().ReadConfig(gomock.Any(), - "/home/coder", - "/home/coder/.devcontainer/devcontainer.json", - []string{}, - ).Return(agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ - Customizations: agentcontainers.DevcontainerCustomizations{ - Coder: agentcontainers.CoderCustomization{ - AutoStart: true, - }, + configMap: map[string]agentcontainers.DevcontainerConfig{ + "/home/coder/.devcontainer/devcontainer.json": { + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: true, }, }, - }, nil), - - // Then: We expect it to be started. - mDCCLI.EXPECT().Up(gomock.Any(), - "/home/coder", - "/home/coder/.devcontainer/devcontainer.json", - gomock.Any(), - ).Return("", nil), - ) - - gomock.InOrder( - // Given: This dev container has auto start disabled. - mDCCLI.EXPECT().ReadConfig(gomock.Any(), - "/home/coder/project", - "/home/coder/project/.devcontainer.json", - []string{}, - ).Return(agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ - Customizations: agentcontainers.DevcontainerCustomizations{ - Coder: agentcontainers.CoderCustomization{ - AutoStart: false, - }, + }, + }, + "/home/coder/project/.devcontainer.json": { + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: false, }, }, - }, nil), - - // Then: We expect it to _not_ be started. - mDCCLI.EXPECT().Up(gomock.Any(), - "/home/coder/project", - "/home/coder/project/.devcontainer.json", - gomock.Any(), - ).Return("", nil).Times(0), - ) + }, + }, }, }, { name: "MultipleEnabled", agentDir: "/home/coder", expectDevcontainerCount: 2, + expectUpCalledCount: 2, fs: map[string]string{ "/home/coder/.git/HEAD": "", "/home/coder/.devcontainer/devcontainer.json": "", "/home/coder/project/.devcontainer.json": "", }, - setupMocks: func(mDCCLI *acmock.MockDevcontainerCLI) { - gomock.InOrder( - // Given: This dev container has auto start enabled. - mDCCLI.EXPECT().ReadConfig(gomock.Any(), - "/home/coder", - "/home/coder/.devcontainer/devcontainer.json", - []string{}, - ).Return(agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ - Customizations: agentcontainers.DevcontainerCustomizations{ - Coder: agentcontainers.CoderCustomization{ - AutoStart: true, - }, + configMap: map[string]agentcontainers.DevcontainerConfig{ + "/home/coder/.devcontainer/devcontainer.json": { + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: true, }, }, - }, nil), - - // Then: We expect it to be started. - mDCCLI.EXPECT().Up(gomock.Any(), - "/home/coder", - "/home/coder/.devcontainer/devcontainer.json", - gomock.Any(), - ).Return("", nil), - ) - - gomock.InOrder( - // Given: This dev container has auto start enabled. - mDCCLI.EXPECT().ReadConfig(gomock.Any(), - "/home/coder/project", - "/home/coder/project/.devcontainer.json", - []string{}, - ).Return(agentcontainers.DevcontainerConfig{ - Configuration: agentcontainers.DevcontainerConfiguration{ - Customizations: agentcontainers.DevcontainerCustomizations{ - Coder: agentcontainers.CoderCustomization{ - AutoStart: true, - }, + }, + }, + "/home/coder/project/.devcontainer.json": { + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: true, }, }, - }, nil), - - // Then: We expect it to be started. - mDCCLI.EXPECT().Up(gomock.Any(), - "/home/coder/project", - "/home/coder/project/.devcontainer.json", - gomock.Any(), - ).Return("", nil), - ) + }, + }, }, }, } @@ -3775,44 +3710,144 @@ func TestDevcontainerDiscovery(t *testing.T) { ctx = testutil.Context(t, testutil.WaitShort) logger = testutil.Logger(t) mClock = quartz.NewMock(t) - mDCCLI = acmock.NewMockDevcontainerCLI(gomock.NewController(t)) + + upCalledMu sync.Mutex + upCalledFor = map[string]bool{} + + fCCLI = &fakeContainerCLI{} + fDCCLI = &fakeDevcontainerCLI{ + configMap: tt.configMap, + up: func(_, configPath string) (string, error) { + upCalledMu.Lock() + upCalledFor[configPath] = true + upCalledMu.Unlock() + return "", nil + }, + } r = chi.NewRouter() ) - // Given: We setup our mocks. These mocks handle our expectations for these - // tests. If there are missing/unexpected mock calls, the test will fail. - tt.setupMocks(mDCCLI) - api := agentcontainers.NewAPI(logger, agentcontainers.WithClock(mClock), agentcontainers.WithWatcher(watcher.NewNoop()), agentcontainers.WithFileSystem(initFS(t, tt.fs)), agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", "/home/coder"), - agentcontainers.WithContainerCLI(&fakeContainerCLI{}), - agentcontainers.WithDevcontainerCLI(mDCCLI), + agentcontainers.WithContainerCLI(fCCLI), + agentcontainers.WithDevcontainerCLI(fDCCLI), agentcontainers.WithProjectDiscovery(true), + agentcontainers.WithDiscoveryAutostart(true), ) api.Start() - defer api.Close() r.Mount("/", api.Routes()) - // When: All expected dev containers have been found. + // Given: We allow the discover routing to progress + var got codersdk.WorkspaceAgentListContainersResponse require.Eventuallyf(t, func() bool { req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) - got := codersdk.WorkspaceAgentListContainersResponse{} + got = codersdk.WorkspaceAgentListContainersResponse{} err := json.NewDecoder(rec.Body).Decode(&got) require.NoError(t, err) - return len(got.Devcontainers) >= tt.expectDevcontainerCount + upCalledMu.Lock() + upCalledCount := len(upCalledFor) + upCalledMu.Unlock() + + return len(got.Devcontainers) >= tt.expectDevcontainerCount && upCalledCount >= tt.expectUpCalledCount }, testutil.WaitShort, testutil.IntervalFast, "dev containers never found") - // Then: We expect the mock infra to not fail. + // Close the API. We expect this not to fail because we should have finished + // at this point. + err := api.Close() + require.NoError(t, err) + + // Then: We expect to find the expected devcontainers + assert.Len(t, got.Devcontainers, tt.expectDevcontainerCount) + + // And: We expect `up` to have been called the expected amount of times. + assert.Len(t, upCalledFor, tt.expectUpCalledCount) + + // And: `up` was called on the correct containers + for configPath, config := range tt.configMap { + autoStart := config.Configuration.Customizations.Coder.AutoStart + wasUpCalled := upCalledFor[configPath] + + require.Equal(t, autoStart, wasUpCalled) + } }) } + + t.Run("Disabled", func(t *testing.T) { + t.Parallel() + var ( + ctx = testutil.Context(t, testutil.WaitShort) + logger = testutil.Logger(t) + mClock = quartz.NewMock(t) + mDCCLI = acmock.NewMockDevcontainerCLI(gomock.NewController(t)) + + fs = map[string]string{ + "/home/coder/.git/HEAD": "", + "/home/coder/.devcontainer/devcontainer.json": "", + } + + r = chi.NewRouter() + ) + + // We expect that neither `ReadConfig`, nor `Up` are called as we + // have explicitly disabled the agentcontainers API from attempting + // to autostart devcontainers that it discovers. + mDCCLI.EXPECT().ReadConfig(gomock.Any(), + "/home/coder", + "/home/coder/.devcontainer/devcontainer.json", + []string{}, + ).Return(agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{ + AutoStart: true, + }, + }, + }, + }, nil).Times(0) + + mDCCLI.EXPECT().Up(gomock.Any(), + "/home/coder", + "/home/coder/.devcontainer/devcontainer.json", + gomock.Any(), + ).Return("", nil).Times(0) + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithFileSystem(initFS(t, fs)), + agentcontainers.WithManifestInfo("owner", "workspace", "parent-agent", "/home/coder"), + agentcontainers.WithContainerCLI(&fakeContainerCLI{}), + agentcontainers.WithDevcontainerCLI(mDCCLI), + agentcontainers.WithProjectDiscovery(true), + agentcontainers.WithDiscoveryAutostart(false), + ) + api.Start() + defer api.Close() + r.Mount("/", api.Routes()) + + // When: All expected dev containers have been found. + require.Eventuallyf(t, func() bool { + req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + got := codersdk.WorkspaceAgentListContainersResponse{} + err := json.NewDecoder(rec.Body).Decode(&got) + require.NoError(t, err) + + return len(got.Devcontainers) >= 1 + }, testutil.WaitShort, testutil.IntervalFast, "dev containers never found") + + // Then: We expect the mock infra to not fail. + }) }) } diff --git a/agent/agentcontainers/containers_dockercli_test.go b/agent/agentcontainers/containers_dockercli_test.go index c69110a757bc7..3c299e353858d 100644 --- a/agent/agentcontainers/containers_dockercli_test.go +++ b/agent/agentcontainers/containers_dockercli_test.go @@ -55,11 +55,11 @@ func TestIntegrationDockerCLI(t *testing.T) { }, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time") dcli := agentcontainers.NewDockerCLI(agentexec.DefaultExecer) - ctx := testutil.Context(t, testutil.WaitMedium) // Longer timeout for multiple subtests containerName := strings.TrimPrefix(ct.Container.Name, "/") t.Run("DetectArchitecture", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) arch, err := dcli.DetectArchitecture(ctx, containerName) require.NoError(t, err, "DetectArchitecture failed") @@ -71,6 +71,7 @@ func TestIntegrationDockerCLI(t *testing.T) { t.Run("Copy", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) want := "Help, I'm trapped!" tempFile := filepath.Join(t.TempDir(), "test-file.txt") @@ -90,6 +91,7 @@ func TestIntegrationDockerCLI(t *testing.T) { t.Run("ExecAs", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) // Test ExecAs without specifying user (should use container's default). want := "root" 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/agentssh_test.go b/agent/agentssh/agentssh_test.go index 159fe345483d2..7bf91123d5852 100644 --- a/agent/agentssh/agentssh_test.go +++ b/agent/agentssh/agentssh_test.go @@ -413,8 +413,9 @@ func TestSSHServer_ClosesStdin(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) logger := testutil.Logger(t) - s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil) + s, err := agentssh.NewServer(ctx, logger.Named("ssh-server"), prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil) require.NoError(t, err) + logger = logger.Named("test") defer s.Close() err = s.UpdateHostSigner(42) assert.NoError(t, err) @@ -469,15 +470,25 @@ func TestSSHServer_ClosesStdin(t *testing.T) { err = testutil.RequireReceive(ctx, t, readCh) require.NoError(t, err) - sess.Close() + err = sess.Close() + require.NoError(t, err) var content []byte + expected := []byte("read exit code: 1\n") testutil.Eventually(ctx, t, func(_ context.Context) bool { content, err = os.ReadFile(filePath) - return err == nil + if err != nil { + logger.Debug(ctx, "failed to read file; will retry", slog.Error(err)) + return false + } + if len(content) != len(expected) { + logger.Debug(ctx, "file is partially written", slog.F("content", content)) + return false + } + return true }, testutil.IntervalFast) require.NoError(t, err) - require.Equal(t, "read exit code: 1\n", string(content)) + require.Equal(t, string(expected), string(content)) } func sshClient(t *testing.T, addr string) *ssh.Client { 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/agent/agentssh/x11_test.go b/agent/agentssh/x11_test.go index 83af8a2f83838..2f2c657f65036 100644 --- a/agent/agentssh/x11_test.go +++ b/agent/agentssh/x11_test.go @@ -135,7 +135,7 @@ func TestServer_X11_EvictionLRU(t *testing.T) { t.Skip("X11 forwarding is only supported on Linux") } - ctx := testutil.Context(t, testutil.WaitLong) + ctx := testutil.Context(t, testutil.WaitSuperLong) logger := testutil.Logger(t) fs := afero.NewMemMapFs() @@ -238,7 +238,9 @@ func TestServer_X11_EvictionLRU(t *testing.T) { payload := "hello world" go func() { conn, err := inproc.Dial(ctx, testutil.NewAddr("tcp", fmt.Sprintf("localhost:%d", agentssh.X11StartPort+agentssh.X11DefaultDisplayOffset))) - assert.NoError(t, err) + if !assert.NoError(t, err) { + return + } _, err = conn.Write([]byte(payload)) assert.NoError(t, err) _ = conn.Close() diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000000000..ae81184cdca0c --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,86 @@ +{ + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "defaultBranch": "main" + }, + "files": { + "includes": [ + "**", + "!**/pnpm-lock.yaml" + ], + "ignoreUnknown": true + }, + "linter": { + "rules": { + "a11y": { + "noSvgWithoutTitle": "off", + "useButtonType": "off", + "useSemanticElements": "off", + "noStaticElementInteractions": "off" + }, + "correctness": { + "noUnusedImports": "warn", + "useUniqueElementIds": "off", // TODO: This is new but we want to fix it + "noNestedComponentDefinitions": "off", // TODO: Investigate, since it is used by shadcn components + "noUnusedVariables": { + "level": "warn", + "options": { + "ignoreRestSiblings": true + } + } + }, + "style": { + "noNonNullAssertion": "off", + "noParameterAssign": "off", + "useDefaultParameterLast": "off", + "useSelfClosingElements": "off", + "useAsConstAssertion": "error", + "useEnumInitializers": "error", + "useSingleVarDeclarator": "error", + "noUnusedTemplateLiteral": "error", + "useNumberNamespace": "error", + "noInferrableTypes": "error", + "noUselessElse": "error", + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "@mui/material": "Use @mui/material/ instead. See: https://material-ui.com/guides/minimizing-bundle-size/.", + "@mui/icons-material": "Use @mui/icons-material/ instead. See: https://material-ui.com/guides/minimizing-bundle-size/.", + "@mui/material/Avatar": "Use components/Avatar/Avatar instead.", + "@mui/material/Alert": "Use components/Alert/Alert instead.", + "@mui/material/Popover": "Use components/Popover/Popover instead.", + "@mui/material/Typography": "Use native HTML elements instead. Eg: ,

,

, etc.", + "@mui/material/Box": "Use a
instead.", + "@mui/material/styles": "Import from @emotion/react instead.", + "lodash": "Use lodash/ instead." + } + } + } + }, + "suspicious": { + "noArrayIndexKey": "off", + "noThenProperty": "off", + "noTemplateCurlyInString": "off", + "useIterableCallbackReturn": "off", + "noUnknownAtRules": "off", // Allow Tailwind directives + "noConsole": { + "level": "error", + "options": { + "allow": [ + "error", + "info", + "warn" + ] + } + } + }, + "complexity": { + "noImportantStyles": "off" // TODO: check and fix !important styles + } + } + }, + "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json" +} diff --git a/cli/agent.go b/cli/agent.go index 4f50fbfe88942..c192d4429ccaf 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -40,23 +40,24 @@ import ( func (r *RootCmd) workspaceAgent() *serpent.Command { var ( - auth string - logDir string - scriptDataDir string - pprofAddress string - noReap bool - sshMaxTimeout time.Duration - tailnetListenPort int64 - prometheusAddress string - debugAddress string - slogHumanPath string - slogJSONPath string - slogStackdriverPath string - blockFileTransfer bool - agentHeaderCommand string - agentHeader []string - devcontainers bool - devcontainerProjectDiscovery bool + auth string + logDir string + scriptDataDir string + pprofAddress string + noReap bool + sshMaxTimeout time.Duration + tailnetListenPort int64 + prometheusAddress string + debugAddress string + slogHumanPath string + slogJSONPath string + slogStackdriverPath string + blockFileTransfer bool + agentHeaderCommand string + agentHeader []string + devcontainers bool + devcontainerProjectDiscovery bool + devcontainerDiscoveryAutostart bool ) cmd := &serpent.Command{ Use: "agent", @@ -366,6 +367,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { DevcontainerAPIOptions: []agentcontainers.Option{ agentcontainers.WithSubAgentURL(r.agentURL.String()), agentcontainers.WithProjectDiscovery(devcontainerProjectDiscovery), + agentcontainers.WithDiscoveryAutostart(devcontainerDiscoveryAutostart), }, }) @@ -519,6 +521,13 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { Description: "Allow the agent to search the filesystem for devcontainer projects.", Value: serpent.BoolOf(&devcontainerProjectDiscovery), }, + { + Flag: "devcontainers-discovery-autostart-enable", + Default: "false", + Env: "CODER_AGENT_DEVCONTAINERS_DISCOVERY_AUTOSTART_ENABLE", + Description: "Allow the agent to autostart devcontainer projects it discovers based on their configuration.", + Value: serpent.BoolOf(&devcontainerDiscoveryAutostart), + }, } return cmd diff --git a/cli/agent_test.go b/cli/agent_test.go index 0a948c0c84e9a..1592235babaef 100644 --- a/cli/agent_test.go +++ b/cli/agent_test.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -67,7 +68,12 @@ func TestWorkspaceAgent(t *testing.T) { t.Parallel() instanceID := "instanceidentifier" certificates, metadataClient := coderdtest.NewAzureInstanceIdentity(t, instanceID) - client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + db, ps := dbtestutil.NewDB(t, + dbtestutil.WithDumpOnFailure(), + ) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: ps, AzureCertificates: certificates, }) user := coderdtest.CreateFirstUser(t, client) @@ -106,7 +112,12 @@ func TestWorkspaceAgent(t *testing.T) { t.Parallel() instanceID := "instanceidentifier" certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID) - client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + db, ps := dbtestutil.NewDB(t, + dbtestutil.WithDumpOnFailure(), + ) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: ps, AWSCertificates: certificates, }) user := coderdtest.CreateFirstUser(t, client) @@ -146,7 +157,12 @@ func TestWorkspaceAgent(t *testing.T) { t.Parallel() instanceID := "instanceidentifier" validator, metadataClient := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) - client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + db, ps := dbtestutil.NewDB(t, + dbtestutil.WithDumpOnFailure(), + ) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: ps, GoogleTokenValidator: validator, }) owner := coderdtest.CreateFirstUser(t, client) diff --git a/cli/cliui/parameter.go b/cli/cliui/parameter.go index 2e639f8dfa425..d972e346bf196 100644 --- a/cli/cliui/parameter.go +++ b/cli/cliui/parameter.go @@ -38,15 +38,16 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te // Move the cursor up a single line for nicer display! _, _ = fmt.Fprint(inv.Stdout, "\033[1A") - var options []string - err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &options) + var defaults []string + err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &defaults) if err != nil { return "", err } - values, err := MultiSelect(inv, MultiSelectOptions{ - Options: options, - Defaults: options, + values, err := RichMultiSelect(inv, RichMultiSelectOptions{ + Options: templateVersionParameter.Options, + Defaults: defaults, + EnableCustomInput: templateVersionParameter.FormType == "tag-select", }) if err == nil { v, err := json.Marshal(&values) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 40f63d92e279d..f609ca81c3e26 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "slices" "strings" "syscall" @@ -299,6 +300,77 @@ func (m selectModel) filteredOptions() []string { return options } +type RichMultiSelectOptions struct { + Message string + Options []codersdk.TemplateVersionParameterOption + Defaults []string + EnableCustomInput bool +} + +func RichMultiSelect(inv *serpent.Invocation, richOptions RichMultiSelectOptions) ([]string, error) { + var opts []string + var defaultOpts []string + + asLine := func(option codersdk.TemplateVersionParameterOption) string { + line := option.Name + if len(option.Description) > 0 { + line += ": " + option.Description + } + return line + } + + var predefinedOpts []string + for i, option := range richOptions.Options { + opts = append(opts, asLine(option)) // Some options may have description defined. + + // Check if option is selected by default + if slices.Contains(richOptions.Defaults, option.Value) { + defaultOpts = append(defaultOpts, opts[i]) + predefinedOpts = append(predefinedOpts, option.Value) + } + } + + // Check if "defaults" contains extra/custom options, user could select them. + for _, def := range richOptions.Defaults { + if !slices.Contains(predefinedOpts, def) { + opts = append(opts, def) + defaultOpts = append(defaultOpts, def) + } + } + + selected, err := MultiSelect(inv, MultiSelectOptions{ + Message: richOptions.Message, + Options: opts, + Defaults: defaultOpts, + EnableCustomInput: richOptions.EnableCustomInput, + }) + if err != nil { + return nil, err + } + + // Check selected option, convert descriptions (line) to values + // + // The function must return an initialized empty array, since it is later marshaled + // into JSON. Otherwise, `var results []string` would be marshaled to "null". + // See: https://github.com/golang/go/issues/27589 + results := []string{} + for _, sel := range selected { + custom := true + for i, option := range richOptions.Options { + if asLine(option) == sel { + results = append(results, richOptions.Options[i].Value) + custom = false + break + } + } + + if custom { + results = append(results, sel) + } + } + return results, nil +} + type MultiSelectOptions struct { Message string Options []string diff --git a/cli/cliui/select_test.go b/cli/cliui/select_test.go index c7630ac4f2460..55ab81f50f01b 100644 --- a/cli/cliui/select_test.go +++ b/cli/cliui/select_test.go @@ -52,15 +52,8 @@ func TestRichSelect(t *testing.T) { go func() { resp, err := newRichSelect(ptty, cliui.RichSelectOptions{ Options: []codersdk.TemplateVersionParameterOption{ - { - Name: "A-Name", - Value: "A-Value", - Description: "A-Description.", - }, { - Name: "B-Name", - Value: "B-Value", - Description: "B-Description.", - }, + {Name: "A-Name", Value: "A-Value", Description: "A-Description."}, + {Name: "B-Name", Value: "B-Value", Description: "B-Description."}, }, }) assert.NoError(t, err) @@ -86,63 +79,130 @@ func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, err return value, inv.Run() } -func TestMultiSelect(t *testing.T) { +func TestRichMultiSelect(t *testing.T) { t.Parallel() - t.Run("MultiSelect", func(t *testing.T) { - items := []string{"aaa", "bbb", "ccc"} - t.Parallel() - ptty := ptytest.New(t) - msgChan := make(chan []string) - go func() { - resp, err := newMultiSelect(ptty, items) - assert.NoError(t, err) - msgChan <- resp - }() - require.Equal(t, items, <-msgChan) - }) + tests := []struct { + name string + options []codersdk.TemplateVersionParameterOption + defaults []string + allowCustom bool + want []string + }{ + { + name: "Predefined", + options: []codersdk.TemplateVersionParameterOption{ + {Name: "AAA", Description: "This is AAA", Value: "aaa"}, + {Name: "BBB", Description: "This is BBB", Value: "bbb"}, + {Name: "CCC", Description: "This is CCC", Value: "ccc"}, + }, + defaults: []string{"bbb", "ccc"}, + allowCustom: false, + want: []string{"bbb", "ccc"}, + }, + { + name: "Custom", + options: []codersdk.TemplateVersionParameterOption{ + {Name: "AAA", Description: "This is AAA", Value: "aaa"}, + {Name: "BBB", Description: "This is BBB", Value: "bbb"}, + {Name: "CCC", Description: "This is CCC", Value: "ccc"}, + }, + defaults: []string{"aaa", "bbb"}, + allowCustom: true, + want: []string{"aaa", "bbb"}, + }, + { + name: "NoOptionSelected", + options: []codersdk.TemplateVersionParameterOption{ + {Name: "AAA", Description: "This is AAA", Value: "aaa"}, + {Name: "BBB", Description: "This is BBB", Value: "bbb"}, + {Name: "CCC", Description: "This is CCC", Value: "ccc"}, + }, + defaults: []string{}, + allowCustom: false, + want: []string{}, + }, + } - t.Run("MultiSelectWithCustomInput", func(t *testing.T) { - t.Parallel() - items := []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"} - ptty := ptytest.New(t) - msgChan := make(chan []string) - go func() { - resp, err := newMultiSelectWithCustomInput(ptty, items) - assert.NoError(t, err) - msgChan <- resp - }() - require.Equal(t, items, <-msgChan) - }) -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() -func newMultiSelectWithCustomInput(ptty *ptytest.PTY, items []string) ([]string, error) { - var values []string - cmd := &serpent.Command{ - Handler: func(inv *serpent.Invocation) error { - selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ - Options: items, - Defaults: items, - EnableCustomInput: true, - }) - if err == nil { - values = selectedItems + var selectedItems []string + var err error + cmd := &serpent.Command{ + Handler: func(inv *serpent.Invocation) error { + selectedItems, err = cliui.RichMultiSelect(inv, cliui.RichMultiSelectOptions{ + Options: tt.options, + Defaults: tt.defaults, + EnableCustomInput: tt.allowCustom, + }) + return err + }, } - return err + + doneChan := make(chan struct{}) + go func() { + defer close(doneChan) + err := cmd.Invoke().Run() + assert.NoError(t, err) + }() + <-doneChan + + require.Equal(t, tt.want, selectedItems) + }) + } +} + +func TestMultiSelect(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + items []string + allowCustom bool + want []string + }{ + { + name: "MultiSelect", + items: []string{"aaa", "bbb", "ccc"}, + allowCustom: false, + want: []string{"aaa", "bbb", "ccc"}, + }, + { + name: "MultiSelectWithCustomInput", + items: []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"}, + allowCustom: true, + want: []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"}, }, } - inv := cmd.Invoke() - ptty.Attach(inv) - return values, inv.Run() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ptty := ptytest.New(t) + msgChan := make(chan []string) + + go func() { + resp, err := newMultiSelect(ptty, tt.items, tt.allowCustom) + assert.NoError(t, err) + msgChan <- resp + }() + + require.Equal(t, tt.want, <-msgChan) + }) + } } -func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { +func newMultiSelect(pty *ptytest.PTY, items []string, custom bool) ([]string, error) { var values []string cmd := &serpent.Command{ Handler: func(inv *serpent.Invocation) error { selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ - Options: items, - Defaults: items, + Options: items, + Defaults: items, + EnableCustomInput: custom, }) if err == nil { values = selectedItems @@ -151,6 +211,6 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { }, } inv := cmd.Invoke() - ptty.Attach(inv) + pty.Attach(inv) return values, inv.Run() } 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.go b/cli/exp.go index dafd85402663e..e20d1e28d5ffe 100644 --- a/cli/exp.go +++ b/cli/exp.go @@ -16,6 +16,7 @@ func (r *RootCmd) expCmd() *serpent.Command { r.mcpCommand(), r.promptExample(), r.rptyCommand(), + r.tasksCommand(), }, } return cmd diff --git a/cli/exp_prompts.go b/cli/exp_prompts.go index 225685a0c375a..ef51a1ce04398 100644 --- a/cli/exp_prompts.go +++ b/cli/exp_prompts.go @@ -174,6 +174,20 @@ func (RootCmd) promptExample() *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", ")) return multiSelectError }, useThingsOption, enableCustomInputOption), + promptCmd("rich-multi-select", func(inv *serpent.Invocation) error { + if len(multiSelectValues) == 0 { + multiSelectValues, multiSelectError = cliui.MultiSelect(inv, cliui.MultiSelectOptions{ + Message: "Select some things:", + Options: []string{ + "Apples", "Plums", "Grapes", "Oranges", "Bananas", + }, + Defaults: []string{"Grapes", "Plums"}, + EnableCustomInput: enableCustomInput, + }) + } + _, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", ")) + return multiSelectError + }, useThingsOption, enableCustomInputOption), promptCmd("rich-parameter", func(inv *serpent.Invocation) error { value, err := cliui.RichSelect(inv, cliui.RichSelectOptions{ Options: []codersdk.TemplateVersionParameterOption{ 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/exp_scaletest.go b/cli/exp_scaletest.go index a844a7e8c6258..4580ff3e1bc8a 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -864,6 +864,7 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command { tickInterval time.Duration bytesPerTick int64 ssh bool + disableDirect bool useHostLogin bool app string template string @@ -1023,15 +1024,16 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command { // Setup our workspace agent connection. config := workspacetraffic.Config{ - AgentID: agent.ID, - BytesPerTick: bytesPerTick, - Duration: strategy.timeout, - TickInterval: tickInterval, - ReadMetrics: metrics.ReadMetrics(ws.OwnerName, ws.Name, agent.Name), - WriteMetrics: metrics.WriteMetrics(ws.OwnerName, ws.Name, agent.Name), - SSH: ssh, - Echo: ssh, - App: appConfig, + AgentID: agent.ID, + BytesPerTick: bytesPerTick, + Duration: strategy.timeout, + TickInterval: tickInterval, + ReadMetrics: metrics.ReadMetrics(ws.OwnerName, ws.Name, agent.Name), + WriteMetrics: metrics.WriteMetrics(ws.OwnerName, ws.Name, agent.Name), + SSH: ssh, + DisableDirect: disableDirect, + Echo: ssh, + App: appConfig, } if webClient != nil { @@ -1117,6 +1119,13 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command { Description: "Send traffic over SSH, cannot be used with --app.", Value: serpent.BoolOf(&ssh), }, + { + Flag: "disable-direct", + Env: "CODER_SCALETEST_WORKSPACE_TRAFFIC_DISABLE_DIRECT_CONNECTIONS", + Default: "false", + Description: "Disable direct connections for SSH traffic to workspaces. Does nothing if `--ssh` is not also set.", + Value: serpent.BoolOf(&disableDirect), + }, { Flag: "app", Env: "CODER_SCALETEST_WORKSPACE_TRAFFIC_APP", diff --git a/cli/exp_task.go b/cli/exp_task.go new file mode 100644 index 0000000000000..005138050b2eb --- /dev/null +++ b/cli/exp_task.go @@ -0,0 +1,22 @@ +package cli + +import ( + "github.com/coder/serpent" +) + +func (r *RootCmd) tasksCommand() *serpent.Command { + cmd := &serpent.Command{ + Use: "task", + Aliases: []string{"tasks"}, + Short: "Experimental task commands.", + Handler: func(i *serpent.Invocation) error { + return i.Command.HelpHandler(i) + }, + Children: []*serpent.Command{ + r.taskList(), + r.taskCreate(), + r.taskStatus(), + }, + } + return cmd +} diff --git a/cli/exp_task_status.go b/cli/exp_task_status.go new file mode 100644 index 0000000000000..7b4b75c1a8ef9 --- /dev/null +++ b/cli/exp_task_status.go @@ -0,0 +1,171 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) taskStatus() *serpent.Command { + var ( + client = new(codersdk.Client) + formatter = cliui.NewOutputFormatter( + cliui.TableFormat( + []taskStatusRow{}, + []string{ + "state changed", + "status", + "state", + "message", + }, + ), + cliui.ChangeFormatterData( + cliui.JSONFormat(), + func(data any) (any, error) { + rows, ok := data.([]taskStatusRow) + if !ok { + return nil, xerrors.Errorf("expected []taskStatusRow, got %T", data) + } + if len(rows) != 1 { + return nil, xerrors.Errorf("expected exactly 1 row, got %d", len(rows)) + } + return rows[0], nil + }, + ), + ) + watchArg bool + watchIntervalArg time.Duration + ) + cmd := &serpent.Command{ + Short: "Show the status of a task.", + Use: "status", + Aliases: []string{"stat"}, + Options: serpent.OptionSet{ + { + Default: "false", + Description: "Watch the task status output. This will stream updates to the terminal until the underlying workspace is stopped.", + Flag: "watch", + Name: "watch", + Value: serpent.BoolOf(&watchArg), + }, + { + Default: "1s", + Description: "Interval to poll the task for updates. Only used in tests.", + Hidden: true, + Flag: "watch-interval", + Name: "watch-interval", + Value: serpent.DurationOf(&watchIntervalArg), + }, + }, + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + r.InitClient(client), + ), + Handler: func(i *serpent.Invocation) error { + ctx := i.Context() + ec := codersdk.NewExperimentalClient(client) + identifier := i.Args[0] + + taskID, err := uuid.Parse(identifier) + if err != nil { + // Try to resolve the task as a named workspace + // TODO: right now tasks are still "workspaces" under the hood. + // We should update this once we have a proper task model. + ws, err := namedWorkspace(ctx, client, identifier) + if err != nil { + return err + } + taskID = ws.ID + } + task, err := ec.TaskByID(ctx, taskID) + if err != nil { + return err + } + + out, err := formatter.Format(ctx, toStatusRow(task)) + if err != nil { + return xerrors.Errorf("format task status: %w", err) + } + _, _ = fmt.Fprintln(i.Stdout, out) + + if !watchArg { + return nil + } + + lastStatus := task.Status + lastState := task.CurrentState + t := time.NewTicker(watchIntervalArg) + defer t.Stop() + // TODO: implement streaming updates instead of polling + for range t.C { + task, err := ec.TaskByID(ctx, taskID) + if err != nil { + return err + } + if lastStatus == task.Status && taskStatusEqual(lastState, task.CurrentState) { + continue + } + out, err := formatter.Format(ctx, toStatusRow(task)) + if err != nil { + return xerrors.Errorf("format task status: %w", err) + } + // hack: skip the extra column header from formatter + if formatter.FormatID() != cliui.JSONFormat().ID() { + out = strings.SplitN(out, "\n", 2)[1] + } + _, _ = fmt.Fprintln(i.Stdout, out) + + if task.Status == codersdk.WorkspaceStatusStopped { + return nil + } + lastStatus = task.Status + lastState = task.CurrentState + } + return nil + }, + } + formatter.AttachOptions(&cmd.Options) + return cmd +} + +func taskStatusEqual(s1, s2 *codersdk.TaskStateEntry) bool { + if s1 == nil && s2 == nil { + return true + } + if s1 == nil || s2 == nil { + return false + } + return s1.State == s2.State +} + +type taskStatusRow struct { + codersdk.Task `table:"-"` + ChangedAgo string `json:"-" table:"state changed,default_sort"` + Timestamp time.Time `json:"-" table:"-"` + TaskStatus string `json:"-" table:"status"` + TaskState string `json:"-" table:"state"` + Message string `json:"-" table:"message"` +} + +func toStatusRow(task codersdk.Task) []taskStatusRow { + tsr := taskStatusRow{ + Task: task, + ChangedAgo: time.Since(task.UpdatedAt).Truncate(time.Second).String() + " ago", + Timestamp: task.UpdatedAt, + TaskStatus: string(task.Status), + } + if task.CurrentState != nil { + tsr.ChangedAgo = time.Since(task.CurrentState.Timestamp).Truncate(time.Second).String() + " ago" + tsr.Timestamp = task.CurrentState.Timestamp + tsr.TaskState = string(task.CurrentState.State) + tsr.Message = task.CurrentState.Message + } + return []taskStatusRow{tsr} +} diff --git a/cli/exp_task_status_test.go b/cli/exp_task_status_test.go new file mode 100644 index 0000000000000..b520d2728804e --- /dev/null +++ b/cli/exp_task_status_test.go @@ -0,0 +1,276 @@ +package cli_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func Test_TaskStatus(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + args []string + expectOutput string + expectError string + hf func(context.Context, time.Time) func(http.ResponseWriter, *http.Request) + }{ + { + args: []string{"doesnotexist"}, + expectError: httpapi.ResourceNotFoundResponse.Message, + hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/workspace/doesnotexist": + httpapi.ResourceNotFound(w) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"err-fetching-workspace"}, + expectError: assert.AnError.Error(), + hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/workspace/err-fetching-workspace": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + }) + case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": + httpapi.InternalServerError(w, assert.AnError) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"exists"}, + expectOutput: `STATE CHANGED STATUS STATE MESSAGE +0s ago running working Thinking furiously...`, + hf: func(ctx context.Context, now time.Time) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/workspace/exists": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + }) + case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now, + UpdatedAt: now, + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateWorking, + Timestamp: now, + Message: "Thinking furiously...", + }, + }) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"exists", "--watch"}, + expectOutput: ` +STATE CHANGED STATUS STATE MESSAGE +4s ago running +3s ago running working Reticulating splines... +2s ago running completed Splines reticulated successfully! +2s ago stopping completed Splines reticulated successfully! +2s ago stopped completed Splines reticulated successfully!`, + hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) { + var calls atomic.Int64 + return func(w http.ResponseWriter, r *http.Request) { + defer calls.Add(1) + switch r.URL.Path { + case "/api/v2/users/me/workspace/exists": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + }) + case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": + switch calls.Load() { + case 0: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusPending, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-5 * time.Second), + }) + case 1: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-4 * time.Second), + }) + case 2: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-4 * time.Second), + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateWorking, + Timestamp: now.Add(-3 * time.Second), + Message: "Reticulating splines...", + }, + }) + case 3: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-4 * time.Second), + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateCompleted, + Timestamp: now.Add(-2 * time.Second), + Message: "Splines reticulated successfully!", + }, + }) + case 4: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusStopping, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-1 * time.Second), + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateCompleted, + Timestamp: now.Add(-2 * time.Second), + Message: "Splines reticulated successfully!", + }, + }) + case 5: + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusStopped, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now, + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateCompleted, + Timestamp: now.Add(-2 * time.Second), + Message: "Splines reticulated successfully!", + }, + }) + default: + httpapi.InternalServerError(w, xerrors.New("too many calls!")) + return + } + default: + httpapi.InternalServerError(w, xerrors.Errorf("unexpected path: %q", r.URL.Path)) + } + } + }, + }, + { + args: []string{"exists", "--output", "json"}, + expectOutput: `{ + "id": "11111111-1111-1111-1111-111111111111", + "organization_id": "00000000-0000-0000-0000-000000000000", + "owner_id": "00000000-0000-0000-0000-000000000000", + "owner_name": "", + "name": "", + "template_id": "00000000-0000-0000-0000-000000000000", + "template_name": "", + "template_display_name": "", + "template_icon": "", + "workspace_id": null, + "workspace_agent_id": null, + "workspace_agent_lifecycle": null, + "workspace_agent_health": null, + "initial_prompt": "", + "status": "running", + "current_state": { + "timestamp": "2025-08-26T12:34:57Z", + "state": "working", + "message": "Thinking furiously...", + "uri": "" + }, + "created_at": "2025-08-26T12:34:56Z", + "updated_at": "2025-08-26T12:34:56Z" +}`, + hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { + ts := time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC) + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/workspace/exists": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + }) + case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": + httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: ts, + UpdatedAt: ts, + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateWorking, + Timestamp: ts.Add(time.Second), + Message: "Thinking furiously...", + }, + }) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + } { + t.Run(strings.Join(tc.args, ","), func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + now = time.Now().UTC() // TODO: replace with quartz + srv = httptest.NewServer(http.HandlerFunc(tc.hf(ctx, now))) + client = codersdk.New(testutil.MustURL(t, srv.URL)) + sb = strings.Builder{} + args = []string{"exp", "task", "status", "--watch-interval", testutil.IntervalFast.String()} + ) + + t.Cleanup(srv.Close) + args = append(args, tc.args...) + inv, root := clitest.New(t, args...) + inv.Stdout = &sb + inv.Stderr = &sb + clitest.SetupConfig(t, client, root) + err := inv.WithContext(ctx).Run() + if tc.expectError == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.expectError) + } + if diff := tableDiff(tc.expectOutput, sb.String()); diff != "" { + t.Errorf("unexpected output diff (-want +got):\n%s", diff) + } + }) + } +} + +func tableDiff(want, got string) string { + var gotTrimmed strings.Builder + for _, line := range strings.Split(got, "\n") { + _, _ = gotTrimmed.WriteString(strings.TrimRight(line, " ") + "\n") + } + return cmp.Diff(strings.TrimSpace(want), strings.TrimSpace(gotTrimmed.String())) +} diff --git a/cli/exp_taskcreate.go b/cli/exp_taskcreate.go new file mode 100644 index 0000000000000..24f0955ea8d78 --- /dev/null +++ b/cli/exp_taskcreate.go @@ -0,0 +1,128 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) taskCreate() *serpent.Command { + var ( + orgContext = NewOrganizationContext() + client = new(codersdk.Client) + + templateName string + templateVersionName string + presetName string + taskInput string + ) + + cmd := &serpent.Command{ + Use: "create [template]", + Short: "Create an experimental task", + Middleware: serpent.Chain( + serpent.RequireRangeArgs(0, 1), + r.InitClient(client), + ), + Options: serpent.OptionSet{ + { + Flag: "input", + Env: "CODER_TASK_INPUT", + Value: serpent.StringOf(&taskInput), + Required: true, + }, + { + Env: "CODER_TASK_TEMPLATE_NAME", + Value: serpent.StringOf(&templateName), + }, + { + Env: "CODER_TASK_TEMPLATE_VERSION", + Value: serpent.StringOf(&templateVersionName), + }, + { + Flag: "preset", + Env: "CODER_TASK_PRESET_NAME", + Value: serpent.StringOf(&presetName), + Default: PresetNone, + }, + }, + Handler: func(inv *serpent.Invocation) error { + var ( + ctx = inv.Context() + expClient = codersdk.NewExperimentalClient(client) + + templateVersionID uuid.UUID + templateVersionPresetID uuid.UUID + ) + + organization, err := orgContext.Selected(inv, client) + if err != nil { + return xerrors.Errorf("get current organization: %w", err) + } + + if len(inv.Args) > 0 { + templateName, templateVersionName, _ = strings.Cut(inv.Args[0], "@") + } + + if templateName == "" { + return xerrors.Errorf("template name not provided") + } + + if templateVersionName != "" { + templateVersion, err := client.TemplateVersionByOrganizationAndName(ctx, organization.ID, templateName, templateVersionName) + if err != nil { + return xerrors.Errorf("get template version: %w", err) + } + + templateVersionID = templateVersion.ID + } else { + template, err := client.TemplateByName(ctx, organization.ID, templateName) + if err != nil { + return xerrors.Errorf("get template: %w", err) + } + + templateVersionID = template.ActiveVersionID + } + + if presetName != PresetNone { + templatePresets, err := client.TemplateVersionPresets(ctx, templateVersionID) + if err != nil { + return xerrors.Errorf("get template presets: %w", err) + } + + preset, err := resolvePreset(templatePresets, presetName) + if err != nil { + return xerrors.Errorf("resolve preset: %w", err) + } + + templateVersionPresetID = preset.ID + } + + task, err := expClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: templateVersionID, + TemplateVersionPresetID: templateVersionPresetID, + Prompt: taskInput, + }) + if err != nil { + return xerrors.Errorf("create task: %w", err) + } + + _, _ = fmt.Fprintf( + inv.Stdout, + "The task %s has been created at %s!\n", + cliui.Keyword(task.Name), + cliui.Timestamp(task.CreatedAt), + ) + + return nil + }, + } + orgContext.AttachOptions(cmd) + return cmd +} diff --git a/cli/exp_taskcreate_test.go b/cli/exp_taskcreate_test.go new file mode 100644 index 0000000000000..f49c2fee1194a --- /dev/null +++ b/cli/exp_taskcreate_test.go @@ -0,0 +1,261 @@ +package cli_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" +) + +func TestTaskCreate(t *testing.T) { + t.Parallel() + + var ( + taskCreatedAt = time.Now() + + organizationID = uuid.New() + anotherOrganizationID = uuid.New() + templateID = uuid.New() + templateVersionID = uuid.New() + templateVersionPresetID = uuid.New() + ) + + templateAndVersionFoundHandler := func(t *testing.T, ctx context.Context, orgID uuid.UUID, templateName, templateVersionName, presetName, prompt string) http.HandlerFunc { + t.Helper() + + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/organizations": + httpapi.Write(ctx, w, http.StatusOK, []codersdk.Organization{ + {MinimalOrganization: codersdk.MinimalOrganization{ + ID: orgID, + }}, + }) + case fmt.Sprintf("/api/v2/organizations/%s/templates/my-template/versions/my-template-version", orgID): + httpapi.Write(ctx, w, http.StatusOK, codersdk.TemplateVersion{ + ID: templateVersionID, + }) + case fmt.Sprintf("/api/v2/organizations/%s/templates/my-template", orgID): + httpapi.Write(ctx, w, http.StatusOK, codersdk.Template{ + ID: templateID, + ActiveVersionID: templateVersionID, + }) + case fmt.Sprintf("/api/v2/templateversions/%s/presets", templateVersionID): + httpapi.Write(ctx, w, http.StatusOK, []codersdk.Preset{ + { + ID: templateVersionPresetID, + Name: presetName, + }, + }) + case "/api/experimental/tasks/me": + var req codersdk.CreateTaskRequest + if !httpapi.Read(ctx, w, r, &req) { + return + } + + assert.Equal(t, prompt, req.Prompt, "prompt mismatch") + assert.Equal(t, templateVersionID, req.TemplateVersionID, "template version mismatch") + + if presetName == "" { + assert.Equal(t, uuid.Nil, req.TemplateVersionPresetID, "expected no template preset id") + } else { + assert.Equal(t, templateVersionPresetID, req.TemplateVersionPresetID, "template version preset id mismatch") + } + + httpapi.Write(ctx, w, http.StatusCreated, codersdk.Workspace{ + Name: "task-wild-goldfish-27", + CreatedAt: taskCreatedAt, + }) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + } + + tests := []struct { + args []string + env []string + expectError string + expectOutput string + handler func(t *testing.T, ctx context.Context) http.HandlerFunc + }{ + { + args: []string{"my-template@my-template-version", "--input", "my custom prompt", "--org", organizationID.String()}, + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, organizationID, "my-template", "my-template-version", "", "my custom prompt") + }, + }, + { + args: []string{"my-template", "--input", "my custom prompt", "--org", organizationID.String()}, + env: []string{"CODER_TASK_TEMPLATE_VERSION=my-template-version"}, + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, organizationID, "my-template", "my-template-version", "", "my custom prompt") + }, + }, + { + args: []string{"--input", "my custom prompt", "--org", organizationID.String()}, + env: []string{"CODER_TASK_TEMPLATE_NAME=my-template", "CODER_TASK_TEMPLATE_VERSION=my-template-version"}, + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, organizationID, "my-template", "my-template-version", "", "my custom prompt") + }, + }, + { + env: []string{"CODER_TASK_TEMPLATE_NAME=my-template", "CODER_TASK_TEMPLATE_VERSION=my-template-version", "CODER_TASK_INPUT=my custom prompt", "CODER_ORGANIZATION=" + organizationID.String()}, + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, organizationID, "my-template", "my-template-version", "", "my custom prompt") + }, + }, + { + args: []string{"my-template", "--input", "my custom prompt", "--org", organizationID.String()}, + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, organizationID, "my-template", "", "", "my custom prompt") + }, + }, + { + args: []string{"my-template", "--input", "my custom prompt", "--preset", "my-preset", "--org", organizationID.String()}, + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, organizationID, "my-template", "", "my-preset", "my custom prompt") + }, + }, + { + args: []string{"my-template", "--input", "my custom prompt"}, + env: []string{"CODER_TASK_PRESET_NAME=my-preset"}, + expectOutput: fmt.Sprintf("The task %s has been created at %s!", cliui.Keyword("task-wild-goldfish-27"), cliui.Timestamp(taskCreatedAt)), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, organizationID, "my-template", "", "my-preset", "my custom prompt") + }, + }, + { + args: []string{"my-template", "--input", "my custom prompt", "--preset", "not-real-preset"}, + expectError: `preset "not-real-preset" not found`, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return templateAndVersionFoundHandler(t, ctx, organizationID, "my-template", "", "my-preset", "my custom prompt") + }, + }, + { + args: []string{"my-template@not-real-template-version", "--input", "my custom prompt"}, + expectError: httpapi.ResourceNotFoundResponse.Message, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/organizations": + httpapi.Write(ctx, w, http.StatusOK, []codersdk.Organization{ + {MinimalOrganization: codersdk.MinimalOrganization{ + ID: organizationID, + }}, + }) + case fmt.Sprintf("/api/v2/organizations/%s/templates/my-template/versions/not-real-template-version", organizationID): + httpapi.ResourceNotFound(w) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"not-real-template", "--input", "my custom prompt", "--org", organizationID.String()}, + expectError: httpapi.ResourceNotFoundResponse.Message, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/organizations": + httpapi.Write(ctx, w, http.StatusOK, []codersdk.Organization{ + {MinimalOrganization: codersdk.MinimalOrganization{ + ID: organizationID, + }}, + }) + case fmt.Sprintf("/api/v2/organizations/%s/templates/not-real-template", organizationID): + httpapi.ResourceNotFound(w) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"template-in-different-org", "--input", "my-custom-prompt", "--org", anotherOrganizationID.String()}, + expectError: httpapi.ResourceNotFoundResponse.Message, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/organizations": + httpapi.Write(ctx, w, http.StatusOK, []codersdk.Organization{ + {MinimalOrganization: codersdk.MinimalOrganization{ + ID: anotherOrganizationID, + }}, + }) + case fmt.Sprintf("/api/v2/organizations/%s/templates/template-in-different-org", anotherOrganizationID): + httpapi.ResourceNotFound(w) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"no-org", "--input", "my-custom-prompt"}, + expectError: "Must select an organization with --org=", + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/organizations": + httpapi.Write(ctx, w, http.StatusOK, []codersdk.Organization{}) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + } + + for _, tt := range tests { + t.Run(strings.Join(tt.args, ","), func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + srv = httptest.NewServer(tt.handler(t, ctx)) + client = codersdk.New(testutil.MustURL(t, srv.URL)) + args = []string{"exp", "task", "create"} + sb strings.Builder + err error + ) + + t.Cleanup(srv.Close) + + inv, root := clitest.New(t, append(args, tt.args...)...) + inv.Environ = serpent.ParseEnviron(tt.env, "") + inv.Stdout = &sb + inv.Stderr = &sb + clitest.SetupConfig(t, client, root) + + err = inv.WithContext(ctx).Run() + if tt.expectError == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.expectError) + } + + assert.Contains(t, sb.String(), tt.expectOutput) + }) + } +} diff --git a/cli/exp_tasklist.go b/cli/exp_tasklist.go new file mode 100644 index 0000000000000..7f2b44d25aa4c --- /dev/null +++ b/cli/exp_tasklist.go @@ -0,0 +1,142 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +type taskListRow struct { + Task codersdk.Task `table:"t,recursive_inline"` + + StateChangedAgo string `table:"state changed"` +} + +func taskListRowFromTask(now time.Time, t codersdk.Task) taskListRow { + var stateAgo string + if t.CurrentState != nil { + stateAgo = now.UTC().Sub(t.CurrentState.Timestamp).Truncate(time.Second).String() + " ago" + } + + return taskListRow{ + Task: t, + + StateChangedAgo: stateAgo, + } +} + +func (r *RootCmd) taskList() *serpent.Command { + var ( + statusFilter string + all bool + user string + + client = new(codersdk.Client) + formatter = cliui.NewOutputFormatter( + cliui.TableFormat( + []taskListRow{}, + []string{ + "id", + "name", + "status", + "state", + "state changed", + "message", + }, + ), + cliui.ChangeFormatterData( + cliui.JSONFormat(), + func(data any) (any, error) { + rows, ok := data.([]taskListRow) + if !ok { + return nil, xerrors.Errorf("expected []taskListRow, got %T", data) + } + out := make([]codersdk.Task, len(rows)) + for i := range rows { + out[i] = rows[i].Task + } + return out, nil + }, + ), + ) + ) + + cmd := &serpent.Command{ + Use: "list", + Short: "List experimental tasks", + Aliases: []string{"ls"}, + Middleware: serpent.Chain( + serpent.RequireNArgs(0), + r.InitClient(client), + ), + Options: serpent.OptionSet{ + { + Name: "status", + Description: "Filter by task status (e.g. running, failed, etc).", + Flag: "status", + Default: "", + Value: serpent.StringOf(&statusFilter), + }, + { + Name: "all", + Description: "List tasks for all users you can view.", + Flag: "all", + FlagShorthand: "a", + Default: "false", + Value: serpent.BoolOf(&all), + }, + { + Name: "user", + Description: "List tasks for the specified user (username, \"me\").", + Flag: "user", + Default: "", + Value: serpent.StringOf(&user), + }, + }, + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + exp := codersdk.NewExperimentalClient(client) + + targetUser := strings.TrimSpace(user) + if targetUser == "" && !all { + targetUser = codersdk.Me + } + + tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{ + Owner: targetUser, + Status: statusFilter, + }) + if err != nil { + return xerrors.Errorf("list tasks: %w", err) + } + + // If no rows and not JSON, show a friendly message. + if len(tasks) == 0 && formatter.FormatID() != cliui.JSONFormat().ID() { + _, _ = fmt.Fprintln(inv.Stderr, "No tasks found.") + return nil + } + + rows := make([]taskListRow, len(tasks)) + now := time.Now() + for i := range tasks { + rows[i] = taskListRowFromTask(now, tasks[i]) + } + + out, err := formatter.Format(ctx, rows) + if err != nil { + return xerrors.Errorf("format tasks: %w", err) + } + _, _ = fmt.Fprintln(inv.Stdout, out) + return nil + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} diff --git a/cli/exp_tasklist_test.go b/cli/exp_tasklist_test.go new file mode 100644 index 0000000000000..1120a11c69e3c --- /dev/null +++ b/cli/exp_tasklist_test.go @@ -0,0 +1,278 @@ +package cli_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "io" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +// makeAITask creates an AI-task workspace. +func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) (workspace database.WorkspaceTable) { + t.Helper() + + tv := dbfake.TemplateVersion(t, db). + Seed(database.TemplateVersion{ + OrganizationID: orgID, + CreatedBy: adminID, + HasAITask: sql.NullBool{ + Bool: true, + Valid: true, + }, + }).Do() + + ws := database.WorkspaceTable{ + OrganizationID: orgID, + OwnerID: ownerID, + TemplateID: tv.Template.ID, + } + build := dbfake.WorkspaceBuild(t, db, ws). + Seed(database.WorkspaceBuild{ + TemplateVersionID: tv.TemplateVersion.ID, + Transition: transition, + }).WithAgent().Do() + dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{ + { + WorkspaceBuildID: build.Build.ID, + Name: codersdk.AITaskPromptParameterName, + Value: prompt, + }, + }) + agents, err := db.GetWorkspaceAgentsByWorkspaceAndBuildNumber( + dbauthz.AsSystemRestricted(context.Background()), + database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ + WorkspaceID: build.Workspace.ID, + BuildNumber: build.Build.BuildNumber, + }, + ) + require.NoError(t, err) + require.NotEmpty(t, agents) + agentID := agents[0].ID + + // Create a workspace app and set it as the sidebar app. + app := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{ + AgentID: agentID, + Slug: "task-sidebar", + DisplayName: "Task Sidebar", + External: false, + }) + + // Update build flags to reference the sidebar app and HasAITask=true. + err = db.UpdateWorkspaceBuildFlagsByID( + dbauthz.AsSystemRestricted(context.Background()), + database.UpdateWorkspaceBuildFlagsByIDParams{ + ID: build.Build.ID, + HasAITask: sql.NullBool{Bool: true, Valid: true}, + HasExternalAgent: sql.NullBool{Bool: false, Valid: false}, + SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + UpdatedAt: build.Build.UpdatedAt, + }, + ) + require.NoError(t, err) + + return build.Workspace +} + +func TestExpTaskList(t *testing.T) { + t.Parallel() + + t.Run("NoTasks_Table", func(t *testing.T) { + t.Parallel() + + // Quiet logger to reduce noise. + quiet := slog.Make(sloghuman.Sink(io.Discard)) + client, _ := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet}) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + inv, root := clitest.New(t, "exp", "task", "list") + clitest.SetupConfig(t, memberClient, root) + + pty := ptytest.New(t).Attach(inv) + ctx := testutil.Context(t, testutil.WaitShort) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + pty.ExpectMatch("No tasks found.") + }) + + t.Run("Single_Table", func(t *testing.T) { + t.Parallel() + + // Quiet logger to reduce noise. + quiet := slog.Make(sloghuman.Sink(io.Discard)) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet}) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + wantPrompt := "build me a web app" + ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt) + + inv, root := clitest.New(t, "exp", "task", "list", "--column", "id,name,status,initial prompt") + clitest.SetupConfig(t, memberClient, root) + + pty := ptytest.New(t).Attach(inv) + ctx := testutil.Context(t, testutil.WaitShort) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + // Validate the table includes the task and status. + pty.ExpectMatch(ws.Name) + pty.ExpectMatch("running") + pty.ExpectMatch(wantPrompt) + }) + + t.Run("StatusFilter_JSON", func(t *testing.T) { + t.Parallel() + + // Quiet logger to reduce noise. + quiet := slog.Make(sloghuman.Sink(io.Discard)) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet}) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Create two AI tasks: one running, one stopped. + running := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running") + stopped := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please") + + // Use JSON output to reliably validate filtering. + inv, root := clitest.New(t, "exp", "task", "list", "--status=stopped", "--output=json") + clitest.SetupConfig(t, memberClient, root) + + ctx := testutil.Context(t, testutil.WaitShort) + var stdout bytes.Buffer + inv.Stdout = &stdout + inv.Stderr = &stdout + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var tasks []codersdk.Task + require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks)) + + // Only the stopped task is returned. + require.Len(t, tasks, 1, "expected one task after filtering") + require.Equal(t, stopped.ID, tasks[0].ID) + require.NotEqual(t, running.ID, tasks[0].ID) + }) + + t.Run("UserFlag_Me_Table", func(t *testing.T) { + t.Parallel() + + quiet := slog.Make(sloghuman.Sink(io.Discard)) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet}) + owner := coderdtest.CreateFirstUser(t, client) + _, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + _ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "other-task") + ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task") + + inv, root := clitest.New(t, "exp", "task", "list", "--user", "me") + //nolint:gocritic // Owner client is intended here smoke test the member task not showing up. + clitest.SetupConfig(t, client, root) + + pty := ptytest.New(t).Attach(inv) + ctx := testutil.Context(t, testutil.WaitShort) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + pty.ExpectMatch(ws.Name) + }) +} + +func TestExpTaskList_OwnerCanListOthers(t *testing.T) { + t.Parallel() + + // Quiet logger to reduce noise. + quiet := slog.Make(sloghuman.Sink(io.Discard)) + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &quiet}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + + // Create two additional members in the owner's organization. + _, memberAUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + _, memberBUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + // Seed an AI task for member A and B. + _ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberAUser.ID, database.WorkspaceTransitionStart, "member-A-task") + _ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberBUser.ID, database.WorkspaceTransitionStart, "member-B-task") + + t.Run("OwnerListsSpecificUserWithUserFlag_JSON", func(t *testing.T) { + t.Parallel() + + // As the owner, list only member A tasks. + inv, root := clitest.New(t, "exp", "task", "list", "--user", memberAUser.Username, "--output=json") + //nolint:gocritic // Owner client is intended here to allow member tasks to be listed. + clitest.SetupConfig(t, ownerClient, root) + + var stdout bytes.Buffer + inv.Stdout = &stdout + + ctx := testutil.Context(t, testutil.WaitShort) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var tasks []codersdk.Task + require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks)) + + // At least one task to belong to member A. + require.NotEmpty(t, tasks, "expected at least one task for member A") + // All tasks should belong to member A. + for _, task := range tasks { + require.Equal(t, memberAUser.ID, task.OwnerID, "expected only member A tasks") + } + }) + + t.Run("OwnerListsAllWithAllFlag_JSON", func(t *testing.T) { + t.Parallel() + + // As the owner, list all tasks to verify both member tasks are present. + // Use JSON output to reliably validate filtering. + inv, root := clitest.New(t, "exp", "task", "list", "--all", "--output=json") + //nolint:gocritic // Owner client is intended here to allow all tasks to be listed. + clitest.SetupConfig(t, ownerClient, root) + + var stdout bytes.Buffer + inv.Stdout = &stdout + + ctx := testutil.Context(t, testutil.WaitShort) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var tasks []codersdk.Task + require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks)) + + // Expect at least two tasks and ensure both owners (member A and member B) are represented. + require.GreaterOrEqual(t, len(tasks), 2, "expected two or more tasks in --all listing") + + // Use slice.Find for concise existence checks. + _, foundA := slice.Find(tasks, func(t codersdk.Task) bool { return t.OwnerID == memberAUser.ID }) + _, foundB := slice.Find(tasks, func(t codersdk.Task) bool { return t.OwnerID == memberBUser.ID }) + + require.True(t, foundA, "expected at least one task for member A in --all listing") + require.True(t, foundB, "expected at least one task for member B in --all listing") + }) +} 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..ed6869b6a1c49 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(), @@ -635,6 +635,9 @@ func (r *RootCmd) HeaderTransport(ctx context.Context, serverURL *url.URL) (*cod } func (r *RootCmd) configureClient(ctx context.Context, client *codersdk.Client, serverURL *url.URL, inv *serpent.Invocation) error { + if client.SessionTokenProvider == nil { + client.SessionTokenProvider = codersdk.FixedSessionTokenProvider{} + } transport := http.DefaultTransport transport = wrapTransportWithTelemetryHeader(transport, inv) if !r.noVersionCheck { diff --git a/cli/schedule.go b/cli/schedule.go index 9ade82b9c4a36..b7d1ff9b1f2bf 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -46,7 +46,7 @@ When enabling scheduled stop, enter a duration in one of the following formats: * 2m (2 minutes) * 2 (2 minutes) ` - scheduleExtendDescriptionLong = ` + scheduleExtendDescriptionLong = `Extends the workspace deadline. * The new stop time is calculated from *now*. * The new stop time must be at least 30 minutes in the future. * The workspace template may restrict the maximum workspace runtime. @@ -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 } @@ -157,6 +157,13 @@ func (r *RootCmd) scheduleStart() *serpent.Command { return err } + // Autostart configuration is not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild { + return xerrors.Errorf("autostart configuration is not supported for prebuilt workspaces") + } + var schedStr *string if inv.Args[1] != "manual" { sched, err := parseCLISchedule(inv.Args[1:]...) @@ -205,6 +212,13 @@ func (r *RootCmd) scheduleStop() *serpent.Command { return err } + // Autostop configuration is not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild { + return xerrors.Errorf("autostop configuration is not supported for prebuilt workspaces") + } + var durMillis *int64 if inv.Args[1] != "manual" { dur, err := parseDuration(inv.Args[1]) @@ -255,6 +269,13 @@ func (r *RootCmd) scheduleExtend() *serpent.Command { return xerrors.Errorf("get workspace: %w", err) } + // Deadline extensions are not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with TTL behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild { + return xerrors.Errorf("extend configuration is not supported for prebuilt workspaces") + } + loc, err := tz.TimezoneIANA() if err != nil { loc = time.UTC // best effort @@ -286,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/server.go b/cli/server.go index 26d0c8f110403..5018007e2b4e8 100644 --- a/cli/server.go +++ b/cli/server.go @@ -55,18 +55,13 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/coder/v2/coderd/pproflabel" "github.com/coder/pretty" "github.com/coder/quartz" "github.com/coder/retry" "github.com/coder/serpent" "github.com/coder/wgtunnel/tunnelsdk" - "github.com/coder/coder/v2/coderd/entitlements" - "github.com/coder/coder/v2/coderd/notifications/reports" - "github.com/coder/coder/v2/coderd/runtimeconfig" - "github.com/coder/coder/v2/coderd/webpush" - "github.com/coder/coder/v2/codersdk/drpcsdk" - "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cli/clilog" "github.com/coder/coder/v2/cli/cliui" @@ -82,15 +77,19 @@ import ( "github.com/coder/coder/v2/coderd/database/migrations" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/devtunnel" + "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/gitsshkey" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/jobreaper" "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/notifications/reports" "github.com/coder/coder/v2/coderd/oauthpki" "github.com/coder/coder/v2/coderd/prometheusmetrics" "github.com/coder/coder/v2/coderd/prometheusmetrics/insights" "github.com/coder/coder/v2/coderd/promoauth" + "github.com/coder/coder/v2/coderd/provisionerdserver" + "github.com/coder/coder/v2/coderd/runtimeconfig" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/tracing" @@ -98,9 +97,11 @@ import ( "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/slice" stringutil "github.com/coder/coder/v2/coderd/util/strings" + "github.com/coder/coder/v2/coderd/webpush" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/drpcsdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisioner/terraform" @@ -279,6 +280,12 @@ func enablePrometheus( } } + provisionerdserverMetrics := provisionerdserver.NewMetrics(logger) + if err := provisionerdserverMetrics.Register(options.PrometheusRegistry); err != nil { + return nil, xerrors.Errorf("failed to register provisionerd_server metrics: %w", err) + } + options.ProvisionerdServerMetrics = provisionerdserverMetrics + //nolint:revive return ServeHandler( ctx, logger, promhttp.InstrumentMetricHandler( @@ -1459,14 +1466,14 @@ func newProvisionerDaemon( tracer := coderAPI.TracerProvider.Tracer(tracing.TracerName) terraformClient, terraformServer := drpcsdk.MemTransportPipe() wg.Add(1) - go func() { + pproflabel.Go(ctx, pproflabel.Service(pproflabel.ServiceTerraformProvisioner), func(ctx context.Context) { defer wg.Done() <-ctx.Done() _ = terraformClient.Close() _ = terraformServer.Close() - }() + }) wg.Add(1) - go func() { + pproflabel.Go(ctx, pproflabel.Service(pproflabel.ServiceTerraformProvisioner), func(ctx context.Context) { defer wg.Done() defer cancel() @@ -1485,7 +1492,7 @@ func newProvisionerDaemon( default: } } - }() + }) connector[string(database.ProvisionerTypeTerraform)] = sdkproto.NewDRPCProvisionerClient(terraformClient) default: 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/ssh_test.go b/cli/ssh_test.go index d11748a51f8b8..be3166cc4d32a 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -20,6 +20,7 @@ import ( "regexp" "runtime" "strings" + "sync" "testing" "time" @@ -1318,9 +1319,6 @@ func TestSSH(t *testing.T) { tmpdir := tempDirUnixSocket(t) localSock := filepath.Join(tmpdir, "local.sock") - l, err := net.Listen("unix", localSock) - require.NoError(t, err) - defer l.Close() remoteSock := filepath.Join(tmpdir, "remote.sock") inv, root := clitest.New(t, @@ -1332,23 +1330,62 @@ func TestSSH(t *testing.T) { clitest.SetupConfig(t, client, root) pty := ptytest.New(t).Attach(inv) inv.Stderr = pty.Output() - cmdDone := tGo(t, func() { - err := inv.WithContext(ctx).Run() - assert.NoError(t, err, "ssh command failed") - }) - // Wait for the prompt or any output really to indicate the command has - // started and accepting input on stdin. + w := clitest.StartWithWaiter(t, inv.WithContext(ctx)) + defer w.Wait() // We don't care about any exit error (exit code 255: SSH connection ended unexpectedly). + + // Since something was output, it should be safe to write input. + // This could show a prompt or "running startup scripts", so it's + // not indicative of the SSH connection being ready. _ = pty.Peek(ctx, 1) - // This needs to support most shells on Linux or macOS - // We can't include exactly what's expected in the input, as that will always be matched - pty.WriteLine(fmt.Sprintf(`echo "results: $(netstat -an | grep %s | wc -l | tr -d ' ')"`, remoteSock)) - pty.ExpectMatchContext(ctx, "results: 1") + // Ensure the SSH connection is ready by testing the shell + // input/output. + pty.WriteLine("echo ping' 'pong") + pty.ExpectMatchContext(ctx, "ping pong") + + // Start the listener on the "local machine". + l, err := net.Listen("unix", localSock) + require.NoError(t, err) + defer l.Close() + testutil.Go(t, func() { + var wg sync.WaitGroup + defer wg.Wait() + for { + fd, err := l.Accept() + if err != nil { + if !errors.Is(err, net.ErrClosed) { + assert.NoError(t, err, "listener accept failed") + } + return + } + + wg.Add(1) + go func() { + defer wg.Done() + defer fd.Close() + agentssh.Bicopy(ctx, fd, fd) + }() + } + }) + + // Dial the forwarded socket on the "remote machine". + d := &net.Dialer{} + fd, err := d.DialContext(ctx, "unix", remoteSock) + require.NoError(t, err) + defer fd.Close() + + // Ping / pong to ensure the socket is working. + _, err = fd.Write([]byte("hello world")) + require.NoError(t, err) + + buf := make([]byte, 11) + _, err = fd.Read(buf) + require.NoError(t, err) + require.Equal(t, "hello world", string(buf)) // And we're done. pty.WriteLine("exit") - <-cmdDone }) // Test that we can forward a local unix socket to a remote unix socket and @@ -1377,6 +1414,8 @@ func TestSSH(t *testing.T) { require.NoError(t, err) defer l.Close() testutil.Go(t, func() { + var wg sync.WaitGroup + defer wg.Wait() for { fd, err := l.Accept() if err != nil { @@ -1386,10 +1425,12 @@ func TestSSH(t *testing.T) { return } - testutil.Go(t, func() { + wg.Add(1) + go func() { + defer wg.Done() defer fd.Close() agentssh.Bicopy(ctx, fd, fd) - }) + }() } }) @@ -1522,6 +1563,8 @@ func TestSSH(t *testing.T) { require.NoError(t, err) defer l.Close() //nolint:revive // Defer is fine in this loop, we only run it twice. testutil.Go(t, func() { + var wg sync.WaitGroup + defer wg.Wait() for { fd, err := l.Accept() if err != nil { @@ -1531,10 +1574,12 @@ func TestSSH(t *testing.T) { return } - testutil.Go(t, func() { + wg.Add(1) + go func() { + defer wg.Done() defer fd.Close() agentssh.Bicopy(ctx, fd, fd) - }) + }() } }) 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/templateedit.go b/cli/templateedit.go index b115350ab4437..fe0323449c9be 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -169,9 +169,9 @@ func (r *RootCmd) templateEdit() *serpent.Command { req := codersdk.UpdateTemplateMeta{ Name: name, - DisplayName: displayName, - Description: description, - Icon: icon, + DisplayName: &displayName, + Description: &description, + Icon: &icon, DefaultTTLMillis: defaultTTL.Milliseconds(), ActivityBumpMillis: activityBump.Milliseconds(), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ 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_agent_--help.golden b/cli/testdata/coder_agent_--help.golden index 0627016855e08..c6d75705a6eb4 100644 --- a/cli/testdata/coder_agent_--help.golden +++ b/cli/testdata/coder_agent_--help.golden @@ -33,6 +33,10 @@ OPTIONS: --debug-address string, $CODER_AGENT_DEBUG_ADDRESS (default: 127.0.0.1:2113) The bind address to serve a debug HTTP server. + --devcontainers-discovery-autostart-enable bool, $CODER_AGENT_DEVCONTAINERS_DISCOVERY_AUTOSTART_ENABLE (default: false) + Allow the agent to autostart devcontainer projects it discovers based + on their configuration. + --devcontainers-enable bool, $CODER_AGENT_DEVCONTAINERS_ENABLE (default: true) Allow the agent to automatically detect running devcontainers. 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/testdata/coder_schedule_extend_--help.golden b/cli/testdata/coder_schedule_extend_--help.golden index 2135b09dc7cc3..57992108cb7c0 100644 --- a/cli/testdata/coder_schedule_extend_--help.golden +++ b/cli/testdata/coder_schedule_extend_--help.golden @@ -7,7 +7,8 @@ USAGE: Aliases: override-stop - * The new stop time is calculated from *now*. + Extends the workspace deadline. + * The new stop time is calculated from *now*. * The new stop time must be at least 30 minutes in the future. * The workspace template may restrict the maximum workspace runtime. 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 a982ccc39b26b..10c3efc96131a 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -1,13 +1,28 @@ package coderd import ( + "context" + "database/sql" + "errors" "fmt" "net/http" + "slices" "strings" + "github.com/go-chi/chi/v5" "github.com/google/uuid" + "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/httpapi/httperror" + "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" ) @@ -61,3 +76,478 @@ func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) { Prompts: promptsByBuildID, }) } + +// This endpoint is experimental and not guaranteed to be stable, so we're not +// generating public-facing documentation for it. +func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + apiKey = httpmw.APIKey(r) + auditor = api.Auditor.Load() + mems = httpmw.OrganizationMembersParam(r) + ) + + var req codersdk.CreateTaskRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + hasAITask, err := api.Database.GetTemplateVersionHasAITask(ctx, req.TemplateVersionID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) || rbac.IsUnauthorizedError(err) { + httpapi.ResourceNotFound(rw) + return + } + + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching whether the template version has an AI task.", + Detail: err.Error(), + }) + return + } + if !hasAITask { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf(`Template does not have required parameter %q`, codersdk.AITaskPromptParameterName), + }) + 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: taskName, + TemplateVersionID: req.TemplateVersionID, + TemplateVersionPresetID: req.TemplateVersionPresetID, + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: codersdk.AITaskPromptParameterName, Value: req.Prompt}, + }, + } + + var owner workspaceOwner + if mems.User != nil { + // This user fetch is an optimization path for the most common case of creating a + // task for 'Me'. + // + // This is also required to allow `owners` to create workspaces for users + // that are not in an organization. + owner = workspaceOwner{ + ID: mems.User.ID, + Username: mems.User.Username, + AvatarURL: mems.User.AvatarURL, + } + } else { + // A task can still be created if the caller can read the organization + // member. The organization is required, which can be sourced from the + // template. + // + // TODO: This code gets called twice for each workspace build request. + // This is inefficient and costs at most 2 extra RTTs to the DB. + // This can be optimized. It exists as it is now for code simplicity. + // The most common case is to create a workspace for 'Me'. Which does + // not enter this code branch. + template, err := requestTemplate(ctx, createReq, api.Database) + if err != nil { + httperror.WriteResponseError(ctx, rw, err) + return + } + + // If the caller can find the organization membership in the same org + // as the template, then they can continue. + orgIndex := slices.IndexFunc(mems.Memberships, func(mem httpmw.OrganizationMember) bool { + return mem.OrganizationID == template.OrganizationID + }) + if orgIndex == -1 { + httpapi.ResourceNotFound(rw) + return + } + + member := mems.Memberships[orgIndex] + owner = workspaceOwner{ + ID: member.UserID, + Username: member.Username, + AvatarURL: member.AvatarURL, + } + } + + aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + AdditionalFields: audit.AdditionalFields{ + WorkspaceOwner: owner.Username, + }, + }) + defer commitAudit() + w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, createReq, r) + if err != nil { + httperror.WriteResponseError(ctx, rw, err) + return + } + + task := taskFromWorkspace(w, req.Prompt) + httpapi.Write(ctx, rw, http.StatusCreated, task) +} + +func taskFromWorkspace(ws codersdk.Workspace, initialPrompt string) codersdk.Task { + // TODO(DanielleMaywood): + // This just picks up the first agent it discovers. + // This approach _might_ break when a task has multiple agents, + // depending on which agent was found first. + // + // We explicitly do not have support for running tasks + // inside of a sub agent at the moment, so we can be sure + // that any sub agents are not the agent we're looking for. + var taskAgentID uuid.NullUUID + var taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle + var taskAgentHealth *codersdk.WorkspaceAgentHealth + for _, resource := range ws.LatestBuild.Resources { + for _, agent := range resource.Agents { + if agent.ParentID.Valid { + continue + } + + taskAgentID = uuid.NullUUID{Valid: true, UUID: agent.ID} + taskAgentLifecycle = &agent.LifecycleState + taskAgentHealth = &agent.Health + break + } + } + + 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, + } + } + + return codersdk.Task{ + ID: ws.ID, + OrganizationID: ws.OrganizationID, + OwnerID: ws.OwnerID, + OwnerName: ws.OwnerName, + Name: ws.Name, + TemplateID: ws.TemplateID, + TemplateName: ws.TemplateName, + TemplateDisplayName: ws.TemplateDisplayName, + TemplateIcon: ws.TemplateIcon, + WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID}, + WorkspaceAgentID: taskAgentID, + WorkspaceAgentLifecycle: taskAgentLifecycle, + WorkspaceAgentHealth: taskAgentHealth, + CreatedAt: ws.CreatedAt, + UpdatedAt: ws.UpdatedAt, + InitialPrompt: initialPrompt, + Status: ws.LatestBuild.Status, + CurrentState: currentState, + } +} + +// 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) { + // 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 { + tasks = append(tasks, taskFromWorkspace(ws, promptsByBuildID[ws.LatestBuild.ID])) + } + + 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.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]) +} + +// taskDelete is an experimental endpoint to delete a task by ID (workspace ID). +// It creates a delete workspace build and returns 202 Accepted if the build was +// created. +func (api *API) taskDelete(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 + } + + // Construct a request to the workspace build creation handler to + // initiate deletion. + buildReq := codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionDelete, + Reason: "Deleted via tasks API", + } + + _, err = api.postWorkspaceBuildsInternal( + ctx, + apiKey, + workspace, + buildReq, + func(action policy.Action, object rbac.Objecter) bool { + return api.Authorize(r, action, object) + }, + audit.WorkspaceBuildBaggageFromRequest(r), + ) + if err != nil { + httperror.WriteWorkspaceBuildError(ctx, rw, err) + return + } + + // Delete build created successfully. + rw.WriteHeader(http.StatusAccepted) +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 53f0174d6f03d..767f52eeab6b2 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -1,13 +1,17 @@ package coderd_test import ( + "net/http" "testing" + "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "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" @@ -139,3 +143,366 @@ func TestAITasksPrompts(t *testing.T) { require.Empty(t, prompts.Prompts) }) } + +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") + }) + + t.Run("Delete", func(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + template := createAITemplate(t, client, user) + + ctx := testutil.Context(t, testutil.WaitLong) + + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Prompt: "delete me", + }) + require.NoError(t, err) + ws, err := client.Workspace(ctx, task.ID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + err = exp.DeleteTask(ctx, "me", task.ID) + require.NoError(t, err, "delete task request should be accepted") + + // Poll until the workspace is deleted. + for { + dws, derr := client.DeletedWorkspace(ctx, task.ID) + if derr == nil && dws.LatestBuild.Status == codersdk.WorkspaceStatusDeleted { + break + } + if ctx.Err() != nil { + require.NoError(t, derr, "expected to fetch deleted workspace before deadline") + require.Equal(t, codersdk.WorkspaceStatusDeleted, dws.LatestBuild.Status, "workspace should be deleted before deadline") + break + } + time.Sleep(testutil.IntervalMedium) + } + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + _ = coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitShort) + + exp := codersdk.NewExperimentalClient(client) + err := exp.DeleteTask(ctx, "me", uuid.New()) + + var sdkErr *codersdk.Error + require.Error(t, err, "expected an error for non-existent task") + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, 404, sdkErr.StatusCode()) + }) + + t.Run("NotTaskWorkspace", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitShort) + + // Create a template without AI tasks support and a workspace from it. + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + exp := codersdk.NewExperimentalClient(client) + err := exp.DeleteTask(ctx, "me", ws.ID) + + var sdkErr *codersdk.Error + require.Error(t, err, "expected an error for non-task workspace delete via tasks endpoint") + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, 404, sdkErr.StatusCode()) + }) + + t.Run("UnauthorizedUserCannotDeleteOthersTask", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + + // Owner's AI-capable template and workspace (task). + template := createAITemplate(t, client, owner) + + ctx := testutil.Context(t, testutil.WaitShort) + + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Prompt: "delete me not", + }) + require.NoError(t, err) + ws, err := client.Workspace(ctx, task.ID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + // Another regular org member without elevated permissions. + otherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + expOther := codersdk.NewExperimentalClient(otherClient) + + // Attempt to delete the owner's task as a non-owner without permissions. + err = expOther.DeleteTask(ctx, "me", task.ID) + + var authErr *codersdk.Error + require.Error(t, err, "expected an authorization error when deleting another user's task") + require.ErrorAs(t, err, &authErr) + // Accept either 403 or 404 depending on authz behavior. + if authErr.StatusCode() != 403 && authErr.StatusCode() != 404 { + t.Fatalf("unexpected status code: %d (expected 403 or 404)", authErr.StatusCode()) + } + }) + }) +} + +func TestTasksCreate(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + + taskPrompt = "Some task prompt" + ) + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + // Given: A template with an "AI Prompt" parameter + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: []*proto.Response{ + {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{{Name: "AI Prompt", Type: "string"}}, + HasAiTasks: true, + }}}, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + expClient := codersdk.NewExperimentalClient(client) + + // When: We attempt to create a Task. + task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Prompt: taskPrompt, + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + ws, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + // Then: We expect a workspace to have been created. + assert.NotEmpty(t, task.Name) + assert.Equal(t, template.ID, task.TemplateID) + + // And: We expect it to have the "AI Prompt" parameter correctly set. + parameters, err := client.WorkspaceBuildParameters(ctx, ws.LatestBuild.ID) + require.NoError(t, err) + require.Len(t, parameters, 1) + assert.Equal(t, codersdk.AITaskPromptParameterName, parameters[0].Name) + assert.Equal(t, taskPrompt, parameters[0].Value) + }) + + t.Run("FailsOnNonTaskTemplate", func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + + taskPrompt = "Some task prompt" + ) + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + // Given: A template without an "AI Prompt" parameter + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + expClient := codersdk.NewExperimentalClient(client) + + // When: We attempt to create a Task. + _, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Prompt: taskPrompt, + }) + + // Then: We expect it to fail. + var sdkErr *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkErr, "error should be of type *codersdk.Error") + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("FailsOnInvalidTemplate", func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + + taskPrompt = "Some task prompt" + ) + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + // Given: A template + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + expClient := codersdk.NewExperimentalClient(client) + + // When: We attempt to create a Task with an invalid template version ID. + _, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: uuid.New(), + Prompt: taskPrompt, + }) + + // Then: We expect it to fail. + var sdkErr *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkErr, "error should be of type *codersdk.Error") + assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fa2aad745ec5a..00478e029e084 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": { @@ -7383,7 +7428,7 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", + "format": "string", "description": "Key ID", "name": "keyid", "in": "path", @@ -7420,7 +7465,7 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", + "format": "string", "description": "Key ID", "name": "keyid", "in": "path", @@ -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" }, @@ -9943,6 +9988,39 @@ const docTemplate = `{ } }, "/workspaces/{workspace}/acl": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Workspaces" + ], + "summary": "Get workspace ACLs", + "operationId": "get-workspace-acls", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceACL" + } + } + } + }, "patch": { "security": [ { @@ -10271,6 +10349,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 +13021,17 @@ const docTemplate = `{ "ExperimentWorkspaceSharing" ] }, + "codersdk.ExternalAgentCredentials": { + "type": "object", + "properties": { + "agent_token": { + "type": "string" + }, + "command": { + "type": "string" + } + } + }, "codersdk.ExternalAuth": { "type": "object", "properties": { @@ -15701,7 +15832,9 @@ const docTemplate = `{ "system", "tailnet_coordinator", "template", + "usage_event", "user", + "user_secret", "webpush_subscription", "workspace", "workspace_agent_devcontainers", @@ -15741,7 +15874,9 @@ const docTemplate = `{ "ResourceSystem", "ResourceTailnetCoordinator", "ResourceTemplate", + "ResourceUsageEvent", "ResourceUser", + "ResourceUserSecret", "ResourceWebpushSubscription", "ResourceWorkspace", "ResourceWorkspaceAgentDevcontainers", @@ -16812,6 +16947,9 @@ const docTemplate = `{ "created_by": { "$ref": "#/definitions/codersdk.MinimalUser" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" @@ -17188,7 +17326,7 @@ const docTemplate = `{ "type": "object", "properties": { "group_perms": { - "description": "GroupPerms should be a mapping of group id to role.", + "description": "GroupPerms is a mapping from valid group UUIDs to the template role they\nshould be granted. To remove a group from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -17199,7 +17337,7 @@ const docTemplate = `{ } }, "user_perms": { - "description": "UserPerms should be a mapping of user id to role. The user id must be the\nuuid of the user, not a username or email address.", + "description": "UserPerms is a mapping from valid user UUIDs to the template role they\nshould be granted. To remove a user from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -17211,6 +17349,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": [ @@ -17281,13 +17502,14 @@ const docTemplate = `{ "type": "object", "properties": { "group_roles": { + "description": "GroupRoles is a mapping from valid group UUIDs to the workspace role they\nshould be granted. To remove a group from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" } }, "user_roles": { - "description": "Keys must be valid UUIDs. To remove a user/group from the ACL use \"\" as the\nrole name (available as a constant named ` + "`" + `codersdk.WorkspaceRoleDeleted` + "`" + `)", + "description": "UserRoles is a mapping from valid user UUIDs to the workspace role they\nshould be granted. To remove a user from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" @@ -17900,6 +18122,23 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceACL": { + "type": "object", + "properties": { + "group": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceGroup" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceUser" + } + } + } + }, "codersdk.WorkspaceAgent": { "type": "object", "properties": { @@ -18671,6 +18910,9 @@ const docTemplate = `{ "has_ai_task": { "type": "boolean" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" @@ -18851,6 +19093,62 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceGroup": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ReducedUser" + } + }, + "name": { + "type": "string" + }, + "organization_display_name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "organization_name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "role": { + "enum": [ + "admin", + "use" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" + }, + "total_member_count": { + "description": "How many members are in this group. Shows the total count,\neven if the user is not authorized to read group member details.\nMay be greater than ` + "`" + `len(Group.Members)` + "`" + `.", + "type": "integer" + } + } + }, "codersdk.WorkspaceHealth": { "type": "object", "properties": { @@ -19080,6 +19378,37 @@ const docTemplate = `{ "WorkspaceTransitionDelete" ] }, + "codersdk.WorkspaceUser": { + "type": "object", + "required": [ + "id", + "username" + ], + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "role": { + "enum": [ + "admin", + "use" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "username": { + "type": "string" + } + } + }, "codersdk.WorkspacesResponse": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e1bcc5bf1013c..3dfa9fdf9792d 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": { @@ -6516,7 +6555,7 @@ }, { "type": "string", - "format": "uuid", + "format": "string", "description": "Key ID", "name": "keyid", "in": "path", @@ -6551,7 +6590,7 @@ }, { "type": "string", - "format": "uuid", + "format": "string", "description": "Key ID", "name": "keyid", "in": "path", @@ -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" }, @@ -8793,6 +8832,35 @@ } }, "/workspaces/{workspace}/acl": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Workspaces"], + "summary": "Get workspace ACLs", + "operationId": "get-workspace-acls", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceACL" + } + } + } + }, "patch": { "security": [ { @@ -9085,6 +9153,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 +11669,17 @@ "ExperimentWorkspaceSharing" ] }, + "codersdk.ExternalAgentCredentials": { + "type": "object", + "properties": { + "agent_token": { + "type": "string" + }, + "command": { + "type": "string" + } + } + }, "codersdk.ExternalAuth": { "type": "object", "properties": { @@ -14262,7 +14379,9 @@ "system", "tailnet_coordinator", "template", + "usage_event", "user", + "user_secret", "webpush_subscription", "workspace", "workspace_agent_devcontainers", @@ -14302,7 +14421,9 @@ "ResourceSystem", "ResourceTailnetCoordinator", "ResourceTemplate", + "ResourceUsageEvent", "ResourceUser", + "ResourceUserSecret", "ResourceWebpushSubscription", "ResourceWorkspace", "ResourceWorkspaceAgentDevcontainers", @@ -15333,6 +15454,9 @@ "created_by": { "$ref": "#/definitions/codersdk.MinimalUser" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" @@ -15689,7 +15813,7 @@ "type": "object", "properties": { "group_perms": { - "description": "GroupPerms should be a mapping of group id to role.", + "description": "GroupPerms is a mapping from valid group UUIDs to the template role they\nshould be granted. To remove a group from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -15700,7 +15824,7 @@ } }, "user_perms": { - "description": "UserPerms should be a mapping of user id to role. The user id must be the\nuuid of the user, not a username or email address.", + "description": "UserPerms is a mapping from valid user UUIDs to the template role they\nshould be granted. To remove a user from the template, use \"\" as the role\n(available as a constant named codersdk.TemplateRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.TemplateRole" @@ -15712,6 +15836,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"], @@ -15773,13 +15980,14 @@ "type": "object", "properties": { "group_roles": { + "description": "GroupRoles is a mapping from valid group UUIDs to the workspace role they\nshould be granted. To remove a group from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" } }, "user_roles": { - "description": "Keys must be valid UUIDs. To remove a user/group from the ACL use \"\" as the\nrole name (available as a constant named `codersdk.WorkspaceRoleDeleted`)", + "description": "UserRoles is a mapping from valid user UUIDs to the workspace role they\nshould be granted. To remove a user from the workspace, use \"\" as the role\n(available as a constant named codersdk.WorkspaceRoleDeleted)", "type": "object", "additionalProperties": { "$ref": "#/definitions/codersdk.WorkspaceRole" @@ -16356,6 +16564,23 @@ } } }, + "codersdk.WorkspaceACL": { + "type": "object", + "properties": { + "group": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceGroup" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceUser" + } + } + } + }, "codersdk.WorkspaceAgent": { "type": "object", "properties": { @@ -17075,6 +17300,9 @@ "has_ai_task": { "type": "boolean" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" @@ -17247,6 +17475,59 @@ } } }, + "codersdk.WorkspaceGroup": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ReducedUser" + } + }, + "name": { + "type": "string" + }, + "organization_display_name": { + "type": "string" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "organization_name": { + "type": "string" + }, + "quota_allowance": { + "type": "integer" + }, + "role": { + "enum": ["admin", "use"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" + }, + "total_member_count": { + "description": "How many members are in this group. Shows the total count,\neven if the user is not authorized to read group member details.\nMay be greater than `len(Group.Members)`.", + "type": "integer" + } + } + }, "codersdk.WorkspaceHealth": { "type": "object", "properties": { @@ -17464,6 +17745,31 @@ "WorkspaceTransitionDelete" ] }, + "codersdk.WorkspaceUser": { + "type": "object", + "required": ["id", "username"], + "properties": { + "avatar_url": { + "type": "string", + "format": "uri" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "role": { + "enum": ["admin", "use"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceRole" + } + ] + }, + "username": { + "type": "string" + } + } + }, "codersdk.WorkspacesResponse": { "type": "object", "properties": { diff --git a/coderd/apikey.go b/coderd/apikey.go index 895be440ef930..0bf2d6ca19a22 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -151,7 +151,7 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Users // @Param user path string true "User ID, name, or me" -// @Param keyid path string true "Key ID" format(uuid) +// @Param keyid path string true "Key ID" format(string) // @Success 200 {object} codersdk.APIKey // @Router /users/{user}/keys/{keyid} [get] func (api *API) apiKeyByID(rw http.ResponseWriter, r *http.Request) { @@ -292,7 +292,7 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Users // @Param user path string true "User ID, name, or me" -// @Param keyid path string true "Key ID" format(uuid) +// @Param keyid path string true "Key ID" format(string) // @Success 204 // @Router /users/{user}/keys/{keyid} [delete] func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 234a72de04c50..945b5f8c7cd6d 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -20,6 +20,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/files" + "github.com/coder/coder/v2/coderd/pproflabel" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" @@ -28,6 +29,7 @@ import ( "github.com/coder/coder/v2/coderd/database/provisionerjobs" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/codersdk" @@ -107,10 +109,10 @@ func (e *Executor) WithStatsChannel(ch chan<- Stats) *Executor { // tick from its channel. It will stop when its context is Done, or when // its channel is closed. func (e *Executor) Run() { - go func() { + pproflabel.Go(e.ctx, pproflabel.Service(pproflabel.ServiceLifecycles), func(ctx context.Context) { for { select { - case <-e.ctx.Done(): + case <-ctx.Done(): return case t, ok := <-e.tick: if !ok { @@ -120,15 +122,48 @@ func (e *Executor) Run() { e.metrics.autobuildExecutionDuration.Observe(stats.Elapsed.Seconds()) if e.statsCh != nil { select { - case <-e.ctx.Done(): + case <-ctx.Done(): return case e.statsCh <- stats: } } - e.log.Debug(e.ctx, "run stats", slog.F("elapsed", stats.Elapsed), slog.F("transitions", stats.Transitions)) + e.log.Debug(ctx, "run stats", slog.F("elapsed", stats.Elapsed), slog.F("transitions", stats.Transitions)) } } - }() + }) +} + +// hasValidProvisioner checks whether there is at least one valid (non-stale, correct tags) provisioner +// based on time t and the tags maps (such as from a templateVersionJob). +func (e *Executor) hasValidProvisioner(ctx context.Context, tx database.Store, t time.Time, ws database.Workspace, tags map[string]string) (bool, error) { + queryParams := database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: ws.OrganizationID, + WantTags: tags, + } + + // nolint: gocritic // The user (in this case, the user/context for autostart builds) may not have the full + // permissions to read provisioner daemons, but we need to check if there's any for the job prior to the + // execution of the job via autostart to fix: https://github.com/coder/coder/issues/17941 + provisionerDaemons, err := tx.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(ctx), queryParams) + if err != nil { + return false, xerrors.Errorf("get provisioner daemons: %w", err) + } + + logger := e.log.With(slog.F("tags", tags)) + // Check if any provisioners are active (not stale) + for _, pd := range provisionerDaemons { + if pd.LastSeenAt.Valid { + age := t.Sub(pd.LastSeenAt.Time) + if age <= provisionerdserver.StaleInterval { + logger.Debug(ctx, "hasValidProvisioner: found active provisioner", + slog.F("daemon_id", pd.ID), + ) + return true, nil + } + } + } + logger.Debug(ctx, "hasValidProvisioner: no active provisioners found") + return false, nil } func (e *Executor) runOnce(t time.Time) Stats { @@ -280,6 +315,22 @@ func (e *Executor) runOnce(t time.Time) Stats { return nil } + // Get the template version job to access tags + templateVersionJob, err := tx.GetProvisionerJobByID(e.ctx, activeTemplateVersion.JobID) + if err != nil { + return xerrors.Errorf("get template version job: %w", err) + } + + // Before creating the workspace build, check for available provisioners + hasProvisioners, err := e.hasValidProvisioner(e.ctx, tx, t, ws, templateVersionJob.Tags) + if err != nil { + return xerrors.Errorf("check provisioner availability: %w", err) + } + if !hasProvisioners { + log.Warn(e.ctx, "skipping autostart - no available provisioners") + return nil // Skip this workspace + } + if nextTransition != "" { builder := wsbuilder.New(ws, nextTransition, *e.buildUsageChecker.Load()). SetLastWorkspaceBuildInTx(&latestBuild). diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 0229a907cbb2e..1e5f0d431e96c 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -4,11 +4,13 @@ import ( "context" "database/sql" "errors" + "sync" "testing" "time" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/quartz" @@ -36,14 +38,18 @@ import ( "github.com/coder/coder/v2/testutil" ) +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m, testutil.GoleakOptions...) +} + func TestExecutorAutostartOK(t *testing.T) { t.Parallel() var ( - sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") - tickCh = make(chan time.Time) - statsCh = make(chan autobuild.Stats) - client = coderdtest.New(t, &coderdtest.Options{ + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, @@ -55,10 +61,13 @@ func TestExecutorAutostartOK(t *testing.T) { ) // Given: workspace is stopped workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) - + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, map[string]string{}) + require.NoError(t, err) // When: the autobuild executor ticks after the scheduled time go func() { - tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) + tickTime := sched.Next(workspace.LatestBuild.CreatedAt) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime close(tickCh) }() @@ -114,8 +123,11 @@ func TestMultipleLifecycleExecutors(t *testing.T) { // Have the workspace stopped so we can perform an autostart workspace = coderdtest.MustTransitionWorkspace(t, clientA, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) // Get both clients to perform a lifecycle execution tick next := sched.Next(workspace.LatestBuild.CreatedAt) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, next) startCh := make(chan struct{}) go func() { @@ -187,14 +199,14 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() var ( - sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") - ctx = context.Background() - err error - tickCh = make(chan time.Time) - statsCh = make(chan autobuild.Stats) - logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: !tc.expectStart}).Leveled(slog.LevelDebug) - enqueuer = notificationstest.FakeEnqueuer{} - client = coderdtest.New(t, &coderdtest.Options{ + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") + ctx = context.Background() + err error + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: !tc.expectStart}).Leveled(slog.LevelDebug) + enqueuer = notificationstest.FakeEnqueuer{} + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, @@ -247,10 +259,15 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { }, )) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + t.Log("sending autobuild tick") // When: the autobuild executor ticks after the scheduled time go func() { - tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) + tickTime := sched.Next(workspace.LatestBuild.CreatedAt) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime close(tickCh) }() @@ -414,9 +431,9 @@ func TestExecutorAutostopOK(t *testing.T) { t.Parallel() var ( - tickCh = make(chan time.Time) - statsCh = make(chan autobuild.Stats) - client = coderdtest.New(t, &coderdtest.Options{ + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, @@ -428,9 +445,14 @@ func TestExecutorAutostopOK(t *testing.T) { require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) require.NotZero(t, workspace.LatestBuild.Deadline) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + // When: the autobuild executor ticks *after* the deadline: go func() { - tickCh <- workspace.LatestBuild.Deadline.Time.Add(time.Minute) + tickTime := workspace.LatestBuild.Deadline.Time.Add(time.Minute) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime close(tickCh) }() @@ -449,10 +471,10 @@ func TestExecutorAutostopExtend(t *testing.T) { t.Parallel() var ( - ctx = context.Background() - tickCh = make(chan time.Time) - statsCh = make(chan autobuild.Stats) - client = coderdtest.New(t, &coderdtest.Options{ + ctx = context.Background() + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, @@ -472,9 +494,14 @@ func TestExecutorAutostopExtend(t *testing.T) { }) require.NoError(t, err, "extend workspace deadline") + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + // When: the autobuild executor ticks *after* the original deadline: go func() { - tickCh <- originalDeadline.Time.Add(time.Minute) + tickTime := originalDeadline.Time.Add(time.Minute) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime }() // Then: nothing should happen and the workspace should stay running @@ -484,7 +511,9 @@ func TestExecutorAutostopExtend(t *testing.T) { // When: the autobuild executor ticks after the *new* deadline: go func() { - tickCh <- newDeadline.Add(time.Minute) + tickTime := newDeadline.Add(time.Minute) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime close(tickCh) }() @@ -666,9 +695,9 @@ func TestExecuteAutostopSuspendedUser(t *testing.T) { t.Parallel() var ( - tickCh = make(chan time.Time) - statsCh = make(chan autobuild.Stats) - client = coderdtest.New(t, &coderdtest.Options{ + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, @@ -676,6 +705,8 @@ func TestExecuteAutostopSuspendedUser(t *testing.T) { ) admin := coderdtest.CreateFirstUser(t, client) + // Wait for provisioner to be available + coderdtest.MustWaitForAnyProvisioner(t, db) version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) @@ -753,17 +784,17 @@ func TestExecutorAutostartMultipleOK(t *testing.T) { t.Parallel() var ( - sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") - tickCh = make(chan time.Time) - tickCh2 = make(chan time.Time) - statsCh1 = make(chan autobuild.Stats) - statsCh2 = make(chan autobuild.Stats) - client = coderdtest.New(t, &coderdtest.Options{ + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") + tickCh = make(chan time.Time) + tickCh2 = make(chan time.Time) + statsCh1 = make(chan autobuild.Stats) + statsCh2 = make(chan autobuild.Stats) + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh1, }) - _ = coderdtest.New(t, &coderdtest.Options{ + _, _ = coderdtest.NewWithDatabase(t, &coderdtest.Options{ AutobuildTicker: tickCh2, IncludeProvisionerDaemon: true, AutobuildStats: statsCh2, @@ -776,10 +807,15 @@ func TestExecutorAutostartMultipleOK(t *testing.T) { // Given: workspace is stopped workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + // When: the autobuild executor ticks past the scheduled time go func() { - tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) - tickCh2 <- sched.Next(workspace.LatestBuild.CreatedAt) + tickTime := sched.Next(workspace.LatestBuild.CreatedAt) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime + tickCh2 <- tickTime close(tickCh) close(tickCh2) }() @@ -809,10 +845,10 @@ func TestExecutorAutostartWithParameters(t *testing.T) { ) var ( - sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") - tickCh = make(chan time.Time) - statsCh = make(chan autobuild.Stats) - client = coderdtest.New(t, &coderdtest.Options{ + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, @@ -841,9 +877,14 @@ func TestExecutorAutostartWithParameters(t *testing.T) { // Given: workspace is stopped workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + // When: the autobuild executor ticks after the scheduled time go func() { - tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) + tickTime := sched.Next(workspace.LatestBuild.CreatedAt) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime close(tickCh) }() @@ -911,7 +952,7 @@ func TestExecutorAutostopTemplateDisabled(t *testing.T) { tickCh = make(chan time.Time) statsCh = make(chan autobuild.Stats) - client = coderdtest.New(t, &coderdtest.Options{ + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, @@ -935,9 +976,14 @@ func TestExecutorAutostopTemplateDisabled(t *testing.T) { // Then: the deadline should be set to the template default TTL assert.WithinDuration(t, workspace.LatestBuild.CreatedAt.Add(time.Hour), workspace.LatestBuild.Deadline.Time, time.Minute) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + // When: the autobuild executor ticks after the workspace setting, but before the template setting: go func() { - tickCh <- workspace.LatestBuild.Job.CompletedAt.Add(45 * time.Minute) + tickTime := workspace.LatestBuild.Job.CompletedAt.Add(45 * time.Minute) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime }() // Then: nothing should happen @@ -947,7 +993,9 @@ func TestExecutorAutostopTemplateDisabled(t *testing.T) { // When: the autobuild executor ticks after the template setting: go func() { - tickCh <- workspace.LatestBuild.Job.CompletedAt.Add(61 * time.Minute) + tickTime := workspace.LatestBuild.Job.CompletedAt.Add(61 * time.Minute) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime close(tickCh) }() @@ -976,6 +1024,9 @@ func TestExecutorRequireActiveVersion(t *testing.T) { TemplateScheduleStore: schedule.NewAGPLTemplateScheduleStore(), }) ) + // Wait for provisioner to be available + coderdtest.MustWaitForAnyProvisioner(t, db) + ctx := testutil.Context(t, testutil.WaitShort) owner := coderdtest.CreateFirstUser(t, ownerClient) me, err := ownerClient.User(ctx, codersdk.Me) @@ -1012,7 +1063,13 @@ func TestExecutorRequireActiveVersion(t *testing.T) { req.TemplateVersionID = inactiveVersion.ID }) require.Equal(t, inactiveVersion.ID, ws.LatestBuild.TemplateVersionID) - ticker <- sched.Next(ws.LatestBuild.CreatedAt) + + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil) + require.NoError(t, err) + + tickTime := sched.Next(ws.LatestBuild.CreatedAt) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime stats := <-statCh require.Len(t, stats.Transitions, 1) @@ -1132,7 +1189,7 @@ func TestNotifications(t *testing.T) { statCh = make(chan autobuild.Stats) notifyEnq = notificationstest.FakeEnqueuer{} timeTilDormant = time.Minute - client = coderdtest.New(t, &coderdtest.Options{ + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ AutobuildTicker: ticker, AutobuildStats: statCh, IncludeProvisionerDaemon: true, @@ -1169,9 +1226,14 @@ func TestNotifications(t *testing.T) { workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + // Wait for workspace to become dormant notifyEnq.Clear() - ticker <- workspace.LastUsedAt.Add(timeTilDormant * 3) + tickTime := workspace.LastUsedAt.Add(timeTilDormant * 3) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime _ = testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statCh) // Check that the workspace is dormant @@ -1245,13 +1307,18 @@ func TestExecutorPrebuilds(t *testing.T) { require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) require.NotZero(t, prebuild.LatestBuild.Deadline) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), prebuild.OrganizationID, nil) + require.NoError(t, err) + // When: the autobuild executor ticks *after* the deadline: go func() { - tickCh <- prebuild.LatestBuild.Deadline.Time.Add(time.Minute) + tickTime := prebuild.LatestBuild.Deadline.Time.Add(time.Minute) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime }() // Then: the prebuilt workspace should remain in a start transition - prebuildStats := <-statsCh + prebuildStats := testutil.RequireReceive(ctx, t, statsCh) require.Len(t, prebuildStats.Errors, 0) require.Len(t, prebuildStats.Transitions, 0) require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) @@ -1259,17 +1326,27 @@ func TestExecutorPrebuilds(t *testing.T) { require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) // Given: a user claims the prebuilt workspace - dbWorkspace := dbgen.ClaimPrebuild(t, db, user.ID, "claimedWorkspace-autostop", preset.ID) + dbWorkspace := dbgen.ClaimPrebuild( + t, db, + clock.Now(), + user.ID, + "claimedWorkspace-autostop", + preset.ID, + sql.NullString{}, + sql.NullTime{}, + sql.NullInt64{}) workspace := coderdtest.MustWorkspace(t, client, dbWorkspace.ID) // When: the autobuild executor ticks *after* the deadline: go func() { - tickCh <- workspace.LatestBuild.Deadline.Time.Add(time.Minute) + tickTime := workspace.LatestBuild.Deadline.Time.Add(time.Minute) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime close(tickCh) }() // Then: the workspace should be stopped - workspaceStats := <-statsCh + workspaceStats := testutil.RequireReceive(ctx, t, statsCh) require.Len(t, workspaceStats.Errors, 0) require.Len(t, workspaceStats.Transitions, 1) require.Contains(t, workspaceStats.Transitions, workspace.ID) @@ -1336,7 +1413,7 @@ func TestExecutorPrebuilds(t *testing.T) { }() // Then: the prebuilt workspace should remain in a stop transition - prebuildStats := <-statsCh + prebuildStats := testutil.RequireReceive(ctx, t, statsCh) require.Len(t, prebuildStats.Errors, 0) require.Len(t, prebuildStats.Transitions, 0) require.Equal(t, codersdk.WorkspaceTransitionStop, prebuild.LatestBuild.Transition) @@ -1353,7 +1430,15 @@ func TestExecutorPrebuilds(t *testing.T) { database.WorkspaceTransitionStart) // Given: a user claims the prebuilt workspace - dbWorkspace := dbgen.ClaimPrebuild(t, db, user.ID, "claimedWorkspace-autostart", preset.ID) + dbWorkspace := dbgen.ClaimPrebuild( + t, db, + clock.Now(), + user.ID, + "claimedWorkspace-autostart", + preset.ID, + autostartSched, + sql.NullTime{}, + sql.NullInt64{}) workspace := coderdtest.MustWorkspace(t, client, dbWorkspace.ID) // Given: the prebuilt workspace goes to a stop status @@ -1374,7 +1459,7 @@ func TestExecutorPrebuilds(t *testing.T) { }() // Then: the workspace should eventually be started - workspaceStats := <-statsCh + workspaceStats := testutil.RequireReceive(ctx, t, statsCh) require.Len(t, workspaceStats.Errors, 0) require.Len(t, workspaceStats.Transitions, 1) require.Contains(t, workspaceStats.Transitions, workspace.ID) @@ -1486,8 +1571,8 @@ func setupTestDBWorkspaceBuild( Architecture: "i386", OperatingSystem: "linux", LifecycleState: database.WorkspaceAgentLifecycleStateReady, - StartedAt: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true}, - ReadyAt: sql.NullTime{Time: time.Now().Add(-1 * time.Hour), Valid: true}, + StartedAt: sql.NullTime{Time: clock.Now().Add(time.Hour), Valid: true}, + ReadyAt: sql.NullTime{Time: clock.Now().Add(-1 * time.Hour), Valid: true}, APIKeyScope: database.AgentKeyScopeEnumAll, }) @@ -1524,8 +1609,9 @@ func setupTestDBPrebuiltWorkspace( OrganizationID: orgID, OwnerID: database.PrebuildsSystemUserID, Deleted: false, - CreatedAt: time.Now().Add(-time.Hour * 2), + CreatedAt: clock.Now().Add(-time.Hour * 2), AutostartSchedule: options.AutostartSchedule, + LastUsedAt: clock.Now(), }) setupTestDBWorkspaceBuild(ctx, t, clock, db, ps, orgID, workspace.ID, templateVersionID, presetID, buildTransition) @@ -1543,6 +1629,25 @@ func mustProvisionWorkspace(t *testing.T, client *codersdk.Client, mut ...func(* return coderdtest.MustWorkspace(t, client, ws.ID) } +// mustProvisionWorkspaceWithProvisionerTags creates a workspace with a template version that has specific provisioner tags +func mustProvisionWorkspaceWithProvisionerTags(t *testing.T, client *codersdk.Client, provisionerTags map[string]string, mut ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace { + t.Helper() + user := coderdtest.CreateFirstUser(t, client) + + // Create template version with specific provisioner tags + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil, func(request *codersdk.CreateTemplateVersionRequest) { + request.ProvisionerTags = provisionerTags + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + t.Logf("template version %s job has completed with provisioner tags %v", version.ID, provisionerTags) + + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ws := coderdtest.CreateWorkspace(t, client, template.ID, mut...) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + return coderdtest.MustWorkspace(t, client, ws.ID) +} + func mustProvisionWorkspaceWithParameters(t *testing.T, client *codersdk.Client, richParameters []*proto.RichParameter, mut ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace { t.Helper() user := coderdtest.CreateFirstUser(t, client) @@ -1580,6 +1685,92 @@ func mustWorkspaceParameters(t *testing.T, client *codersdk.Client, workspaceID require.NotEmpty(t, buildParameters) } -func TestMain(m *testing.M) { - goleak.VerifyTestMain(m, testutil.GoleakOptions...) +func TestExecutorAutostartSkipsWhenNoProvisionersAvailable(t *testing.T) { + t.Parallel() + + var ( + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + ) + + // Use provisioner daemon tags so we can test `hasAvailableProvisioner` more thoroughly. + // We can't overwrite owner or scope as there's a `provisionersdk.MutateTags` function that has restrictions on those. + provisionerDaemonTags := map[string]string{"test-tag": "asdf"} + t.Logf("Setting provisioner daemon tags: %v", provisionerDaemonTags) + + db, ps := dbtestutil.NewDB(t) + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: db, + Pubsub: ps, + IncludeProvisionerDaemon: false, + AutobuildTicker: tickCh, + AutobuildStats: statsCh, + }) + + daemon1Closer := coderdtest.NewTaggedProvisionerDaemon(t, api, "name", provisionerDaemonTags) + t.Cleanup(func() { + _ = daemon1Closer.Close() + }) + + // Create workspace with autostart enabled and matching provisioner tags + workspace := mustProvisionWorkspaceWithProvisionerTags(t, client, provisionerDaemonTags, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) + + // Stop the workspace while provisioner is available + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, provisionerDaemonTags) + require.NoError(t, err, "Error getting provisioner for workspace") + + var wg sync.WaitGroup + wg.Add(2) + + next := sched.Next(workspace.LatestBuild.CreatedAt) + go func() { + defer wg.Done() + // Ensure the provisioner is stale + staleTime := next.Add(-(provisionerdserver.StaleInterval * 2)) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, staleTime) + p, err = coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, provisionerDaemonTags) + assert.NoError(t, err, "Error getting provisioner for workspace") + assert.Eventually(t, func() bool { return p.LastSeenAt.Time.UnixNano() == staleTime.UnixNano() }, testutil.WaitMedium, testutil.IntervalFast) + }() + + go func() { + defer wg.Done() + // Ensure the provisioner is gone or stale before triggering the autobuild + coderdtest.MustWaitForProvisionersUnavailable(t, db, workspace, provisionerDaemonTags, next) + // Trigger autobuild + tickCh <- next + }() + + wg.Wait() + + stats := <-statsCh + + // This assertion should FAIL when provisioner is available (not stale), can confirm by commenting out the + // UpdateProvisionerLastSeenAt call above. + assert.Len(t, stats.Transitions, 0, "should not create builds when no provisioners available") + + daemon2Closer := coderdtest.NewTaggedProvisionerDaemon(t, api, "name", provisionerDaemonTags) + t.Cleanup(func() { + _ = daemon2Closer.Close() + }) + + // Ensure the provisioner is NOT stale, and see if we get a successful state transition. + p, err = coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, provisionerDaemonTags) + require.NoError(t, err, "Error getting provisioner for workspace") + notStaleTime := sched.Next(workspace.LatestBuild.CreatedAt).Add((-1 * provisionerdserver.StaleInterval) + 10*time.Second) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, notStaleTime) + + // Trigger autobuild + go func() { + tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) + close(tickCh) + }() + stats = <-statsCh + + assert.Len(t, stats.Transitions, 1, "should create builds when provisioners are available") } diff --git a/coderd/coderd.go b/coderd/coderd.go index 26bf4a7bf9b63..c06f44b10b40e 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -14,13 +14,16 @@ import ( "net/url" "path/filepath" "regexp" + "runtime/pprof" "strings" "sync" "sync/atomic" "time" "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" @@ -198,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 @@ -237,6 +241,8 @@ type Options struct { UpdateAgentMetrics func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric) StatsBatcher workspacestats.Batcher + ProvisionerdServerMetrics *provisionerdserver.Metrics + // WorkspaceAppAuditSessionTimeout allows changing the timeout for audit // sessions. Raising or lowering this value will directly affect the write // load of the audit log table. This is used for testing. Default 1 hour. @@ -323,6 +329,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() { @@ -379,9 +388,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() } @@ -426,6 +432,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 } @@ -588,6 +601,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, @@ -852,6 +866,7 @@ func New(options *Options) *API { r.Use( httpmw.Recover(api.Logger), + httpmw.WithProfilingLabels, tracing.StatusWriterMiddleware, tracing.Middleware(api.TracerProvider), httpmw.AttachRequestID, @@ -992,6 +1007,18 @@ func New(options *Options) *API { r.Route("/aitasks", func(r chi.Router) { r.Get("/prompts", api.aiTasksPrompts) }) + r.Route("/tasks", func(r chi.Router) { + r.Use(apiRateLimiter) + + r.Get("/", api.tasksList) + + r.Route("/{user}", func(r chi.Router) { + r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize)) + r.Get("/{id}", api.taskGet) + r.Delete("/{id}", api.taskDelete) + r.Post("/", api.tasksCreate) + }) + }) r.Route("/mcp", func(r chi.Router) { r.Use( httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2, codersdk.ExperimentMCPServerHTTP), @@ -1339,7 +1366,13 @@ func New(options *Options) *API { ).Get("/connection", api.workspaceAgentConnectionGeneric) r.Route("/me", func(r chi.Router) { r.Use(workspaceAgentInfo) - r.Get("/rpc", api.workspaceAgentRPC) + r.Group(func(r chi.Router) { + r.Use( + // Override the request_type for agent rpc traffic. + httpmw.WithStaticProfilingLabels(pprof.Labels(pproflabel.RequestTypeTag, "agent-rpc")), + ) + r.Get("/rpc", api.workspaceAgentRPC) + }) r.Patch("/logs", api.patchWorkspaceAgentLogs) r.Patch("/app-status", api.patchWorkspaceAgentAppStatus) // Deprecated: Required to support legacy agents @@ -1415,8 +1448,10 @@ func New(options *Options) *API { r.Get("/timings", api.workspaceTimings) r.Route("/acl", func(r chi.Router) { r.Use( - httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentWorkspaceSharing)) + httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentWorkspaceSharing), + ) + r.Get("/", api.workspaceACL) r.Patch("/", api.patchWorkspaceACL) }) }) @@ -1547,6 +1582,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 { @@ -1668,6 +1706,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 @@ -1883,6 +1924,7 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n &api.Auditor, api.TemplateScheduleStore, api.UserQuietHoursScheduleStore, + api.UsageInserter, api.DeploymentValues, provisionerdserver.Options{ OIDCConfig: api.OIDCConfig, @@ -1891,6 +1933,7 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n }, api.NotificationsEnqueuer, &api.PrebuildsReconciler, + api.ProvisionerdServerMetrics, ) if err != nil { return nil, err diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 7085068e97ff4..b6aafc53daffa 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -55,6 +55,7 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/archive" "github.com/coder/coder/v2/coderd/files" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/quartz" @@ -183,6 +184,8 @@ type Options struct { OIDCConvertKeyCache cryptokeys.SigningKeycache Clock quartz.Clock TelemetryReporter telemetry.Reporter + + ProvisionerdServerMetrics *provisionerdserver.Metrics } // New constructs a codersdk client connected to an in-memory API instance. @@ -386,6 +389,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can options.NotificationsEnqueuer, experiments, ).WithStatsChannel(options.AutobuildStats) + lifecycleExecutor.Run() jobReaperTicker := time.NewTicker(options.DeploymentValues.JobReaperDetectorInterval.Value()) @@ -469,7 +473,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can serverURL, err := url.Parse(srv.URL) require.NoError(t, err) - serverURL.Host = fmt.Sprintf("localhost:%d", tcpAddr.Port) + serverURL.Host = fmt.Sprintf("127.0.0.1:%d", tcpAddr.Port) derpPort, err := strconv.Atoi(serverURL.Port()) require.NoError(t, err) @@ -602,6 +606,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can Clock: options.Clock, AppEncryptionKeyCache: options.APIKeyEncryptionCache, OIDCConvertKeyCache: options.OIDCConvertKeyCache, + ProvisionerdServerMetrics: options.ProvisionerdServerMetrics, } } @@ -1590,3 +1595,140 @@ func DeploymentValues(t testing.TB, mut ...func(*codersdk.DeploymentValues)) *co } return cfg } + +// GetProvisionerForTags returns the first valid provisioner for a workspace + template tags. +func GetProvisionerForTags(tx database.Store, curTime time.Time, orgID uuid.UUID, tags map[string]string) (database.ProvisionerDaemon, error) { + if tags == nil { + tags = map[string]string{} + } + queryParams := database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: orgID, + WantTags: tags, + } + + // nolint: gocritic // The user (in this case, the user/context for autostart builds) may not have the full + // permissions to read provisioner daemons, but we need to check if there's any for the job prior to the + // execution of the job via autostart to fix: https://github.com/coder/coder/issues/17941 + provisionerDaemons, err := tx.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(context.Background()), queryParams) + if err != nil { + return database.ProvisionerDaemon{}, xerrors.Errorf("get provisioner daemons: %w", err) + } + + // Check if any provisioners are active (not stale) + for _, pd := range provisionerDaemons { + if pd.LastSeenAt.Valid { + age := curTime.Sub(pd.LastSeenAt.Time) + if age <= provisionerdserver.StaleInterval { + return pd, nil + } + } + } + return database.ProvisionerDaemon{}, xerrors.New("no available provisioners found") +} + +func ctxWithProvisionerPermissions(ctx context.Context) context.Context { + // Use system restricted context which has permissions to update provisioner daemons + //nolint: gocritic // We need system context to modify this. + return dbauthz.AsSystemRestricted(ctx) +} + +// UpdateProvisionerLastSeenAt updates the provisioner daemon's LastSeenAt timestamp +// to the specified time to prevent it from appearing stale during autobuild operations +func UpdateProvisionerLastSeenAt(t *testing.T, db database.Store, id uuid.UUID, tickTime time.Time) { + t.Helper() + ctx := ctxWithProvisionerPermissions(context.Background()) + t.Logf("Updating provisioner %s LastSeenAt to %v", id, tickTime) + err := db.UpdateProvisionerDaemonLastSeenAt(ctx, database.UpdateProvisionerDaemonLastSeenAtParams{ + ID: id, + LastSeenAt: sql.NullTime{Time: tickTime, Valid: true}, + }) + require.NoError(t, err) + t.Logf("Successfully updated provisioner LastSeenAt") +} + +func MustWaitForAnyProvisioner(t *testing.T, db database.Store) { + t.Helper() + ctx := ctxWithProvisionerPermissions(testutil.Context(t, testutil.WaitShort)) + // testutil.Eventually(t, func) + testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) { + daemons, err := db.GetProvisionerDaemons(ctx) + return err == nil && len(daemons) > 0 + }, testutil.IntervalFast, "no provisioner daemons found") +} + +// MustWaitForProvisionersUnavailable waits for provisioners to become unavailable for a specific workspace +func MustWaitForProvisionersUnavailable(t *testing.T, db database.Store, workspace codersdk.Workspace, tags map[string]string, checkTime time.Time) { + t.Helper() + ctx := ctxWithProvisionerPermissions(testutil.Context(t, testutil.WaitMedium)) + + testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) { + // Use the same logic as hasValidProvisioner but expect false + provisionerDaemons, err := db.GetProvisionerDaemonsByOrganization(ctx, database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: workspace.OrganizationID, + WantTags: tags, + }) + if err != nil { + return false + } + + // Check if NO provisioners are active (all are stale or gone) + for _, pd := range provisionerDaemons { + if pd.LastSeenAt.Valid { + age := checkTime.Sub(pd.LastSeenAt.Time) + if age <= provisionerdserver.StaleInterval { + return false // Found an active provisioner, keep waiting + } + } + } + return true // No active provisioners found + }, testutil.IntervalFast, "there are still provisioners available for workspace, expected none") +} + +// MustWaitForProvisionersAvailable waits for provisioners to be available for a specific workspace. +func MustWaitForProvisionersAvailable(t *testing.T, db database.Store, workspace codersdk.Workspace, ts time.Time) uuid.UUID { + t.Helper() + ctx := ctxWithProvisionerPermissions(testutil.Context(t, testutil.WaitLong)) + id := uuid.UUID{} + // Get the workspace from the database + testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) { + ws, err := db.GetWorkspaceByID(ctx, workspace.ID) + if err != nil { + return false + } + + // Get the latest build + latestBuild, err := db.GetWorkspaceBuildByID(ctx, workspace.LatestBuild.ID) + if err != nil { + return false + } + + // Get the template version job + templateVersionJob, err := db.GetProvisionerJobByID(ctx, latestBuild.JobID) + if err != nil { + return false + } + + // Check if provisioners are available using the same logic as hasAvailableProvisioners + provisionerDaemons, err := db.GetProvisionerDaemonsByOrganization(ctx, database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: ws.OrganizationID, + WantTags: templateVersionJob.Tags, + }) + if err != nil { + return false + } + + // Check if any provisioners are active (not stale) + for _, pd := range provisionerDaemons { + if pd.LastSeenAt.Valid { + age := ts.Sub(pd.LastSeenAt.Time) + if age <= provisionerdserver.StaleInterval { + id = pd.ID + return true // Found an active provisioner + } + } + } + return false // No active provisioners found + }, testutil.IntervalFast, "no active provisioners available for workspace, expected at least one (non-stale)") + + return id +} diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index c7f7d35937198..a76f6447dcabd 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -641,7 +641,7 @@ func (f *FakeIDP) LoginWithClient(t testing.TB, client *codersdk.Client, idToken // ExternalLogin does the oauth2 flow for external auth providers. This requires // an authenticated coder client. -func (f *FakeIDP) ExternalLogin(t testing.TB, client *codersdk.Client, opts ...func(r *http.Request)) { +func (f *FakeIDP) ExternalLogin(t testing.TB, client *codersdk.Client, opts ...codersdk.RequestOption) { coderOauthURL, err := client.URL.Parse(fmt.Sprintf("/external-auth/%s/callback", f.externalProviderID)) require.NoError(t, err) f.SetRedirect(t, coderOauthURL.String()) @@ -660,11 +660,7 @@ func (f *FakeIDP) ExternalLogin(t testing.TB, client *codersdk.Client, opts ...f req, err := http.NewRequestWithContext(ctx, "GET", coderOauthURL.String(), nil) require.NoError(t, err) // External auth flow requires the user be authenticated. - headerName := client.SessionTokenHeader - if headerName == "" { - headerName = codersdk.SessionTokenHeader - } - req.Header.Set(headerName, client.SessionToken()) + opts = append([]codersdk.RequestOption{client.SessionTokenProvider.AsRequestOption()}, opts...) if cli.Jar == nil { cli.Jar, err = cookiejar.New(nil) require.NoError(t, err, "failed to create cookie jar") 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 new file mode 100644 index 0000000000000..ac204f85f5603 --- /dev/null +++ b/coderd/database/check_constraint.go @@ -0,0 +1,18 @@ +// Code generated by scripts/dbgen/main.go. DO NOT EDIT. +package database + +// CheckConstraint represents a named check constraint on a table. +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 + CheckMaxLogsLength CheckConstraint = "max_logs_length" // workspace_agents + CheckSubsystemsNotNone CheckConstraint = "subsystems_not_none" // workspace_agents + CheckWorkspaceBuildsAiTaskSidebarAppIDRequired CheckConstraint = "workspace_builds_ai_task_sidebar_app_id_required" // workspace_builds + CheckWorkspaceBuildsDeadlineBelowMaxDeadline CheckConstraint = "workspace_builds_deadline_below_max_deadline" // workspace_builds +) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 48f6ff44af70f..65fa399c1de90 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -184,20 +184,24 @@ func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk }, nil } +func MinimalUser(user database.User) codersdk.MinimalUser { + return codersdk.MinimalUser{ + ID: user.ID, + Username: user.Username, + AvatarURL: user.AvatarURL, + } +} + func ReducedUser(user database.User) codersdk.ReducedUser { return codersdk.ReducedUser{ - MinimalUser: codersdk.MinimalUser{ - ID: user.ID, - Username: user.Username, - AvatarURL: user.AvatarURL, - }, - Email: user.Email, - Name: user.Name, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - LastSeenAt: user.LastSeenAt, - Status: codersdk.UserStatus(user.Status), - LoginType: codersdk.LoginType(user.LoginType), + MinimalUser: MinimalUser(user), + Email: user.Email, + Name: user.Name, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + LastSeenAt: user.LastSeenAt, + Status: codersdk.UserStatus(user.Status), + LoginType: codersdk.LoginType(user.LoginType), } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 402097f13deae..a87e49ef2d9ed 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{}, @@ -485,6 +487,16 @@ var ( rbac.ResourceFile.Type: { policy.ActionRead, }, + // Needs to be able to add the prebuilds system user to the "prebuilds" group in each organization that needs prebuilt workspaces + // so that prebuilt workspaces can be scheduled and owned in those organizations. + rbac.ResourceGroup.Type: { + policy.ActionRead, + policy.ActionCreate, + policy.ActionUpdate, + }, + rbac.ResourceGroupMember.Type: { + policy.ActionRead, + }, }), }, }), @@ -509,6 +521,27 @@ var ( }), Scope: rbac.ScopeAll, }.WithCachedASTValue() + + subjectUsagePublisher = rbac.Subject{ + Type: rbac.SubjectTypeUsagePublisher, + FriendlyName: "Usage Publisher", + ID: uuid.Nil.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "usage-publisher"}, + DisplayName: "Usage Publisher", + Site: rbac.Permissions(map[string][]policy.Action{ + 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{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() ) // AsProvisionerd returns a context with an actor that has permissions required @@ -579,10 +612,18 @@ func AsPrebuildsOrchestrator(ctx context.Context) context.Context { return As(ctx, subjectPrebuildsOrchestrator) } +// AsFileReader returns a context with an actor that has permissions required +// for reading all files. func AsFileReader(ctx context.Context) context.Context { return As(ctx, subjectFileReader) } +// 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{ ID: "remove-actor", } @@ -1387,6 +1428,14 @@ func (q *querier) CountUnreadInboxNotificationsByUserID(ctx context.Context, use return q.db.CountUnreadInboxNotificationsByUserID(ctx, userID) } +func (q *querier) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) { + obj := rbac.ResourceUserSecret.WithOwner(arg.UserID.String()) + if err := q.authorizeContext(ctx, policy.ActionCreate, obj); err != nil { + return database.UserSecret{}, err + } + return q.db.CreateUserSecret(ctx, arg) +} + // TODO: Handle org scoped lookups func (q *querier) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { roleObject := rbac.ResourceAssignRole @@ -1657,6 +1706,19 @@ func (q *querier) DeleteTailnetTunnel(ctx context.Context, arg database.DeleteTa return q.db.DeleteTailnetTunnel(ctx, arg) } +func (q *querier) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { + // First get the secret to check ownership + secret, err := q.GetUserSecret(ctx, id) + if err != nil { + return err + } + + if err := q.authorizeContext(ctx, policy.ActionDelete, secret); err != nil { + return err + } + return q.db.DeleteUserSecret(ctx, id) +} + func (q *querier) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceWebpushSubscription.WithOwner(arg.UserID.String())); err != nil { return err @@ -1789,6 +1851,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) } @@ -2157,17 +2227,6 @@ func (q *querier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, work return q.db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspaceID) } -func (q *querier) GetLatestWorkspaceBuilds(ctx context.Context) ([]database.WorkspaceBuild, error) { - // This function is a system function until we implement a join for workspace builds. - // This is because we need to query for all related workspaces to the returned builds. - // This is a very inefficient method of fetching the latest workspace builds. - // We should just join the rbac properties. - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { - return nil, err - } - return q.db.GetLatestWorkspaceBuilds(ctx) -} - func (q *querier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceBuild, error) { // This function is a system function until we implement a join for workspace builds. if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { @@ -2193,14 +2252,6 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) { return q.db.GetLogoURL(ctx) } -func (q *querier) GetManagedAgentCount(ctx context.Context, arg database.GetManagedAgentCountParams) (int64, error) { - // Must be able to read all workspaces to check usage. - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace); err != nil { - return 0, xerrors.Errorf("authorize read all workspaces: %w", err) - } - return q.db.GetManagedAgentCount(ctx, arg) -} - func (q *querier) GetNotificationMessagesByStatus(ctx context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationMessage); err != nil { return nil, err @@ -2640,6 +2691,13 @@ func (q *querier) GetQuotaConsumedForUser(ctx context.Context, params database.G return q.db.GetQuotaConsumedForUser(ctx, params) } +func (q *querier) GetRegularWorkspaceCreateMetrics(ctx context.Context) ([]database.GetRegularWorkspaceCreateMetricsRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace.All()); err != nil { + return nil, err + } + return q.db.GetRegularWorkspaceCreateMetrics(ctx) +} + func (q *querier) GetReplicaByID(ctx context.Context, id uuid.UUID) (database.Replica, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return database.Replica{}, err @@ -2853,6 +2911,17 @@ func (q *querier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg return tv, nil } +func (q *querier) GetTemplateVersionHasAITask(ctx context.Context, id uuid.UUID) (bool, error) { + // If we can successfully call `GetTemplateVersionByID`, then + // we know the actor has sufficient permissions to know if the + // template has an AI task. + if _, err := q.GetTemplateVersionByID(ctx, id); err != nil { + return false, err + } + + return q.db.GetTemplateVersionHasAITask(ctx, id) +} + func (q *querier) GetTemplateVersionParameters(ctx context.Context, templateVersionID uuid.UUID) ([]database.TemplateVersionParameter, error) { // An actor can read template version parameters if they can read the related template. tv, err := q.db.GetTemplateVersionByID(ctx, templateVersionID) @@ -2981,8 +3050,15 @@ func (q *querier) GetTemplatesWithFilter(ctx context.Context, arg database.GetTe return q.db.GetAuthorizedTemplates(ctx, arg, prep) } +func (q *querier) GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg database.GetTotalUsageDCManagedAgentsV1Params) (int64, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUsageEvent); err != nil { + return 0, err + } + return q.db.GetTotalUsageDCManagedAgentsV1(ctx, arg) +} + 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) @@ -3075,6 +3151,28 @@ func (q *querier) GetUserNotificationPreferences(ctx context.Context, userID uui return q.db.GetUserNotificationPreferences(ctx, userID) } +func (q *querier) GetUserSecret(ctx context.Context, id uuid.UUID) (database.UserSecret, error) { + // First get the secret to check ownership + secret, err := q.db.GetUserSecret(ctx, id) + if err != nil { + return database.UserSecret{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionRead, secret); err != nil { + return database.UserSecret{}, err + } + return secret, nil +} + +func (q *querier) GetUserSecretByUserIDAndName(ctx context.Context, arg database.GetUserSecretByUserIDAndNameParams) (database.UserSecret, error) { + obj := rbac.ResourceUserSecret.WithOwner(arg.UserID.String()) + if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil { + return database.UserSecret{}, err + } + + return q.db.GetUserSecretByUserIDAndName(ctx, arg) +} + func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil { return nil, err @@ -3154,6 +3252,17 @@ func (q *querier) GetWebpushVAPIDKeys(ctx context.Context) (database.GetWebpushV return q.db.GetWebpushVAPIDKeys(ctx) } +func (q *querier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceACLByIDRow, error) { + workspace, err := q.db.GetWorkspaceByID(ctx, id) + if err != nil { + return database.GetWorkspaceACLByIDRow{}, err + } + if err := q.authorizeContext(ctx, policy.ActionCreate, workspace); err != nil { + return database.GetWorkspaceACLByIDRow{}, err + } + return q.db.GetWorkspaceACLByID(ctx, id) +} + func (q *querier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { // This is a system function if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { @@ -3617,11 +3726,6 @@ func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now ti return q.db.GetWorkspacesEligibleForTransition(ctx, now) } -func (q *querier) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) { - // Anyone can call HasTemplateVersionsWithAITask. - return q.db.HasTemplateVersionsWithAITask(ctx) -} - func (q *querier) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { return insert(q.log, q.auth, rbac.ResourceApiKey.WithOwner(arg.UserID.String()), @@ -3913,6 +4017,13 @@ func (q *querier) InsertTemplateVersionWorkspaceTag(ctx context.Context, arg dat return q.db.InsertTemplateVersionWorkspaceTag(ctx, arg) } +func (q *querier) InsertUsageEvent(ctx context.Context, arg database.InsertUsageEventParams) error { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceUsageEvent); err != nil { + return err + } + return q.db.InsertUsageEvent(ctx, arg) +} + func (q *querier) InsertUser(ctx context.Context, arg database.InsertUserParams) (database.User, error) { // Always check if the assigned roles can actually be assigned by this actor. impliedRoles := append([]rbac.RoleIdentifier{rbac.RoleMember()}, q.convertToDeploymentRoles(arg.RBACRoles)...) @@ -4158,6 +4269,14 @@ func (q *querier) ListProvisionerKeysByOrganizationExcludeReserved(ctx context.C return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.ListProvisionerKeysByOrganizationExcludeReserved)(ctx, organizationID) } +func (q *querier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) { + obj := rbac.ResourceUserSecret.WithOwner(userID.String()) + if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil { + return nil, err + } + return q.db.ListUserSecrets(ctx, userID) +} + func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { workspace, err := q.db.GetWorkspaceByID(ctx, workspaceID) if err != nil { @@ -4260,6 +4379,14 @@ func (q *querier) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) return q.db.RevokeDBCryptKey(ctx, activeKeyDigest) } +func (q *querier) SelectUsageEventsForPublishing(ctx context.Context, arg time.Time) ([]database.UsageEvent, error) { + // ActionUpdate because we're updating the publish_started_at column. + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceUsageEvent); err != nil { + return nil, err + } + return q.db.SelectUsageEventsForPublishing(ctx, arg) +} + func (q *querier) TryAcquireLock(ctx context.Context, id int64) (bool, error) { return q.db.TryAcquireLock(ctx, id) } @@ -4463,6 +4590,7 @@ func (q *querier) UpdatePresetPrebuildStatus(ctx context.Context, arg database.U return err } + // TODO: This does not check the acl list on the template. Should it? object := rbac.ResourceTemplate. WithID(preset.TemplateID.UUID). InOrg(preset.OrganizationID) @@ -4645,9 +4773,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 } @@ -4664,12 +4792,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 } @@ -4686,11 +4814,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 @@ -4708,11 +4836,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 @@ -4730,7 +4858,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 { @@ -4741,6 +4869,13 @@ func (q *querier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg da return fetchAndExec(q.log, q.auth, policy.ActionUpdate, fetch, q.db.UpdateTemplateWorkspacesLastUsedAt)(ctx, arg) } +func (q *querier) UpdateUsageEventsPostPublish(ctx context.Context, arg database.UpdateUsageEventsPostPublishParams) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceUsageEvent); err != nil { + return err + } + return q.db.UpdateUsageEventsPostPublish(ctx, arg) +} + func (q *querier) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error { return deleteQ(q.log, q.auth, q.db.GetUserByID, q.db.UpdateUserDeletedByID)(ctx, id) } @@ -4871,6 +5006,19 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo return q.db.UpdateUserRoles(ctx, arg) } +func (q *querier) UpdateUserSecret(ctx context.Context, arg database.UpdateUserSecretParams) (database.UserSecret, error) { + // First get the secret to check ownership + secret, err := q.db.GetUserSecret(ctx, arg.ID) + if err != nil { + return database.UserSecret{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionUpdate, secret); err != nil { + return database.UserSecret{}, err + } + return q.db.UpdateUserSecret(ctx, arg) +} + func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { fetch := func(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { return q.db.GetUserByID(ctx, arg.ID) @@ -5035,7 +5183,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 @@ -5050,18 +5206,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 @@ -5076,7 +5224,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 { @@ -5381,6 +5529,26 @@ func (q *querier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg databa return q.db.UpsertWorkspaceAppAuditSession(ctx, arg) } +func (q *querier) ValidateGroupIDs(ctx context.Context, groupIDs []uuid.UUID) (database.ValidateGroupIDsRow, error) { + // This check is probably overly restrictive, but the "correct" check isn't + // necessarily obvious. It's only used as a verification check for ACLs right + // now, which are performed as system. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return database.ValidateGroupIDsRow{}, err + } + return q.db.ValidateGroupIDs(ctx, groupIDs) +} + +func (q *querier) ValidateUserIDs(ctx context.Context, userIDs []uuid.UUID) (database.ValidateUserIDsRow, error) { + // This check is probably overly restrictive, but the "correct" check isn't + // necessarily obvious. It's only used as a verification check for ACLs right + // now, which are performed as system. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return database.ValidateUserIDsRow{}, err + } + return q.db.ValidateUserIDs(ctx, userIDs) +} + func (q *querier) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, _ rbac.PreparedAuthorized) ([]database.Template, error) { // TODO Delete this function, all GetTemplates should be authorized. For now just call getTemplates on the authz querier. return q.GetTemplatesWithFilter(ctx, arg) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 9e4f1f80fe05f..a51fdd397a0d5 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7,13 +7,14 @@ import ( "fmt" "net" "reflect" - "strings" "testing" "time" + "github.com/brianvoe/gofakeit/v7" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.org/x/xerrors" "cdr.dev/slog" @@ -22,6 +23,7 @@ import ( "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/notifications" @@ -71,7 +73,9 @@ func TestAsNoActor(t *testing.T) { func TestPing(t *testing.T) { t.Parallel() - db, _ := dbtestutil.NewDB(t) + db := dbmock.NewMockStore(gomock.NewController(t)) + db.EXPECT().Wrappers().Times(1).Return([]string{}) + db.EXPECT().Ping(gomock.Any()).Times(1).Return(time.Second, nil) q := dbauthz.New(db, &coderdtest.RecordingAuthorizer{}, slog.Make(), coderdtest.AccessControlStorePointer()) _, err := q.Ping(context.Background()) require.NoError(t, err, "must not error") @@ -81,34 +85,39 @@ func TestPing(t *testing.T) { func TestInTX(t *testing.T) { t.Parallel() - db, _ := dbtestutil.NewDB(t) + var ( + ctrl = gomock.NewController(t) + db = dbmock.NewMockStore(ctrl) + mTx = dbmock.NewMockStore(ctrl) // to record the 'in tx' calls + faker = gofakeit.New(0) + w = testutil.Fake(t, faker, database.Workspace{}) + actor = rbac.Subject{ + ID: uuid.NewString(), + Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, + Groups: []string{}, + Scope: rbac.ScopeAll, + } + ctx = dbauthz.As(context.Background(), actor) + ) + + db.EXPECT().Wrappers().Times(1).Return([]string{}) // called by dbauthz.New q := dbauthz.New(db, &coderdtest.RecordingAuthorizer{ Wrapped: (&coderdtest.FakeAuthorizer{}).AlwaysReturn(xerrors.New("custom error")), }, slog.Make(), coderdtest.AccessControlStorePointer()) - actor := rbac.Subject{ - ID: uuid.NewString(), - Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, - Groups: []string{}, - Scope: rbac.ScopeAll, - } - u := dbgen.User(t, db, database.User{}) - o := dbgen.Organization(t, db, database.Organization{}) - tpl := dbgen.Template(t, db, database.Template{ - CreatedBy: u.ID, - OrganizationID: o.ID, - }) - w := dbgen.Workspace(t, db, database.WorkspaceTable{ - OwnerID: u.ID, - TemplateID: tpl.ID, - OrganizationID: o.ID, - }) - ctx := dbauthz.As(context.Background(), actor) + + db.EXPECT().InTx(gomock.Any(), gomock.Any()).Times(1).DoAndReturn( + func(f func(database.Store) error, _ *database.TxOptions) error { + return f(mTx) + }, + ) + mTx.EXPECT().Wrappers().Times(1).Return([]string{}) + mTx.EXPECT().GetWorkspaceByID(gomock.Any(), gomock.Any()).Times(1).Return(w, nil) err := q.InTx(func(tx database.Store) error { // The inner tx should use the parent's authz _, err := tx.GetWorkspaceByID(ctx, w.ID) return err }, nil) - require.Error(t, err, "must error") + require.ErrorContains(t, err, "custom error", "must be our custom error") require.ErrorAs(t, err, &dbauthz.NotAuthorizedError{}, "must be an authorized error") require.True(t, dbauthz.IsNotAuthorizedError(err), "must be an authorized error") } @@ -118,24 +127,18 @@ func TestNew(t *testing.T) { t.Parallel() var ( - db, _ = dbtestutil.NewDB(t) + ctrl = gomock.NewController(t) + db = dbmock.NewMockStore(ctrl) + faker = gofakeit.New(0) rec = &coderdtest.RecordingAuthorizer{ Wrapped: &coderdtest.FakeAuthorizer{}, } subj = rbac.Subject{} ctx = dbauthz.As(context.Background(), rbac.Subject{}) ) - u := dbgen.User(t, db, database.User{}) - org := dbgen.Organization(t, db, database.Organization{}) - tpl := dbgen.Template(t, db, database.Template{ - OrganizationID: org.ID, - CreatedBy: u.ID, - }) - exp := dbgen.Workspace(t, db, database.WorkspaceTable{ - OwnerID: u.ID, - OrganizationID: org.ID, - TemplateID: tpl.ID, - }) + db.EXPECT().Wrappers().Times(1).Return([]string{}).Times(2) // two calls to New() + exp := testutil.Fake(t, faker, database.Workspace{}) + db.EXPECT().GetWorkspaceByID(gomock.Any(), exp.ID).Times(1).Return(exp, nil) // Double wrap should not cause an actual double wrap. So only 1 rbac call // should be made. az := dbauthz.New(db, rec, slog.Make(), coderdtest.AccessControlStorePointer()) @@ -143,7 +146,7 @@ func TestNew(t *testing.T) { w, err := az.GetWorkspaceByID(ctx, exp.ID) require.NoError(t, err, "must not error") - require.Equal(t, exp, w.WorkspaceTable(), "must be equal") + require.Equal(t, exp, w, "must be equal") rec.AssertActor(t, subj, rec.Pair(policy.ActionRead, exp)) require.NoError(t, rec.AllAsserted(), "should only be 1 rbac call") @@ -152,6 +155,8 @@ func TestNew(t *testing.T) { // TestDBAuthzRecursive is a simple test to search for infinite recursion // bugs. It isn't perfect, and only catches a subset of the possible bugs // as only the first db call will be made. But it is better than nothing. +// This can be removed when all tests in this package are migrated to +// dbmock as it will immediately detect recursive calls. func TestDBAuthzRecursive(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -204,270 +209,171 @@ func defaultIPAddress() pqtype.Inet { } func (s *MethodTestSuite) TestAPIKey() { - s.Run("DeleteAPIKeyByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - key, _ := dbgen.APIKey(s.T(), db, database.APIKey{}) + s.Run("DeleteAPIKeyByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.APIKey{}) + dbm.EXPECT().GetAPIKeyByID(gomock.Any(), key.ID).Return(key, nil).AnyTimes() + dbm.EXPECT().DeleteAPIKeyByID(gomock.Any(), key.ID).Return(nil).AnyTimes() check.Args(key.ID).Asserts(key, policy.ActionDelete).Returns() })) - s.Run("GetAPIKeyByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - key, _ := dbgen.APIKey(s.T(), db, database.APIKey{}) + s.Run("GetAPIKeyByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.APIKey{}) + dbm.EXPECT().GetAPIKeyByID(gomock.Any(), key.ID).Return(key, nil).AnyTimes() check.Args(key.ID).Asserts(key, policy.ActionRead).Returns(key) })) - s.Run("GetAPIKeyByName", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - key, _ := dbgen.APIKey(s.T(), db, database.APIKey{ - TokenName: "marge-cat", - LoginType: database.LoginTypeToken, - }) - check.Args(database.GetAPIKeyByNameParams{ - TokenName: key.TokenName, - UserID: key.UserID, - }).Asserts(key, policy.ActionRead).Returns(key) + s.Run("GetAPIKeyByName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.APIKey{LoginType: database.LoginTypeToken, TokenName: "marge-cat"}) + dbm.EXPECT().GetAPIKeyByName(gomock.Any(), database.GetAPIKeyByNameParams{TokenName: key.TokenName, UserID: key.UserID}).Return(key, nil).AnyTimes() + check.Args(database.GetAPIKeyByNameParams{TokenName: key.TokenName, UserID: key.UserID}).Asserts(key, policy.ActionRead).Returns(key) })) - s.Run("GetAPIKeysByLoginType", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - a, _ := dbgen.APIKey(s.T(), db, database.APIKey{LoginType: database.LoginTypePassword}) - b, _ := dbgen.APIKey(s.T(), db, database.APIKey{LoginType: database.LoginTypePassword}) - _, _ = dbgen.APIKey(s.T(), db, database.APIKey{LoginType: database.LoginTypeGithub}) - check.Args(database.LoginTypePassword). - Asserts(a, policy.ActionRead, b, policy.ActionRead). - Returns(slice.New(a, b)) + s.Run("GetAPIKeysByLoginType", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + a := testutil.Fake(s.T(), faker, database.APIKey{LoginType: database.LoginTypePassword}) + b := testutil.Fake(s.T(), faker, database.APIKey{LoginType: database.LoginTypePassword}) + dbm.EXPECT().GetAPIKeysByLoginType(gomock.Any(), database.LoginTypePassword).Return([]database.APIKey{a, b}, nil).AnyTimes() + check.Args(database.LoginTypePassword).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b)) })) - s.Run("GetAPIKeysByUserID", s.Subtest(func(db database.Store, check *expects) { - u1 := dbgen.User(s.T(), db, database.User{}) - u2 := dbgen.User(s.T(), db, database.User{}) - - keyA, _ := dbgen.APIKey(s.T(), db, database.APIKey{UserID: u1.ID, LoginType: database.LoginTypeToken, TokenName: "key-a"}) - keyB, _ := dbgen.APIKey(s.T(), db, database.APIKey{UserID: u1.ID, LoginType: database.LoginTypeToken, TokenName: "key-b"}) - _, _ = dbgen.APIKey(s.T(), db, database.APIKey{UserID: u2.ID, LoginType: database.LoginTypeToken}) + s.Run("GetAPIKeysByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u1 := testutil.Fake(s.T(), faker, database.User{}) + keyA := testutil.Fake(s.T(), faker, database.APIKey{UserID: u1.ID, LoginType: database.LoginTypeToken, TokenName: "key-a"}) + keyB := testutil.Fake(s.T(), faker, database.APIKey{UserID: u1.ID, LoginType: database.LoginTypeToken, TokenName: "key-b"}) + dbm.EXPECT().GetAPIKeysByUserID(gomock.Any(), gomock.Any()).Return(slice.New(keyA, keyB), nil).AnyTimes() check.Args(database.GetAPIKeysByUserIDParams{LoginType: database.LoginTypeToken, UserID: u1.ID}). Asserts(keyA, policy.ActionRead, keyB, policy.ActionRead). Returns(slice.New(keyA, keyB)) })) - s.Run("GetAPIKeysLastUsedAfter", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - a, _ := dbgen.APIKey(s.T(), db, database.APIKey{LastUsed: time.Now().Add(time.Hour)}) - b, _ := dbgen.APIKey(s.T(), db, database.APIKey{LastUsed: time.Now().Add(time.Hour)}) - _, _ = dbgen.APIKey(s.T(), db, database.APIKey{LastUsed: time.Now().Add(-time.Hour)}) - check.Args(time.Now()). - Asserts(a, policy.ActionRead, b, policy.ActionRead). - Returns(slice.New(a, b)) - })) - s.Run("InsertAPIKey", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - - check.Args(database.InsertAPIKeyParams{ - UserID: u.ID, - LoginType: database.LoginTypePassword, - Scope: database.APIKeyScopeAll, - IPAddress: defaultIPAddress(), - }).Asserts(rbac.ResourceApiKey.WithOwner(u.ID.String()), policy.ActionCreate) - })) - s.Run("UpdateAPIKeyByID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - a, _ := dbgen.APIKey(s.T(), db, database.APIKey{UserID: u.ID, IPAddress: defaultIPAddress()}) - check.Args(database.UpdateAPIKeyByIDParams{ - ID: a.ID, - IPAddress: defaultIPAddress(), - LastUsed: time.Now(), - ExpiresAt: time.Now().Add(time.Hour), - }).Asserts(a, policy.ActionUpdate).Returns() - })) - s.Run("DeleteApplicationConnectAPIKeysByUserID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - a, _ := dbgen.APIKey(s.T(), db, database.APIKey{ - Scope: database.APIKeyScopeApplicationConnect, - }) + s.Run("GetAPIKeysLastUsedAfter", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + now := time.Now() + a := database.APIKey{LastUsed: now.Add(time.Hour)} + b := database.APIKey{LastUsed: now.Add(time.Hour)} + dbm.EXPECT().GetAPIKeysLastUsedAfter(gomock.Any(), gomock.Any()).Return([]database.APIKey{a, b}, nil).AnyTimes() + check.Args(now).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b)) + })) + s.Run("InsertAPIKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.InsertAPIKeyParams{UserID: u.ID, LoginType: database.LoginTypePassword, Scope: database.APIKeyScopeAll, IPAddress: defaultIPAddress()} + ret := testutil.Fake(s.T(), faker, database.APIKey{UserID: u.ID, LoginType: database.LoginTypePassword}) + dbm.EXPECT().InsertAPIKey(gomock.Any(), arg).Return(ret, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceApiKey.WithOwner(u.ID.String()), policy.ActionCreate) + })) + s.Run("UpdateAPIKeyByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + a := testutil.Fake(s.T(), faker, database.APIKey{UserID: u.ID, IPAddress: defaultIPAddress()}) + arg := database.UpdateAPIKeyByIDParams{ID: a.ID, IPAddress: defaultIPAddress(), LastUsed: time.Now(), ExpiresAt: time.Now().Add(time.Hour)} + dbm.EXPECT().GetAPIKeyByID(gomock.Any(), a.ID).Return(a, nil).AnyTimes() + dbm.EXPECT().UpdateAPIKeyByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(a, policy.ActionUpdate).Returns() + })) + s.Run("DeleteApplicationConnectAPIKeysByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + a := testutil.Fake(s.T(), faker, database.APIKey{Scope: database.APIKeyScopeApplicationConnect}) + dbm.EXPECT().DeleteApplicationConnectAPIKeysByUserID(gomock.Any(), a.UserID).Return(nil).AnyTimes() check.Args(a.UserID).Asserts(rbac.ResourceApiKey.WithOwner(a.UserID.String()), policy.ActionDelete).Returns() })) - s.Run("DeleteExternalAuthLink", s.Subtest(func(db database.Store, check *expects) { - a := dbgen.ExternalAuthLink(s.T(), db, database.ExternalAuthLink{}) - check.Args(database.DeleteExternalAuthLinkParams{ - ProviderID: a.ProviderID, - UserID: a.UserID, - }).Asserts(rbac.ResourceUserObject(a.UserID), policy.ActionUpdatePersonal).Returns() - })) - s.Run("GetExternalAuthLinksByUserID", s.Subtest(func(db database.Store, check *expects) { - a := dbgen.ExternalAuthLink(s.T(), db, database.ExternalAuthLink{}) - b := dbgen.ExternalAuthLink(s.T(), db, database.ExternalAuthLink{ - UserID: a.UserID, - }) - check.Args(a.UserID).Asserts( - rbac.ResourceUserObject(a.UserID), policy.ActionReadPersonal, - rbac.ResourceUserObject(b.UserID), policy.ActionReadPersonal) + s.Run("DeleteExternalAuthLink", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + a := testutil.Fake(s.T(), faker, database.ExternalAuthLink{}) + dbm.EXPECT().GetExternalAuthLink(gomock.Any(), database.GetExternalAuthLinkParams{ProviderID: a.ProviderID, UserID: a.UserID}).Return(a, nil).AnyTimes() + dbm.EXPECT().DeleteExternalAuthLink(gomock.Any(), database.DeleteExternalAuthLinkParams{ProviderID: a.ProviderID, UserID: a.UserID}).Return(nil).AnyTimes() + check.Args(database.DeleteExternalAuthLinkParams{ProviderID: a.ProviderID, UserID: a.UserID}).Asserts(a, policy.ActionUpdatePersonal).Returns() + })) + s.Run("GetExternalAuthLinksByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + a := testutil.Fake(s.T(), faker, database.ExternalAuthLink{}) + b := testutil.Fake(s.T(), faker, database.ExternalAuthLink{UserID: a.UserID}) + dbm.EXPECT().GetExternalAuthLinksByUserID(gomock.Any(), a.UserID).Return([]database.ExternalAuthLink{a, b}, nil).AnyTimes() + check.Args(a.UserID).Asserts(a, policy.ActionReadPersonal, b, policy.ActionReadPersonal) })) } func (s *MethodTestSuite) TestAuditLogs() { - s.Run("InsertAuditLog", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertAuditLogParams{ - ResourceType: database.ResourceTypeOrganization, - Action: database.AuditActionCreate, - Diff: json.RawMessage("{}"), - AdditionalFields: json.RawMessage("{}"), - }).Asserts(rbac.ResourceAuditLog, policy.ActionCreate) - })) - s.Run("GetAuditLogsOffset", s.Subtest(func(db database.Store, check *expects) { - _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) - _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) - check.Args(database.GetAuditLogsOffsetParams{ - LimitOpt: 10, - }).Asserts(rbac.ResourceAuditLog, policy.ActionRead).WithNotAuthorized("nil") - })) - s.Run("GetAuthorizedAuditLogsOffset", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) - _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) - check.Args(database.GetAuditLogsOffsetParams{ - LimitOpt: 10, - }, emptyPreparedAuthorized{}).Asserts(rbac.ResourceAuditLog, policy.ActionRead) - })) - s.Run("CountAuditLogs", s.Subtest(func(db database.Store, check *expects) { - _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) - _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) + s.Run("InsertAuditLog", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertAuditLogParams{ResourceType: database.ResourceTypeOrganization, Action: database.AuditActionCreate, Diff: json.RawMessage("{}"), AdditionalFields: json.RawMessage("{}")} + dbm.EXPECT().InsertAuditLog(gomock.Any(), arg).Return(database.AuditLog{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionCreate) + })) + s.Run("GetAuditLogsOffset", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetAuditLogsOffsetParams{LimitOpt: 10} + dbm.EXPECT().GetAuditLogsOffset(gomock.Any(), arg).Return([]database.GetAuditLogsOffsetRow{}, nil).AnyTimes() + dbm.EXPECT().GetAuthorizedAuditLogsOffset(gomock.Any(), arg, gomock.Any()).Return([]database.GetAuditLogsOffsetRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionRead).WithNotAuthorized("nil") + })) + s.Run("GetAuthorizedAuditLogsOffset", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetAuditLogsOffsetParams{LimitOpt: 10} + dbm.EXPECT().GetAuthorizedAuditLogsOffset(gomock.Any(), arg, gomock.Any()).Return([]database.GetAuditLogsOffsetRow{}, nil).AnyTimes() + dbm.EXPECT().GetAuditLogsOffset(gomock.Any(), arg).Return([]database.GetAuditLogsOffsetRow{}, nil).AnyTimes() + check.Args(arg, emptyPreparedAuthorized{}).Asserts(rbac.ResourceAuditLog, policy.ActionRead) + })) + s.Run("CountAuditLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().CountAuditLogs(gomock.Any(), database.CountAuditLogsParams{}).Return(int64(0), nil).AnyTimes() + dbm.EXPECT().CountAuthorizedAuditLogs(gomock.Any(), database.CountAuditLogsParams{}, gomock.Any()).Return(int64(0), nil).AnyTimes() check.Args(database.CountAuditLogsParams{}).Asserts(rbac.ResourceAuditLog, policy.ActionRead).WithNotAuthorized("nil") })) - s.Run("CountAuthorizedAuditLogs", s.Subtest(func(db database.Store, check *expects) { - _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) - _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) + s.Run("CountAuthorizedAuditLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().CountAuthorizedAuditLogs(gomock.Any(), database.CountAuditLogsParams{}, gomock.Any()).Return(int64(0), nil).AnyTimes() + dbm.EXPECT().CountAuditLogs(gomock.Any(), database.CountAuditLogsParams{}).Return(int64(0), nil).AnyTimes() check.Args(database.CountAuditLogsParams{}, emptyPreparedAuthorized{}).Asserts(rbac.ResourceAuditLog, policy.ActionRead) })) - s.Run("DeleteOldAuditLogConnectionEvents", s.Subtest(func(db database.Store, check *expects) { - _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) + s.Run("DeleteOldAuditLogConnectionEvents", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), database.DeleteOldAuditLogConnectionEventsParams{}).Return(nil).AnyTimes() check.Args(database.DeleteOldAuditLogConnectionEventsParams{}).Asserts(rbac.ResourceSystem, policy.ActionDelete) })) } func (s *MethodTestSuite) TestConnectionLogs() { - createWorkspace := func(t *testing.T, db database.Store) database.WorkspaceTable { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - tpl := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - return dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - ID: uuid.New(), - OwnerID: u.ID, - OrganizationID: o.ID, - AutomaticUpdates: database.AutomaticUpdatesNever, - TemplateID: tpl.ID, - }) - } - s.Run("UpsertConnectionLog", s.Subtest(func(db database.Store, check *expects) { - ws := createWorkspace(s.T(), db) - check.Args(database.UpsertConnectionLogParams{ - Ip: defaultIPAddress(), - Type: database.ConnectionTypeSsh, - WorkspaceID: ws.ID, - OrganizationID: ws.OrganizationID, - ConnectionStatus: database.ConnectionStatusConnected, - WorkspaceOwnerID: ws.OwnerID, - }).Asserts(rbac.ResourceConnectionLog, policy.ActionUpdate) - })) - s.Run("GetConnectionLogsOffset", s.Subtest(func(db database.Store, check *expects) { - ws := createWorkspace(s.T(), db) - _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ - Ip: defaultIPAddress(), - Type: database.ConnectionTypeSsh, - WorkspaceID: ws.ID, - OrganizationID: ws.OrganizationID, - WorkspaceOwnerID: ws.OwnerID, - }) - _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ - Ip: defaultIPAddress(), - Type: database.ConnectionTypeSsh, - WorkspaceID: ws.ID, - OrganizationID: ws.OrganizationID, - WorkspaceOwnerID: ws.OwnerID, - }) - check.Args(database.GetConnectionLogsOffsetParams{ - LimitOpt: 10, - }).Asserts(rbac.ResourceConnectionLog, policy.ActionRead).WithNotAuthorized("nil") - })) - s.Run("GetAuthorizedConnectionLogsOffset", s.Subtest(func(db database.Store, check *expects) { - ws := createWorkspace(s.T(), db) - _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ - Ip: defaultIPAddress(), - Type: database.ConnectionTypeSsh, - WorkspaceID: ws.ID, - OrganizationID: ws.OrganizationID, - WorkspaceOwnerID: ws.OwnerID, - }) - _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ - Ip: defaultIPAddress(), - Type: database.ConnectionTypeSsh, - WorkspaceID: ws.ID, - OrganizationID: ws.OrganizationID, - WorkspaceOwnerID: ws.OwnerID, - }) - check.Args(database.GetConnectionLogsOffsetParams{ - LimitOpt: 10, - }, emptyPreparedAuthorized{}).Asserts(rbac.ResourceConnectionLog, policy.ActionRead) - })) - s.Run("CountConnectionLogs", s.Subtest(func(db database.Store, check *expects) { - ws := createWorkspace(s.T(), db) - _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ - Type: database.ConnectionTypeSsh, - WorkspaceID: ws.ID, - OrganizationID: ws.OrganizationID, - WorkspaceOwnerID: ws.OwnerID, - }) - _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ - Type: database.ConnectionTypeSsh, - WorkspaceID: ws.ID, - OrganizationID: ws.OrganizationID, - WorkspaceOwnerID: ws.OwnerID, - }) - check.Args(database.CountConnectionLogsParams{}).Asserts( - rbac.ResourceConnectionLog, policy.ActionRead, - ).WithNotAuthorized("nil") - })) - s.Run("CountAuthorizedConnectionLogs", s.Subtest(func(db database.Store, check *expects) { - ws := createWorkspace(s.T(), db) - _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ - Type: database.ConnectionTypeSsh, - WorkspaceID: ws.ID, - OrganizationID: ws.OrganizationID, - WorkspaceOwnerID: ws.OwnerID, - }) - _ = dbgen.ConnectionLog(s.T(), db, database.UpsertConnectionLogParams{ - Type: database.ConnectionTypeSsh, - WorkspaceID: ws.ID, - OrganizationID: ws.OrganizationID, - WorkspaceOwnerID: ws.OwnerID, - }) - check.Args(database.CountConnectionLogsParams{}, emptyPreparedAuthorized{}).Asserts( - rbac.ResourceConnectionLog, policy.ActionRead, - ) + s.Run("UpsertConnectionLog", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.WorkspaceTable{}) + arg := database.UpsertConnectionLogParams{Ip: defaultIPAddress(), Type: database.ConnectionTypeSsh, WorkspaceID: ws.ID, OrganizationID: ws.OrganizationID, ConnectionStatus: database.ConnectionStatusConnected, WorkspaceOwnerID: ws.OwnerID} + dbm.EXPECT().UpsertConnectionLog(gomock.Any(), arg).Return(database.ConnectionLog{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceConnectionLog, policy.ActionUpdate) + })) + s.Run("GetConnectionLogsOffset", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetConnectionLogsOffsetParams{LimitOpt: 10} + dbm.EXPECT().GetConnectionLogsOffset(gomock.Any(), arg).Return([]database.GetConnectionLogsOffsetRow{}, nil).AnyTimes() + dbm.EXPECT().GetAuthorizedConnectionLogsOffset(gomock.Any(), arg, gomock.Any()).Return([]database.GetConnectionLogsOffsetRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceConnectionLog, policy.ActionRead).WithNotAuthorized("nil") + })) + s.Run("GetAuthorizedConnectionLogsOffset", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetConnectionLogsOffsetParams{LimitOpt: 10} + dbm.EXPECT().GetAuthorizedConnectionLogsOffset(gomock.Any(), arg, gomock.Any()).Return([]database.GetConnectionLogsOffsetRow{}, nil).AnyTimes() + dbm.EXPECT().GetConnectionLogsOffset(gomock.Any(), arg).Return([]database.GetConnectionLogsOffsetRow{}, nil).AnyTimes() + check.Args(arg, emptyPreparedAuthorized{}).Asserts(rbac.ResourceConnectionLog, policy.ActionRead) + })) + s.Run("CountConnectionLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().CountConnectionLogs(gomock.Any(), database.CountConnectionLogsParams{}).Return(int64(0), nil).AnyTimes() + dbm.EXPECT().CountAuthorizedConnectionLogs(gomock.Any(), database.CountConnectionLogsParams{}, gomock.Any()).Return(int64(0), nil).AnyTimes() + check.Args(database.CountConnectionLogsParams{}).Asserts(rbac.ResourceConnectionLog, policy.ActionRead).WithNotAuthorized("nil") + })) + s.Run("CountAuthorizedConnectionLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().CountAuthorizedConnectionLogs(gomock.Any(), database.CountConnectionLogsParams{}, gomock.Any()).Return(int64(0), nil).AnyTimes() + dbm.EXPECT().CountConnectionLogs(gomock.Any(), database.CountConnectionLogsParams{}).Return(int64(0), nil).AnyTimes() + check.Args(database.CountConnectionLogsParams{}, emptyPreparedAuthorized{}).Asserts(rbac.ResourceConnectionLog, policy.ActionRead) })) } func (s *MethodTestSuite) TestFile() { - s.Run("GetFileByHashAndCreator", s.Subtest(func(db database.Store, check *expects) { - f := dbgen.File(s.T(), db, database.File{}) + s.Run("GetFileByHashAndCreator", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + f := testutil.Fake(s.T(), faker, database.File{}) + dbm.EXPECT().GetFileByHashAndCreator(gomock.Any(), gomock.Any()).Return(f, nil).AnyTimes() + // dbauthz may attempt to check template access on NotAuthorized; ensure mock handles it. + dbm.EXPECT().GetFileTemplates(gomock.Any(), f.ID).Return([]database.GetFileTemplatesRow{}, nil).AnyTimes() check.Args(database.GetFileByHashAndCreatorParams{ Hash: f.Hash, CreatedBy: f.CreatedBy, }).Asserts(f, policy.ActionRead).Returns(f) })) - s.Run("GetFileByID", s.Subtest(func(db database.Store, check *expects) { - f := dbgen.File(s.T(), db, database.File{}) + s.Run("GetFileByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + f := testutil.Fake(s.T(), faker, database.File{}) + dbm.EXPECT().GetFileByID(gomock.Any(), f.ID).Return(f, nil).AnyTimes() + dbm.EXPECT().GetFileTemplates(gomock.Any(), f.ID).Return([]database.GetFileTemplatesRow{}, nil).AnyTimes() check.Args(f.ID).Asserts(f, policy.ActionRead).Returns(f) })) - s.Run("GetFileIDByTemplateVersionID", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID}) - f := dbgen.File(s.T(), db, database.File{CreatedBy: u.ID}) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{StorageMethod: database.ProvisionerStorageMethodFile, FileID: f.ID}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{OrganizationID: o.ID, JobID: j.ID, CreatedBy: u.ID}) - check.Args(tv.ID).Asserts(rbac.ResourceFile.WithID(f.ID), policy.ActionRead).Returns(f.ID) + s.Run("GetFileIDByTemplateVersionID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + tvID := uuid.New() + fileID := uuid.New() + dbm.EXPECT().GetFileIDByTemplateVersionID(gomock.Any(), tvID).Return(fileID, nil).AnyTimes() + check.Args(tvID).Asserts(rbac.ResourceFile.WithID(fileID), policy.ActionRead).Returns(fileID) })) - s.Run("InsertFile", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) + s.Run("InsertFile", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + ret := testutil.Fake(s.T(), faker, database.File{CreatedBy: u.ID}) + dbm.EXPECT().InsertFile(gomock.Any(), gomock.Any()).Return(ret, nil).AnyTimes() check.Args(database.InsertFileParams{ CreatedBy: u.ID, }).Asserts(rbac.ResourceFile.WithOwner(u.ID.String()), policy.ActionCreate) @@ -475,759 +381,551 @@ func (s *MethodTestSuite) TestFile() { } func (s *MethodTestSuite) TestGroup() { - s.Run("DeleteGroupByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - g := dbgen.Group(s.T(), db, database.Group{}) + s.Run("DeleteGroupByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + g := testutil.Fake(s.T(), faker, database.Group{}) + dbm.EXPECT().GetGroupByID(gomock.Any(), g.ID).Return(g, nil).AnyTimes() + dbm.EXPECT().DeleteGroupByID(gomock.Any(), g.ID).Return(nil).AnyTimes() check.Args(g.ID).Asserts(g, policy.ActionDelete).Returns() })) - s.Run("DeleteGroupMemberFromGroup", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - g := dbgen.Group(s.T(), db, database.Group{}) - u := dbgen.User(s.T(), db, database.User{}) - m := dbgen.GroupMember(s.T(), db, database.GroupMemberTable{ - GroupID: g.ID, - UserID: u.ID, - }) - check.Args(database.DeleteGroupMemberFromGroupParams{ - UserID: m.UserID, - GroupID: g.ID, - }).Asserts(g, policy.ActionUpdate).Returns() + + s.Run("DeleteGroupMemberFromGroup", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + g := testutil.Fake(s.T(), faker, database.Group{}) + u := testutil.Fake(s.T(), faker, database.User{}) + m := testutil.Fake(s.T(), faker, database.GroupMember{GroupID: g.ID, UserID: u.ID}) + dbm.EXPECT().GetGroupByID(gomock.Any(), g.ID).Return(g, nil).AnyTimes() + dbm.EXPECT().DeleteGroupMemberFromGroup(gomock.Any(), database.DeleteGroupMemberFromGroupParams{UserID: m.UserID, GroupID: g.ID}).Return(nil).AnyTimes() + check.Args(database.DeleteGroupMemberFromGroupParams{UserID: m.UserID, GroupID: g.ID}).Asserts(g, policy.ActionUpdate).Returns() })) - s.Run("GetGroupByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - g := dbgen.Group(s.T(), db, database.Group{}) + + s.Run("GetGroupByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + g := testutil.Fake(s.T(), faker, database.Group{}) + dbm.EXPECT().GetGroupByID(gomock.Any(), g.ID).Return(g, nil).AnyTimes() check.Args(g.ID).Asserts(g, policy.ActionRead).Returns(g) })) - s.Run("GetGroupByOrgAndName", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - g := dbgen.Group(s.T(), db, database.Group{}) - check.Args(database.GetGroupByOrgAndNameParams{ - OrganizationID: g.OrganizationID, - Name: g.Name, - }).Asserts(g, policy.ActionRead).Returns(g) + + s.Run("GetGroupByOrgAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + g := testutil.Fake(s.T(), faker, database.Group{}) + dbm.EXPECT().GetGroupByOrgAndName(gomock.Any(), database.GetGroupByOrgAndNameParams{OrganizationID: g.OrganizationID, Name: g.Name}).Return(g, nil).AnyTimes() + check.Args(database.GetGroupByOrgAndNameParams{OrganizationID: g.OrganizationID, Name: g.Name}).Asserts(g, policy.ActionRead).Returns(g) })) - s.Run("GetGroupMembersByGroupID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - g := dbgen.Group(s.T(), db, database.Group{}) - u := dbgen.User(s.T(), db, database.User{}) - gm := dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g.ID, UserID: u.ID}) - check.Args(database.GetGroupMembersByGroupIDParams{ - GroupID: g.ID, - IncludeSystem: false, - }).Asserts(gm, policy.ActionRead) + + s.Run("GetGroupMembersByGroupID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + g := testutil.Fake(s.T(), faker, database.Group{}) + u := testutil.Fake(s.T(), faker, database.User{}) + gm := testutil.Fake(s.T(), faker, database.GroupMember{GroupID: g.ID, UserID: u.ID}) + arg := database.GetGroupMembersByGroupIDParams{GroupID: g.ID, IncludeSystem: false} + dbm.EXPECT().GetGroupMembersByGroupID(gomock.Any(), arg).Return([]database.GroupMember{gm}, nil).AnyTimes() + check.Args(arg).Asserts(gm, policy.ActionRead) })) - s.Run("GetGroupMembersCountByGroupID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - g := dbgen.Group(s.T(), db, database.Group{}) - check.Args(database.GetGroupMembersCountByGroupIDParams{ - GroupID: g.ID, - IncludeSystem: false, - }).Asserts(g, policy.ActionRead) + + s.Run("GetGroupMembersCountByGroupID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + g := testutil.Fake(s.T(), faker, database.Group{}) + arg := database.GetGroupMembersCountByGroupIDParams{GroupID: g.ID, IncludeSystem: false} + dbm.EXPECT().GetGroupByID(gomock.Any(), g.ID).Return(g, nil).AnyTimes() + dbm.EXPECT().GetGroupMembersCountByGroupID(gomock.Any(), arg).Return(int64(0), nil).AnyTimes() + check.Args(arg).Asserts(g, policy.ActionRead) })) - s.Run("GetGroupMembers", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - g := dbgen.Group(s.T(), db, database.Group{}) - u := dbgen.User(s.T(), db, database.User{}) - dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g.ID, UserID: u.ID}) + + s.Run("GetGroupMembers", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetGroupMembers(gomock.Any(), false).Return([]database.GroupMember{}, nil).AnyTimes() check.Args(false).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("System/GetGroups", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - _ = dbgen.Group(s.T(), db, database.Group{}) - check.Args(database.GetGroupsParams{}). - Asserts(rbac.ResourceSystem, policy.ActionRead) + + s.Run("System/GetGroups", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + g := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + row := database.GetGroupsRow{Group: g, OrganizationName: o.Name, OrganizationDisplayName: o.DisplayName} + dbm.EXPECT().GetGroups(gomock.Any(), database.GetGroupsParams{}).Return([]database.GetGroupsRow{row}, nil).AnyTimes() + check.Args(database.GetGroupsParams{}).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("GetGroups", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - g := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - u := dbgen.User(s.T(), db, database.User{}) - gm := dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g.ID, UserID: u.ID}) - check.Args(database.GetGroupsParams{ - OrganizationID: g.OrganizationID, - HasMemberID: gm.UserID, - }).Asserts(rbac.ResourceSystem, policy.ActionRead, g, policy.ActionRead). - // Fail the system resource skip - FailSystemObjectChecks() + + s.Run("GetGroups", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + g := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + u := testutil.Fake(s.T(), faker, database.User{}) + gm := testutil.Fake(s.T(), faker, database.GroupMember{GroupID: g.ID, UserID: u.ID}) + params := database.GetGroupsParams{OrganizationID: g.OrganizationID, HasMemberID: gm.UserID} + row := database.GetGroupsRow{Group: g, OrganizationName: o.Name, OrganizationDisplayName: o.DisplayName} + dbm.EXPECT().GetGroups(gomock.Any(), params).Return([]database.GetGroupsRow{row}, nil).AnyTimes() + check.Args(params).Asserts(rbac.ResourceSystem, policy.ActionRead, g, policy.ActionRead).FailSystemObjectChecks() })) - s.Run("InsertAllUsersGroup", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) + + s.Run("InsertAllUsersGroup", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + ret := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + dbm.EXPECT().InsertAllUsersGroup(gomock.Any(), o.ID).Return(ret, nil).AnyTimes() check.Args(o.ID).Asserts(rbac.ResourceGroup.InOrg(o.ID), policy.ActionCreate) })) - s.Run("InsertGroup", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - check.Args(database.InsertGroupParams{ - OrganizationID: o.ID, - Name: "test", - }).Asserts(rbac.ResourceGroup.InOrg(o.ID), policy.ActionCreate) + + s.Run("InsertGroup", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + arg := database.InsertGroupParams{OrganizationID: o.ID, Name: "test"} + ret := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID, Name: arg.Name}) + dbm.EXPECT().InsertGroup(gomock.Any(), arg).Return(ret, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceGroup.InOrg(o.ID), policy.ActionCreate) })) - s.Run("InsertGroupMember", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - g := dbgen.Group(s.T(), db, database.Group{}) - check.Args(database.InsertGroupMemberParams{ - UserID: uuid.New(), - GroupID: g.ID, - }).Asserts(g, policy.ActionUpdate).Returns() + + s.Run("InsertGroupMember", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + g := testutil.Fake(s.T(), faker, database.Group{}) + arg := database.InsertGroupMemberParams{UserID: uuid.New(), GroupID: g.ID} + dbm.EXPECT().GetGroupByID(gomock.Any(), g.ID).Return(g, nil).AnyTimes() + dbm.EXPECT().InsertGroupMember(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(g, policy.ActionUpdate).Returns() })) - s.Run("InsertUserGroupsByName", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u1 := dbgen.User(s.T(), db, database.User{}) - g1 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - g2 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - check.Args(database.InsertUserGroupsByNameParams{ - OrganizationID: o.ID, - UserID: u1.ID, - GroupNames: slice.New(g1.Name, g2.Name), - }).Asserts(rbac.ResourceGroup.InOrg(o.ID), policy.ActionUpdate).Returns() + + s.Run("InsertUserGroupsByName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + u1 := testutil.Fake(s.T(), faker, database.User{}) + g1 := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + g2 := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + arg := database.InsertUserGroupsByNameParams{OrganizationID: o.ID, UserID: u1.ID, GroupNames: slice.New(g1.Name, g2.Name)} + dbm.EXPECT().InsertUserGroupsByName(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceGroup.InOrg(o.ID), policy.ActionUpdate).Returns() })) - s.Run("InsertUserGroupsByID", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u1 := dbgen.User(s.T(), db, database.User{}) - g1 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - g2 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - g3 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - _ = dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g1.ID, UserID: u1.ID}) + + s.Run("InsertUserGroupsByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + u1 := testutil.Fake(s.T(), faker, database.User{}) + g1 := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + g2 := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + g3 := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) returns := slice.New(g2.ID, g3.ID) - if !dbtestutil.WillUsePostgres() { - returns = slice.New(g1.ID, g2.ID, g3.ID) - } - check.Args(database.InsertUserGroupsByIDParams{ - UserID: u1.ID, - GroupIds: slice.New(g1.ID, g2.ID, g3.ID), - }).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(returns) + arg := database.InsertUserGroupsByIDParams{UserID: u1.ID, GroupIds: slice.New(g1.ID, g2.ID, g3.ID)} + dbm.EXPECT().InsertUserGroupsByID(gomock.Any(), arg).Return(returns, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(returns) })) - s.Run("RemoveUserFromAllGroups", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u1 := dbgen.User(s.T(), db, database.User{}) - g1 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - g2 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - _ = dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g1.ID, UserID: u1.ID}) - _ = dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g2.ID, UserID: u1.ID}) + + s.Run("RemoveUserFromAllGroups", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u1 := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().RemoveUserFromAllGroups(gomock.Any(), u1.ID).Return(nil).AnyTimes() check.Args(u1.ID).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns() })) - s.Run("RemoveUserFromGroups", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u1 := dbgen.User(s.T(), db, database.User{}) - g1 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - g2 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - _ = dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g1.ID, UserID: u1.ID}) - _ = dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g2.ID, UserID: u1.ID}) - check.Args(database.RemoveUserFromGroupsParams{ - UserID: u1.ID, - GroupIds: []uuid.UUID{g1.ID, g2.ID}, - }).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(slice.New(g1.ID, g2.ID)) - })) - s.Run("UpdateGroupByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - g := dbgen.Group(s.T(), db, database.Group{}) - check.Args(database.UpdateGroupByIDParams{ - ID: g.ID, - }).Asserts(g, policy.ActionUpdate) + + s.Run("RemoveUserFromGroups", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + u1 := testutil.Fake(s.T(), faker, database.User{}) + g1 := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + g2 := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + arg := database.RemoveUserFromGroupsParams{UserID: u1.ID, GroupIds: []uuid.UUID{g1.ID, g2.ID}} + dbm.EXPECT().RemoveUserFromGroups(gomock.Any(), arg).Return(slice.New(g1.ID, g2.ID), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(slice.New(g1.ID, g2.ID)) })) -} -func (s *MethodTestSuite) TestProvisionerJob() { - s.Run("ArchiveUnusedTemplateVersions", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeTemplateVersionImport, - Error: sql.NullString{ - String: "failed", - Valid: true, - }, - }) - tpl := dbgen.Template(s.T(), db, database.Template{}) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - }) - check.Args(database.ArchiveUnusedTemplateVersionsParams{ - UpdatedAt: dbtime.Now(), - TemplateID: tpl.ID, - TemplateVersionID: uuid.Nil, - JobStatus: database.NullProvisionerJobStatus{}, - }).Asserts(v.RBACObject(tpl), policy.ActionUpdate) - })) - s.Run("UnarchiveTemplateVersion", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeTemplateVersionImport, - }) - tpl := dbgen.Template(s.T(), db, database.Template{}) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - Archived: true, - }) - check.Args(database.UnarchiveTemplateVersionParams{ - UpdatedAt: dbtime.Now(), - TemplateVersionID: v.ID, - }).Asserts(v.RBACObject(tpl), policy.ActionUpdate) + s.Run("UpdateGroupByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + g := testutil.Fake(s.T(), faker, database.Group{}) + arg := database.UpdateGroupByIDParams{ID: g.ID} + dbm.EXPECT().GetGroupByID(gomock.Any(), g.ID).Return(g, nil).AnyTimes() + dbm.EXPECT().UpdateGroupByID(gomock.Any(), arg).Return(g, nil).AnyTimes() + check.Args(arg).Asserts(g, policy.ActionUpdate) })) - s.Run("Build/GetProvisionerJobByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OwnerID: u.ID, - OrganizationID: o.ID, - TemplateID: tpl.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: w.ID, - TemplateVersionID: tv.ID, - }) - check.Args(j.ID).Asserts(w, policy.ActionRead).Returns(j) + + s.Run("ValidateGroupIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + g := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + ids := []uuid.UUID{g.ID} + dbm.EXPECT().ValidateGroupIDs(gomock.Any(), ids).Return(database.ValidateGroupIDsRow{}, nil).AnyTimes() + check.Args(ids).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("TemplateVersion/GetProvisionerJobByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeTemplateVersionImport, - }) - tpl := dbgen.Template(s.T(), db, database.Template{}) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - }) +} + +func (s *MethodTestSuite) TestProvisionerJob() { + s.Run("ArchiveUnusedTemplateVersions", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + v := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}}) + arg := database.ArchiveUnusedTemplateVersionsParams{UpdatedAt: dbtime.Now(), TemplateID: tpl.ID, TemplateVersionID: v.ID, JobStatus: database.NullProvisionerJobStatus{}} + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().ArchiveUnusedTemplateVersions(gomock.Any(), arg).Return([]uuid.UUID{}, nil).AnyTimes() + check.Args(arg).Asserts(tpl.RBACObject(), policy.ActionUpdate) + })) + s.Run("UnarchiveTemplateVersion", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + v := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, Archived: true}) + arg := database.UnarchiveTemplateVersionParams{UpdatedAt: dbtime.Now(), TemplateVersionID: v.ID} + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), v.ID).Return(v, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().UnarchiveTemplateVersion(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(tpl.RBACObject(), policy.ActionUpdate) + })) + s.Run("Build/GetProvisionerJobByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) + build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: j.ID}) + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), j.ID).Return(build, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), build.WorkspaceID).Return(ws, nil).AnyTimes() + check.Args(j.ID).Asserts(ws, policy.ActionRead).Returns(j) + })) + s.Run("TemplateVersion/GetProvisionerJobByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeTemplateVersionImport}) + v := testutil.Fake(s.T(), faker, database.TemplateVersion{JobID: j.ID, TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}}) + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionByJobID(gomock.Any(), j.ID).Return(v, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() check.Args(j.ID).Asserts(v.RBACObject(tpl), policy.ActionRead).Returns(j) })) - s.Run("TemplateVersionDryRun/GetProvisionerJobByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - tpl := dbgen.Template(s.T(), db, database.Template{}) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeTemplateVersionDryRun, - Input: must(json.Marshal(struct { - TemplateVersionID uuid.UUID `json:"template_version_id"` - }{TemplateVersionID: v.ID})), - }) + s.Run("TemplateVersionDryRun/GetProvisionerJobByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeTemplateVersionDryRun}) + v := testutil.Fake(s.T(), faker, database.TemplateVersion{JobID: j.ID, TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}}) + j.Input = must(json.Marshal(struct { + TemplateVersionID uuid.UUID `json:"template_version_id"` + }{TemplateVersionID: v.ID})) + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), v.ID).Return(v, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() check.Args(j.ID).Asserts(v.RBACObject(tpl), policy.ActionRead).Returns(j) })) - s.Run("Build/UpdateProvisionerJobWithCancelByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - AllowUserCancelWorkspaceJobs: true, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: w.ID, - TemplateVersionID: tv.ID, - }) - check.Args(database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID}).Asserts(w, policy.ActionUpdate).Returns() - })) - s.Run("BuildFalseCancel/UpdateProvisionerJobWithCancelByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - AllowUserCancelWorkspaceJobs: false, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{TemplateID: tpl.ID, OrganizationID: o.ID, OwnerID: u.ID}) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: w.ID, - TemplateVersionID: tv.ID, - }) - check.Args(database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID}).Asserts(w, policy.ActionUpdate).Returns() - })) - s.Run("TemplateVersion/UpdateProvisionerJobWithCancelByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeTemplateVersionImport, - }) - tpl := dbgen.Template(s.T(), db, database.Template{}) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - }) - check.Args(database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID}). - Asserts(v.RBACObject(tpl), []policy.Action{policy.ActionRead, policy.ActionUpdate}).Returns() - })) - s.Run("TemplateVersionNoTemplate/UpdateProvisionerJobWithCancelByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeTemplateVersionImport, - }) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - JobID: j.ID, - }) - check.Args(database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID}). - Asserts(v.RBACObjectNoTemplate(), []policy.Action{policy.ActionRead, policy.ActionUpdate}).Returns() - })) - s.Run("TemplateVersionDryRun/UpdateProvisionerJobWithCancelByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - tpl := dbgen.Template(s.T(), db, database.Template{}) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeTemplateVersionDryRun, - Input: must(json.Marshal(struct { - TemplateVersionID uuid.UUID `json:"template_version_id"` - }{TemplateVersionID: v.ID})), - }) - check.Args(database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID}). - Asserts(v.RBACObject(tpl), []policy.Action{policy.ActionRead, policy.ActionUpdate}).Returns() - })) - s.Run("GetProvisionerJobsByIDs", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - a := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{OrganizationID: o.ID}) - b := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{OrganizationID: o.ID}) - check.Args([]uuid.UUID{a.ID, b.ID}). - Asserts(rbac.ResourceProvisionerJobs.InOrg(o.ID), policy.ActionRead). - Returns(slice.New(a, b)) - })) - s.Run("GetProvisionerLogsAfterID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OrganizationID: o.ID, - OwnerID: u.ID, - TemplateID: tpl.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: w.ID, - TemplateVersionID: tv.ID, - }) - check.Args(database.GetProvisionerLogsAfterIDParams{ - JobID: j.ID, - }).Asserts(w, policy.ActionRead).Returns([]database.ProvisionerJobLog{}) + s.Run("Build/UpdateProvisionerJobWithCancelByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{AllowUserCancelWorkspaceJobs: true}) + ws := testutil.Fake(s.T(), faker, database.Workspace{TemplateID: tpl.ID}) + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) + build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{JobID: j.ID, WorkspaceID: ws.ID}) + arg := database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID} + + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), j.ID).Return(build, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().UpdateProvisionerJobWithCancelByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(ws, policy.ActionUpdate).Returns() + })) + s.Run("BuildFalseCancel/UpdateProvisionerJobWithCancelByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{AllowUserCancelWorkspaceJobs: false}) + ws := testutil.Fake(s.T(), faker, database.Workspace{TemplateID: tpl.ID}) + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) + build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{JobID: j.ID, WorkspaceID: ws.ID}) + arg := database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID} + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), j.ID).Return(build, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().UpdateProvisionerJobWithCancelByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(ws, policy.ActionUpdate).Returns() + })) + s.Run("TemplateVersion/UpdateProvisionerJobWithCancelByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeTemplateVersionImport}) + v := testutil.Fake(s.T(), faker, database.TemplateVersion{JobID: j.ID, TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}}) + arg := database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID} + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionByJobID(gomock.Any(), j.ID).Return(v, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().UpdateProvisionerJobWithCancelByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(v.RBACObject(tpl), []policy.Action{policy.ActionRead, policy.ActionUpdate}).Returns() + })) + s.Run("TemplateVersionNoTemplate/UpdateProvisionerJobWithCancelByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeTemplateVersionImport}) + v := testutil.Fake(s.T(), faker, database.TemplateVersion{JobID: j.ID}) + // uuid.NullUUID{Valid: false} is a zero value. faker overwrites zero values + // with random data, so we need to set TemplateID after faker is done with it. + v.TemplateID = uuid.NullUUID{UUID: uuid.Nil, Valid: false} + arg := database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID} + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionByJobID(gomock.Any(), j.ID).Return(v, nil).AnyTimes() + dbm.EXPECT().UpdateProvisionerJobWithCancelByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(v.RBACObjectNoTemplate(), []policy.Action{policy.ActionRead, policy.ActionUpdate}).Returns() + })) + s.Run("TemplateVersionDryRun/UpdateProvisionerJobWithCancelByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeTemplateVersionDryRun}) + v := testutil.Fake(s.T(), faker, database.TemplateVersion{JobID: j.ID, TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}}) + j.Input = must(json.Marshal(struct { + TemplateVersionID uuid.UUID `json:"template_version_id"` + }{TemplateVersionID: v.ID})) + arg := database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID} + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), v.ID).Return(v, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().UpdateProvisionerJobWithCancelByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(v.RBACObject(tpl), []policy.Action{policy.ActionRead, policy.ActionUpdate}).Returns() + })) + s.Run("GetProvisionerJobsByIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) + org2 := testutil.Fake(s.T(), faker, database.Organization{}) + a := testutil.Fake(s.T(), faker, database.ProvisionerJob{OrganizationID: org.ID}) + b := testutil.Fake(s.T(), faker, database.ProvisionerJob{OrganizationID: org2.ID}) + ids := []uuid.UUID{a.ID, b.ID} + dbm.EXPECT().GetProvisionerJobsByIDs(gomock.Any(), ids).Return([]database.ProvisionerJob{a, b}, nil).AnyTimes() + check.Args(ids).Asserts( + rbac.ResourceProvisionerJobs.InOrg(org.ID), policy.ActionRead, + rbac.ResourceProvisionerJobs.InOrg(org2.ID), policy.ActionRead, + ).OutOfOrder().Returns(slice.New(a, b)) + })) + s.Run("GetProvisionerLogsAfterID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) + build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{JobID: j.ID, WorkspaceID: ws.ID}) + arg := database.GetProvisionerLogsAfterIDParams{JobID: j.ID} + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), j.ID).Return(build, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetProvisionerLogsAfterID(gomock.Any(), arg).Return([]database.ProvisionerJobLog{}, nil).AnyTimes() + check.Args(arg).Asserts(ws, policy.ActionRead).Returns([]database.ProvisionerJobLog{}) })) } 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) { - start := dbtime.Now() - end := start.Add(time.Hour) - 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{}) - })) - s.Run("ByOrganization/GetGroups", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - a := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - b := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - check.Args(database.GetGroupsParams{ - OrganizationID: o.ID, - }).Asserts(rbac.ResourceSystem, policy.ActionRead, a, policy.ActionRead, b, policy.ActionRead). - Returns([]database.GetGroupsRow{ - {Group: a, OrganizationName: o.Name, OrganizationDisplayName: o.DisplayName}, - {Group: b, OrganizationName: o.Name, OrganizationDisplayName: o.DisplayName}, - }). - // Fail the system check shortcut + 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.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + a := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + b := testutil.Fake(s.T(), faker, database.Group{OrganizationID: o.ID}) + params := database.GetGroupsParams{OrganizationID: o.ID} + rows := []database.GetGroupsRow{ + {Group: a, OrganizationName: o.Name, OrganizationDisplayName: o.DisplayName}, + {Group: b, OrganizationName: o.Name, OrganizationDisplayName: o.DisplayName}, + } + dbm.EXPECT().GetGroups(gomock.Any(), params).Return(rows, nil).AnyTimes() + check.Args(params). + Asserts(rbac.ResourceSystem, policy.ActionRead, a, policy.ActionRead, b, policy.ActionRead). + Returns(rows). FailSystemObjectChecks() })) - s.Run("GetOrganizationByID", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) + s.Run("GetOrganizationByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + dbm.EXPECT().GetOrganizationByID(gomock.Any(), o.ID).Return(o, nil).AnyTimes() check.Args(o.ID).Asserts(o, policy.ActionRead).Returns(o) })) - s.Run("GetOrganizationResourceCountByID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - - t := dbgen.Template(s.T(), db, database.Template{ - CreatedBy: u.ID, - OrganizationID: o.ID, - }) - dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OrganizationID: o.ID, - OwnerID: u.ID, - TemplateID: t.ID, - }) - dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) - dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ - OrganizationID: o.ID, - UserID: u.ID, - }) - - check.Args(o.ID).Asserts( - rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionRead, - rbac.ResourceWorkspace.InOrg(o.ID), policy.ActionRead, - rbac.ResourceGroup.InOrg(o.ID), policy.ActionRead, - rbac.ResourceTemplate.InOrg(o.ID), policy.ActionRead, - rbac.ResourceProvisionerDaemon.InOrg(o.ID), policy.ActionRead, - ).Returns(database.GetOrganizationResourceCountByIDRow{ + s.Run("GetOrganizationResourceCountByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + row := database.GetOrganizationResourceCountByIDRow{ WorkspaceCount: 1, GroupCount: 1, TemplateCount: 1, MemberCount: 1, ProvisionerKeyCount: 0, - }) + } + dbm.EXPECT().GetOrganizationResourceCountByID(gomock.Any(), o.ID).Return(row, nil).AnyTimes() + check.Args(o.ID).Asserts( + rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionRead, + rbac.ResourceWorkspace.InOrg(o.ID), policy.ActionRead, + rbac.ResourceGroup.InOrg(o.ID), policy.ActionRead, + rbac.ResourceTemplate.InOrg(o.ID), policy.ActionRead, + rbac.ResourceProvisionerDaemon.InOrg(o.ID), policy.ActionRead, + ).Returns(row) })) - s.Run("GetDefaultOrganization", s.Subtest(func(db database.Store, check *expects) { - o, _ := db.GetDefaultOrganization(context.Background()) + s.Run("GetDefaultOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + dbm.EXPECT().GetDefaultOrganization(gomock.Any()).Return(o, nil).AnyTimes() check.Args().Asserts(o, policy.ActionRead).Returns(o) })) - s.Run("GetOrganizationByName", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - check.Args(database.GetOrganizationByNameParams{Name: o.Name, Deleted: o.Deleted}).Asserts(o, policy.ActionRead).Returns(o) - })) - s.Run("GetOrganizationIDsByMemberIDs", s.Subtest(func(db database.Store, check *expects) { - oa := dbgen.Organization(s.T(), db, database.Organization{}) - ob := dbgen.Organization(s.T(), db, database.Organization{}) - ua := dbgen.User(s.T(), db, database.User{}) - ub := dbgen.User(s.T(), db, database.User{}) - ma := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: oa.ID, UserID: ua.ID}) - mb := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: ob.ID, UserID: ub.ID}) - check.Args([]uuid.UUID{ma.UserID, mb.UserID}). - Asserts(rbac.ResourceUserObject(ma.UserID), policy.ActionRead, rbac.ResourceUserObject(mb.UserID), policy.ActionRead).OutOfOrder() - })) - s.Run("GetOrganizations", s.Subtest(func(db database.Store, check *expects) { - def, _ := db.GetDefaultOrganization(context.Background()) - a := dbgen.Organization(s.T(), db, database.Organization{}) - b := dbgen.Organization(s.T(), db, database.Organization{}) - check.Args(database.GetOrganizationsParams{}).Asserts(def, policy.ActionRead, a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(def, a, b)) - })) - s.Run("GetOrganizationsByUserID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - a := dbgen.Organization(s.T(), db, database.Organization{}) - _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{UserID: u.ID, OrganizationID: a.ID}) - b := dbgen.Organization(s.T(), db, database.Organization{}) - _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{UserID: u.ID, OrganizationID: b.ID}) - check.Args(database.GetOrganizationsByUserIDParams{UserID: u.ID, Deleted: sql.NullBool{Valid: true, Bool: false}}).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b)) - })) - s.Run("InsertOrganization", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertOrganizationParams{ - ID: uuid.New(), - Name: "new-org", - }).Asserts(rbac.ResourceOrganization, policy.ActionCreate) - })) - s.Run("InsertOrganizationMember", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - - check.Args(database.InsertOrganizationMemberParams{ - OrganizationID: o.ID, - UserID: u.ID, - Roles: []string{codersdk.RoleOrganizationAdmin}, - }).Asserts( + s.Run("GetOrganizationByName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + arg := database.GetOrganizationByNameParams{Name: o.Name, Deleted: o.Deleted} + dbm.EXPECT().GetOrganizationByName(gomock.Any(), arg).Return(o, nil).AnyTimes() + check.Args(arg).Asserts(o, policy.ActionRead).Returns(o) + })) + s.Run("GetOrganizationIDsByMemberIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + oa := testutil.Fake(s.T(), faker, database.Organization{}) + ob := testutil.Fake(s.T(), faker, database.Organization{}) + ua := testutil.Fake(s.T(), faker, database.User{}) + ub := testutil.Fake(s.T(), faker, database.User{}) + ids := []uuid.UUID{ua.ID, ub.ID} + rows := []database.GetOrganizationIDsByMemberIDsRow{ + {UserID: ua.ID, OrganizationIDs: []uuid.UUID{oa.ID}}, + {UserID: ub.ID, OrganizationIDs: []uuid.UUID{ob.ID}}, + } + dbm.EXPECT().GetOrganizationIDsByMemberIDs(gomock.Any(), ids).Return(rows, nil).AnyTimes() + check.Args(ids). + Asserts(rows[0].RBACObject(), policy.ActionRead, rows[1].RBACObject(), policy.ActionRead). + OutOfOrder() + })) + s.Run("GetOrganizations", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + def := testutil.Fake(s.T(), faker, database.Organization{}) + a := testutil.Fake(s.T(), faker, database.Organization{}) + b := testutil.Fake(s.T(), faker, database.Organization{}) + arg := database.GetOrganizationsParams{} + dbm.EXPECT().GetOrganizations(gomock.Any(), arg).Return([]database.Organization{def, a, b}, nil).AnyTimes() + check.Args(arg).Asserts(def, policy.ActionRead, a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(def, a, b)) + })) + s.Run("GetOrganizationsByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + a := testutil.Fake(s.T(), faker, database.Organization{}) + b := testutil.Fake(s.T(), faker, database.Organization{}) + arg := database.GetOrganizationsByUserIDParams{UserID: u.ID, Deleted: sql.NullBool{Valid: true, Bool: false}} + dbm.EXPECT().GetOrganizationsByUserID(gomock.Any(), arg).Return([]database.Organization{a, b}, nil).AnyTimes() + check.Args(arg).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b)) + })) + s.Run("InsertOrganization", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertOrganizationParams{ID: uuid.New(), Name: "new-org"} + dbm.EXPECT().InsertOrganization(gomock.Any(), arg).Return(database.Organization{ID: arg.ID, Name: arg.Name}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceOrganization, policy.ActionCreate) + })) + s.Run("InsertOrganizationMember", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.InsertOrganizationMemberParams{OrganizationID: o.ID, UserID: u.ID, Roles: []string{codersdk.RoleOrganizationAdmin}} + dbm.EXPECT().InsertOrganizationMember(gomock.Any(), arg).Return(database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID, Roles: arg.Roles}, nil).AnyTimes() + check.Args(arg).Asserts( rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign, - rbac.ResourceOrganizationMember.InOrg(o.ID).WithID(u.ID), policy.ActionCreate) + rbac.ResourceOrganizationMember.InOrg(o.ID).WithID(u.ID), policy.ActionCreate, + ) })) - s.Run("InsertPreset", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - user := dbgen.User(s.T(), db, database.User{}) - template := dbgen.Template(s.T(), db, database.Template{ - CreatedBy: user.ID, - OrganizationID: org.ID, - }) - templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - workspace := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OrganizationID: org.ID, - OwnerID: user.ID, - TemplateID: template.ID, - }) - job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - OrganizationID: org.ID, - }) - workspaceBuild := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - WorkspaceID: workspace.ID, - TemplateVersionID: templateVersion.ID, - InitiatorID: user.ID, - JobID: job.ID, - }) - insertPresetParams := database.InsertPresetParams{ - TemplateVersionID: workspaceBuild.TemplateVersionID, - Name: "test", - } - check.Args(insertPresetParams).Asserts(rbac.ResourceTemplate, policy.ActionUpdate) + s.Run("InsertPreset", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertPresetParams{TemplateVersionID: uuid.New(), Name: "test"} + dbm.EXPECT().InsertPreset(gomock.Any(), arg).Return(database.TemplateVersionPreset{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionUpdate) })) - s.Run("InsertPresetParameters", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - user := dbgen.User(s.T(), db, database.User{}) - template := dbgen.Template(s.T(), db, database.Template{ - CreatedBy: user.ID, - OrganizationID: org.ID, - }) - templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - workspace := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OrganizationID: org.ID, - OwnerID: user.ID, - TemplateID: template.ID, - }) - job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - OrganizationID: org.ID, - }) - workspaceBuild := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - WorkspaceID: workspace.ID, - TemplateVersionID: templateVersion.ID, - InitiatorID: user.ID, - JobID: job.ID, - }) - insertPresetParams := database.InsertPresetParams{ - TemplateVersionID: workspaceBuild.TemplateVersionID, - Name: "test", - } - preset := dbgen.Preset(s.T(), db, insertPresetParams) - insertPresetParametersParams := database.InsertPresetParametersParams{ - TemplateVersionPresetID: preset.ID, - Names: []string{"test"}, - Values: []string{"test"}, - } - check.Args(insertPresetParametersParams).Asserts(rbac.ResourceTemplate, policy.ActionUpdate) + s.Run("InsertPresetParameters", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertPresetParametersParams{TemplateVersionPresetID: uuid.New(), Names: []string{"test"}, Values: []string{"test"}} + dbm.EXPECT().InsertPresetParameters(gomock.Any(), arg).Return([]database.TemplateVersionPresetParameter{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionUpdate) })) - s.Run("InsertPresetPrebuildSchedule", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - user := dbgen.User(s.T(), db, database.User{}) - template := dbgen.Template(s.T(), db, database.Template{ - CreatedBy: user.ID, - OrganizationID: org.ID, - }) - templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - preset := dbgen.Preset(s.T(), db, database.InsertPresetParams{ - TemplateVersionID: templateVersion.ID, - Name: "test", - }) - arg := database.InsertPresetPrebuildScheduleParams{ - PresetID: preset.ID, - } - check.Args(arg). - Asserts(rbac.ResourceTemplate, policy.ActionUpdate) + s.Run("InsertPresetPrebuildSchedule", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertPresetPrebuildScheduleParams{PresetID: uuid.New()} + dbm.EXPECT().InsertPresetPrebuildSchedule(gomock.Any(), arg).Return(database.TemplateVersionPresetPrebuildSchedule{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionUpdate) })) - s.Run("DeleteOrganizationMember", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - member := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{UserID: u.ID, OrganizationID: o.ID}) + s.Run("DeleteOrganizationMember", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + u := testutil.Fake(s.T(), faker, database.User{}) + member := testutil.Fake(s.T(), faker, database.OrganizationMember{UserID: u.ID, OrganizationID: o.ID}) - cancelledErr := "fetch object: context canceled" - if !dbtestutil.WillUsePostgres() { - cancelledErr = sql.ErrNoRows.Error() - } + params := database.OrganizationMembersParams{OrganizationID: o.ID, UserID: u.ID, IncludeSystem: false} + dbm.EXPECT().OrganizationMembers(gomock.Any(), params).Return([]database.OrganizationMembersRow{{OrganizationMember: member}}, nil).AnyTimes() + dbm.EXPECT().DeleteOrganizationMember(gomock.Any(), database.DeleteOrganizationMemberParams{OrganizationID: o.ID, UserID: u.ID}).Return(nil).AnyTimes() - check.Args(database.DeleteOrganizationMemberParams{ - OrganizationID: o.ID, - UserID: u.ID, - }).Asserts( - // Reads the org member before it tries to delete it + check.Args(database.DeleteOrganizationMemberParams{OrganizationID: o.ID, UserID: u.ID}).Asserts( member, policy.ActionRead, - member, policy.ActionDelete). - WithNotAuthorized("no rows"). - WithCancelled(cancelledErr) - })) - s.Run("UpdateOrganization", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{ - Name: "something-unique", - }) - check.Args(database.UpdateOrganizationParams{ - ID: o.ID, - Name: "something-different", - }).Asserts(o, policy.ActionUpdate) - })) - s.Run("UpdateOrganizationDeletedByID", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{ - Name: "doomed", - }) - check.Args(database.UpdateOrganizationDeletedByIDParams{ - ID: o.ID, - UpdatedAt: o.UpdatedAt, - }).Asserts(o, policy.ActionDelete).Returns() - })) - s.Run("OrganizationMembers", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ - OrganizationID: o.ID, - UserID: u.ID, - Roles: []string{rbac.RoleOrgAdmin()}, - }) - - check.Args(database.OrganizationMembersParams{ - OrganizationID: o.ID, - UserID: u.ID, - }).Asserts( - mem, policy.ActionRead, - ) - })) - s.Run("PaginatedOrganizationMembers", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ - OrganizationID: o.ID, - UserID: u.ID, - Roles: []string{rbac.RoleOrgAdmin()}, - }) - - check.Args(database.PaginatedOrganizationMembersParams{ - OrganizationID: o.ID, - LimitOpt: 0, - }).Asserts( - rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionRead, - ).Returns([]database.PaginatedOrganizationMembersRow{ - { - OrganizationMember: mem, - Username: u.Username, - AvatarURL: u.AvatarURL, - Name: u.Name, - Email: u.Email, - GlobalRoles: u.RBACRoles, - Count: 1, - }, - }) - })) - s.Run("UpdateMemberRoles", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ - OrganizationID: o.ID, - UserID: u.ID, - Roles: []string{codersdk.RoleOrganizationAdmin}, - }) + member, policy.ActionDelete, + ).WithNotAuthorized("no rows").WithCancelled(sql.ErrNoRows.Error()) + })) + s.Run("UpdateOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{Name: "something-unique"}) + arg := database.UpdateOrganizationParams{ID: o.ID, Name: "something-different"} + + dbm.EXPECT().GetOrganizationByID(gomock.Any(), o.ID).Return(o, nil).AnyTimes() + dbm.EXPECT().UpdateOrganization(gomock.Any(), arg).Return(o, nil).AnyTimes() + check.Args(arg).Asserts(o, policy.ActionUpdate) + })) + s.Run("UpdateOrganizationDeletedByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{Name: "doomed"}) + dbm.EXPECT().GetOrganizationByID(gomock.Any(), o.ID).Return(o, nil).AnyTimes() + dbm.EXPECT().UpdateOrganizationDeletedByID(gomock.Any(), gomock.AssignableToTypeOf(database.UpdateOrganizationDeletedByIDParams{})).Return(nil).AnyTimes() + check.Args(database.UpdateOrganizationDeletedByIDParams{ID: o.ID, UpdatedAt: o.UpdatedAt}).Asserts(o, policy.ActionDelete).Returns() + })) + s.Run("OrganizationMembers", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + u := testutil.Fake(s.T(), faker, database.User{}) + mem := testutil.Fake(s.T(), faker, database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID, Roles: []string{rbac.RoleOrgAdmin()}}) + + arg := database.OrganizationMembersParams{OrganizationID: o.ID, UserID: u.ID} + dbm.EXPECT().OrganizationMembers(gomock.Any(), gomock.AssignableToTypeOf(database.OrganizationMembersParams{})).Return([]database.OrganizationMembersRow{{OrganizationMember: mem}}, nil).AnyTimes() + + check.Args(arg).Asserts(mem, policy.ActionRead) + })) + s.Run("PaginatedOrganizationMembers", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + u := testutil.Fake(s.T(), faker, database.User{}) + mem := testutil.Fake(s.T(), faker, database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID, Roles: []string{rbac.RoleOrgAdmin()}}) + + arg := database.PaginatedOrganizationMembersParams{OrganizationID: o.ID, LimitOpt: 0} + rows := []database.PaginatedOrganizationMembersRow{{ + OrganizationMember: mem, + Username: u.Username, + AvatarURL: u.AvatarURL, + Name: u.Name, + Email: u.Email, + GlobalRoles: u.RBACRoles, + Count: 1, + }} + dbm.EXPECT().PaginatedOrganizationMembers(gomock.Any(), arg).Return(rows, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionRead).Returns(rows) + })) + s.Run("UpdateMemberRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + o := testutil.Fake(s.T(), faker, database.Organization{}) + u := testutil.Fake(s.T(), faker, database.User{}) + mem := testutil.Fake(s.T(), faker, database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID, Roles: []string{codersdk.RoleOrganizationAdmin}}) out := mem out.Roles = []string{} - cancelledErr := "fetch object: context canceled" - if !dbtestutil.WillUsePostgres() { - cancelledErr = sql.ErrNoRows.Error() - } + dbm.EXPECT().OrganizationMembers(gomock.Any(), database.OrganizationMembersParams{OrganizationID: o.ID, UserID: u.ID, IncludeSystem: false}).Return([]database.OrganizationMembersRow{{OrganizationMember: mem}}, nil).AnyTimes() + arg := database.UpdateMemberRolesParams{GrantedRoles: []string{}, UserID: u.ID, OrgID: o.ID} + dbm.EXPECT().UpdateMemberRoles(gomock.Any(), arg).Return(out, nil).AnyTimes() - check.Args(database.UpdateMemberRolesParams{ - GrantedRoles: []string{}, - UserID: u.ID, - OrgID: o.ID, - }). + check.Args(arg). WithNotAuthorized(sql.ErrNoRows.Error()). - WithCancelled(cancelledErr). + WithCancelled(sql.ErrNoRows.Error()). Asserts( mem, policy.ActionRead, rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign, // org-mem @@ -1237,804 +935,643 @@ 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)) })) } func (s *MethodTestSuite) TestTemplate() { - s.Run("GetPreviousTemplateVersion", s.Subtest(func(db database.Store, check *expects) { - tvid := uuid.New() - now := time.Now() - u := dbgen.User(s.T(), db, database.User{}) - o1 := dbgen.Organization(s.T(), db, database.Organization{}) - t1 := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o1.ID, - ActiveVersionID: tvid, - CreatedBy: u.ID, - }) - _ = dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - CreatedAt: now.Add(-time.Hour), - ID: tvid, - Name: t1.Name, - OrganizationID: o1.ID, - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - CreatedBy: u.ID, - }) - b := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - CreatedAt: now.Add(-2 * time.Hour), - Name: t1.Name + "b", - OrganizationID: o1.ID, - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - CreatedBy: u.ID, - }) - check.Args(database.GetPreviousTemplateVersionParams{ - Name: t1.Name, - OrganizationID: o1.ID, - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }).Asserts(t1, policy.ActionRead).Returns(b) - })) - s.Run("GetTemplateByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) + s.Run("GetPreviousTemplateVersion", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + b := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}}) + arg := database.GetPreviousTemplateVersionParams{Name: b.Name, OrganizationID: t1.OrganizationID, TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().GetPreviousTemplateVersion(gomock.Any(), arg).Return(b, nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionRead).Returns(b) + })) + s.Run("GetTemplateByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() check.Args(t1.ID).Asserts(t1, policy.ActionRead).Returns(t1) })) - s.Run("GetTemplateByOrganizationAndName", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - o1 := dbgen.Organization(s.T(), db, database.Organization{}) - t1 := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o1.ID, - }) - check.Args(database.GetTemplateByOrganizationAndNameParams{ - Name: t1.Name, - OrganizationID: o1.ID, - }).Asserts(t1, policy.ActionRead).Returns(t1) - })) - s.Run("GetTemplateVersionByJobID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }) + s.Run("GetTemplateByOrganizationAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.GetTemplateByOrganizationAndNameParams{Name: t1.Name, OrganizationID: t1.OrganizationID} + dbm.EXPECT().GetTemplateByOrganizationAndName(gomock.Any(), arg).Return(t1, nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionRead).Returns(t1) + })) + s.Run("GetTemplateVersionByJobID", 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().GetTemplateVersionByJobID(gomock.Any(), tv.JobID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() check.Args(tv.JobID).Asserts(t1, policy.ActionRead).Returns(tv) })) - s.Run("GetTemplateVersionByTemplateIDAndName", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }) - check.Args(database.GetTemplateVersionByTemplateIDAndNameParams{ - Name: tv.Name, - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }).Asserts(t1, policy.ActionRead).Returns(tv) - })) - s.Run("GetTemplateVersionParameters", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }) + s.Run("GetTemplateVersionByTemplateIDAndName", 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}}) + arg := database.GetTemplateVersionByTemplateIDAndNameParams{Name: tv.Name, TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}} + dbm.EXPECT().GetTemplateVersionByTemplateIDAndName(gomock.Any(), arg).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionRead).Returns(tv) + })) + s.Run("GetTemplateVersionParameters", 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().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionParameters(gomock.Any(), tv.ID).Return([]database.TemplateVersionParameter{}, nil).AnyTimes() check.Args(tv.ID).Asserts(t1, policy.ActionRead).Returns([]database.TemplateVersionParameter{}) })) - s.Run("GetTemplateVersionTerraformValues", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID}) - t := dbgen.Template(s.T(), db, database.Template{OrganizationID: o.ID, CreatedBy: u.ID}) - job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{OrganizationID: o.ID}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - OrganizationID: o.ID, - CreatedBy: u.ID, - JobID: job.ID, - TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}, - }) - dbgen.TemplateVersionTerraformValues(s.T(), db, database.TemplateVersionTerraformValue{ - TemplateVersionID: tv.ID, - }) + s.Run("GetTemplateVersionTerraformValues", 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}}) + val := testutil.Fake(s.T(), faker, database.TemplateVersionTerraformValue{TemplateVersionID: tv.ID}) + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t.ID).Return(t, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionTerraformValues(gomock.Any(), tv.ID).Return(val, nil).AnyTimes() check.Args(tv.ID).Asserts(t, policy.ActionRead) })) - s.Run("GetTemplateVersionVariables", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }) - tvv1 := dbgen.TemplateVersionVariable(s.T(), db, database.TemplateVersionVariable{ - TemplateVersionID: tv.ID, - }) + s.Run("GetTemplateVersionVariables", 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}}) + tvv1 := testutil.Fake(s.T(), faker, database.TemplateVersionVariable{TemplateVersionID: tv.ID}) + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionVariables(gomock.Any(), tv.ID).Return([]database.TemplateVersionVariable{tvv1}, nil).AnyTimes() check.Args(tv.ID).Asserts(t1, policy.ActionRead).Returns([]database.TemplateVersionVariable{tvv1}) })) - s.Run("GetTemplateVersionWorkspaceTags", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }) - wt1 := dbgen.TemplateVersionWorkspaceTag(s.T(), db, database.TemplateVersionWorkspaceTag{ - TemplateVersionID: tv.ID, - }) + s.Run("GetTemplateVersionWorkspaceTags", 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}}) + wt1 := testutil.Fake(s.T(), faker, database.TemplateVersionWorkspaceTag{TemplateVersionID: tv.ID}) + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionWorkspaceTags(gomock.Any(), tv.ID).Return([]database.TemplateVersionWorkspaceTag{wt1}, nil).AnyTimes() check.Args(tv.ID).Asserts(t1, policy.ActionRead).Returns([]database.TemplateVersionWorkspaceTag{wt1}) })) - s.Run("GetTemplateGroupRoles", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) + s.Run("GetTemplateGroupRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().GetTemplateGroupRoles(gomock.Any(), t1.ID).Return([]database.TemplateGroup{}, nil).AnyTimes() check.Args(t1.ID).Asserts(t1, policy.ActionUpdate) })) - s.Run("GetTemplateUserRoles", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) + s.Run("GetTemplateUserRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().GetTemplateUserRoles(gomock.Any(), t1.ID).Return([]database.TemplateUser{}, nil).AnyTimes() check.Args(t1.ID).Asserts(t1, policy.ActionUpdate) })) - s.Run("GetTemplateVersionByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }) + s.Run("GetTemplateVersionByID", 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().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() check.Args(tv.ID).Asserts(t1, policy.ActionRead).Returns(tv) })) - s.Run("GetTemplateVersionsByTemplateID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - a := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }) - b := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }) - check.Args(database.GetTemplateVersionsByTemplateIDParams{ - TemplateID: t1.ID, - }).Asserts(t1, policy.ActionRead). - Returns(slice.New(a, b)) - })) - s.Run("GetTemplateVersionsCreatedAfter", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) + s.Run("Orphaned/GetTemplateVersionByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{}) + // uuid.NullUUID{Valid: false} is a zero value. faker overwrites zero values + // with random data, so we need to set TemplateID after faker is done with it. + tv.TemplateID = uuid.NullUUID{Valid: false} + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + check.Args(tv.ID).Asserts(tv.RBACObjectNoTemplate(), policy.ActionRead).Returns(tv) + })) + s.Run("GetTemplateVersionsByTemplateID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + a := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}}) + b := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}}) + arg := database.GetTemplateVersionsByTemplateIDParams{TemplateID: t1.ID} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionsByTemplateID(gomock.Any(), arg).Return([]database.TemplateVersion{a, b}, nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionRead).Returns(slice.New(a, b)) + })) + s.Run("GetTemplateVersionsCreatedAfter", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { now := time.Now() - t1 := dbgen.Template(s.T(), db, database.Template{}) - _ = dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - CreatedAt: now.Add(-time.Hour), - }) - _ = dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - CreatedAt: now.Add(-2 * time.Hour), - }) + dbm.EXPECT().GetTemplateVersionsCreatedAfter(gomock.Any(), now.Add(-time.Hour)).Return([]database.TemplateVersion{}, nil).AnyTimes() check.Args(now.Add(-time.Hour)).Asserts(rbac.ResourceTemplate.All(), policy.ActionRead) })) - s.Run("GetTemplatesWithFilter", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - a := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - // No asserts because SQLFilter. - check.Args(database.GetTemplatesWithFilterParams{}). - Asserts().Returns(slice.New(a)) + s.Run("GetTemplateVersionHasAITask", 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}}) + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t.ID).Return(t, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionHasAITask(gomock.Any(), tv.ID).Return(false, nil).AnyTimes() + check.Args(tv.ID).Asserts(t, policy.ActionRead) })) - s.Run("GetAuthorizedTemplates", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - a := dbgen.Template(s.T(), db, database.Template{}) + s.Run("GetTemplatesWithFilter", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + a := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.GetTemplatesWithFilterParams{} + dbm.EXPECT().GetAuthorizedTemplates(gomock.Any(), arg, gomock.Any()).Return([]database.Template{a}, nil).AnyTimes() // No asserts because SQLFilter. - check.Args(database.GetTemplatesWithFilterParams{}, emptyPreparedAuthorized{}). - Asserts(). - Returns(slice.New(a)) - })) - s.Run("InsertTemplate", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - orgID := uuid.New() - check.Args(database.InsertTemplateParams{ - Provisioner: "echo", - OrganizationID: orgID, - MaxPortSharingLevel: database.AppSharingLevelOwner, - CorsBehavior: database.CorsBehaviorSimple, - }).Asserts(rbac.ResourceTemplate.InOrg(orgID), policy.ActionCreate) - })) - s.Run("InsertTemplateVersion", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - check.Args(database.InsertTemplateVersionParams{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - OrganizationID: t1.OrganizationID, - }).Asserts(t1, policy.ActionRead, t1, policy.ActionCreate) - })) - s.Run("InsertTemplateVersionTerraformValuesByJobID", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID}) - t := dbgen.Template(s.T(), db, database.Template{OrganizationID: o.ID, CreatedBy: u.ID}) - job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{OrganizationID: o.ID}) - _ = dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - OrganizationID: o.ID, - CreatedBy: u.ID, - JobID: job.ID, - TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}, - }) - check.Args(database.InsertTemplateVersionTerraformValuesByJobIDParams{ - JobID: job.ID, - CachedPlan: []byte("{}"), - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) + check.Args(arg).Asserts().Returns(slice.New(a)) })) - s.Run("SoftDeleteTemplateByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) + s.Run("GetAuthorizedTemplates", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + a := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.GetTemplatesWithFilterParams{} + dbm.EXPECT().GetAuthorizedTemplates(gomock.Any(), arg, gomock.Any()).Return([]database.Template{a}, nil).AnyTimes() + // No asserts because SQLFilter. + check.Args(arg, emptyPreparedAuthorized{}).Asserts().Returns(slice.New(a)) + })) + s.Run("InsertTemplate", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertTemplateParams{OrganizationID: uuid.New()} + dbm.EXPECT().InsertTemplate(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate.InOrg(arg.OrganizationID), policy.ActionCreate) + })) + s.Run("InsertTemplateVersion", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.InsertTemplateVersionParams{TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, OrganizationID: t1.OrganizationID} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().InsertTemplateVersion(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionRead, t1, policy.ActionCreate) + })) + s.Run("InsertTemplateVersionTerraformValuesByJobID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + job := uuid.New() + arg := database.InsertTemplateVersionTerraformValuesByJobIDParams{JobID: job, CachedPlan: []byte("{}")} + dbm.EXPECT().InsertTemplateVersionTerraformValuesByJobID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("SoftDeleteTemplateByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateTemplateDeletedByID(gomock.Any(), gomock.AssignableToTypeOf(database.UpdateTemplateDeletedByIDParams{})).Return(nil).AnyTimes() check.Args(t1.ID).Asserts(t1, policy.ActionDelete) })) - s.Run("UpdateTemplateACLByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - check.Args(database.UpdateTemplateACLByIDParams{ - ID: t1.ID, - }).Asserts(t1, policy.ActionCreate) - })) - s.Run("UpdateTemplateAccessControlByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - check.Args(database.UpdateTemplateAccessControlByIDParams{ - ID: t1.ID, - }).Asserts(t1, policy.ActionUpdate) - })) - s.Run("UpdateTemplateScheduleByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - check.Args(database.UpdateTemplateScheduleByIDParams{ - ID: t1.ID, - }).Asserts(t1, policy.ActionUpdate) - })) - s.Run("UpdateTemplateVersionAITaskByJobID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - o := dbgen.Organization(s.T(), db, database.Organization{}) - u := dbgen.User(s.T(), db, database.User{}) - _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: o.ID, UserID: u.ID}) - t := dbgen.Template(s.T(), db, database.Template{OrganizationID: o.ID, CreatedBy: u.ID}) - job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{OrganizationID: o.ID}) - _ = dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - OrganizationID: o.ID, - CreatedBy: u.ID, - JobID: job.ID, - TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}, - }) - check.Args(database.UpdateTemplateVersionAITaskByJobIDParams{ - JobID: job.ID, - HasAITask: sql.NullBool{Bool: true, Valid: true}, - }).Asserts(t, policy.ActionUpdate) - })) - s.Run("UpdateTemplateWorkspacesLastUsedAt", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - check.Args(database.UpdateTemplateWorkspacesLastUsedAtParams{ - TemplateID: t1.ID, - }).Asserts(t1, policy.ActionUpdate) - })) - s.Run("UpdateWorkspacesDormantDeletingAtByTemplateID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - check.Args(database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams{ - TemplateID: t1.ID, - }).Asserts(t1, policy.ActionUpdate) - })) - s.Run("UpdateWorkspacesTTLByTemplateID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - check.Args(database.UpdateWorkspacesTTLByTemplateIDParams{ - TemplateID: t1.ID, - }).Asserts(t1, policy.ActionUpdate) - })) - s.Run("UpdateTemplateActiveVersionByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{ - ActiveVersionID: uuid.New(), - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - ID: t1.ActiveVersionID, - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }) - check.Args(database.UpdateTemplateActiveVersionByIDParams{ - ID: t1.ID, - ActiveVersionID: tv.ID, - }).Asserts(t1, policy.ActionUpdate).Returns() - })) - s.Run("UpdateTemplateDeletedByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - check.Args(database.UpdateTemplateDeletedByIDParams{ - ID: t1.ID, - Deleted: true, - }).Asserts(t1, policy.ActionDelete).Returns() - })) - s.Run("UpdateTemplateMetaByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - check.Args(database.UpdateTemplateMetaByIDParams{ - ID: t1.ID, - MaxPortSharingLevel: "owner", - CorsBehavior: database.CorsBehaviorSimple, - }).Asserts(t1, policy.ActionUpdate) - })) - s.Run("UpdateTemplateVersionByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }) - check.Args(database.UpdateTemplateVersionByIDParams{ - ID: tv.ID, - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - Name: tv.Name, - UpdatedAt: tv.UpdatedAt, - }).Asserts(t1, policy.ActionUpdate) - })) - s.Run("UpdateTemplateVersionDescriptionByJobID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - jobID := uuid.New() - t1 := dbgen.Template(s.T(), db, database.Template{}) - _ = dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - JobID: jobID, - }) - check.Args(database.UpdateTemplateVersionDescriptionByJobIDParams{ - JobID: jobID, - Readme: "foo", - }).Asserts(t1, policy.ActionUpdate).Returns() - })) - s.Run("UpdateTemplateVersionExternalAuthProvidersByJobID", s.Subtest(func(db database.Store, check *expects) { - jobID := uuid.New() - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - t1 := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - _ = dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - CreatedBy: u.ID, - OrganizationID: o.ID, - JobID: jobID, - }) - check.Args(database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams{ - JobID: jobID, - ExternalAuthProviders: json.RawMessage("{}"), - }).Asserts(t1, policy.ActionUpdate).Returns() - })) - s.Run("GetTemplateInsights", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetTemplateInsightsParams{}).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) - })) - s.Run("GetUserLatencyInsights", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetUserLatencyInsightsParams{}).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) - })) - s.Run("GetUserActivityInsights", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetUserActivityInsightsParams{}).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights). - ErrorsWithInMemDB(sql.ErrNoRows). - Returns([]database.GetUserActivityInsightsRow{}) - })) - s.Run("GetTemplateParameterInsights", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetTemplateParameterInsightsParams{}).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) - })) - s.Run("GetTemplateInsightsByInterval", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetTemplateInsightsByIntervalParams{ - IntervalDays: 7, - StartTime: dbtime.Now().Add(-time.Hour * 24 * 7), - EndTime: dbtime.Now(), - }).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) - })) - s.Run("GetTemplateInsightsByTemplate", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetTemplateInsightsByTemplateParams{}).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) - })) - s.Run("GetTemplateAppInsights", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetTemplateAppInsightsParams{}).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) - })) - s.Run("GetTemplateAppInsightsByTemplate", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetTemplateAppInsightsByTemplateParams{}).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) - })) - s.Run("GetTemplateUsageStats", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetTemplateUsageStatsParams{}).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights). - ErrorsWithInMemDB(sql.ErrNoRows). - Returns([]database.TemplateUsageStat{}) - })) - s.Run("UpsertTemplateUsageStats", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpdateTemplateACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.UpdateTemplateACLByIDParams{ID: t1.ID} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateTemplateACLByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionCreate) + })) + s.Run("UpdateTemplateAccessControlByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.UpdateTemplateAccessControlByIDParams{ID: t1.ID} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateTemplateAccessControlByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionUpdate) + })) + s.Run("UpdateTemplateScheduleByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.UpdateTemplateScheduleByIDParams{ID: t1.ID} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateTemplateScheduleByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionUpdate) + })) + 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.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().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) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.UpdateTemplateWorkspacesLastUsedAtParams{TemplateID: t1.ID} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateTemplateWorkspacesLastUsedAt(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionUpdate) + })) + s.Run("UpdateWorkspacesDormantDeletingAtByTemplateID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams{TemplateID: t1.ID} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspacesDormantDeletingAtByTemplateID(gomock.Any(), arg).Return([]database.WorkspaceTable{}, nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionUpdate) + })) + s.Run("UpdateWorkspacesTTLByTemplateID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.UpdateWorkspacesTTLByTemplateIDParams{TemplateID: t1.ID} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspacesTTLByTemplateID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionUpdate) + })) + s.Run("UpdateTemplateActiveVersionByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{ActiveVersionID: uuid.New()}) + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{ID: t1.ActiveVersionID, TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}}) + arg := database.UpdateTemplateActiveVersionByIDParams{ID: t1.ID, ActiveVersionID: tv.ID} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateTemplateActiveVersionByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionUpdate).Returns() + })) + s.Run("UpdateTemplateDeletedByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.UpdateTemplateDeletedByIDParams{ID: t1.ID, Deleted: true} + // The method delegates to SoftDeleteTemplateByID, which fetches then updates. + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateTemplateDeletedByID(gomock.Any(), gomock.AssignableToTypeOf(database.UpdateTemplateDeletedByIDParams{})).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionDelete).Returns() + })) + s.Run("UpdateTemplateMetaByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.UpdateTemplateMetaByIDParams{ID: t1.ID, MaxPortSharingLevel: "owner", CorsBehavior: database.CorsBehaviorSimple} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateTemplateMetaByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionUpdate) + })) + s.Run("UpdateTemplateVersionByID", 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}}) + arg := database.UpdateTemplateVersionByIDParams{ID: tv.ID, TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, Name: tv.Name, UpdatedAt: tv.UpdatedAt} + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateTemplateVersionByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionUpdate) + })) + s.Run("UpdateTemplateVersionDescriptionByJobID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + tv := database.TemplateVersion{ID: uuid.New(), JobID: uuid.New(), TemplateID: uuid.NullUUID{UUID: uuid.New(), Valid: true}} + t1 := database.Template{ID: tv.TemplateID.UUID} + arg := database.UpdateTemplateVersionDescriptionByJobIDParams{JobID: tv.JobID, Readme: "foo"} + dbm.EXPECT().GetTemplateVersionByJobID(gomock.Any(), tv.JobID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateTemplateVersionDescriptionByJobID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionUpdate).Returns() + })) + s.Run("UpdateTemplateVersionExternalAuthProvidersByJobID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + tv := database.TemplateVersion{ID: uuid.New(), JobID: uuid.New(), TemplateID: uuid.NullUUID{UUID: uuid.New(), Valid: true}} + t1 := database.Template{ID: tv.TemplateID.UUID} + arg := database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams{JobID: tv.JobID, ExternalAuthProviders: json.RawMessage("{}")} + dbm.EXPECT().GetTemplateVersionByJobID(gomock.Any(), tv.JobID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdateTemplateVersionExternalAuthProvidersByJobID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionUpdate).Returns() + })) + s.Run("GetTemplateInsights", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetTemplateInsightsParams{} + dbm.EXPECT().GetTemplateInsights(gomock.Any(), arg).Return(database.GetTemplateInsightsRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) + })) + s.Run("GetUserLatencyInsights", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetUserLatencyInsightsParams{} + dbm.EXPECT().GetUserLatencyInsights(gomock.Any(), arg).Return([]database.GetUserLatencyInsightsRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) + })) + s.Run("GetUserActivityInsights", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetUserActivityInsightsParams{} + dbm.EXPECT().GetUserActivityInsights(gomock.Any(), arg).Return([]database.GetUserActivityInsightsRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights).Returns([]database.GetUserActivityInsightsRow{}) + })) + s.Run("GetTemplateParameterInsights", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetTemplateParameterInsightsParams{} + dbm.EXPECT().GetTemplateParameterInsights(gomock.Any(), arg).Return([]database.GetTemplateParameterInsightsRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) + })) + s.Run("GetTemplateInsightsByInterval", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetTemplateInsightsByIntervalParams{IntervalDays: 7, StartTime: dbtime.Now().Add(-time.Hour * 24 * 7), EndTime: dbtime.Now()} + dbm.EXPECT().GetTemplateInsightsByInterval(gomock.Any(), arg).Return([]database.GetTemplateInsightsByIntervalRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) + })) + s.Run("GetTemplateInsightsByTemplate", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetTemplateInsightsByTemplateParams{} + dbm.EXPECT().GetTemplateInsightsByTemplate(gomock.Any(), arg).Return([]database.GetTemplateInsightsByTemplateRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) + })) + s.Run("GetTemplateAppInsights", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetTemplateAppInsightsParams{} + dbm.EXPECT().GetTemplateAppInsights(gomock.Any(), arg).Return([]database.GetTemplateAppInsightsRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) + })) + s.Run("GetTemplateAppInsightsByTemplate", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetTemplateAppInsightsByTemplateParams{} + dbm.EXPECT().GetTemplateAppInsightsByTemplate(gomock.Any(), arg).Return([]database.GetTemplateAppInsightsByTemplateRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights) + })) + s.Run("GetTemplateUsageStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetTemplateUsageStatsParams{} + dbm.EXPECT().GetTemplateUsageStats(gomock.Any(), arg).Return([]database.TemplateUsageStat{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate, policy.ActionViewInsights).Returns([]database.TemplateUsageStat{}) + })) + s.Run("UpsertTemplateUsageStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertTemplateUsageStats(gomock.Any()).Return(nil).AnyTimes() check.Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) } func (s *MethodTestSuite) TestUser() { - s.Run("GetAuthorizedUsers", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - dbgen.User(s.T(), db, database.User{}) + s.Run("GetAuthorizedUsers", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetUsersParams{} + dbm.EXPECT().GetAuthorizedUsers(gomock.Any(), arg, gomock.Any()).Return([]database.GetUsersRow{}, nil).AnyTimes() // No asserts because SQLFilter. - check.Args(database.GetUsersParams{}, emptyPreparedAuthorized{}). - Asserts() + check.Args(arg, emptyPreparedAuthorized{}).Asserts() + })) + s.Run("DeleteAPIKeysByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.APIKey{}) + dbm.EXPECT().DeleteAPIKeysByUserID(gomock.Any(), key.UserID).Return(nil).AnyTimes() + check.Args(key.UserID).Asserts(rbac.ResourceApiKey.WithOwner(key.UserID.String()), policy.ActionDelete).Returns() + })) + s.Run("GetQuotaAllowanceForUser", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.GetQuotaAllowanceForUserParams{UserID: u.ID, OrganizationID: uuid.New()} + dbm.EXPECT().GetQuotaAllowanceForUser(gomock.Any(), arg).Return(int64(0), nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionRead).Returns(int64(0)) + })) + s.Run("GetQuotaConsumedForUser", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.GetQuotaConsumedForUserParams{OwnerID: u.ID, OrganizationID: uuid.New()} + dbm.EXPECT().GetQuotaConsumedForUser(gomock.Any(), arg).Return(int64(0), nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionRead).Returns(int64(0)) + })) + s.Run("GetUserByEmailOrUsername", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.GetUserByEmailOrUsernameParams{Email: u.Email} + dbm.EXPECT().GetUserByEmailOrUsername(gomock.Any(), arg).Return(u, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionRead).Returns(u) + })) + s.Run("GetUserByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + check.Args(u.ID).Asserts(u, policy.ActionRead).Returns(u) })) - s.Run("DeleteAPIKeysByUserID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(u.ID).Asserts(rbac.ResourceApiKey.WithOwner(u.ID.String()), policy.ActionDelete).Returns() + s.Run("GetUsersByIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + a := testutil.Fake(s.T(), faker, database.User{CreatedAt: dbtime.Now().Add(-time.Hour)}) + b := testutil.Fake(s.T(), faker, database.User{CreatedAt: dbtime.Now()}) + ids := []uuid.UUID{a.ID, b.ID} + dbm.EXPECT().GetUsersByIDs(gomock.Any(), ids).Return([]database.User{a, b}, nil).AnyTimes() + check.Args(ids).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b)) + })) + s.Run("GetUsers", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetUsersParams{} + dbm.EXPECT().GetAuthorizedUsers(gomock.Any(), arg, gomock.Any()).Return([]database.GetUsersRow{}, nil).AnyTimes() + // Asserts are done in a SQL filter + check.Args(arg).Asserts() + })) + s.Run("InsertUser", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertUserParams{ID: uuid.New(), LoginType: database.LoginTypePassword, RBACRoles: []string{}} + dbm.EXPECT().InsertUser(gomock.Any(), arg).Return(database.User{ID: arg.ID, LoginType: arg.LoginType}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAssignRole, policy.ActionAssign, rbac.ResourceUser, policy.ActionCreate) + })) + s.Run("InsertUserLink", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.InsertUserLinkParams{UserID: u.ID, LoginType: database.LoginTypeOIDC} + dbm.EXPECT().InsertUserLink(gomock.Any(), arg).Return(database.UserLink{}, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdate) + })) + s.Run("UpdateUserDeletedByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserDeletedByID(gomock.Any(), u.ID).Return(nil).AnyTimes() + check.Args(u.ID).Asserts(u, policy.ActionDelete).Returns() })) - s.Run("GetQuotaAllowanceForUser", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.GetQuotaAllowanceForUserParams{ - UserID: u.ID, - OrganizationID: uuid.New(), - }).Asserts(u, policy.ActionRead).Returns(int64(0)) + s.Run("UpdateUserGithubComUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpdateUserGithubComUserIDParams{ID: u.ID} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserGithubComUserID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal) + })) + s.Run("UpdateUserHashedPassword", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpdateUserHashedPasswordParams{ID: u.ID} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserHashedPassword(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns() + })) + s.Run("UpdateUserHashedOneTimePasscode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpdateUserHashedOneTimePasscodeParams{ID: u.ID} + dbm.EXPECT().UpdateUserHashedOneTimePasscode(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns() + })) + s.Run("UpdateUserQuietHoursSchedule", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpdateUserQuietHoursScheduleParams{ID: u.ID} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserQuietHoursSchedule(gomock.Any(), arg).Return(database.User{}, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal) + })) + s.Run("UpdateUserLastSeenAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpdateUserLastSeenAtParams{ID: u.ID, UpdatedAt: u.UpdatedAt, LastSeenAt: u.LastSeenAt} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserLastSeenAt(gomock.Any(), arg).Return(u, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdate).Returns(u) + })) + s.Run("UpdateUserProfile", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpdateUserProfileParams{ID: u.ID, Email: u.Email, Username: u.Username, Name: u.Name, UpdatedAt: u.UpdatedAt} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserProfile(gomock.Any(), arg).Return(u, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(u) + })) + s.Run("GetUserWorkspaceBuildParameters", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.GetUserWorkspaceBuildParametersParams{OwnerID: u.ID, TemplateID: uuid.Nil} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().GetUserWorkspaceBuildParameters(gomock.Any(), arg).Return([]database.GetUserWorkspaceBuildParametersRow{}, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionReadPersonal).Returns([]database.GetUserWorkspaceBuildParametersRow{}) + })) + s.Run("GetUserThemePreference", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().GetUserThemePreference(gomock.Any(), u.ID).Return("light", nil).AnyTimes() + check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("light") })) - s.Run("GetQuotaConsumedForUser", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.GetQuotaConsumedForUserParams{ - OwnerID: u.ID, - OrganizationID: uuid.New(), - }).Asserts(u, policy.ActionRead).Returns(int64(0)) + s.Run("UpdateUserThemePreference", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + uc := database.UserConfig{UserID: u.ID, Key: "theme_preference", Value: "dark"} + arg := database.UpdateUserThemePreferenceParams{UserID: u.ID, ThemePreference: uc.Value} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserThemePreference(gomock.Any(), arg).Return(uc, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) + })) + s.Run("GetUserTerminalFont", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().GetUserTerminalFont(gomock.Any(), u.ID).Return("ibm-plex-mono", nil).AnyTimes() + check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("ibm-plex-mono") })) - s.Run("GetUserByEmailOrUsername", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.GetUserByEmailOrUsernameParams{ - Username: u.Username, - Email: u.Email, - }).Asserts(u, policy.ActionRead).Returns(u) + s.Run("UpdateUserTerminalFont", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + uc := database.UserConfig{UserID: u.ID, Key: "terminal_font", Value: "ibm-plex-mono"} + arg := database.UpdateUserTerminalFontParams{UserID: u.ID, TerminalFont: uc.Value} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserTerminalFont(gomock.Any(), arg).Return(uc, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) + })) + s.Run("UpdateUserStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpdateUserStatusParams{ID: u.ID, Status: u.Status, UpdatedAt: u.UpdatedAt} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserStatus(gomock.Any(), arg).Return(u, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdate).Returns(u) + })) + s.Run("DeleteGitSSHKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.GitSSHKey{}) + dbm.EXPECT().GetGitSSHKey(gomock.Any(), key.UserID).Return(key, nil).AnyTimes() + dbm.EXPECT().DeleteGitSSHKey(gomock.Any(), key.UserID).Return(nil).AnyTimes() + check.Args(key.UserID).Asserts(rbac.ResourceUserObject(key.UserID), policy.ActionUpdatePersonal).Returns() })) - s.Run("GetUserByID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(u.ID).Asserts(u, policy.ActionRead).Returns(u) + s.Run("GetGitSSHKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.GitSSHKey{}) + dbm.EXPECT().GetGitSSHKey(gomock.Any(), key.UserID).Return(key, nil).AnyTimes() + check.Args(key.UserID).Asserts(rbac.ResourceUserObject(key.UserID), policy.ActionReadPersonal).Returns(key) })) - s.Run("GetUsersByIDs", s.Subtest(func(db database.Store, check *expects) { - a := dbgen.User(s.T(), db, database.User{CreatedAt: dbtime.Now().Add(-time.Hour)}) - b := dbgen.User(s.T(), db, database.User{CreatedAt: dbtime.Now()}) - check.Args([]uuid.UUID{a.ID, b.ID}). - Asserts(a, policy.ActionRead, b, policy.ActionRead). - Returns(slice.New(a, b)) + s.Run("InsertGitSSHKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.InsertGitSSHKeyParams{UserID: u.ID} + dbm.EXPECT().InsertGitSSHKey(gomock.Any(), arg).Return(database.GitSSHKey{UserID: u.ID}, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal) + })) + s.Run("UpdateGitSSHKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.GitSSHKey{}) + arg := database.UpdateGitSSHKeyParams{UserID: key.UserID, UpdatedAt: key.UpdatedAt} + dbm.EXPECT().GetGitSSHKey(gomock.Any(), key.UserID).Return(key, nil).AnyTimes() + dbm.EXPECT().UpdateGitSSHKey(gomock.Any(), arg).Return(key, nil).AnyTimes() + check.Args(arg).Asserts(key, policy.ActionUpdatePersonal).Returns(key) + })) + s.Run("GetExternalAuthLink", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + link := testutil.Fake(s.T(), faker, database.ExternalAuthLink{}) + arg := database.GetExternalAuthLinkParams{ProviderID: link.ProviderID, UserID: link.UserID} + dbm.EXPECT().GetExternalAuthLink(gomock.Any(), arg).Return(link, nil).AnyTimes() + check.Args(arg).Asserts(link, policy.ActionReadPersonal).Returns(link) + })) + s.Run("InsertExternalAuthLink", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.InsertExternalAuthLinkParams{ProviderID: uuid.NewString(), UserID: u.ID} + dbm.EXPECT().InsertExternalAuthLink(gomock.Any(), arg).Return(database.ExternalAuthLink{}, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal) + })) + s.Run("UpdateExternalAuthLinkRefreshToken", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + link := testutil.Fake(s.T(), faker, database.ExternalAuthLink{}) + arg := database.UpdateExternalAuthLinkRefreshTokenParams{OAuthRefreshToken: "", OAuthRefreshTokenKeyID: "", ProviderID: link.ProviderID, UserID: link.UserID, UpdatedAt: link.UpdatedAt} + dbm.EXPECT().GetExternalAuthLink(gomock.Any(), database.GetExternalAuthLinkParams{ProviderID: link.ProviderID, UserID: link.UserID}).Return(link, nil).AnyTimes() + dbm.EXPECT().UpdateExternalAuthLinkRefreshToken(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(link, policy.ActionUpdatePersonal) + })) + s.Run("UpdateExternalAuthLink", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + link := testutil.Fake(s.T(), faker, database.ExternalAuthLink{}) + arg := database.UpdateExternalAuthLinkParams{ProviderID: link.ProviderID, UserID: link.UserID, OAuthAccessToken: link.OAuthAccessToken, OAuthRefreshToken: link.OAuthRefreshToken, OAuthExpiry: link.OAuthExpiry, UpdatedAt: link.UpdatedAt} + dbm.EXPECT().GetExternalAuthLink(gomock.Any(), database.GetExternalAuthLinkParams{ProviderID: link.ProviderID, UserID: link.UserID}).Return(link, nil).AnyTimes() + dbm.EXPECT().UpdateExternalAuthLink(gomock.Any(), arg).Return(link, nil).AnyTimes() + check.Args(arg).Asserts(link, policy.ActionUpdatePersonal).Returns(link) + })) + s.Run("UpdateUserLink", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + link := testutil.Fake(s.T(), faker, database.UserLink{}) + arg := database.UpdateUserLinkParams{OAuthAccessToken: link.OAuthAccessToken, OAuthRefreshToken: link.OAuthRefreshToken, OAuthExpiry: link.OAuthExpiry, UserID: link.UserID, LoginType: link.LoginType, Claims: database.UserLinkClaims{}} + dbm.EXPECT().GetUserLinkByUserIDLoginType(gomock.Any(), database.GetUserLinkByUserIDLoginTypeParams{UserID: link.UserID, LoginType: link.LoginType}).Return(link, nil).AnyTimes() + dbm.EXPECT().UpdateUserLink(gomock.Any(), arg).Return(link, nil).AnyTimes() + check.Args(arg).Asserts(link, policy.ActionUpdatePersonal).Returns(link) + })) + s.Run("UpdateUserRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{RBACRoles: []string{codersdk.RoleTemplateAdmin}}) + o := u + o.RBACRoles = []string{codersdk.RoleUserAdmin} + arg := database.UpdateUserRolesParams{GrantedRoles: []string{codersdk.RoleUserAdmin}, ID: u.ID} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserRoles(gomock.Any(), arg).Return(o, nil).AnyTimes() + check.Args(arg).Asserts( + u, policy.ActionRead, + rbac.ResourceAssignRole, policy.ActionAssign, + rbac.ResourceAssignRole, policy.ActionUnassign, + ).Returns(o) })) - s.Run("GetUsers", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - dbgen.User(s.T(), db, database.User{Username: "GetUsers-a-user"}) - dbgen.User(s.T(), db, database.User{Username: "GetUsers-b-user"}) - check.Args(database.GetUsersParams{}). - // Asserts are done in a SQL filter - Asserts() - })) - s.Run("InsertUser", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertUserParams{ - ID: uuid.New(), - LoginType: database.LoginTypePassword, - RBACRoles: []string{}, - }).Asserts(rbac.ResourceAssignRole, policy.ActionAssign, rbac.ResourceUser, policy.ActionCreate) - })) - s.Run("InsertUserLink", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.InsertUserLinkParams{ - UserID: u.ID, - LoginType: database.LoginTypeOIDC, - }).Asserts(u, policy.ActionUpdate) + s.Run("AllUserIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + a := testutil.Fake(s.T(), faker, database.User{}) + b := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().AllUserIDs(gomock.Any(), false).Return([]uuid.UUID{a.ID, b.ID}, nil).AnyTimes() + check.Args(false).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(slice.New(a.ID, b.ID)) })) - s.Run("UpdateUserDeletedByID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(u.ID).Asserts(u, policy.ActionDelete).Returns() + s.Run("CustomRoles", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.CustomRolesParams{} + dbm.EXPECT().CustomRoles(gomock.Any(), arg).Return([]database.CustomRole{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAssignRole, policy.ActionRead).Returns([]database.CustomRole{}) })) - s.Run("UpdateUserGithubComUserID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.UpdateUserGithubComUserIDParams{ - ID: u.ID, - }).Asserts(u, policy.ActionUpdatePersonal) + s.Run("Organization/DeleteCustomRole", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + orgID := uuid.New() + arg := database.DeleteCustomRoleParams{Name: "role", OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true}} + dbm.EXPECT().DeleteCustomRole(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAssignOrgRole.InOrg(orgID), policy.ActionDelete) })) - s.Run("UpdateUserHashedPassword", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.UpdateUserHashedPasswordParams{ - ID: u.ID, - }).Asserts(u, policy.ActionUpdatePersonal).Returns() + s.Run("Site/DeleteCustomRole", s.Mocked(func(_ *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.DeleteCustomRoleParams{Name: "role"} + check.Args(arg).Asserts().Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}) })) - s.Run("UpdateUserHashedOneTimePasscode", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.UpdateUserHashedOneTimePasscodeParams{ - ID: u.ID, - HashedOneTimePasscode: []byte{}, - OneTimePasscodeExpiresAt: sql.NullTime{Time: u.CreatedAt, Valid: true}, - }).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns() + s.Run("Blank/UpdateCustomRole", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + orgID := uuid.New() + arg := database.UpdateCustomRoleParams{Name: "name", DisplayName: "Test Name", OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true}} + dbm.EXPECT().UpdateCustomRole(gomock.Any(), arg).Return(database.CustomRole{}, nil).AnyTimes() + // Blank perms -> no escalation asserts beyond org role update + check.Args(arg).Asserts(rbac.ResourceAssignOrgRole.InOrg(orgID), policy.ActionUpdate) })) - s.Run("UpdateUserQuietHoursSchedule", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.UpdateUserQuietHoursScheduleParams{ - ID: u.ID, - }).Asserts(u, policy.ActionUpdatePersonal) + s.Run("SitePermissions/UpdateCustomRole", s.Mocked(func(_ *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.UpdateCustomRoleParams{ + Name: "", + OrganizationID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, + DisplayName: "Test Name", + SitePermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionDelete, codersdk.ActionViewInsights}, + }), convertSDKPerm), + OrgPermissions: nil, + UserPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), convertSDKPerm), + } + check.Args(arg).Asserts().Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}) })) - s.Run("UpdateUserLastSeenAt", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.UpdateUserLastSeenAtParams{ - ID: u.ID, - UpdatedAt: u.UpdatedAt, - LastSeenAt: u.LastSeenAt, - }).Asserts(u, policy.ActionUpdate).Returns(u) - })) - s.Run("UpdateUserProfile", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.UpdateUserProfileParams{ - ID: u.ID, - Email: u.Email, - Username: u.Username, - Name: u.Name, - UpdatedAt: u.UpdatedAt, - }).Asserts(u, policy.ActionUpdatePersonal).Returns(u) - })) - s.Run("GetUserWorkspaceBuildParameters", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args( - database.GetUserWorkspaceBuildParametersParams{ - OwnerID: u.ID, - TemplateID: uuid.UUID{}, - }, - ).Asserts(u, policy.ActionReadPersonal).Returns( - []database.GetUserWorkspaceBuildParametersRow{}, - ) - })) - s.Run("GetUserThemePreference", s.Subtest(func(db database.Store, check *expects) { - ctx := context.Background() - u := dbgen.User(s.T(), db, database.User{}) - db.UpdateUserThemePreference(ctx, database.UpdateUserThemePreferenceParams{ - UserID: u.ID, - ThemePreference: "light", - }) - check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("light") - })) - s.Run("UpdateUserThemePreference", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - uc := database.UserConfig{ - UserID: u.ID, - Key: "theme_preference", - Value: "dark", - } - check.Args(database.UpdateUserThemePreferenceParams{ - UserID: u.ID, - ThemePreference: uc.Value, - }).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) - })) - s.Run("GetUserTerminalFont", s.Subtest(func(db database.Store, check *expects) { - ctx := context.Background() - u := dbgen.User(s.T(), db, database.User{}) - db.UpdateUserTerminalFont(ctx, database.UpdateUserTerminalFontParams{ - UserID: u.ID, - TerminalFont: "ibm-plex-mono", - }) - check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("ibm-plex-mono") - })) - s.Run("UpdateUserTerminalFont", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - uc := database.UserConfig{ - UserID: u.ID, - Key: "terminal_font", - Value: "ibm-plex-mono", - } - check.Args(database.UpdateUserTerminalFontParams{ - UserID: u.ID, - TerminalFont: uc.Value, - }).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) - })) - s.Run("UpdateUserStatus", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.UpdateUserStatusParams{ - ID: u.ID, - Status: u.Status, - UpdatedAt: u.UpdatedAt, - }).Asserts(u, policy.ActionUpdate).Returns(u) - })) - s.Run("DeleteGitSSHKey", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - key := dbgen.GitSSHKey(s.T(), db, database.GitSSHKey{}) - check.Args(key.UserID).Asserts(rbac.ResourceUserObject(key.UserID), policy.ActionUpdatePersonal).Returns() - })) - s.Run("GetGitSSHKey", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - key := dbgen.GitSSHKey(s.T(), db, database.GitSSHKey{}) - check.Args(key.UserID).Asserts(rbac.ResourceUserObject(key.UserID), policy.ActionReadPersonal).Returns(key) - })) - s.Run("InsertGitSSHKey", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.InsertGitSSHKeyParams{ - UserID: u.ID, - }).Asserts(u, policy.ActionUpdatePersonal) - })) - s.Run("UpdateGitSSHKey", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - key := dbgen.GitSSHKey(s.T(), db, database.GitSSHKey{}) - check.Args(database.UpdateGitSSHKeyParams{ - UserID: key.UserID, - UpdatedAt: key.UpdatedAt, - }).Asserts(rbac.ResourceUserObject(key.UserID), policy.ActionUpdatePersonal).Returns(key) - })) - s.Run("GetExternalAuthLink", s.Subtest(func(db database.Store, check *expects) { - link := dbgen.ExternalAuthLink(s.T(), db, database.ExternalAuthLink{}) - check.Args(database.GetExternalAuthLinkParams{ - ProviderID: link.ProviderID, - UserID: link.UserID, - }).Asserts(rbac.ResourceUserObject(link.UserID), policy.ActionReadPersonal).Returns(link) - })) - s.Run("InsertExternalAuthLink", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.InsertExternalAuthLinkParams{ - ProviderID: uuid.NewString(), - UserID: u.ID, - }).Asserts(u, policy.ActionUpdatePersonal) - })) - s.Run("UpdateExternalAuthLinkRefreshToken", s.Subtest(func(db database.Store, check *expects) { - link := dbgen.ExternalAuthLink(s.T(), db, database.ExternalAuthLink{}) - check.Args(database.UpdateExternalAuthLinkRefreshTokenParams{ - OAuthRefreshToken: "", - OAuthRefreshTokenKeyID: "", - ProviderID: link.ProviderID, - UserID: link.UserID, - UpdatedAt: link.UpdatedAt, - }).Asserts(rbac.ResourceUserObject(link.UserID), policy.ActionUpdatePersonal) - })) - s.Run("UpdateExternalAuthLink", s.Subtest(func(db database.Store, check *expects) { - link := dbgen.ExternalAuthLink(s.T(), db, database.ExternalAuthLink{}) - check.Args(database.UpdateExternalAuthLinkParams{ - ProviderID: link.ProviderID, - UserID: link.UserID, - OAuthAccessToken: link.OAuthAccessToken, - OAuthRefreshToken: link.OAuthRefreshToken, - OAuthExpiry: link.OAuthExpiry, - UpdatedAt: link.UpdatedAt, - }).Asserts(rbac.ResourceUserObject(link.UserID), policy.ActionUpdatePersonal).Returns(link) - })) - s.Run("UpdateUserLink", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - link := dbgen.UserLink(s.T(), db, database.UserLink{}) - check.Args(database.UpdateUserLinkParams{ - OAuthAccessToken: link.OAuthAccessToken, - OAuthRefreshToken: link.OAuthRefreshToken, - OAuthExpiry: link.OAuthExpiry, - UserID: link.UserID, - LoginType: link.LoginType, - Claims: database.UserLinkClaims{}, - }).Asserts(rbac.ResourceUserObject(link.UserID), policy.ActionUpdatePersonal).Returns(link) - })) - s.Run("UpdateUserRoles", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{RBACRoles: []string{codersdk.RoleTemplateAdmin}}) - o := u - o.RBACRoles = []string{codersdk.RoleUserAdmin} - check.Args(database.UpdateUserRolesParams{ - GrantedRoles: []string{codersdk.RoleUserAdmin}, - ID: u.ID, - }).Asserts( - u, policy.ActionRead, - rbac.ResourceAssignRole, policy.ActionAssign, - rbac.ResourceAssignRole, policy.ActionUnassign, - ).Returns(o) - })) - s.Run("AllUserIDs", s.Subtest(func(db database.Store, check *expects) { - a := dbgen.User(s.T(), db, database.User{}) - b := dbgen.User(s.T(), db, database.User{}) - check.Args(false).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(slice.New(a.ID, b.ID)) - })) - s.Run("CustomRoles", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.CustomRolesParams{}).Asserts(rbac.ResourceAssignRole, policy.ActionRead).Returns([]database.CustomRole{}) - })) - s.Run("Organization/DeleteCustomRole", s.Subtest(func(db database.Store, check *expects) { - customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{ - OrganizationID: uuid.NullUUID{ - UUID: uuid.New(), - Valid: true, - }, - }) - check.Args(database.DeleteCustomRoleParams{ - Name: customRole.Name, - OrganizationID: customRole.OrganizationID, - }).Asserts( - rbac.ResourceAssignOrgRole.InOrg(customRole.OrganizationID.UUID), policy.ActionDelete) - })) - s.Run("Site/DeleteCustomRole", s.Subtest(func(db database.Store, check *expects) { - customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{ - OrganizationID: uuid.NullUUID{ - UUID: uuid.Nil, - Valid: false, - }, - }) - check.Args(database.DeleteCustomRoleParams{ - Name: customRole.Name, - }).Asserts( - // fails immediately, missing organization id - ).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}) - })) - s.Run("Blank/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{ - OrganizationID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, - }) - // Blank is no perms in the role - check.Args(database.UpdateCustomRoleParams{ - Name: customRole.Name, - DisplayName: "Test Name", - OrganizationID: customRole.OrganizationID, - SitePermissions: nil, - OrgPermissions: nil, - UserPermissions: nil, - }).Asserts(rbac.ResourceAssignOrgRole.InOrg(customRole.OrganizationID.UUID), policy.ActionUpdate) - })) - s.Run("SitePermissions/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.UpdateCustomRoleParams{ - Name: "", - OrganizationID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - DisplayName: "Test Name", - SitePermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ - codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionDelete, codersdk.ActionViewInsights}, - }), convertSDKPerm), - OrgPermissions: nil, - UserPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ - codersdk.ResourceWorkspace: {codersdk.ActionRead}, - }), convertSDKPerm), - }).Asserts( - // fails immediately, missing organization id - ).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}) - })) - s.Run("OrgPermissions/UpdateCustomRole", s.Subtest(func(db database.Store, check *expects) { + s.Run("OrgPermissions/UpdateCustomRole", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { orgID := uuid.New() - customRole := dbgen.CustomRole(s.T(), db, database.CustomRole{ - OrganizationID: uuid.NullUUID{ - UUID: orgID, - Valid: true, - }, - }) - - check.Args(database.UpdateCustomRoleParams{ - Name: customRole.Name, - DisplayName: "Test Name", - OrganizationID: customRole.OrganizationID, - SitePermissions: nil, + arg := database.UpdateCustomRoleParams{ + Name: "name", + DisplayName: "Test Name", + OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true}, OrgPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead}, }), convertSDKPerm), - UserPermissions: nil, - }).Asserts( - // First check + } + dbm.EXPECT().UpdateCustomRole(gomock.Any(), arg).Return(database.CustomRole{}, nil).AnyTimes() + check.Args(arg).Asserts( rbac.ResourceAssignOrgRole.InOrg(orgID), policy.ActionUpdate, // Escalation checks rbac.ResourceTemplate.InOrg(orgID), policy.ActionCreate, rbac.ResourceTemplate.InOrg(orgID), policy.ActionRead, ) })) - s.Run("Blank/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) { - // Blank is no perms in the role + s.Run("Blank/InsertCustomRole", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { orgID := uuid.New() - check.Args(database.InsertCustomRoleParams{ - Name: "test", - DisplayName: "Test Name", - OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true}, - SitePermissions: nil, - OrgPermissions: nil, - UserPermissions: nil, - }).Asserts(rbac.ResourceAssignOrgRole.InOrg(orgID), policy.ActionCreate) - })) - s.Run("SitePermissions/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertCustomRoleParams{ + arg := database.InsertCustomRoleParams{Name: "test", DisplayName: "Test Name", OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true}} + dbm.EXPECT().InsertCustomRole(gomock.Any(), arg).Return(database.CustomRole{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAssignOrgRole.InOrg(orgID), policy.ActionCreate) + })) + s.Run("SitePermissions/InsertCustomRole", s.Mocked(func(_ *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertCustomRoleParams{ Name: "test", DisplayName: "Test Name", SitePermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ @@ -2044,1441 +1581,600 @@ func (s *MethodTestSuite) TestUser() { UserPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ codersdk.ResourceWorkspace: {codersdk.ActionRead}, }), convertSDKPerm), - }).Asserts( - // fails immediately, missing organization id - ).Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}) + } + check.Args(arg).Asserts().Errors(dbauthz.NotAuthorizedError{Err: xerrors.New("custom roles must belong to an organization")}) })) - s.Run("OrgPermissions/InsertCustomRole", s.Subtest(func(db database.Store, check *expects) { + s.Run("OrgPermissions/InsertCustomRole", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { orgID := uuid.New() - check.Args(database.InsertCustomRoleParams{ - Name: "test", - DisplayName: "Test Name", - OrganizationID: uuid.NullUUID{ - UUID: orgID, - Valid: true, - }, - SitePermissions: nil, + arg := database.InsertCustomRoleParams{ + Name: "test", + DisplayName: "Test Name", + OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true}, OrgPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead}, }), convertSDKPerm), - UserPermissions: nil, - }).Asserts( - // First check + } + dbm.EXPECT().InsertCustomRole(gomock.Any(), arg).Return(database.CustomRole{}, nil).AnyTimes() + check.Args(arg).Asserts( rbac.ResourceAssignOrgRole.InOrg(orgID), policy.ActionCreate, // Escalation checks rbac.ResourceTemplate.InOrg(orgID), policy.ActionCreate, rbac.ResourceTemplate.InOrg(orgID), policy.ActionRead, ) })) - s.Run("GetUserStatusCounts", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetUserStatusCountsParams{ - StartTime: time.Now().Add(-time.Hour * 24 * 30), - EndTime: time.Now(), - Interval: int32((time.Hour * 24).Seconds()), - }).Asserts(rbac.ResourceUser, policy.ActionRead) + s.Run("GetUserStatusCounts", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetUserStatusCountsParams{StartTime: time.Now().Add(-time.Hour * 24 * 30), EndTime: time.Now(), Interval: int32((time.Hour * 24).Seconds())} + dbm.EXPECT().GetUserStatusCounts(gomock.Any(), arg).Return([]database.GetUserStatusCountsRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceUser, policy.ActionRead) + })) + s.Run("ValidateUserIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + ids := []uuid.UUID{u.ID} + dbm.EXPECT().ValidateUserIDs(gomock.Any(), ids).Return(database.ValidateUserIDsRow{}, nil).AnyTimes() + check.Args(ids).Asserts(rbac.ResourceSystem, policy.ActionRead) })) } func (s *MethodTestSuite) TestWorkspace() { - s.Run("GetWorkspaceByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OwnerID: u.ID, - OrganizationID: o.ID, - TemplateID: tpl.ID, - }) - check.Args(ws.ID).Asserts(ws, policy.ActionRead) - })) - s.Run("GetWorkspaceByResourceID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) - tpl := dbgen.Template(s.T(), db, database.Template{CreatedBy: u.ID, OrganizationID: o.ID}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{OwnerID: u.ID, TemplateID: tpl.ID, OrganizationID: o.ID}) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: j.ID, TemplateVersionID: tv.ID}) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: j.ID}) - check.Args(res.ID).Asserts(ws, policy.ActionRead) - })) - s.Run("GetWorkspaces", s.Subtest(func(_ database.Store, check *expects) { + s.Run("GetWorkspaceByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + check.Args(ws.ID).Asserts(ws, policy.ActionRead).Returns(ws) + })) + s.Run("GetWorkspaceByResourceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + res := testutil.Fake(s.T(), faker, database.WorkspaceResource{}) + dbm.EXPECT().GetWorkspaceByResourceID(gomock.Any(), res.ID).Return(ws, nil).AnyTimes() + check.Args(res.ID).Asserts(ws, policy.ActionRead).Returns(ws) + })) + s.Run("GetWorkspaces", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetWorkspacesParams{} + dbm.EXPECT().GetAuthorizedWorkspaces(gomock.Any(), arg, gomock.Any()).Return([]database.GetWorkspacesRow{}, nil).AnyTimes() // No asserts here because SQLFilter. - check.Args(database.GetWorkspacesParams{}).Asserts() + check.Args(arg).Asserts() })) - s.Run("GetAuthorizedWorkspaces", s.Subtest(func(_ database.Store, check *expects) { + s.Run("GetAuthorizedWorkspaces", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetWorkspacesParams{} + dbm.EXPECT().GetAuthorizedWorkspaces(gomock.Any(), arg, gomock.Any()).Return([]database.GetWorkspacesRow{}, nil).AnyTimes() // No asserts here because SQLFilter. - check.Args(database.GetWorkspacesParams{}, emptyPreparedAuthorized{}).Asserts() + check.Args(arg, emptyPreparedAuthorized{}).Asserts() })) - s.Run("GetWorkspacesAndAgentsByOwnerID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) - _ = dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) - _ = dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) + s.Run("GetWorkspacesAndAgentsByOwnerID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + dbm.EXPECT().GetAuthorizedWorkspacesAndAgentsByOwnerID(gomock.Any(), ws.OwnerID, gomock.Any()).Return([]database.GetWorkspacesAndAgentsByOwnerIDRow{}, nil).AnyTimes() // No asserts here because SQLFilter. check.Args(ws.OwnerID).Asserts() })) - s.Run("GetAuthorizedWorkspacesAndAgentsByOwnerID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) - _ = dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) - _ = dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) + s.Run("GetAuthorizedWorkspacesAndAgentsByOwnerID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + dbm.EXPECT().GetAuthorizedWorkspacesAndAgentsByOwnerID(gomock.Any(), ws.OwnerID, gomock.Any()).Return([]database.GetWorkspacesAndAgentsByOwnerIDRow{}, nil).AnyTimes() // No asserts here because SQLFilter. check.Args(ws.OwnerID, emptyPreparedAuthorized{}).Asserts() })) - s.Run("GetWorkspaceBuildParametersByBuildIDs", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetWorkspaceBuildParametersByBuildIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ids := []uuid.UUID{} + dbm.EXPECT().GetAuthorizedWorkspaceBuildParametersByBuildIDs(gomock.Any(), ids, gomock.Any()).Return([]database.WorkspaceBuildParameter{}, nil).AnyTimes() // no asserts here because SQLFilter - check.Args([]uuid.UUID{}).Asserts() + check.Args(ids).Asserts() })) - s.Run("GetAuthorizedWorkspaceBuildParametersByBuildIDs", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetAuthorizedWorkspaceBuildParametersByBuildIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ids := []uuid.UUID{} + dbm.EXPECT().GetAuthorizedWorkspaceBuildParametersByBuildIDs(gomock.Any(), ids, gomock.Any()).Return([]database.WorkspaceBuildParameter{}, nil).AnyTimes() // no asserts here because SQLFilter - check.Args([]uuid.UUID{}, emptyPreparedAuthorized{}).Asserts() - })) - s.Run("UpdateWorkspaceACLByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OwnerID: u.ID, - OrganizationID: o.ID, - TemplateID: tpl.ID, - }) - check.Args(database.UpdateWorkspaceACLByIDParams{ - ID: ws.ID, - }).Asserts(ws, policy.ActionCreate) - })) - s.Run("GetLatestWorkspaceBuildByWorkspaceID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: w.ID, - TemplateVersionID: tv.ID, - }) + check.Args(ids, emptyPreparedAuthorized{}).Asserts() + })) + s.Run("GetWorkspaceACLByID", s.Mocked(func(dbM *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + dbM.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbM.EXPECT().GetWorkspaceACLByID(gomock.Any(), ws.ID).Return(database.GetWorkspaceACLByIDRow{}, nil).AnyTimes() + check.Args(ws.ID).Asserts(ws, policy.ActionCreate) + })) + s.Run("UpdateWorkspaceACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + arg := database.UpdateWorkspaceACLByIDParams{ID: w.ID} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceACLByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionCreate) + })) + s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: w.ID}) + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), w.ID).Return(b, nil).AnyTimes() check.Args(w.ID).Asserts(w, policy.ActionRead).Returns(b) })) - s.Run("GetWorkspaceAgentByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, 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}) + s.Run("GetWorkspaceAgentByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agt.ID).Return(agt, nil).AnyTimes() check.Args(agt.ID).Asserts(w, policy.ActionRead).Returns(agt) })) - s.Run("GetWorkspaceAgentsByWorkspaceAndBuildNumber", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, 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}) - check.Args(database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ - WorkspaceID: w.ID, - BuildNumber: 1, - }).Asserts(w, policy.ActionRead).Returns([]database.WorkspaceAgent{agt}) - })) - s.Run("GetWorkspaceAgentLifecycleStateByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, 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}) + s.Run("GetWorkspaceAgentsByWorkspaceAndBuildNumber", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + arg := database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{WorkspaceID: w.ID, BuildNumber: 1} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAgentsByWorkspaceAndBuildNumber(gomock.Any(), arg).Return([]database.WorkspaceAgent{agt}, nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionRead).Returns([]database.WorkspaceAgent{agt}) + })) + s.Run("GetWorkspaceAgentLifecycleStateByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + row := testutil.Fake(s.T(), faker, database.GetWorkspaceAgentLifecycleStateByIDRow{}) + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agt.ID).Return(agt, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAgentLifecycleStateByID(gomock.Any(), agt.ID).Return(row, nil).AnyTimes() check.Args(agt.ID).Asserts(w, policy.ActionRead) })) - s.Run("GetWorkspaceAgentMetadata", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, 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}) - _ = db.InsertWorkspaceAgentMetadata(context.Background(), database.InsertWorkspaceAgentMetadataParams{ - WorkspaceAgentID: agt.ID, - DisplayName: "test", - Key: "test", - }) - check.Args(database.GetWorkspaceAgentMetadataParams{ + s.Run("GetWorkspaceAgentMetadata", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + arg := database.GetWorkspaceAgentMetadataParams{ WorkspaceAgentID: agt.ID, Keys: []string{"test"}, - }).Asserts(w, policy.ActionRead) + } + dt := testutil.Fake(s.T(), faker, database.WorkspaceAgentMetadatum{}) + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAgentMetadata(gomock.Any(), arg).Return([]database.WorkspaceAgentMetadatum{dt}, nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionRead).Returns([]database.WorkspaceAgentMetadatum{dt}) + })) + s.Run("GetWorkspaceAgentByInstanceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + authInstanceID := "instance-id" + dbm.EXPECT().GetWorkspaceAgentByInstanceID(gomock.Any(), authInstanceID).Return(agt, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes() + check.Args(authInstanceID).Asserts(w, policy.ActionRead).Returns(agt) + })) + s.Run("UpdateWorkspaceAgentLifecycleStateByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + arg := database.UpdateWorkspaceAgentLifecycleStateByIDParams{ID: agt.ID, LifecycleState: database.WorkspaceAgentLifecycleStateCreated} + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceAgentLifecycleStateByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate).Returns() + })) + s.Run("UpdateWorkspaceAgentMetadata", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + arg := database.UpdateWorkspaceAgentMetadataParams{WorkspaceAgentID: agt.ID} + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate).Returns() + })) + s.Run("UpdateWorkspaceAgentLogOverflowByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + arg := database.UpdateWorkspaceAgentLogOverflowByIDParams{ID: agt.ID, LogsOverflowed: true} + dbm.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agt.ID).Return(agt, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceAgentLogOverflowByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate).Returns() + })) + s.Run("UpdateWorkspaceAgentStartupByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + arg := database.UpdateWorkspaceAgentStartupByIDParams{ + ID: agt.ID, + Subsystems: []database.WorkspaceAgentSubsystem{ + database.WorkspaceAgentSubsystemEnvbox, + }, + } + dbm.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agt.ID).Return(agt, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceAgentStartupByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate).Returns() + })) + s.Run("GetWorkspaceAgentLogsAfter", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + log := testutil.Fake(s.T(), faker, database.WorkspaceAgentLog{}) + arg := database.GetWorkspaceAgentLogsAfterParams{AgentID: agt.ID} + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agt.ID).Return(agt, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAgentLogsAfter(gomock.Any(), arg).Return([]database.WorkspaceAgentLog{log}, nil).AnyTimes() + check.Args(arg).Asserts(ws, policy.ActionRead).Returns([]database.WorkspaceAgentLog{log}) + })) + s.Run("GetWorkspaceAppByAgentIDAndSlug", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + app := testutil.Fake(s.T(), faker, database.WorkspaceApp{AgentID: agt.ID}) + arg := database.GetWorkspaceAppByAgentIDAndSlugParams{AgentID: agt.ID, Slug: app.Slug} + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAppByAgentIDAndSlug(gomock.Any(), arg).Return(app, nil).AnyTimes() + check.Args(arg).Asserts(ws, policy.ActionRead).Returns(app) + })) + s.Run("GetWorkspaceAppsByAgentID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + appA := testutil.Fake(s.T(), faker, database.WorkspaceApp{}) + appB := testutil.Fake(s.T(), faker, database.WorkspaceApp{AgentID: appA.AgentID}) + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), appA.AgentID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAppsByAgentID(gomock.Any(), appA.AgentID).Return([]database.WorkspaceApp{appA, appB}, nil).AnyTimes() + check.Args(appA.AgentID).Asserts(ws, policy.ActionRead).Returns(slice.New(appA, appB)) + })) + s.Run("GetWorkspaceBuildByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: ws.ID}) + dbm.EXPECT().GetWorkspaceBuildByID(gomock.Any(), build.ID).Return(build, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + check.Args(build.ID).Asserts(ws, policy.ActionRead).Returns(build) })) - s.Run("GetWorkspaceAgentByInstanceID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, 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}) - check.Args(agt.AuthInstanceID.String).Asserts(w, policy.ActionRead).Returns(agt) + s.Run("GetWorkspaceBuildByJobID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: ws.ID}) + dbm.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), build.JobID).Return(build, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + check.Args(build.JobID).Asserts(ws, policy.ActionRead).Returns(build) })) - s.Run("UpdateWorkspaceAgentLifecycleStateByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, 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}) - check.Args(database.UpdateWorkspaceAgentLifecycleStateByIDParams{ - ID: agt.ID, - LifecycleState: database.WorkspaceAgentLifecycleStateCreated, - }).Asserts(w, policy.ActionUpdate).Returns() - })) - s.Run("UpdateWorkspaceAgentMetadata", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, 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}) - check.Args(database.UpdateWorkspaceAgentMetadataParams{ - WorkspaceAgentID: agt.ID, - }).Asserts(w, policy.ActionUpdate).Returns() - })) - s.Run("UpdateWorkspaceAgentLogOverflowByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, 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}) - check.Args(database.UpdateWorkspaceAgentLogOverflowByIDParams{ - ID: agt.ID, - LogsOverflowed: true, - }).Asserts(w, policy.ActionUpdate).Returns() - })) - s.Run("UpdateWorkspaceAgentStartupByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, 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}) - check.Args(database.UpdateWorkspaceAgentStartupByIDParams{ - ID: agt.ID, - Subsystems: []database.WorkspaceAgentSubsystem{ - database.WorkspaceAgentSubsystemEnvbox, - }, - }).Asserts(w, policy.ActionUpdate).Returns() - })) - s.Run("GetWorkspaceAgentLogsAfter", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - }) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) - agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - check.Args(database.GetWorkspaceAgentLogsAfterParams{ - AgentID: agt.ID, - }).Asserts(ws, policy.ActionRead).Returns([]database.WorkspaceAgentLog{}) - })) - s.Run("GetWorkspaceAppByAgentIDAndSlug", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - }) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.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.GetWorkspaceAppByAgentIDAndSlugParams{ - AgentID: agt.ID, - Slug: app.Slug, - }).Asserts(ws, policy.ActionRead).Returns(app) - })) - s.Run("GetWorkspaceAppsByAgentID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - }) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) - agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - a := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agt.ID}) - b := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agt.ID}) - - check.Args(agt.ID).Asserts(ws, policy.ActionRead).Returns(slice.New(a, b)) - })) - s.Run("GetWorkspaceBuildByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - }) - check.Args(build.ID).Asserts(ws, policy.ActionRead).Returns(build) - })) - s.Run("GetWorkspaceBuildByJobID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - }) - check.Args(build.JobID).Asserts(ws, policy.ActionRead).Returns(build) - })) - s.Run("GetWorkspaceBuildByWorkspaceIDAndBuildNumber", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - BuildNumber: 10, - }) - check.Args(database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{ - WorkspaceID: ws.ID, - BuildNumber: build.BuildNumber, - }).Asserts(ws, policy.ActionRead).Returns(build) - })) - s.Run("GetWorkspaceBuildParameters", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - }) - check.Args(build.ID).Asserts(ws, policy.ActionRead). - Returns([]database.WorkspaceBuildParameter{}) - })) - s.Run("GetWorkspaceBuildsByWorkspaceID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j1 := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j1.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - BuildNumber: 1, - }) - j2 := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j2.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - BuildNumber: 2, - }) - j3 := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j3.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - BuildNumber: 3, - }) - check.Args(database.GetWorkspaceBuildsByWorkspaceIDParams{WorkspaceID: ws.ID}).Asserts(ws, policy.ActionRead) // ordering - })) - s.Run("GetWorkspaceByAgentID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - }) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) - agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - check.Args(agt.ID).Asserts(ws, policy.ActionRead) - })) - s.Run("GetWorkspaceAgentsInLatestBuildByWorkspaceID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - }) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) - dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - check.Args(ws.ID).Asserts(ws, policy.ActionRead) - })) - s.Run("GetWorkspaceByOwnerIDAndName", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - check.Args(database.GetWorkspaceByOwnerIDAndNameParams{ - OwnerID: ws.OwnerID, - Deleted: ws.Deleted, - Name: ws.Name, - }).Asserts(ws, policy.ActionRead) - })) - s.Run("GetWorkspaceResourceByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - }) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) - check.Args(res.ID).Asserts(ws, policy.ActionRead).Returns(res) - })) - s.Run("Build/GetWorkspaceResourcesByJobID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: ws.ID, - TemplateVersionID: tv.ID, - }) - check.Args(build.JobID).Asserts(ws, policy.ActionRead).Returns([]database.WorkspaceResource{}) - })) - s.Run("Template/GetWorkspaceResourcesByJobID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - JobID: uuid.New(), - }) - job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - ID: v.JobID, - Type: database.ProvisionerJobTypeTemplateVersionImport, - }) - check.Args(job.ID).Asserts(v.RBACObject(tpl), []policy.Action{policy.ActionRead, policy.ActionRead}).Returns([]database.WorkspaceResource{}) - })) - s.Run("InsertWorkspace", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - check.Args(database.InsertWorkspaceParams{ - ID: uuid.New(), - OwnerID: u.ID, - OrganizationID: o.ID, - AutomaticUpdates: database.AutomaticUpdatesNever, - TemplateID: tpl.ID, - }).Asserts(tpl, policy.ActionRead, tpl, policy.ActionUse, rbac.ResourceWorkspace.WithOwner(u.ID.String()).InOrg(o.ID), policy.ActionCreate) - })) - s.Run("Start/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - t := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: t.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - OrganizationID: o.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - check.Args(database.InsertWorkspaceBuildParams{ - WorkspaceID: w.ID, - TemplateVersionID: tv.ID, - Transition: database.WorkspaceTransitionStart, - Reason: database.BuildReasonInitiator, - JobID: pj.ID, - }).Asserts(w, policy.ActionWorkspaceStart) - })) - s.Run("Stop/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - t := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: t.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - OrganizationID: o.ID, - }) - check.Args(database.InsertWorkspaceBuildParams{ - WorkspaceID: w.ID, - TemplateVersionID: tv.ID, - Transition: database.WorkspaceTransitionStop, - Reason: database.BuildReasonInitiator, - JobID: pj.ID, - }).Asserts(w, policy.ActionWorkspaceStop) - })) - s.Run("Start/RequireActiveVersion/VersionMismatch/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - t := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ctx := testutil.Context(s.T(), testutil.WaitShort) - err := db.UpdateTemplateAccessControlByID(ctx, database.UpdateTemplateAccessControlByIDParams{ - ID: t.ID, - RequireActiveVersion: true, - }) - require.NoError(s.T(), err) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t.ID}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: t.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - OrganizationID: o.ID, - }) - check.Args(database.InsertWorkspaceBuildParams{ - WorkspaceID: w.ID, - Transition: database.WorkspaceTransitionStart, - Reason: database.BuildReasonInitiator, - TemplateVersionID: v.ID, - JobID: pj.ID, - }).Asserts( - w, policy.ActionWorkspaceStart, - t, policy.ActionUpdate, - ) - })) - s.Run("Start/RequireActiveVersion/VersionsMatch/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - t := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - ActiveVersionID: v.ID, - }) - - ctx := testutil.Context(s.T(), testutil.WaitShort) - err := db.UpdateTemplateAccessControlByID(ctx, database.UpdateTemplateAccessControlByIDParams{ - ID: t.ID, - RequireActiveVersion: true, - }) - require.NoError(s.T(), err) - - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: t.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - OrganizationID: o.ID, - }) - // Assert that we do not check for template update permissions - // if versions match. - check.Args(database.InsertWorkspaceBuildParams{ - WorkspaceID: w.ID, - Transition: database.WorkspaceTransitionStart, - Reason: database.BuildReasonInitiator, - TemplateVersionID: v.ID, - JobID: pj.ID, - }).Asserts( - w, policy.ActionWorkspaceStart, - ) - })) - s.Run("Delete/InsertWorkspaceBuild", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - OrganizationID: o.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - check.Args(database.InsertWorkspaceBuildParams{ - WorkspaceID: w.ID, - Transition: database.WorkspaceTransitionDelete, - Reason: database.BuildReasonInitiator, - TemplateVersionID: tv.ID, - JobID: pj.ID, - }).Asserts(w, policy.ActionDelete) - })) - s.Run("InsertWorkspaceBuildParameters", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: w.ID, - TemplateVersionID: tv.ID, - }) - check.Args(database.InsertWorkspaceBuildParametersParams{ - WorkspaceBuildID: b.ID, - Name: []string{"foo", "bar"}, - Value: []string{"baz", "qux"}, - }).Asserts(w, policy.ActionUpdate) - })) - s.Run("UpdateWorkspace", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - expected := w - expected.Name = "" - check.Args(database.UpdateWorkspaceParams{ - ID: w.ID, - }).Asserts(w, policy.ActionUpdate).Returns(expected) - })) - s.Run("UpdateWorkspaceDormantDeletingAt", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - check.Args(database.UpdateWorkspaceDormantDeletingAtParams{ - ID: w.ID, - }).Asserts(w, policy.ActionUpdate) + s.Run("GetWorkspaceBuildByWorkspaceIDAndBuildNumber", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: ws.ID}) + arg := database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{WorkspaceID: ws.ID, BuildNumber: build.BuildNumber} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceBuildByWorkspaceIDAndBuildNumber(gomock.Any(), arg).Return(build, nil).AnyTimes() + check.Args(arg).Asserts(ws, policy.ActionRead).Returns(build) + })) + s.Run("GetWorkspaceBuildParameters", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: ws.ID}) + p1 := testutil.Fake(s.T(), faker, database.WorkspaceBuildParameter{}) + p2 := testutil.Fake(s.T(), faker, database.WorkspaceBuildParameter{}) + dbm.EXPECT().GetWorkspaceBuildByID(gomock.Any(), build.ID).Return(build, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceBuildParameters(gomock.Any(), build.ID).Return([]database.WorkspaceBuildParameter{p1, p2}, nil).AnyTimes() + check.Args(build.ID).Asserts(ws, policy.ActionRead).Returns([]database.WorkspaceBuildParameter{p1, p2}) + })) + s.Run("GetWorkspaceBuildsByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + b1 := testutil.Fake(s.T(), faker, database.WorkspaceBuild{}) + arg := database.GetWorkspaceBuildsByWorkspaceIDParams{WorkspaceID: ws.ID} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceBuildsByWorkspaceID(gomock.Any(), arg).Return([]database.WorkspaceBuild{b1}, nil).AnyTimes() + check.Args(arg).Asserts(ws, policy.ActionRead).Returns([]database.WorkspaceBuild{b1}) + })) + s.Run("GetWorkspaceByAgentID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(ws, nil).AnyTimes() + check.Args(agt.ID).Asserts(ws, policy.ActionRead).Returns(ws) + })) + s.Run("GetWorkspaceAgentsInLatestBuildByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), ws.ID).Return([]database.WorkspaceAgent{agt}, nil).AnyTimes() + check.Args(ws.ID).Asserts(ws, policy.ActionRead).Returns([]database.WorkspaceAgent{agt}) + })) + s.Run("GetWorkspaceByOwnerIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + arg := database.GetWorkspaceByOwnerIDAndNameParams{ + OwnerID: ws.OwnerID, + Deleted: ws.Deleted, + Name: ws.Name, + } + dbm.EXPECT().GetWorkspaceByOwnerIDAndName(gomock.Any(), arg).Return(ws, nil).AnyTimes() + check.Args(arg).Asserts(ws, policy.ActionRead).Returns(ws) + })) + s.Run("GetWorkspaceResourceByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: ws.ID}) + job := testutil.Fake(s.T(), faker, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) + res := testutil.Fake(s.T(), faker, database.WorkspaceResource{JobID: build.JobID}) + dbm.EXPECT().GetWorkspaceResourceByID(gomock.Any(), res.ID).Return(res, nil).AnyTimes() + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), res.JobID).Return(job, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), res.JobID).Return(build, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), build.WorkspaceID).Return(ws, nil).AnyTimes() + check.Args(res.ID).Asserts(ws, policy.ActionRead).Returns(res) })) - s.Run("UpdateWorkspaceAutomaticUpdates", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - check.Args(database.UpdateWorkspaceAutomaticUpdatesParams{ - ID: w.ID, - AutomaticUpdates: database.AutomaticUpdatesAlways, - }).Asserts(w, policy.ActionUpdate) + s.Run("Build/GetWorkspaceResourcesByJobID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: ws.ID}) + job := testutil.Fake(s.T(), faker, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), job.ID).Return(job, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), job.ID).Return(build, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceResourcesByJobID(gomock.Any(), job.ID).Return([]database.WorkspaceResource{}, nil).AnyTimes() + check.Args(job.ID).Asserts(ws, policy.ActionRead).Returns([]database.WorkspaceResource{}) + })) + s.Run("Template/GetWorkspaceResourcesByJobID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + v := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}}) + job := testutil.Fake(s.T(), faker, database.ProvisionerJob{ID: v.JobID, Type: database.ProvisionerJobTypeTemplateVersionImport}) + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), job.ID).Return(job, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionByJobID(gomock.Any(), job.ID).Return(v, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceResourcesByJobID(gomock.Any(), job.ID).Return([]database.WorkspaceResource{}, nil).AnyTimes() + check.Args(job.ID).Asserts(v.RBACObject(tpl), []policy.Action{policy.ActionRead, policy.ActionRead}).Returns([]database.WorkspaceResource{}) })) - s.Run("UpdateWorkspaceAppHealthByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, + s.Run("InsertWorkspace", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.InsertWorkspaceParams{ + ID: uuid.New(), + OwnerID: uuid.New(), + OrganizationID: uuid.New(), + AutomaticUpdates: database.AutomaticUpdatesNever, + TemplateID: tpl.ID, + } + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().InsertWorkspace(gomock.Any(), arg).Return(database.WorkspaceTable{}, nil).AnyTimes() + check.Args(arg).Asserts(tpl, policy.ActionRead, tpl, policy.ActionUse, rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), policy.ActionCreate) + })) + s.Run("Start/InsertWorkspaceBuild", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t := testutil.Fake(s.T(), faker, database.Template{}) + // Ensure active-version requirement is disabled to avoid extra RBAC checks. + // This case is covered by the `Start/RequireActiveVersion` test. + t.RequireActiveVersion = false + w := testutil.Fake(s.T(), faker, database.Workspace{TemplateID: t.ID}) + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}}) + pj := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.InsertWorkspaceBuildParams{ 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.UpdateWorkspaceAppHealthByIDParams{ - ID: app.ID, - Health: database.WorkspaceAppHealthDisabled, - }).Asserts(w, policy.ActionUpdate).Returns() - })) - s.Run("UpdateWorkspaceAutostart", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - check.Args(database.UpdateWorkspaceAutostartParams{ - ID: w.ID, - }).Asserts(w, policy.ActionUpdate).Returns() - })) - s.Run("UpdateWorkspaceBuildDeadlineByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + JobID: pj.ID, + } + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t.ID).Return(t, nil).AnyTimes() + dbm.EXPECT().InsertWorkspaceBuild(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionWorkspaceStart) + })) + s.Run("Stop/InsertWorkspaceBuild", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{}) + pj := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.InsertWorkspaceBuildParams{ WorkspaceID: w.ID, TemplateVersionID: tv.ID, - }) - check.Args(database.UpdateWorkspaceBuildDeadlineByIDParams{ - ID: b.ID, - UpdatedAt: b.UpdatedAt, - 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, + Transition: database.WorkspaceTransitionStop, + Reason: database.BuildReasonInitiator, + JobID: pj.ID, + } + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().InsertWorkspaceBuild(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionWorkspaceStop) + })) + s.Run("Start/RequireActiveVersion/VersionMismatch/InsertWorkspaceBuild", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + // Require active version and mismatch triggers template update authorization + t := testutil.Fake(s.T(), faker, database.Template{RequireActiveVersion: true, ActiveVersionID: uuid.New()}) + w := testutil.Fake(s.T(), faker, database.Workspace{TemplateID: t.ID}) + v := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}}) + pj := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.InsertWorkspaceBuildParams{ 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, - }).Asserts(w, policy.ActionUpdate) - })) - s.Run("SoftDeleteWorkspaceByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - w.Deleted = true - check.Args(w.ID).Asserts(w, policy.ActionDelete).Returns() - })) - s.Run("UpdateWorkspaceDeletedByID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - Deleted: true, - }) - check.Args(database.UpdateWorkspaceDeletedByIDParams{ - ID: w.ID, - Deleted: true, - }).Asserts(w, policy.ActionDelete).Returns() - })) - s.Run("UpdateWorkspaceLastUsedAt", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - check.Args(database.UpdateWorkspaceLastUsedAtParams{ - ID: w.ID, - }).Asserts(w, policy.ActionUpdate).Returns() - })) - s.Run("UpdateWorkspaceNextStartAt", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - tpl := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - check.Args(database.UpdateWorkspaceNextStartAtParams{ - ID: ws.ID, - NextStartAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, - }).Asserts(ws, policy.ActionUpdate) - })) - s.Run("BatchUpdateWorkspaceNextStartAt", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.BatchUpdateWorkspaceNextStartAtParams{ - IDs: []uuid.UUID{uuid.New()}, - NextStartAts: []time.Time{dbtime.Now()}, - }).Asserts(rbac.ResourceWorkspace.All(), policy.ActionUpdate) - })) - s.Run("BatchUpdateWorkspaceLastUsedAt", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w1 := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - w2 := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - check.Args(database.BatchUpdateWorkspaceLastUsedAtParams{ - IDs: []uuid.UUID{w1.ID, w2.ID}, - }).Asserts(rbac.ResourceWorkspace.All(), policy.ActionUpdate).Returns() - })) - s.Run("UpdateWorkspaceTTL", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - check.Args(database.UpdateWorkspaceTTLParams{ - ID: w.ID, - }).Asserts(w, policy.ActionUpdate).Returns() + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + TemplateVersionID: v.ID, + JobID: pj.ID, + } + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t.ID).Return(t, nil).AnyTimes() + dbm.EXPECT().InsertWorkspaceBuild(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts( + w, policy.ActionWorkspaceStart, + t, policy.ActionUpdate, + ) })) - s.Run("GetWorkspaceByWorkspaceAppID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, + s.Run("Start/RequireActiveVersion/VersionsMatch/InsertWorkspaceBuild", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + v := testutil.Fake(s.T(), faker, database.TemplateVersion{}) + t := testutil.Fake(s.T(), faker, database.Template{RequireActiveVersion: true, ActiveVersionID: v.ID}) + w := testutil.Fake(s.T(), faker, database.Workspace{TemplateID: t.ID}) + pj := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.InsertWorkspaceBuildParams{ + WorkspaceID: w.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + TemplateVersionID: v.ID, + JobID: pj.ID, + } + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), t.ID).Return(t, nil).AnyTimes() + dbm.EXPECT().InsertWorkspaceBuild(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts( + w, policy.ActionWorkspaceStart, + ) + })) + s.Run("Delete/InsertWorkspaceBuild", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{}) + pj := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.InsertWorkspaceBuildParams{ WorkspaceID: w.ID, + Transition: database.WorkspaceTransitionDelete, + Reason: database.BuildReasonInitiator, 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(app.ID).Asserts(w, policy.ActionRead) - })) - s.Run("ActivityBumpWorkspace", 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{ + JobID: pj.ID, + } + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().InsertWorkspaceBuild(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionDelete) + })) + s.Run("InsertWorkspaceBuildParameters", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: w.ID}) + arg := database.InsertWorkspaceBuildParametersParams{ + WorkspaceBuildID: b.ID, + Name: []string{"foo", "bar"}, + Value: []string{"baz", "qux"}, + } + dbm.EXPECT().GetWorkspaceBuildByID(gomock.Any(), b.ID).Return(b, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().InsertWorkspaceBuildParameters(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate) + })) + s.Run("UpdateWorkspace", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + expected := testutil.Fake(s.T(), faker, database.WorkspaceTable{ID: w.ID}) + expected.Name = "" + arg := database.UpdateWorkspaceParams{ID: w.ID} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspace(gomock.Any(), arg).Return(expected, nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate).Returns(expected) + })) + s.Run("UpdateWorkspaceDormantDeletingAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + arg := database.UpdateWorkspaceDormantDeletingAtParams{ID: w.ID} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceDormantDeletingAt(gomock.Any(), arg).Return(testutil.Fake(s.T(), faker, database.WorkspaceTable{ID: w.ID}), nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate) + })) + s.Run("UpdateWorkspaceAutomaticUpdates", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + arg := database.UpdateWorkspaceAutomaticUpdatesParams{ID: w.ID, AutomaticUpdates: database.AutomaticUpdatesAlways} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceAutomaticUpdates(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate) + })) + s.Run("UpdateWorkspaceAppHealthByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + app := testutil.Fake(s.T(), faker, database.WorkspaceApp{}) + arg := database.UpdateWorkspaceAppHealthByIDParams{ID: app.ID, Health: database.WorkspaceAppHealthDisabled} + dbm.EXPECT().GetWorkspaceByWorkspaceAppID(gomock.Any(), app.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceAppHealthByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate).Returns() + })) + s.Run("UpdateWorkspaceAutostart", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + arg := database.UpdateWorkspaceAutostartParams{ID: w.ID} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceAutostart(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate).Returns() + })) + s.Run("UpdateWorkspaceBuildDeadlineByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: w.ID}) + arg := database.UpdateWorkspaceBuildDeadlineByIDParams{ID: b.ID, UpdatedAt: b.UpdatedAt, Deadline: b.Deadline} + dbm.EXPECT().GetWorkspaceBuildByID(gomock.Any(), b.ID).Return(b, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceBuildDeadlineByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate) + })) + 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, }) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ + b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{ JobID: j.ID, WorkspaceID: w.ID, TemplateVersionID: tv.ID, }) - check.Args(database.ActivityBumpWorkspaceParams{ - WorkspaceID: w.ID, - }).Asserts(w, policy.ActionUpdate).Returns() + 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("FavoriteWorkspace", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) + s.Run("SoftDeleteWorkspaceByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + w.Deleted = true + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceDeletedByID(gomock.Any(), database.UpdateWorkspaceDeletedByIDParams{ID: w.ID, Deleted: true}).Return(nil).AnyTimes() + check.Args(w.ID).Asserts(w, policy.ActionDelete).Returns() + })) + s.Run("UpdateWorkspaceDeletedByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{Deleted: true}) + arg := database.UpdateWorkspaceDeletedByIDParams{ID: w.ID, Deleted: true} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceDeletedByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionDelete).Returns() + })) + s.Run("UpdateWorkspaceLastUsedAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + arg := database.UpdateWorkspaceLastUsedAtParams{ID: w.ID} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate).Returns() + })) + s.Run("UpdateWorkspaceNextStartAt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), gofakeit.New(0), database.Workspace{}) + arg := database.UpdateWorkspaceNextStartAtParams{ID: ws.ID, NextStartAt: sql.NullTime{Valid: true, Time: dbtime.Now()}} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceNextStartAt(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(ws, policy.ActionUpdate) + })) + s.Run("BatchUpdateWorkspaceNextStartAt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.BatchUpdateWorkspaceNextStartAtParams{IDs: []uuid.UUID{uuid.New()}, NextStartAts: []time.Time{dbtime.Now()}} + dbm.EXPECT().BatchUpdateWorkspaceNextStartAt(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceWorkspace.All(), policy.ActionUpdate) + })) + s.Run("BatchUpdateWorkspaceLastUsedAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w1 := testutil.Fake(s.T(), faker, database.Workspace{}) + w2 := testutil.Fake(s.T(), faker, database.Workspace{}) + arg := database.BatchUpdateWorkspaceLastUsedAtParams{IDs: []uuid.UUID{w1.ID, w2.ID}} + dbm.EXPECT().BatchUpdateWorkspaceLastUsedAt(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceWorkspace.All(), policy.ActionUpdate).Returns() + })) + s.Run("UpdateWorkspaceTTL", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + arg := database.UpdateWorkspaceTTLParams{ID: w.ID} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceTTL(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate).Returns() + })) + s.Run("GetWorkspaceByWorkspaceAppID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + app := testutil.Fake(s.T(), faker, database.WorkspaceApp{}) + dbm.EXPECT().GetWorkspaceByWorkspaceAppID(gomock.Any(), app.ID).Return(w, nil).AnyTimes() + check.Args(app.ID).Asserts(w, policy.ActionRead) + })) + s.Run("ActivityBumpWorkspace", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + arg := database.ActivityBumpWorkspaceParams{WorkspaceID: w.ID} + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().ActivityBumpWorkspace(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate).Returns() + })) + s.Run("FavoriteWorkspace", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().FavoriteWorkspace(gomock.Any(), w.ID).Return(nil).AnyTimes() check.Args(w.ID).Asserts(w, policy.ActionUpdate).Returns() })) - s.Run("UnfavoriteWorkspace", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) + s.Run("UnfavoriteWorkspace", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UnfavoriteWorkspace(gomock.Any(), w.ID).Return(nil).AnyTimes() check.Args(w.ID).Asserts(w, policy.ActionUpdate).Returns() })) - s.Run("GetWorkspaceAgentDevcontainersByAgentID", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OrganizationID: o.ID, - OwnerID: u.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, 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}) - d := dbgen.WorkspaceAgentDevcontainer(s.T(), db, database.WorkspaceAgentDevcontainer{WorkspaceAgentID: agt.ID}) + s.Run("GetWorkspaceAgentDevcontainersByAgentID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + d := testutil.Fake(s.T(), faker, database.WorkspaceAgentDevcontainer{WorkspaceAgentID: agt.ID}) + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agt.ID).Return(agt, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAgentDevcontainersByAgentID(gomock.Any(), agt.ID).Return([]database.WorkspaceAgentDevcontainer{d}, nil).AnyTimes() check.Args(agt.ID).Asserts(w, policy.ActionRead).Returns([]database.WorkspaceAgentDevcontainer{d}) })) + s.Run("GetRegularWorkspaceCreateMetrics", s.Subtest(func(_ database.Store, check *expects) { + check.Args(). + Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead) + })) } func (s *MethodTestSuite) TestWorkspacePortSharing() { @@ -3590,73 +2286,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() })) } @@ -3910,21 +2583,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() @@ -3932,1394 +2604,1039 @@ 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) })) } func (s *MethodTestSuite) TestSystemFunctions() { - s.Run("UpdateUserLinkedID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - l := dbgen.UserLink(s.T(), db, database.UserLink{UserID: u.ID}) - check.Args(database.UpdateUserLinkedIDParams{ - UserID: u.ID, - LinkedID: l.LinkedID, - LoginType: database.LoginTypeGithub, - }).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(l) - })) - s.Run("GetLatestWorkspaceAppStatusesByWorkspaceIDs", s.Subtest(func(db database.Store, check *expects) { - check.Args([]uuid.UUID{}).Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("GetWorkspaceAppStatusesByAppIDs", s.Subtest(func(db database.Store, check *expects) { - check.Args([]uuid.UUID{}).Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("GetLatestWorkspaceBuildsByWorkspaceIDs", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID}) - check.Args([]uuid.UUID{ws.ID}).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(slice.New(b)) - })) - s.Run("UpsertDefaultProxy", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.UpsertDefaultProxyParams{}).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns() - })) - s.Run("GetUserLinkByLinkedID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - l := dbgen.UserLink(s.T(), db, database.UserLink{UserID: u.ID}) + s.Run("UpdateUserLinkedID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + l := testutil.Fake(s.T(), faker, database.UserLink{UserID: u.ID}) + arg := database.UpdateUserLinkedIDParams{UserID: u.ID, LinkedID: l.LinkedID, LoginType: database.LoginTypeGithub} + dbm.EXPECT().UpdateUserLinkedID(gomock.Any(), arg).Return(l, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(l) + })) + s.Run("GetLatestWorkspaceAppStatusesByWorkspaceIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ids := []uuid.UUID{uuid.New()} + dbm.EXPECT().GetLatestWorkspaceAppStatusesByWorkspaceIDs(gomock.Any(), ids).Return([]database.WorkspaceAppStatus{}, nil).AnyTimes() + check.Args(ids).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspaceAppStatusesByAppIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ids := []uuid.UUID{uuid.New()} + dbm.EXPECT().GetWorkspaceAppStatusesByAppIDs(gomock.Any(), ids).Return([]database.WorkspaceAppStatus{}, nil).AnyTimes() + check.Args(ids).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetLatestWorkspaceBuildsByWorkspaceIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + wsID := uuid.New() + b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{}) + dbm.EXPECT().GetLatestWorkspaceBuildsByWorkspaceIDs(gomock.Any(), []uuid.UUID{wsID}).Return([]database.WorkspaceBuild{b}, nil).AnyTimes() + check.Args([]uuid.UUID{wsID}).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(slice.New(b)) + })) + s.Run("UpsertDefaultProxy", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.UpsertDefaultProxyParams{} + dbm.EXPECT().UpsertDefaultProxy(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns() + })) + s.Run("GetUserLinkByLinkedID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + l := testutil.Fake(s.T(), faker, database.UserLink{}) + dbm.EXPECT().GetUserLinkByLinkedID(gomock.Any(), l.LinkedID).Return(l, nil).AnyTimes() check.Args(l.LinkedID).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(l) })) - s.Run("GetUserLinkByUserIDLoginType", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - l := dbgen.UserLink(s.T(), db, database.UserLink{}) - check.Args(database.GetUserLinkByUserIDLoginTypeParams{ - UserID: l.UserID, - LoginType: l.LoginType, - }).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(l) - })) - s.Run("GetLatestWorkspaceBuilds", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{}) - dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{}) - check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("GetUserLinkByUserIDLoginType", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + l := testutil.Fake(s.T(), faker, database.UserLink{}) + arg := database.GetUserLinkByUserIDLoginTypeParams{UserID: l.UserID, LoginType: l.LoginType} + dbm.EXPECT().GetUserLinkByUserIDLoginType(gomock.Any(), arg).Return(l, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(l) })) - s.Run("GetActiveUserCount", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetActiveUserCount", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(0), nil).AnyTimes() 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{}) + s.Run("GetAuthorizationUserRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().GetAuthorizationUserRoles(gomock.Any(), u.ID).Return(database.GetAuthorizationUserRolesRow{}, nil).AnyTimes() check.Args(u.ID).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("GetDERPMeshKey", s.Subtest(func(db database.Store, check *expects) { - db.InsertDERPMeshKey(context.Background(), "testing") + s.Run("GetDERPMeshKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetDERPMeshKey(gomock.Any()).Return("testing", nil).AnyTimes() check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("InsertDERPMeshKey", s.Subtest(func(db database.Store, check *expects) { + s.Run("InsertDERPMeshKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().InsertDERPMeshKey(gomock.Any(), "value").Return(nil).AnyTimes() check.Args("value").Asserts(rbac.ResourceSystem, policy.ActionCreate).Returns() })) - s.Run("InsertDeploymentID", s.Subtest(func(db database.Store, check *expects) { + s.Run("InsertDeploymentID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().InsertDeploymentID(gomock.Any(), "value").Return(nil).AnyTimes() check.Args("value").Asserts(rbac.ResourceSystem, policy.ActionCreate).Returns() })) - s.Run("InsertReplica", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertReplicaParams{ - ID: uuid.New(), - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("UpdateReplica", s.Subtest(func(db database.Store, check *expects) { - replica, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ID: uuid.New()}) - require.NoError(s.T(), err) - check.Args(database.UpdateReplicaParams{ - ID: replica.ID, - DatabaseLatency: 100, - }).Asserts(rbac.ResourceSystem, policy.ActionUpdate) - })) - s.Run("DeleteReplicasUpdatedBefore", s.Subtest(func(db database.Store, check *expects) { - _, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ID: uuid.New(), UpdatedAt: time.Now()}) - require.NoError(s.T(), err) - check.Args(time.Now().Add(time.Hour)).Asserts(rbac.ResourceSystem, policy.ActionDelete) - })) - s.Run("GetReplicasUpdatedAfter", s.Subtest(func(db database.Store, check *expects) { - _, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ID: uuid.New(), UpdatedAt: time.Now()}) - require.NoError(s.T(), err) - check.Args(time.Now().Add(time.Hour*-1)).Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("GetUserCount", s.Subtest(func(db database.Store, check *expects) { + s.Run("InsertReplica", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertReplicaParams{ID: uuid.New()} + dbm.EXPECT().InsertReplica(gomock.Any(), arg).Return(database.Replica{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("UpdateReplica", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + rep := testutil.Fake(s.T(), faker, database.Replica{}) + arg := database.UpdateReplicaParams{ID: rep.ID, DatabaseLatency: 100} + dbm.EXPECT().UpdateReplica(gomock.Any(), arg).Return(rep, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate) + })) + s.Run("DeleteReplicasUpdatedBefore", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t := dbtime.Now().Add(time.Hour) + dbm.EXPECT().DeleteReplicasUpdatedBefore(gomock.Any(), t).Return(nil).AnyTimes() + check.Args(t).Asserts(rbac.ResourceSystem, policy.ActionDelete) + })) + s.Run("GetReplicasUpdatedAfter", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t := dbtime.Now().Add(-time.Hour) + dbm.EXPECT().GetReplicasUpdatedAfter(gomock.Any(), t).Return([]database.Replica{}, nil).AnyTimes() + check.Args(t).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetUserCount", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetUserCount(gomock.Any(), false).Return(int64(0), nil).AnyTimes() check.Args(false).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(int64(0)) })) - s.Run("GetTemplates", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - _ = dbgen.Template(s.T(), db, database.Template{}) - check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("UpdateWorkspaceBuildCostByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{}) - o := b - o.DailyCost = 10 - check.Args(database.UpdateWorkspaceBuildCostByIDParams{ - ID: b.ID, - DailyCost: 10, - }).Asserts(rbac.ResourceSystem, policy.ActionUpdate) - })) - s.Run("UpdateWorkspaceBuildProvisionerStateByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) - check.Args(database.UpdateWorkspaceBuildProvisionerStateByIDParams{ - ID: build.ID, - ProvisionerState: []byte("testing"), - }).Asserts(rbac.ResourceSystem, policy.ActionUpdate) - })) - s.Run("UpsertLastUpdateCheck", s.Subtest(func(db database.Store, check *expects) { - check.Args("value").Asserts(rbac.ResourceSystem, policy.ActionUpdate) - })) - s.Run("GetLastUpdateCheck", s.Subtest(func(db database.Store, check *expects) { - err := db.UpsertLastUpdateCheck(context.Background(), "value") - require.NoError(s.T(), err) + s.Run("GetTemplates", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetTemplates(gomock.Any()).Return([]database.Template{}, nil).AnyTimes() check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("GetWorkspaceBuildsCreatedAfter", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{CreatedAt: time.Now().Add(-time.Hour)}) - check.Args(time.Now()).Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("GetWorkspaceAgentsCreatedAfter", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - _ = dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{CreatedAt: time.Now().Add(-time.Hour)}) - check.Args(time.Now()).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("UpdateWorkspaceBuildCostByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{}) + arg := database.UpdateWorkspaceBuildCostByIDParams{ID: b.ID, DailyCost: 10} + dbm.EXPECT().UpdateWorkspaceBuildCostByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) - s.Run("GetWorkspaceAppsCreatedAfter", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - _ = dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{CreatedAt: time.Now().Add(-time.Hour), OpenIn: database.WorkspaceAppOpenInSlimWindow}) - check.Args(time.Now()).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("UpdateWorkspaceBuildProvisionerStateByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{}) + arg := database.UpdateWorkspaceBuildProvisionerStateByIDParams{ID: b.ID, ProvisionerState: []byte("testing")} + dbm.EXPECT().UpdateWorkspaceBuildProvisionerStateByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) - s.Run("GetWorkspaceResourcesCreatedAfter", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - _ = dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{CreatedAt: time.Now().Add(-time.Hour)}) - check.Args(time.Now()).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("UpsertLastUpdateCheck", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertLastUpdateCheck(gomock.Any(), "value").Return(nil).AnyTimes() + check.Args("value").Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) - s.Run("GetWorkspaceResourceMetadataCreatedAfter", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - _ = dbgen.WorkspaceResourceMetadatums(s.T(), db, database.WorkspaceResourceMetadatum{}) - check.Args(time.Now()).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("GetLastUpdateCheck", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetLastUpdateCheck(gomock.Any()).Return("value", nil).AnyTimes() + check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("DeleteOldWorkspaceAgentStats", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetWorkspaceBuildsCreatedAfter", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ts := dbtime.Now() + dbm.EXPECT().GetWorkspaceBuildsCreatedAfter(gomock.Any(), ts).Return([]database.WorkspaceBuild{}, nil).AnyTimes() + check.Args(ts).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspaceAgentsCreatedAfter", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ts := dbtime.Now() + dbm.EXPECT().GetWorkspaceAgentsCreatedAfter(gomock.Any(), ts).Return([]database.WorkspaceAgent{}, nil).AnyTimes() + check.Args(ts).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspaceAppsCreatedAfter", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ts := dbtime.Now() + dbm.EXPECT().GetWorkspaceAppsCreatedAfter(gomock.Any(), ts).Return([]database.WorkspaceApp{}, nil).AnyTimes() + check.Args(ts).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspaceResourcesCreatedAfter", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ts := dbtime.Now() + dbm.EXPECT().GetWorkspaceResourcesCreatedAfter(gomock.Any(), ts).Return([]database.WorkspaceResource{}, nil).AnyTimes() + check.Args(ts).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspaceResourceMetadataCreatedAfter", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ts := dbtime.Now() + dbm.EXPECT().GetWorkspaceResourceMetadataCreatedAfter(gomock.Any(), ts).Return([]database.WorkspaceResourceMetadatum{}, nil).AnyTimes() + check.Args(ts).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("DeleteOldWorkspaceAgentStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().DeleteOldWorkspaceAgentStats(gomock.Any()).Return(nil).AnyTimes() check.Args().Asserts(rbac.ResourceSystem, policy.ActionDelete) })) - s.Run("GetProvisionerJobsCreatedAfter", s.Subtest(func(db database.Store, check *expects) { - _ = dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{CreatedAt: time.Now().Add(-time.Hour)}) - check.Args(time.Now()).Asserts(rbac.ResourceProvisionerJobs, policy.ActionRead) - })) - s.Run("GetTemplateVersionsByIDs", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - t1 := dbgen.Template(s.T(), db, database.Template{}) - t2 := dbgen.Template(s.T(), db, database.Template{}) - tv1 := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, - }) - tv2 := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t2.ID, Valid: true}, - }) - tv3 := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: t2.ID, Valid: true}, - }) - check.Args([]uuid.UUID{tv1.ID, tv2.ID, tv3.ID}). + s.Run("GetProvisionerJobsCreatedAfter", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ts := dbtime.Now() + dbm.EXPECT().GetProvisionerJobsCreatedAfter(gomock.Any(), ts).Return([]database.ProvisionerJob{}, nil).AnyTimes() + check.Args(ts).Asserts(rbac.ResourceProvisionerJobs, policy.ActionRead) + })) + s.Run("GetTemplateVersionsByIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tv1 := testutil.Fake(s.T(), faker, database.TemplateVersion{}) + tv2 := testutil.Fake(s.T(), faker, database.TemplateVersion{}) + tv3 := testutil.Fake(s.T(), faker, database.TemplateVersion{}) + ids := []uuid.UUID{tv1.ID, tv2.ID, tv3.ID} + dbm.EXPECT().GetTemplateVersionsByIDs(gomock.Any(), ids).Return([]database.TemplateVersion{tv1, tv2, tv3}, nil).AnyTimes() + check.Args(ids). Asserts(rbac.ResourceSystem, policy.ActionRead). Returns(slice.New(tv1, tv2, tv3)) })) - s.Run("GetParameterSchemasByJobID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - tpl := dbgen.Template(s.T(), db, database.Template{}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - }) - job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ID: tv.JobID}) - check.Args(job.ID). + s.Run("GetParameterSchemasByJobID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + v := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}}) + jobID := v.JobID + dbm.EXPECT().GetTemplateVersionByJobID(gomock.Any(), jobID).Return(v, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().GetParameterSchemasByJobID(gomock.Any(), jobID).Return([]database.ParameterSchema{}, nil).AnyTimes() + check.Args(jobID). Asserts(tpl, policy.ActionRead). ErrorsWithInMemDB(sql.ErrNoRows). Returns([]database.ParameterSchema{}) })) - s.Run("GetWorkspaceAppsByAgentIDs", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - aWs := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) - aBuild := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: aWs.ID, JobID: uuid.New()}) - aRes := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: aBuild.JobID}) - aAgt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: aRes.ID}) - a := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: aAgt.ID, OpenIn: database.WorkspaceAppOpenInSlimWindow}) - - bWs := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) - bBuild := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: bWs.ID, JobID: uuid.New()}) - bRes := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: bBuild.JobID}) - bAgt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: bRes.ID}) - b := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: bAgt.ID, OpenIn: database.WorkspaceAppOpenInSlimWindow}) - - check.Args([]uuid.UUID{a.AgentID, b.AgentID}). + s.Run("GetWorkspaceAppsByAgentIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + a := testutil.Fake(s.T(), faker, database.WorkspaceApp{}) + b := testutil.Fake(s.T(), faker, database.WorkspaceApp{}) + ids := []uuid.UUID{a.AgentID, b.AgentID} + dbm.EXPECT().GetWorkspaceAppsByAgentIDs(gomock.Any(), ids).Return([]database.WorkspaceApp{a, b}, nil).AnyTimes() + check.Args(ids). Asserts(rbac.ResourceSystem, policy.ActionRead). Returns([]database.WorkspaceApp{a, b}) })) - s.Run("GetWorkspaceResourcesByJobIDs", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - tpl := dbgen.Template(s.T(), db, database.Template{}) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, JobID: uuid.New()}) - tJob := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ID: v.JobID, Type: database.ProvisionerJobTypeTemplateVersionImport}) - - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) - wJob := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) - check.Args([]uuid.UUID{tJob.ID, wJob.ID}). + s.Run("GetWorkspaceResourcesByJobIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ids := []uuid.UUID{uuid.New(), uuid.New()} + dbm.EXPECT().GetWorkspaceResourcesByJobIDs(gomock.Any(), ids).Return([]database.WorkspaceResource{}, nil).AnyTimes() + check.Args(ids). Asserts(rbac.ResourceSystem, policy.ActionRead). Returns([]database.WorkspaceResource{}) })) - s.Run("GetWorkspaceResourceMetadataByResourceIDs", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) - _ = dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) - a := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) - b := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) - check.Args([]uuid.UUID{a.ID, b.ID}). + s.Run("GetWorkspaceResourceMetadataByResourceIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ids := []uuid.UUID{uuid.New(), uuid.New()} + dbm.EXPECT().GetWorkspaceResourceMetadataByResourceIDs(gomock.Any(), ids).Return([]database.WorkspaceResourceMetadatum{}, nil).AnyTimes() + check.Args(ids). Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("GetWorkspaceAgentsByResourceIDs", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) - agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - check.Args([]uuid.UUID{res.ID}). + s.Run("GetWorkspaceAgentsByResourceIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + resID := uuid.New() + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + dbm.EXPECT().GetWorkspaceAgentsByResourceIDs(gomock.Any(), []uuid.UUID{resID}).Return([]database.WorkspaceAgent{agt}, nil).AnyTimes() + check.Args([]uuid.UUID{resID}). Asserts(rbac.ResourceSystem, policy.ActionRead). Returns([]database.WorkspaceAgent{agt}) })) - s.Run("GetProvisionerJobsByIDs", s.Subtest(func(db database.Store, check *expects) { - o := dbgen.Organization(s.T(), db, database.Organization{}) - a := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{OrganizationID: o.ID}) - b := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{OrganizationID: o.ID}) - check.Args([]uuid.UUID{a.ID, b.ID}). - Asserts(rbac.ResourceProvisionerJobs.InOrg(o.ID), policy.ActionRead). + s.Run("GetProvisionerJobsByIDs", 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.ProvisionerJob{OrganizationID: org.ID}) + b := testutil.Fake(s.T(), faker, database.ProvisionerJob{OrganizationID: org.ID}) + ids := []uuid.UUID{a.ID, b.ID} + dbm.EXPECT().GetProvisionerJobsByIDs(gomock.Any(), ids).Return([]database.ProvisionerJob{a, b}, nil).AnyTimes() + check.Args(ids). + Asserts(rbac.ResourceProvisionerJobs.InOrg(org.ID), policy.ActionRead). Returns(slice.New(a, b)) })) - s.Run("DeleteWorkspaceSubAgentByID", s.Subtest(func(db database.Store, check *expects) { - _ = dbgen.User(s.T(), db, database.User{}) - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) - tpl := dbgen.Template(s.T(), db, database.Template{CreatedBy: u.ID, OrganizationID: o.ID}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{OwnerID: u.ID, TemplateID: tpl.ID, OrganizationID: o.ID}) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: j.ID, TemplateVersionID: tv.ID}) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: j.ID}) - agent := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - _ = dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID, ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID}}) + s.Run("DeleteWorkspaceSubAgentByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + agent := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().DeleteWorkspaceSubAgentByID(gomock.Any(), agent.ID).Return(nil).AnyTimes() check.Args(agent.ID).Asserts(ws, policy.ActionDeleteAgent) })) - s.Run("GetWorkspaceAgentsByParentID", s.Subtest(func(db database.Store, check *expects) { - _ = dbgen.User(s.T(), db, database.User{}) - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) - tpl := dbgen.Template(s.T(), db, database.Template{CreatedBy: u.ID, OrganizationID: o.ID}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{OwnerID: u.ID, TemplateID: tpl.ID, OrganizationID: o.ID}) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: j.ID, TemplateVersionID: tv.ID}) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: j.ID}) - agent := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - _ = dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID, ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID}}) - check.Args(agent.ID).Asserts(ws, policy.ActionRead) - })) - s.Run("InsertWorkspaceAgent", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) - tpl := dbgen.Template(s.T(), db, database.Template{CreatedBy: u.ID, OrganizationID: o.ID}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{OwnerID: u.ID, TemplateID: tpl.ID, OrganizationID: o.ID}) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: j.ID, TemplateVersionID: tv.ID}) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: j.ID}) - check.Args(database.InsertWorkspaceAgentParams{ - ID: uuid.New(), - ResourceID: res.ID, - Name: "dev", - APIKeyScope: database.AgentKeyScopeEnumAll, - }).Asserts(ws, policy.ActionCreateAgent) - })) - s.Run("UpsertWorkspaceApp", s.Subtest(func(db database.Store, check *expects) { - _ = dbgen.User(s.T(), db, database.User{}) - u := dbgen.User(s.T(), db, database.User{}) - o := dbgen.Organization(s.T(), db, database.Organization{}) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) - tpl := dbgen.Template(s.T(), db, database.Template{CreatedBy: u.ID, OrganizationID: o.ID}) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{OwnerID: u.ID, TemplateID: tpl.ID, OrganizationID: o.ID}) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: j.ID, TemplateVersionID: tv.ID}) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: j.ID}) - agent := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - check.Args(database.UpsertWorkspaceAppParams{ - ID: uuid.New(), - AgentID: agent.ID, - Health: database.WorkspaceAppHealthDisabled, - SharingLevel: database.AppSharingLevelOwner, - OpenIn: database.WorkspaceAppOpenInSlimWindow, - }).Asserts(ws, policy.ActionUpdate) - })) - s.Run("InsertWorkspaceResourceMetadata", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertWorkspaceResourceMetadataParams{ - WorkspaceResourceID: uuid.New(), - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("UpdateWorkspaceAgentConnectionByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) - agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - check.Args(database.UpdateWorkspaceAgentConnectionByIDParams{ - ID: agt.ID, - }).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns() - })) - s.Run("AcquireProvisionerJob", s.Subtest(func(db database.Store, check *expects) { - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - StartedAt: sql.NullTime{Valid: false}, - UpdatedAt: time.Now(), - }) - check.Args(database.AcquireProvisionerJobParams{ - StartedAt: sql.NullTime{Valid: true, Time: time.Now()}, - OrganizationID: j.OrganizationID, - Types: []database.ProvisionerType{j.Provisioner}, - ProvisionerTags: must(json.Marshal(j.Tags)), - }).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) - })) - s.Run("UpdateProvisionerJobWithCompleteByID", s.Subtest(func(db database.Store, check *expects) { - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) - check.Args(database.UpdateProvisionerJobWithCompleteByIDParams{ - ID: j.ID, - }).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) - })) - s.Run("UpdateProvisionerJobWithCompleteWithStartedAtByID", s.Subtest(func(db database.Store, check *expects) { - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) - check.Args(database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams{ - ID: j.ID, - }).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) - })) - s.Run("UpdateProvisionerJobByID", s.Subtest(func(db database.Store, check *expects) { - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) - check.Args(database.UpdateProvisionerJobByIDParams{ - ID: j.ID, - UpdatedAt: time.Now(), - }).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) - })) - s.Run("UpdateProvisionerJobLogsLength", s.Subtest(func(db database.Store, check *expects) { - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) - check.Args(database.UpdateProvisionerJobLogsLengthParams{ - ID: j.ID, - LogsLength: 100, - }).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) - })) - s.Run("UpdateProvisionerJobLogsOverflowed", s.Subtest(func(db database.Store, check *expects) { - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) - check.Args(database.UpdateProvisionerJobLogsOverflowedParams{ - ID: j.ID, - LogsOverflowed: true, - }).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) - })) - s.Run("InsertProvisionerJob", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - check.Args(database.InsertProvisionerJobParams{ + s.Run("GetWorkspaceAgentsByParentID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + parent := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + child := testutil.Fake(s.T(), faker, database.WorkspaceAgent{ParentID: uuid.NullUUID{Valid: true, UUID: parent.ID}}) + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), parent.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceAgentsByParentID(gomock.Any(), parent.ID).Return([]database.WorkspaceAgent{child}, nil).AnyTimes() + check.Args(parent.ID).Asserts(ws, policy.ActionRead) + })) + s.Run("InsertWorkspaceAgent", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + res := testutil.Fake(s.T(), faker, database.WorkspaceResource{}) + arg := database.InsertWorkspaceAgentParams{ID: uuid.New(), ResourceID: res.ID, Name: "dev", APIKeyScope: database.AgentKeyScopeEnumAll} + dbm.EXPECT().GetWorkspaceByResourceID(gomock.Any(), res.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().InsertWorkspaceAgent(gomock.Any(), arg).Return(testutil.Fake(s.T(), faker, database.WorkspaceAgent{ResourceID: res.ID}), nil).AnyTimes() + check.Args(arg).Asserts(ws, policy.ActionCreateAgent) + })) + s.Run("UpsertWorkspaceApp", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + agent := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + arg := database.UpsertWorkspaceAppParams{ID: uuid.New(), AgentID: agent.ID, Health: database.WorkspaceAppHealthDisabled, SharingLevel: database.AppSharingLevelOwner, OpenIn: database.WorkspaceAppOpenInSlimWindow} + dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().UpsertWorkspaceApp(gomock.Any(), arg).Return(testutil.Fake(s.T(), faker, database.WorkspaceApp{AgentID: agent.ID}), nil).AnyTimes() + check.Args(arg).Asserts(ws, policy.ActionUpdate) + })) + s.Run("InsertWorkspaceResourceMetadata", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertWorkspaceResourceMetadataParams{WorkspaceResourceID: uuid.New()} + dbm.EXPECT().InsertWorkspaceResourceMetadata(gomock.Any(), arg).Return([]database.WorkspaceResourceMetadatum{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("UpdateWorkspaceAgentConnectionByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + arg := database.UpdateWorkspaceAgentConnectionByIDParams{ID: agt.ID} + dbm.EXPECT().UpdateWorkspaceAgentConnectionByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns() + })) + s.Run("AcquireProvisionerJob", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := database.AcquireProvisionerJobParams{StartedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, OrganizationID: uuid.New(), Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, ProvisionerTags: json.RawMessage("{}")} + dbm.EXPECT().AcquireProvisionerJob(gomock.Any(), arg).Return(testutil.Fake(s.T(), faker, database.ProvisionerJob{}), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) + })) + s.Run("UpdateProvisionerJobWithCompleteByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.UpdateProvisionerJobWithCompleteByIDParams{ID: j.ID} + dbm.EXPECT().UpdateProvisionerJobWithCompleteByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) + })) + s.Run("UpdateProvisionerJobWithCompleteWithStartedAtByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams{ID: j.ID} + dbm.EXPECT().UpdateProvisionerJobWithCompleteWithStartedAtByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) + })) + s.Run("UpdateProvisionerJobByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.UpdateProvisionerJobByIDParams{ID: j.ID, UpdatedAt: dbtime.Now()} + dbm.EXPECT().UpdateProvisionerJobByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) + })) + s.Run("UpdateProvisionerJobLogsLength", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.UpdateProvisionerJobLogsLengthParams{ID: j.ID, LogsLength: 100} + dbm.EXPECT().UpdateProvisionerJobLogsLength(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) + })) + s.Run("UpdateProvisionerJobLogsOverflowed", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.UpdateProvisionerJobLogsOverflowedParams{ID: j.ID, LogsOverflowed: true} + dbm.EXPECT().UpdateProvisionerJobLogsOverflowed(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) + })) + s.Run("InsertProvisionerJob", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertProvisionerJobParams{ ID: uuid.New(), Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeWorkspaceBuild, Input: json.RawMessage("{}"), - }).Asserts( /* rbac.ResourceProvisionerJobs, policy.ActionCreate */ ) - })) - s.Run("InsertProvisionerJobLogs", s.Subtest(func(db database.Store, check *expects) { - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) - check.Args(database.InsertProvisionerJobLogsParams{ - JobID: j.ID, - }).Asserts( /* rbac.ResourceProvisionerJobs, policy.ActionUpdate */ ) - })) - s.Run("InsertProvisionerJobTimings", s.Subtest(func(db database.Store, check *expects) { - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) - check.Args(database.InsertProvisionerJobTimingsParams{ - JobID: j.ID, - }).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) - })) - s.Run("UpsertProvisionerDaemon", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - org := dbgen.Organization(s.T(), db, database.Organization{}) + } + dbm.EXPECT().InsertProvisionerJob(gomock.Any(), arg).Return(testutil.Fake(s.T(), gofakeit.New(0), database.ProvisionerJob{}), nil).AnyTimes() + check.Args(arg).Asserts( /* rbac.ResourceProvisionerJobs, policy.ActionCreate */ ) + })) + s.Run("InsertProvisionerJobLogs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.InsertProvisionerJobLogsParams{JobID: j.ID} + dbm.EXPECT().InsertProvisionerJobLogs(gomock.Any(), arg).Return([]database.ProvisionerJobLog{}, nil).AnyTimes() + check.Args(arg).Asserts( /* rbac.ResourceProvisionerJobs, policy.ActionUpdate */ ) + })) + s.Run("InsertProvisionerJobTimings", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + arg := database.InsertProvisionerJobTimingsParams{JobID: j.ID} + dbm.EXPECT().InsertProvisionerJobTimings(gomock.Any(), arg).Return([]database.ProvisionerJobTiming{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceProvisionerJobs, policy.ActionUpdate) + })) + s.Run("UpsertProvisionerDaemon", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) pd := rbac.ResourceProvisionerDaemon.InOrg(org.ID) - check.Args(database.UpsertProvisionerDaemonParams{ + argOrg := database.UpsertProvisionerDaemonParams{ OrganizationID: org.ID, Provisioners: []database.ProvisionerType{}, - Tags: database.StringMap(map[string]string{ - provisionersdk.TagScope: provisionersdk.ScopeOrganization, - }), - }).Asserts(pd, policy.ActionCreate) - check.Args(database.UpsertProvisionerDaemonParams{ + Tags: database.StringMap(map[string]string{provisionersdk.TagScope: provisionersdk.ScopeOrganization}), + } + dbm.EXPECT().UpsertProvisionerDaemon(gomock.Any(), argOrg).Return(testutil.Fake(s.T(), faker, database.ProvisionerDaemon{OrganizationID: org.ID}), nil).AnyTimes() + check.Args(argOrg).Asserts(pd, policy.ActionCreate) + + argUser := database.UpsertProvisionerDaemonParams{ OrganizationID: org.ID, Provisioners: []database.ProvisionerType{}, - Tags: database.StringMap(map[string]string{ - provisionersdk.TagScope: provisionersdk.ScopeUser, - provisionersdk.TagOwner: "11111111-1111-1111-1111-111111111111", - }), - }).Asserts(pd.WithOwner("11111111-1111-1111-1111-111111111111"), policy.ActionCreate) - })) - s.Run("InsertTemplateVersionParameter", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{}) - check.Args(database.InsertTemplateVersionParameterParams{ - TemplateVersionID: v.ID, - Options: json.RawMessage("{}"), - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("InsertWorkspaceAppStatus", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - check.Args(database.InsertWorkspaceAppStatusParams{ - ID: uuid.New(), - State: "working", - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("InsertWorkspaceResource", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - check.Args(database.InsertWorkspaceResourceParams{ - ID: uuid.New(), - Transition: database.WorkspaceTransitionStart, - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("DeleteOldWorkspaceAgentLogs", s.Subtest(func(db database.Store, check *expects) { - check.Args(time.Time{}).Asserts(rbac.ResourceSystem, policy.ActionDelete) - })) - s.Run("InsertWorkspaceAgentStats", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertWorkspaceAgentStatsParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate).Errors(errMatchAny) - })) - s.Run("InsertWorkspaceAppStats", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertWorkspaceAppStatsParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("UpsertWorkspaceAppAuditSession", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) - res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: pj.ID}) - agent := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - app := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agent.ID}) - check.Args(database.UpsertWorkspaceAppAuditSessionParams{ - AgentID: agent.ID, - AppID: app.ID, - UserID: u.ID, - Ip: "127.0.0.1", - }).Asserts(rbac.ResourceSystem, policy.ActionUpdate) - })) - s.Run("InsertWorkspaceAgentScriptTimings", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - check.Args(database.InsertWorkspaceAgentScriptTimingsParams{ - ScriptID: uuid.New(), - Stage: database.WorkspaceAgentScriptTimingStageStart, - Status: database.WorkspaceAgentScriptTimingStatusOk, - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("InsertWorkspaceAgentScripts", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertWorkspaceAgentScriptsParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("InsertWorkspaceAgentMetadata", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - check.Args(database.InsertWorkspaceAgentMetadataParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("InsertWorkspaceAgentLogs", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertWorkspaceAgentLogsParams{}).Asserts() - })) - s.Run("InsertWorkspaceAgentLogSources", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertWorkspaceAgentLogSourcesParams{}).Asserts() - })) - s.Run("GetTemplateDAUs", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetTemplateDAUsParams{}).Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("GetActiveWorkspaceBuildsByTemplateID", s.Subtest(func(db database.Store, check *expects) { - check.Args(uuid.New()). - Asserts(rbac.ResourceSystem, policy.ActionRead). - ErrorsWithInMemDB(sql.ErrNoRows). - Returns([]database.WorkspaceBuild{}) + Tags: database.StringMap(map[string]string{provisionersdk.TagScope: provisionersdk.ScopeUser, provisionersdk.TagOwner: "11111111-1111-1111-1111-111111111111"}), + } + dbm.EXPECT().UpsertProvisionerDaemon(gomock.Any(), argUser).Return(testutil.Fake(s.T(), faker, database.ProvisionerDaemon{OrganizationID: org.ID}), nil).AnyTimes() + check.Args(argUser).Asserts(pd.WithOwner("11111111-1111-1111-1111-111111111111"), policy.ActionCreate) + })) + s.Run("InsertTemplateVersionParameter", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + v := testutil.Fake(s.T(), faker, database.TemplateVersion{}) + arg := database.InsertTemplateVersionParameterParams{TemplateVersionID: v.ID, Options: json.RawMessage("{}")} + dbm.EXPECT().InsertTemplateVersionParameter(gomock.Any(), arg).Return(testutil.Fake(s.T(), faker, database.TemplateVersionParameter{TemplateVersionID: v.ID}), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("InsertWorkspaceAppStatus", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertWorkspaceAppStatusParams{ID: uuid.New(), State: "working"} + dbm.EXPECT().InsertWorkspaceAppStatus(gomock.Any(), arg).Return(testutil.Fake(s.T(), gofakeit.New(0), database.WorkspaceAppStatus{ID: arg.ID, State: arg.State}), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("InsertWorkspaceResource", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := database.InsertWorkspaceResourceParams{ID: uuid.New(), Transition: database.WorkspaceTransitionStart} + dbm.EXPECT().InsertWorkspaceResource(gomock.Any(), arg).Return(testutil.Fake(s.T(), faker, database.WorkspaceResource{ID: arg.ID}), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("DeleteOldWorkspaceAgentLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t := time.Time{} + dbm.EXPECT().DeleteOldWorkspaceAgentLogs(gomock.Any(), t).Return(nil).AnyTimes() + check.Args(t).Asserts(rbac.ResourceSystem, policy.ActionDelete) + })) + s.Run("InsertWorkspaceAgentStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertWorkspaceAgentStatsParams{} + dbm.EXPECT().InsertWorkspaceAgentStats(gomock.Any(), arg).Return(xerrors.New("any error")).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate).Errors(errMatchAny) + })) + s.Run("InsertWorkspaceAppStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertWorkspaceAppStatsParams{} + dbm.EXPECT().InsertWorkspaceAppStats(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("UpsertWorkspaceAppAuditSession", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + agent := testutil.Fake(s.T(), faker, database.WorkspaceAgent{}) + app := testutil.Fake(s.T(), faker, database.WorkspaceApp{}) + arg := database.UpsertWorkspaceAppAuditSessionParams{AgentID: agent.ID, AppID: app.ID, UserID: u.ID, Ip: "127.0.0.1"} + dbm.EXPECT().UpsertWorkspaceAppAuditSession(gomock.Any(), arg).Return(true, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate) + })) + s.Run("InsertWorkspaceAgentScriptTimings", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertWorkspaceAgentScriptTimingsParams{ScriptID: uuid.New(), Stage: database.WorkspaceAgentScriptTimingStageStart, Status: database.WorkspaceAgentScriptTimingStatusOk} + dbm.EXPECT().InsertWorkspaceAgentScriptTimings(gomock.Any(), arg).Return(testutil.Fake(s.T(), gofakeit.New(0), database.WorkspaceAgentScriptTiming{ScriptID: arg.ScriptID}), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("InsertWorkspaceAgentScripts", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertWorkspaceAgentScriptsParams{} + dbm.EXPECT().InsertWorkspaceAgentScripts(gomock.Any(), arg).Return([]database.WorkspaceAgentScript{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("InsertWorkspaceAgentMetadata", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertWorkspaceAgentMetadataParams{} + dbm.EXPECT().InsertWorkspaceAgentMetadata(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("InsertWorkspaceAgentLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertWorkspaceAgentLogsParams{} + dbm.EXPECT().InsertWorkspaceAgentLogs(gomock.Any(), arg).Return([]database.WorkspaceAgentLog{}, nil).AnyTimes() + check.Args(arg).Asserts() + })) + s.Run("InsertWorkspaceAgentLogSources", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertWorkspaceAgentLogSourcesParams{} + dbm.EXPECT().InsertWorkspaceAgentLogSources(gomock.Any(), arg).Return([]database.WorkspaceAgentLogSource{}, nil).AnyTimes() + check.Args(arg).Asserts() + })) + s.Run("GetTemplateDAUs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetTemplateDAUsParams{} + dbm.EXPECT().GetTemplateDAUs(gomock.Any(), arg).Return([]database.GetTemplateDAUsRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetActiveWorkspaceBuildsByTemplateID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + id := uuid.New() + dbm.EXPECT().GetActiveWorkspaceBuildsByTemplateID(gomock.Any(), id).Return([]database.WorkspaceBuild{}, nil).AnyTimes() + check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns([]database.WorkspaceBuild{}) })) - s.Run("GetDeploymentDAUs", s.Subtest(func(db database.Store, check *expects) { - check.Args(int32(0)).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("GetDeploymentDAUs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + tz := int32(0) + dbm.EXPECT().GetDeploymentDAUs(gomock.Any(), tz).Return([]database.GetDeploymentDAUsRow{}, nil).AnyTimes() + check.Args(tz).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("GetAppSecurityKey", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetAppSecurityKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetAppSecurityKey(gomock.Any()).Return("", sql.ErrNoRows).AnyTimes() check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead).ErrorsWithPG(sql.ErrNoRows) })) - s.Run("UpsertAppSecurityKey", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertAppSecurityKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertAppSecurityKey(gomock.Any(), "foo").Return(nil).AnyTimes() check.Args("foo").Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) - s.Run("GetApplicationName", s.Subtest(func(db database.Store, check *expects) { - db.UpsertApplicationName(context.Background(), "foo") + s.Run("GetApplicationName", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetApplicationName(gomock.Any()).Return("foo", nil).AnyTimes() check.Args().Asserts() })) - s.Run("UpsertApplicationName", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertApplicationName", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertApplicationName(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("GetHealthSettings", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetHealthSettings", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetHealthSettings(gomock.Any()).Return("{}", nil).AnyTimes() check.Args().Asserts() })) - s.Run("UpsertHealthSettings", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertHealthSettings", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertHealthSettings(gomock.Any(), "foo").Return(nil).AnyTimes() check.Args("foo").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("GetNotificationsSettings", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetNotificationsSettings", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetNotificationsSettings(gomock.Any()).Return("{}", nil).AnyTimes() check.Args().Asserts() })) - s.Run("UpsertNotificationsSettings", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertNotificationsSettings", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertNotificationsSettings(gomock.Any(), "foo").Return(nil).AnyTimes() check.Args("foo").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("GetDeploymentWorkspaceAgentStats", s.Subtest(func(db database.Store, check *expects) { - check.Args(time.Time{}).Asserts() - })) - s.Run("GetDeploymentWorkspaceAgentUsageStats", s.Subtest(func(db database.Store, check *expects) { - check.Args(time.Time{}).Asserts() - })) - s.Run("GetDeploymentWorkspaceStats", s.Subtest(func(db database.Store, check *expects) { - check.Args().Asserts() - })) - s.Run("GetFileTemplates", s.Subtest(func(db database.Store, check *expects) { - check.Args(uuid.New()).Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("GetProvisionerJobsToBeReaped", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetProvisionerJobsToBeReapedParams{}).Asserts(rbac.ResourceProvisionerJobs, policy.ActionRead) - })) - s.Run("UpsertOAuthSigningKey", s.Subtest(func(db database.Store, check *expects) { - check.Args("foo").Asserts(rbac.ResourceSystem, policy.ActionUpdate) - })) - s.Run("GetOAuthSigningKey", s.Subtest(func(db database.Store, check *expects) { - db.UpsertOAuthSigningKey(context.Background(), "foo") - check.Args().Asserts(rbac.ResourceSystem, policy.ActionUpdate) - })) - s.Run("UpsertCoordinatorResumeTokenSigningKey", s.Subtest(func(db database.Store, check *expects) { - check.Args("foo").Asserts(rbac.ResourceSystem, policy.ActionUpdate) - })) - s.Run("GetCoordinatorResumeTokenSigningKey", s.Subtest(func(db database.Store, check *expects) { - db.UpsertCoordinatorResumeTokenSigningKey(context.Background(), "foo") - check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("InsertMissingGroups", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertMissingGroupsParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate).Errors(errMatchAny) - })) - s.Run("UpdateUserLoginType", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - check.Args(database.UpdateUserLoginTypeParams{ - NewLoginType: database.LoginTypePassword, - UserID: u.ID, - }).Asserts(rbac.ResourceSystem, policy.ActionUpdate) - })) - s.Run("GetWorkspaceAgentStatsAndLabels", s.Subtest(func(db database.Store, check *expects) { - check.Args(time.Time{}).Asserts() + s.Run("GetDeploymentWorkspaceAgentStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t := time.Time{} + dbm.EXPECT().GetDeploymentWorkspaceAgentStats(gomock.Any(), t).Return(database.GetDeploymentWorkspaceAgentStatsRow{}, nil).AnyTimes() + check.Args(t).Asserts() })) - s.Run("GetWorkspaceAgentUsageStatsAndLabels", s.Subtest(func(db database.Store, check *expects) { - check.Args(time.Time{}).Asserts() + s.Run("GetDeploymentWorkspaceAgentUsageStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t := time.Time{} + dbm.EXPECT().GetDeploymentWorkspaceAgentUsageStats(gomock.Any(), t).Return(database.GetDeploymentWorkspaceAgentUsageStatsRow{}, nil).AnyTimes() + check.Args(t).Asserts() })) - s.Run("GetWorkspaceAgentStats", s.Subtest(func(db database.Store, check *expects) { - check.Args(time.Time{}).Asserts() - })) - s.Run("GetWorkspaceAgentUsageStats", s.Subtest(func(db database.Store, check *expects) { - check.Args(time.Time{}).Asserts() - })) - s.Run("GetWorkspaceProxyByHostname", s.Subtest(func(db database.Store, check *expects) { - p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{ - WildcardHostname: "*.example.com", - }) - check.Args(database.GetWorkspaceProxyByHostnameParams{ - Hostname: "foo.example.com", - AllowWildcardHostname: true, - }).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(p) - })) - s.Run("GetTemplateAverageBuildTime", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetTemplateAverageBuildTimeParams{}).Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("GetWorkspacesByTemplateID", s.Subtest(func(db database.Store, check *expects) { - check.Args(uuid.Nil).Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("GetWorkspacesEligibleForTransition", s.Subtest(func(db database.Store, check *expects) { - check.Args(time.Time{}).Asserts() - })) - s.Run("InsertTemplateVersionVariable", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - check.Args(database.InsertTemplateVersionVariableParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate) + s.Run("GetDeploymentWorkspaceStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetDeploymentWorkspaceStats(gomock.Any()).Return(database.GetDeploymentWorkspaceStatsRow{}, nil).AnyTimes() + check.Args().Asserts() })) - s.Run("InsertTemplateVersionWorkspaceTag", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - check.Args(database.InsertTemplateVersionWorkspaceTagParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate) + s.Run("GetFileTemplates", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + id := uuid.New() + dbm.EXPECT().GetFileTemplates(gomock.Any(), id).Return([]database.GetFileTemplatesRow{}, nil).AnyTimes() + check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("UpdateInactiveUsersToDormant", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.UpdateInactiveUsersToDormantParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate). - ErrorsWithInMemDB(sql.ErrNoRows). - Returns([]database.UpdateInactiveUsersToDormantRow{}) + s.Run("GetProvisionerJobsToBeReaped", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetProvisionerJobsToBeReapedParams{} + dbm.EXPECT().GetProvisionerJobsToBeReaped(gomock.Any(), arg).Return([]database.ProvisionerJob{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceProvisionerJobs, policy.ActionRead) })) - s.Run("GetWorkspaceUniqueOwnerCountByTemplateIDs", s.Subtest(func(db database.Store, check *expects) { - check.Args([]uuid.UUID{uuid.New()}).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("UpsertOAuthSigningKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertOAuthSigningKey(gomock.Any(), "foo").Return(nil).AnyTimes() + check.Args("foo").Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) - s.Run("GetWorkspaceAgentScriptsByAgentIDs", s.Subtest(func(db database.Store, check *expects) { - check.Args([]uuid.UUID{uuid.New()}).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("GetOAuthSigningKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetOAuthSigningKey(gomock.Any()).Return("foo", nil).AnyTimes() + check.Args().Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) - s.Run("GetWorkspaceAgentLogSourcesByAgentIDs", s.Subtest(func(db database.Store, check *expects) { - check.Args([]uuid.UUID{uuid.New()}).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("UpsertCoordinatorResumeTokenSigningKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertCoordinatorResumeTokenSigningKey(gomock.Any(), "foo").Return(nil).AnyTimes() + check.Args("foo").Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) - s.Run("GetProvisionerJobsByIDsWithQueuePosition", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetProvisionerJobsByIDsWithQueuePositionParams{}).Asserts() + s.Run("GetCoordinatorResumeTokenSigningKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetCoordinatorResumeTokenSigningKey(gomock.Any()).Return("foo", nil).AnyTimes() + check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("GetReplicaByID", s.Subtest(func(db database.Store, check *expects) { - check.Args(uuid.New()).Asserts(rbac.ResourceSystem, policy.ActionRead).Errors(sql.ErrNoRows) + s.Run("InsertMissingGroups", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertMissingGroupsParams{} + dbm.EXPECT().InsertMissingGroups(gomock.Any(), arg).Return([]database.Group{}, xerrors.New("any error")).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate).Errors(errMatchAny) + })) + s.Run("UpdateUserLoginType", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpdateUserLoginTypeParams{NewLoginType: database.LoginTypePassword, UserID: u.ID} + dbm.EXPECT().UpdateUserLoginType(gomock.Any(), arg).Return(testutil.Fake(s.T(), faker, database.User{}), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate) + })) + s.Run("GetWorkspaceAgentStatsAndLabels", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t := time.Time{} + dbm.EXPECT().GetWorkspaceAgentStatsAndLabels(gomock.Any(), t).Return([]database.GetWorkspaceAgentStatsAndLabelsRow{}, nil).AnyTimes() + check.Args(t).Asserts() + })) + s.Run("GetWorkspaceAgentUsageStatsAndLabels", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t := time.Time{} + dbm.EXPECT().GetWorkspaceAgentUsageStatsAndLabels(gomock.Any(), t).Return([]database.GetWorkspaceAgentUsageStatsAndLabelsRow{}, nil).AnyTimes() + check.Args(t).Asserts() + })) + s.Run("GetWorkspaceAgentStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t := time.Time{} + dbm.EXPECT().GetWorkspaceAgentStats(gomock.Any(), t).Return([]database.GetWorkspaceAgentStatsRow{}, nil).AnyTimes() + check.Args(t).Asserts() + })) + s.Run("GetWorkspaceAgentUsageStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t := time.Time{} + dbm.EXPECT().GetWorkspaceAgentUsageStats(gomock.Any(), t).Return([]database.GetWorkspaceAgentUsageStatsRow{}, nil).AnyTimes() + check.Args(t).Asserts() + })) + s.Run("GetWorkspaceProxyByHostname", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + p := testutil.Fake(s.T(), faker, database.WorkspaceProxy{WildcardHostname: "*.example.com"}) + arg := database.GetWorkspaceProxyByHostnameParams{Hostname: "foo.example.com", AllowWildcardHostname: true} + dbm.EXPECT().GetWorkspaceProxyByHostname(gomock.Any(), arg).Return(p, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(p) + })) + s.Run("GetTemplateAverageBuildTime", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetTemplateAverageBuildTimeParams{} + dbm.EXPECT().GetTemplateAverageBuildTime(gomock.Any(), arg).Return(database.GetTemplateAverageBuildTimeRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspacesByTemplateID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + id := uuid.Nil + dbm.EXPECT().GetWorkspacesByTemplateID(gomock.Any(), id).Return([]database.WorkspaceTable{}, nil).AnyTimes() + check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspacesEligibleForTransition", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t := time.Time{} + dbm.EXPECT().GetWorkspacesEligibleForTransition(gomock.Any(), t).Return([]database.GetWorkspacesEligibleForTransitionRow{}, nil).AnyTimes() + check.Args(t).Asserts() + })) + s.Run("InsertTemplateVersionVariable", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertTemplateVersionVariableParams{} + dbm.EXPECT().InsertTemplateVersionVariable(gomock.Any(), arg).Return(testutil.Fake(s.T(), gofakeit.New(0), database.TemplateVersionVariable{}), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("InsertTemplateVersionWorkspaceTag", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertTemplateVersionWorkspaceTagParams{} + dbm.EXPECT().InsertTemplateVersionWorkspaceTag(gomock.Any(), arg).Return(testutil.Fake(s.T(), gofakeit.New(0), database.TemplateVersionWorkspaceTag{}), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("UpdateInactiveUsersToDormant", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.UpdateInactiveUsersToDormantParams{} + dbm.EXPECT().UpdateInactiveUsersToDormant(gomock.Any(), arg).Return([]database.UpdateInactiveUsersToDormantRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate).Returns([]database.UpdateInactiveUsersToDormantRow{}) + })) + s.Run("GetWorkspaceUniqueOwnerCountByTemplateIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ids := []uuid.UUID{uuid.New()} + dbm.EXPECT().GetWorkspaceUniqueOwnerCountByTemplateIDs(gomock.Any(), ids).Return([]database.GetWorkspaceUniqueOwnerCountByTemplateIDsRow{}, nil).AnyTimes() + check.Args(ids).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspaceAgentScriptsByAgentIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ids := []uuid.UUID{uuid.New()} + dbm.EXPECT().GetWorkspaceAgentScriptsByAgentIDs(gomock.Any(), ids).Return([]database.WorkspaceAgentScript{}, nil).AnyTimes() + check.Args(ids).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspaceAgentLogSourcesByAgentIDs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + ids := []uuid.UUID{uuid.New()} + dbm.EXPECT().GetWorkspaceAgentLogSourcesByAgentIDs(gomock.Any(), ids).Return([]database.WorkspaceAgentLogSource{}, nil).AnyTimes() + check.Args(ids).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetProvisionerJobsByIDsWithQueuePosition", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetProvisionerJobsByIDsWithQueuePositionParams{} + dbm.EXPECT().GetProvisionerJobsByIDsWithQueuePosition(gomock.Any(), arg).Return([]database.GetProvisionerJobsByIDsWithQueuePositionRow{}, nil).AnyTimes() + check.Args(arg).Asserts() + })) + s.Run("GetReplicaByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + id := uuid.New() + dbm.EXPECT().GetReplicaByID(gomock.Any(), id).Return(database.Replica{}, sql.ErrNoRows).AnyTimes() + check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionRead).Errors(sql.ErrNoRows) })) - s.Run("GetWorkspaceAgentAndLatestBuildByAuthToken", s.Subtest(func(db database.Store, check *expects) { - check.Args(uuid.New()).Asserts(rbac.ResourceSystem, policy.ActionRead).Errors(sql.ErrNoRows) + s.Run("GetWorkspaceAgentAndLatestBuildByAuthToken", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + tok := uuid.New() + dbm.EXPECT().GetWorkspaceAgentAndLatestBuildByAuthToken(gomock.Any(), tok).Return(database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow{}, sql.ErrNoRows).AnyTimes() + check.Args(tok).Asserts(rbac.ResourceSystem, policy.ActionRead).Errors(sql.ErrNoRows) })) - s.Run("GetUserLinksByUserID", s.Subtest(func(db database.Store, check *expects) { - check.Args(uuid.New()).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("GetUserLinksByUserID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + id := uuid.New() + dbm.EXPECT().GetUserLinksByUserID(gomock.Any(), id).Return([]database.UserLink{}, nil).AnyTimes() + check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("DeleteRuntimeConfig", s.Subtest(func(db database.Store, check *expects) { + s.Run("DeleteRuntimeConfig", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().DeleteRuntimeConfig(gomock.Any(), "test").Return(nil).AnyTimes() check.Args("test").Asserts(rbac.ResourceSystem, policy.ActionDelete) })) - s.Run("GetRuntimeConfig", s.Subtest(func(db database.Store, check *expects) { - _ = db.UpsertRuntimeConfig(context.Background(), database.UpsertRuntimeConfigParams{ - Key: "test", - Value: "value", - }) + s.Run("GetRuntimeConfig", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetRuntimeConfig(gomock.Any(), "test").Return("value", nil).AnyTimes() check.Args("test").Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("UpsertRuntimeConfig", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.UpsertRuntimeConfigParams{ - Key: "test", - Value: "value", - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("GetFailedWorkspaceBuildsByTemplateID", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.GetFailedWorkspaceBuildsByTemplateIDParams{ - TemplateID: uuid.New(), - Since: dbtime.Now(), - }).Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("GetNotificationReportGeneratorLogByTemplate", s.Subtest(func(db database.Store, check *expects) { - _ = db.UpsertNotificationReportGeneratorLog(context.Background(), database.UpsertNotificationReportGeneratorLogParams{ - NotificationTemplateID: notifications.TemplateWorkspaceBuildsFailedReport, - LastGeneratedAt: dbtime.Now(), - }) - check.Args(notifications.TemplateWorkspaceBuildsFailedReport).Asserts(rbac.ResourceSystem, policy.ActionRead) - })) - s.Run("GetWorkspaceBuildStatsByTemplates", s.Subtest(func(db database.Store, check *expects) { - check.Args(dbtime.Now()).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("UpsertRuntimeConfig", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.UpsertRuntimeConfigParams{Key: "test", Value: "value"} + dbm.EXPECT().UpsertRuntimeConfig(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) })) - s.Run("UpsertNotificationReportGeneratorLog", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.UpsertNotificationReportGeneratorLogParams{ - NotificationTemplateID: uuid.New(), - LastGeneratedAt: dbtime.Now(), - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("GetProvisionerJobTimingsByJobID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - org := dbgen.Organization(s.T(), db, database.Organization{}) - tpl := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: org.ID, - CreatedBy: u.ID, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - OrganizationID: org.ID, - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OwnerID: u.ID, - OrganizationID: org.ID, - TemplateID: tpl.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{JobID: j.ID, WorkspaceID: w.ID, TemplateVersionID: tv.ID}) - t := dbgen.ProvisionerJobTimings(s.T(), db, b, 2) - check.Args(j.ID).Asserts(w, policy.ActionRead).Returns(t) + s.Run("GetFailedWorkspaceBuildsByTemplateID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetFailedWorkspaceBuildsByTemplateIDParams{TemplateID: uuid.New(), Since: dbtime.Now()} + dbm.EXPECT().GetFailedWorkspaceBuildsByTemplateID(gomock.Any(), arg).Return([]database.GetFailedWorkspaceBuildsByTemplateIDRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("GetWorkspaceAgentScriptTimingsByBuildID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - workspace := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) - job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{JobID: job.ID, WorkspaceID: workspace.ID}) - resource := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{ - JobID: build.JobID, - }) - agent := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ - ResourceID: resource.ID, - }) - script := dbgen.WorkspaceAgentScript(s.T(), db, database.WorkspaceAgentScript{ - WorkspaceAgentID: agent.ID, - }) - timing := dbgen.WorkspaceAgentScriptTiming(s.T(), db, database.WorkspaceAgentScriptTiming{ - ScriptID: script.ID, - }) - rows := []database.GetWorkspaceAgentScriptTimingsByBuildIDRow{ - { - StartedAt: timing.StartedAt, - EndedAt: timing.EndedAt, - Stage: timing.Stage, - ScriptID: timing.ScriptID, - ExitCode: timing.ExitCode, - Status: timing.Status, - DisplayName: script.DisplayName, - WorkspaceAgentID: agent.ID, - WorkspaceAgentName: agent.Name, - }, - } - check.Args(build.ID).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(rows) + s.Run("GetNotificationReportGeneratorLogByTemplate", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetNotificationReportGeneratorLogByTemplate(gomock.Any(), notifications.TemplateWorkspaceBuildsFailedReport).Return(database.NotificationReportGeneratorLog{}, nil).AnyTimes() + check.Args(notifications.TemplateWorkspaceBuildsFailedReport).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("DisableForeignKeysAndTriggers", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetWorkspaceBuildStatsByTemplates", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + at := dbtime.Now() + dbm.EXPECT().GetWorkspaceBuildStatsByTemplates(gomock.Any(), at).Return([]database.GetWorkspaceBuildStatsByTemplatesRow{}, nil).AnyTimes() + check.Args(at).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("UpsertNotificationReportGeneratorLog", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.UpsertNotificationReportGeneratorLogParams{NotificationTemplateID: uuid.New(), LastGeneratedAt: dbtime.Now()} + dbm.EXPECT().UpsertNotificationReportGeneratorLog(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("GetProvisionerJobTimingsByJobID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) + b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{JobID: j.ID}) + ws := testutil.Fake(s.T(), faker, database.Workspace{ID: b.WorkspaceID}) + dbm.EXPECT().GetProvisionerJobByID(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), j.ID).Return(b, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), b.WorkspaceID).Return(ws, nil).AnyTimes() + dbm.EXPECT().GetProvisionerJobTimingsByJobID(gomock.Any(), j.ID).Return([]database.ProvisionerJobTiming{}, nil).AnyTimes() + check.Args(j.ID).Asserts(ws, policy.ActionRead) + })) + s.Run("GetWorkspaceAgentScriptTimingsByBuildID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + build := testutil.Fake(s.T(), faker, database.WorkspaceBuild{}) + dbm.EXPECT().GetWorkspaceAgentScriptTimingsByBuildID(gomock.Any(), build.ID).Return([]database.GetWorkspaceAgentScriptTimingsByBuildIDRow{}, nil).AnyTimes() + check.Args(build.ID).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns([]database.GetWorkspaceAgentScriptTimingsByBuildIDRow{}) + })) + s.Run("DisableForeignKeysAndTriggers", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().DisableForeignKeysAndTriggers(gomock.Any()).Return(nil).AnyTimes() check.Args().Asserts() })) - s.Run("InsertWorkspaceModule", s.Subtest(func(db database.Store, check *expects) { - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - check.Args(database.InsertWorkspaceModuleParams{ - JobID: j.ID, - Transition: database.WorkspaceTransitionStart, - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) + s.Run("InsertWorkspaceModule", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) + arg := database.InsertWorkspaceModuleParams{JobID: j.ID, Transition: database.WorkspaceTransitionStart} + dbm.EXPECT().InsertWorkspaceModule(gomock.Any(), arg).Return(testutil.Fake(s.T(), faker, database.WorkspaceModule{JobID: j.ID}), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) })) - s.Run("GetWorkspaceModulesByJobID", s.Subtest(func(db database.Store, check *expects) { - check.Args(uuid.New()).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("GetWorkspaceModulesByJobID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + id := uuid.New() + dbm.EXPECT().GetWorkspaceModulesByJobID(gomock.Any(), id).Return([]database.WorkspaceModule{}, nil).AnyTimes() + check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("GetWorkspaceModulesCreatedAfter", s.Subtest(func(db database.Store, check *expects) { - check.Args(dbtime.Now()).Asserts(rbac.ResourceSystem, policy.ActionRead) + s.Run("GetWorkspaceModulesCreatedAfter", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + at := dbtime.Now() + dbm.EXPECT().GetWorkspaceModulesCreatedAfter(gomock.Any(), at).Return([]database.WorkspaceModule{}, nil).AnyTimes() + check.Args(at).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("GetTelemetryItem", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetTelemetryItem", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetTelemetryItem(gomock.Any(), "test").Return(database.TelemetryItem{}, sql.ErrNoRows).AnyTimes() check.Args("test").Asserts(rbac.ResourceSystem, policy.ActionRead).Errors(sql.ErrNoRows) })) - s.Run("GetTelemetryItems", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetTelemetryItems", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetTelemetryItems(gomock.Any()).Return([]database.TelemetryItem{}, nil).AnyTimes() check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("InsertTelemetryItemIfNotExists", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.InsertTelemetryItemIfNotExistsParams{ - Key: "test", - Value: "value", - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) + s.Run("InsertTelemetryItemIfNotExists", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertTelemetryItemIfNotExistsParams{Key: "test", Value: "value"} + dbm.EXPECT().InsertTelemetryItemIfNotExists(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionCreate) })) - s.Run("UpsertTelemetryItem", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.UpsertTelemetryItemParams{ - Key: "test", - Value: "value", - }).Asserts(rbac.ResourceSystem, policy.ActionUpdate) + s.Run("UpsertTelemetryItem", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.UpsertTelemetryItemParams{Key: "test", Value: "value"} + dbm.EXPECT().UpsertTelemetryItem(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) - s.Run("GetOAuth2GithubDefaultEligible", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetOAuth2GithubDefaultEligible", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetOAuth2GithubDefaultEligible(gomock.Any()).Return(false, sql.ErrNoRows).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Errors(sql.ErrNoRows) })) - s.Run("UpsertOAuth2GithubDefaultEligible", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertOAuth2GithubDefaultEligible", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertOAuth2GithubDefaultEligible(gomock.Any(), true).Return(nil).AnyTimes() check.Args(true).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("GetWebpushVAPIDKeys", s.Subtest(func(db database.Store, check *expects) { - require.NoError(s.T(), db.UpsertWebpushVAPIDKeys(context.Background(), database.UpsertWebpushVAPIDKeysParams{ - VapidPublicKey: "test", - VapidPrivateKey: "test", - })) - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(database.GetWebpushVAPIDKeysRow{ - VapidPublicKey: "test", - VapidPrivateKey: "test", - }) - })) - s.Run("UpsertWebpushVAPIDKeys", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.UpsertWebpushVAPIDKeysParams{ - VapidPublicKey: "test", - VapidPrivateKey: "test", - }).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) - })) - s.Run("Build/GetProvisionerJobByIDForUpdate", 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{ - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OwnerID: u.ID, - OrganizationID: o.ID, - TemplateID: tpl.ID, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - OrganizationID: o.ID, - CreatedBy: u.ID, - }) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - JobID: j.ID, - WorkspaceID: w.ID, - TemplateVersionID: tv.ID, - }) + s.Run("GetWebpushVAPIDKeys", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetWebpushVAPIDKeys(gomock.Any()).Return(database.GetWebpushVAPIDKeysRow{VapidPublicKey: "test", VapidPrivateKey: "test"}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(database.GetWebpushVAPIDKeysRow{VapidPublicKey: "test", VapidPrivateKey: "test"}) + })) + s.Run("UpsertWebpushVAPIDKeys", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.UpsertWebpushVAPIDKeysParams{VapidPublicKey: "test", VapidPrivateKey: "test"} + dbm.EXPECT().UpsertWebpushVAPIDKeys(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) + s.Run("Build/GetProvisionerJobByIDForUpdate", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) + dbm.EXPECT().GetProvisionerJobByIDForUpdate(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + // Minimal assertion check argument + b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{JobID: j.ID}) + w := testutil.Fake(s.T(), faker, database.Workspace{ID: b.WorkspaceID}) + dbm.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), j.ID).Return(b, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), b.WorkspaceID).Return(w, nil).AnyTimes() check.Args(j.ID).Asserts(w, policy.ActionRead).Returns(j) })) - s.Run("TemplateVersion/GetProvisionerJobByIDForUpdate", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeTemplateVersionImport, - }) - tpl := dbgen.Template(s.T(), db, database.Template{}) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: j.ID, - }) - check.Args(j.ID).Asserts(v.RBACObject(tpl), policy.ActionRead).Returns(j) - })) - s.Run("TemplateVersionDryRun/GetProvisionerJobByIDForUpdate", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - tpl := dbgen.Template(s.T(), db, database.Template{}) - v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - }) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeTemplateVersionDryRun, - Input: must(json.Marshal(struct { - TemplateVersionID uuid.UUID `json:"template_version_id"` - }{TemplateVersionID: v.ID})), - }) - check.Args(j.ID).Asserts(v.RBACObject(tpl), policy.ActionRead).Returns(j) - })) - s.Run("HasTemplateVersionsWithAITask", s.Subtest(func(db database.Store, check *expects) { - check.Args().Asserts() + s.Run("TemplateVersion/GetProvisionerJobByIDForUpdate", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{Type: database.ProvisionerJobTypeTemplateVersionImport}) + tpl := testutil.Fake(s.T(), faker, database.Template{}) + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}}) + dbm.EXPECT().GetProvisionerJobByIDForUpdate(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionByJobID(gomock.Any(), j.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + check.Args(j.ID).Asserts(tv.RBACObject(tpl), policy.ActionRead).Returns(j) + })) + s.Run("TemplateVersionDryRun/GetProvisionerJobByIDForUpdate", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}}) + j := testutil.Fake(s.T(), faker, database.ProvisionerJob{}) + j.Type = database.ProvisionerJobTypeTemplateVersionDryRun + j.Input = must(json.Marshal(struct { + TemplateVersionID uuid.UUID `json:"template_version_id"` + }{TemplateVersionID: tv.ID})) + dbm.EXPECT().GetProvisionerJobByIDForUpdate(gomock.Any(), j.ID).Return(j, nil).AnyTimes() + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + check.Args(j.ID).Asserts(tv.RBACObject(tpl), policy.ActionRead).Returns(j) })) } func (s *MethodTestSuite) TestNotifications() { // System functions - s.Run("AcquireNotificationMessages", s.Subtest(func(_ database.Store, check *expects) { + s.Run("AcquireNotificationMessages", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().AcquireNotificationMessages(gomock.Any(), database.AcquireNotificationMessagesParams{}).Return([]database.AcquireNotificationMessagesRow{}, nil).AnyTimes() check.Args(database.AcquireNotificationMessagesParams{}).Asserts(rbac.ResourceNotificationMessage, policy.ActionUpdate) })) - s.Run("BulkMarkNotificationMessagesFailed", s.Subtest(func(_ database.Store, check *expects) { + s.Run("BulkMarkNotificationMessagesFailed", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().BulkMarkNotificationMessagesFailed(gomock.Any(), database.BulkMarkNotificationMessagesFailedParams{}).Return(int64(0), nil).AnyTimes() check.Args(database.BulkMarkNotificationMessagesFailedParams{}).Asserts(rbac.ResourceNotificationMessage, policy.ActionUpdate) })) - s.Run("BulkMarkNotificationMessagesSent", s.Subtest(func(_ database.Store, check *expects) { + s.Run("BulkMarkNotificationMessagesSent", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().BulkMarkNotificationMessagesSent(gomock.Any(), database.BulkMarkNotificationMessagesSentParams{}).Return(int64(0), nil).AnyTimes() check.Args(database.BulkMarkNotificationMessagesSentParams{}).Asserts(rbac.ResourceNotificationMessage, policy.ActionUpdate) })) - s.Run("DeleteOldNotificationMessages", s.Subtest(func(_ database.Store, check *expects) { + s.Run("DeleteOldNotificationMessages", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().DeleteOldNotificationMessages(gomock.Any()).Return(nil).AnyTimes() check.Args().Asserts(rbac.ResourceNotificationMessage, policy.ActionDelete) })) - s.Run("EnqueueNotificationMessage", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) + s.Run("EnqueueNotificationMessage", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.EnqueueNotificationMessageParams{Method: database.NotificationMethodWebhook, Payload: []byte("{}")} + dbm.EXPECT().EnqueueNotificationMessage(gomock.Any(), arg).Return(nil).AnyTimes() // TODO: update this test once we have a specific role for notifications - check.Args(database.EnqueueNotificationMessageParams{ - Method: database.NotificationMethodWebhook, - Payload: []byte("{}"), - }).Asserts(rbac.ResourceNotificationMessage, policy.ActionCreate) + check.Args(arg).Asserts(rbac.ResourceNotificationMessage, policy.ActionCreate) })) - s.Run("FetchNewMessageMetadata", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) + s.Run("FetchNewMessageMetadata", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().FetchNewMessageMetadata(gomock.Any(), database.FetchNewMessageMetadataParams{UserID: u.ID}).Return(database.FetchNewMessageMetadataRow{}, nil).AnyTimes() check.Args(database.FetchNewMessageMetadataParams{UserID: u.ID}). - Asserts(rbac.ResourceNotificationMessage, policy.ActionRead). - ErrorsWithPG(sql.ErrNoRows) + Asserts(rbac.ResourceNotificationMessage, policy.ActionRead) })) - s.Run("GetNotificationMessagesByStatus", s.Subtest(func(_ database.Store, check *expects) { - check.Args(database.GetNotificationMessagesByStatusParams{ - Status: database.NotificationMessageStatusLeased, - Limit: 10, - }).Asserts(rbac.ResourceNotificationMessage, policy.ActionRead) + s.Run("GetNotificationMessagesByStatus", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.GetNotificationMessagesByStatusParams{Status: database.NotificationMessageStatusLeased, Limit: 10} + dbm.EXPECT().GetNotificationMessagesByStatus(gomock.Any(), arg).Return([]database.NotificationMessage{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceNotificationMessage, policy.ActionRead) })) // webpush subscriptions - s.Run("GetWebpushSubscriptionsByUserID", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) + s.Run("GetWebpushSubscriptionsByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().GetWebpushSubscriptionsByUserID(gomock.Any(), user.ID).Return([]database.WebpushSubscription{}, nil).AnyTimes() check.Args(user.ID).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionRead) })) - s.Run("InsertWebpushSubscription", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - check.Args(database.InsertWebpushSubscriptionParams{ - UserID: user.ID, - }).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionCreate) + s.Run("InsertWebpushSubscription", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + arg := database.InsertWebpushSubscriptionParams{UserID: user.ID} + dbm.EXPECT().InsertWebpushSubscription(gomock.Any(), arg).Return(testutil.Fake(s.T(), faker, database.WebpushSubscription{UserID: user.ID}), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionCreate) })) - s.Run("DeleteWebpushSubscriptions", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - push := dbgen.WebpushSubscription(s.T(), db, database.InsertWebpushSubscriptionParams{ - UserID: user.ID, - }) + s.Run("DeleteWebpushSubscriptions", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + push := testutil.Fake(s.T(), faker, database.WebpushSubscription{UserID: user.ID}) + dbm.EXPECT().DeleteWebpushSubscriptions(gomock.Any(), []uuid.UUID{push.ID}).Return(nil).AnyTimes() check.Args([]uuid.UUID{push.ID}).Asserts(rbac.ResourceSystem, policy.ActionDelete) })) - s.Run("DeleteWebpushSubscriptionByUserIDAndEndpoint", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - push := dbgen.WebpushSubscription(s.T(), db, database.InsertWebpushSubscriptionParams{ - UserID: user.ID, - }) - check.Args(database.DeleteWebpushSubscriptionByUserIDAndEndpointParams{ - UserID: user.ID, - Endpoint: push.Endpoint, - }).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionDelete) + s.Run("DeleteWebpushSubscriptionByUserIDAndEndpoint", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + push := testutil.Fake(s.T(), faker, database.WebpushSubscription{UserID: user.ID}) + arg := database.DeleteWebpushSubscriptionByUserIDAndEndpointParams{UserID: user.ID, Endpoint: push.Endpoint} + dbm.EXPECT().DeleteWebpushSubscriptionByUserIDAndEndpoint(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceWebpushSubscription.WithOwner(user.ID.String()), policy.ActionDelete) })) - s.Run("DeleteAllWebpushSubscriptions", s.Subtest(func(_ database.Store, check *expects) { - check.Args(). - Asserts(rbac.ResourceWebpushSubscription, policy.ActionDelete) + s.Run("DeleteAllWebpushSubscriptions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().DeleteAllWebpushSubscriptions(gomock.Any()).Return(nil).AnyTimes() + check.Args().Asserts(rbac.ResourceWebpushSubscription, policy.ActionDelete) })) // Notification templates - s.Run("GetNotificationTemplateByID", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) - user := dbgen.User(s.T(), db, database.User{}) - check.Args(user.ID).Asserts(rbac.ResourceNotificationTemplate, policy.ActionRead). - ErrorsWithPG(sql.ErrNoRows) - })) - s.Run("GetNotificationTemplatesByKind", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.NotificationTemplateKindSystem). - Asserts() + s.Run("GetNotificationTemplateByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.NotificationTemplate{}) + dbm.EXPECT().GetNotificationTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + check.Args(tpl.ID).Asserts(rbac.ResourceNotificationTemplate, policy.ActionRead) + })) + s.Run("GetNotificationTemplatesByKind", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetNotificationTemplatesByKind(gomock.Any(), database.NotificationTemplateKindSystem).Return([]database.NotificationTemplate{}, nil).AnyTimes() + check.Args(database.NotificationTemplateKindSystem).Asserts() // TODO(dannyk): add support for other database.NotificationTemplateKind types once implemented. })) - s.Run("UpdateNotificationTemplateMethodByID", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.UpdateNotificationTemplateMethodByIDParams{ - Method: database.NullNotificationMethod{NotificationMethod: database.NotificationMethodWebhook, Valid: true}, - ID: notifications.TemplateWorkspaceDormant, - }).Asserts(rbac.ResourceNotificationTemplate, policy.ActionUpdate) + s.Run("UpdateNotificationTemplateMethodByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.UpdateNotificationTemplateMethodByIDParams{Method: database.NullNotificationMethod{NotificationMethod: database.NotificationMethodWebhook, Valid: true}, ID: notifications.TemplateWorkspaceDormant} + dbm.EXPECT().UpdateNotificationTemplateMethodByID(gomock.Any(), arg).Return(database.NotificationTemplate{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceNotificationTemplate, policy.ActionUpdate) })) // Notification preferences - s.Run("GetUserNotificationPreferences", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - check.Args(user.ID). - Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionRead) + s.Run("GetUserNotificationPreferences", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().GetUserNotificationPreferences(gomock.Any(), user.ID).Return([]database.NotificationPreference{}, nil).AnyTimes() + check.Args(user.ID).Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionRead) })) - s.Run("UpdateUserNotificationPreferences", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - check.Args(database.UpdateUserNotificationPreferencesParams{ - UserID: user.ID, - NotificationTemplateIds: []uuid.UUID{notifications.TemplateWorkspaceAutoUpdated, notifications.TemplateWorkspaceDeleted}, - Disableds: []bool{true, false}, - }).Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionUpdate) + s.Run("UpdateUserNotificationPreferences", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpdateUserNotificationPreferencesParams{UserID: user.ID, NotificationTemplateIds: []uuid.UUID{notifications.TemplateWorkspaceAutoUpdated, notifications.TemplateWorkspaceDeleted}, Disableds: []bool{true, false}} + dbm.EXPECT().UpdateUserNotificationPreferences(gomock.Any(), arg).Return(int64(2), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionUpdate) })) - s.Run("GetInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - - notifID := uuid.New() - - notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{ - ID: notifID, - UserID: u.ID, - TemplateID: notifications.TemplateWorkspaceAutoUpdated, - Title: "test title", - Content: "test content notification", - Icon: "https://coder.com/favicon.ico", - Actions: json.RawMessage("{}"), - }) - - check.Args(database.GetInboxNotificationsByUserIDParams{ - UserID: u.ID, - ReadStatus: database.InboxNotificationReadStatusAll, - }).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns([]database.InboxNotification{notif}) + s.Run("GetInboxNotificationsByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + notif := testutil.Fake(s.T(), faker, database.InboxNotification{UserID: u.ID, TemplateID: notifications.TemplateWorkspaceAutoUpdated}) + arg := database.GetInboxNotificationsByUserIDParams{UserID: u.ID, ReadStatus: database.InboxNotificationReadStatusAll} + dbm.EXPECT().GetInboxNotificationsByUserID(gomock.Any(), arg).Return([]database.InboxNotification{notif}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceInboxNotification.WithID(notif.ID).WithOwner(u.ID.String()), policy.ActionRead).Returns([]database.InboxNotification{notif}) })) - s.Run("GetFilteredInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - - notifID := uuid.New() - - targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated} - - notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{ - ID: notifID, - UserID: u.ID, - TemplateID: notifications.TemplateWorkspaceAutoUpdated, - Targets: targets, - Title: "test title", - Content: "test content notification", - Icon: "https://coder.com/favicon.ico", - Actions: json.RawMessage("{}"), - }) - - check.Args(database.GetFilteredInboxNotificationsByUserIDParams{ - UserID: u.ID, - Templates: []uuid.UUID{notifications.TemplateWorkspaceAutoUpdated}, - Targets: []uuid.UUID{u.ID}, - ReadStatus: database.InboxNotificationReadStatusAll, - }).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns([]database.InboxNotification{notif}) + s.Run("GetFilteredInboxNotificationsByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + notif := testutil.Fake(s.T(), faker, database.InboxNotification{UserID: u.ID, TemplateID: notifications.TemplateWorkspaceAutoUpdated, Targets: []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}}) + arg := database.GetFilteredInboxNotificationsByUserIDParams{UserID: u.ID, Templates: []uuid.UUID{notifications.TemplateWorkspaceAutoUpdated}, Targets: []uuid.UUID{u.ID}, ReadStatus: database.InboxNotificationReadStatusAll} + dbm.EXPECT().GetFilteredInboxNotificationsByUserID(gomock.Any(), arg).Return([]database.InboxNotification{notif}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceInboxNotification.WithID(notif.ID).WithOwner(u.ID.String()), policy.ActionRead).Returns([]database.InboxNotification{notif}) })) - s.Run("GetInboxNotificationByID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - - notifID := uuid.New() - - targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated} - - notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{ - ID: notifID, - UserID: u.ID, - TemplateID: notifications.TemplateWorkspaceAutoUpdated, - Targets: targets, - Title: "test title", - Content: "test content notification", - Icon: "https://coder.com/favicon.ico", - Actions: json.RawMessage("{}"), - }) - - check.Args(notifID).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionRead).Returns(notif) + s.Run("GetInboxNotificationByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + notif := testutil.Fake(s.T(), faker, database.InboxNotification{UserID: u.ID, TemplateID: notifications.TemplateWorkspaceAutoUpdated, Targets: []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}}) + dbm.EXPECT().GetInboxNotificationByID(gomock.Any(), notif.ID).Return(notif, nil).AnyTimes() + check.Args(notif.ID).Asserts(rbac.ResourceInboxNotification.WithID(notif.ID).WithOwner(u.ID.String()), policy.ActionRead).Returns(notif) })) - s.Run("CountUnreadInboxNotificationsByUserID", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - - notifID := uuid.New() - - targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated} - - _ = dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{ - ID: notifID, - UserID: u.ID, - TemplateID: notifications.TemplateWorkspaceAutoUpdated, - Targets: targets, - Title: "test title", - Content: "test content notification", - Icon: "https://coder.com/favicon.ico", - Actions: json.RawMessage("{}"), - }) - + s.Run("CountUnreadInboxNotificationsByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().CountUnreadInboxNotificationsByUserID(gomock.Any(), u.ID).Return(int64(1), nil).AnyTimes() check.Args(u.ID).Asserts(rbac.ResourceInboxNotification.WithOwner(u.ID.String()), policy.ActionRead).Returns(int64(1)) })) - s.Run("InsertInboxNotification", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - + s.Run("InsertInboxNotification", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) notifID := uuid.New() - - targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated} - - check.Args(database.InsertInboxNotificationParams{ - ID: notifID, - UserID: u.ID, - TemplateID: notifications.TemplateWorkspaceAutoUpdated, - Targets: targets, - Title: "test title", - Content: "test content notification", - Icon: "https://coder.com/favicon.ico", - Actions: json.RawMessage("{}"), - }).Asserts(rbac.ResourceInboxNotification.WithOwner(u.ID.String()), policy.ActionCreate) + arg := database.InsertInboxNotificationParams{ID: notifID, UserID: u.ID, TemplateID: notifications.TemplateWorkspaceAutoUpdated, Targets: []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated}, Title: "test title", Content: "test content notification", Icon: "https://coder.com/favicon.ico", Actions: json.RawMessage("{}")} + dbm.EXPECT().InsertInboxNotification(gomock.Any(), arg).Return(testutil.Fake(s.T(), faker, database.InboxNotification{ID: notifID, UserID: u.ID}), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceInboxNotification.WithOwner(u.ID.String()), policy.ActionCreate) })) - s.Run("UpdateInboxNotificationReadStatus", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - - notifID := uuid.New() - - targets := []uuid.UUID{u.ID, notifications.TemplateWorkspaceAutoUpdated} - readAt := dbtestutil.NowInDefaultTimezone() + s.Run("UpdateInboxNotificationReadStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + notif := testutil.Fake(s.T(), faker, database.InboxNotification{UserID: u.ID}) + arg := database.UpdateInboxNotificationReadStatusParams{ID: notif.ID} - notif := dbgen.NotificationInbox(s.T(), db, database.InsertInboxNotificationParams{ - ID: notifID, - UserID: u.ID, - TemplateID: notifications.TemplateWorkspaceAutoUpdated, - Targets: targets, - Title: "test title", - Content: "test content notification", - Icon: "https://coder.com/favicon.ico", - Actions: json.RawMessage("{}"), - }) - - notif.ReadAt = sql.NullTime{Time: readAt, Valid: true} - - check.Args(database.UpdateInboxNotificationReadStatusParams{ - ID: notifID, - ReadAt: sql.NullTime{Time: readAt, Valid: true}, - }).Asserts(rbac.ResourceInboxNotification.WithID(notifID).WithOwner(u.ID.String()), policy.ActionUpdate) + dbm.EXPECT().GetInboxNotificationByID(gomock.Any(), notif.ID).Return(notif, nil).AnyTimes() + dbm.EXPECT().UpdateInboxNotificationReadStatus(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(notif, policy.ActionUpdate) })) - s.Run("MarkAllInboxNotificationsAsRead", s.Subtest(func(db database.Store, check *expects) { - u := dbgen.User(s.T(), db, database.User{}) - - check.Args(database.MarkAllInboxNotificationsAsReadParams{ - UserID: u.ID, - ReadAt: sql.NullTime{Time: dbtestutil.NowInDefaultTimezone(), Valid: true}, - }).Asserts(rbac.ResourceInboxNotification.WithOwner(u.ID.String()), policy.ActionUpdate) + s.Run("MarkAllInboxNotificationsAsRead", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.MarkAllInboxNotificationsAsReadParams{UserID: u.ID, ReadAt: sql.NullTime{Time: dbtestutil.NowInDefaultTimezone(), Valid: true}} + dbm.EXPECT().MarkAllInboxNotificationsAsRead(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceInboxNotification.WithOwner(u.ID.String()), policy.ActionUpdate) })) } func (s *MethodTestSuite) TestPrebuilds() { - s.Run("GetPresetByWorkspaceBuildID", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - user := dbgen.User(s.T(), db, database.User{}) - template := dbgen.Template(s.T(), db, database.Template{ - CreatedBy: user.ID, - OrganizationID: org.ID, - }) - templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - preset, err := db.InsertPreset(context.Background(), database.InsertPresetParams{ - TemplateVersionID: templateVersion.ID, - Name: "test", - }) - require.NoError(s.T(), err) - workspace := dbgen.Workspace(s.T(), db, database.WorkspaceTable{ - OrganizationID: org.ID, - OwnerID: user.ID, - TemplateID: template.ID, - }) - job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - OrganizationID: org.ID, - }) - workspaceBuild := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{ - WorkspaceID: workspace.ID, - TemplateVersionID: templateVersion.ID, - TemplateVersionPresetID: uuid.NullUUID{UUID: preset.ID, Valid: true}, - InitiatorID: user.ID, - JobID: job.ID, - }) - _, err = db.GetPresetByWorkspaceBuildID(context.Background(), workspaceBuild.ID) - require.NoError(s.T(), err) - check.Args(workspaceBuild.ID).Asserts(rbac.ResourceTemplate, policy.ActionRead) - })) - s.Run("GetPresetParametersByTemplateVersionID", s.Subtest(func(db database.Store, check *expects) { - ctx := context.Background() - org := dbgen.Organization(s.T(), db, database.Organization{}) - user := dbgen.User(s.T(), db, database.User{}) - template := dbgen.Template(s.T(), db, database.Template{ - CreatedBy: user.ID, - OrganizationID: org.ID, - }) - templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - preset, err := db.InsertPreset(ctx, database.InsertPresetParams{ - TemplateVersionID: templateVersion.ID, - Name: "test", - }) - require.NoError(s.T(), err) - insertedParameters, err := db.InsertPresetParameters(ctx, database.InsertPresetParametersParams{ - TemplateVersionPresetID: preset.ID, - Names: []string{"test"}, - Values: []string{"test"}, - }) - require.NoError(s.T(), err) - check. - Args(templateVersion.ID). - Asserts(template.RBACObject(), policy.ActionRead). - Returns(insertedParameters) - })) - s.Run("GetPresetParametersByPresetID", s.Subtest(func(db database.Store, check *expects) { - ctx := context.Background() - org := dbgen.Organization(s.T(), db, database.Organization{}) - user := dbgen.User(s.T(), db, database.User{}) - template := dbgen.Template(s.T(), db, database.Template{ - CreatedBy: user.ID, - OrganizationID: org.ID, - }) - templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - preset, err := db.InsertPreset(ctx, database.InsertPresetParams{ - TemplateVersionID: templateVersion.ID, - Name: "test", - }) - require.NoError(s.T(), err) - insertedParameters, err := db.InsertPresetParameters(ctx, database.InsertPresetParametersParams{ - TemplateVersionPresetID: preset.ID, - Names: []string{"test"}, - Values: []string{"test"}, - }) - require.NoError(s.T(), err) - check. - Args(preset.ID). - Asserts(template.RBACObject(), policy.ActionRead). - Returns(insertedParameters) - })) - s.Run("GetActivePresetPrebuildSchedules", s.Subtest(func(db database.Store, check *expects) { - check.Args(). - Asserts(rbac.ResourceTemplate.All(), policy.ActionRead). - Returns([]database.TemplateVersionPresetPrebuildSchedule{}) - })) - s.Run("GetPresetsByTemplateVersionID", s.Subtest(func(db database.Store, check *expects) { - ctx := context.Background() - org := dbgen.Organization(s.T(), db, database.Organization{}) - user := dbgen.User(s.T(), db, database.User{}) - template := dbgen.Template(s.T(), db, database.Template{ - CreatedBy: user.ID, - OrganizationID: org.ID, - }) - templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - - _, err := db.InsertPreset(ctx, database.InsertPresetParams{ - TemplateVersionID: templateVersion.ID, - Name: "test", - }) - require.NoError(s.T(), err) - - presets, err := db.GetPresetsByTemplateVersionID(ctx, templateVersion.ID) - require.NoError(s.T(), err) - - check.Args(templateVersion.ID).Asserts(template.RBACObject(), policy.ActionRead).Returns(presets) - })) - s.Run("ClaimPrebuiltWorkspace", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - user := dbgen.User(s.T(), db, database.User{}) - template := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{ - UUID: template.ID, - Valid: true, - }, - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - preset := dbgen.Preset(s.T(), db, database.InsertPresetParams{ - TemplateVersionID: templateVersion.ID, - }) - check.Args(database.ClaimPrebuiltWorkspaceParams{ - NewUserID: user.ID, - NewName: "", - PresetID: preset.ID, - }).Asserts( - rbac.ResourceWorkspace.WithOwner(user.ID.String()).InOrg(org.ID), policy.ActionCreate, - template, policy.ActionRead, - template, policy.ActionUse, + s.Run("GetPresetByWorkspaceBuildID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + wbID := uuid.New() + dbm.EXPECT().GetPresetByWorkspaceBuildID(gomock.Any(), wbID).Return(testutil.Fake(s.T(), faker, database.TemplateVersionPreset{}), nil).AnyTimes() + check.Args(wbID).Asserts(rbac.ResourceTemplate, policy.ActionRead) + })) + s.Run("GetPresetParametersByTemplateVersionID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, OrganizationID: tpl.OrganizationID, CreatedBy: tpl.CreatedBy}) + resp := []database.TemplateVersionPresetParameter{testutil.Fake(s.T(), faker, database.TemplateVersionPresetParameter{})} + + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().GetPresetParametersByTemplateVersionID(gomock.Any(), tv.ID).Return(resp, nil).AnyTimes() + check.Args(tv.ID).Asserts(tpl.RBACObject(), policy.ActionRead).Returns(resp) + })) + s.Run("GetPresetParametersByPresetID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + prow := database.GetPresetByIDRow{TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, OrganizationID: tpl.OrganizationID} + resp := []database.TemplateVersionPresetParameter{testutil.Fake(s.T(), faker, database.TemplateVersionPresetParameter{})} + + dbm.EXPECT().GetPresetByID(gomock.Any(), prow.ID).Return(prow, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().GetPresetParametersByPresetID(gomock.Any(), prow.ID).Return(resp, nil).AnyTimes() + check.Args(prow.ID).Asserts(tpl.RBACObject(), policy.ActionRead).Returns(resp) + })) + s.Run("GetActivePresetPrebuildSchedules", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetActivePresetPrebuildSchedules(gomock.Any()).Return([]database.TemplateVersionPresetPrebuildSchedule{}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceTemplate.All(), policy.ActionRead).Returns([]database.TemplateVersionPresetPrebuildSchedule{}) + })) + s.Run("GetPresetsByTemplateVersionID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, OrganizationID: tpl.OrganizationID, CreatedBy: tpl.CreatedBy}) + presets := []database.TemplateVersionPreset{testutil.Fake(s.T(), faker, database.TemplateVersionPreset{TemplateVersionID: tv.ID})} + + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().GetPresetsByTemplateVersionID(gomock.Any(), tv.ID).Return(presets, nil).AnyTimes() + check.Args(tv.ID).Asserts(tpl.RBACObject(), policy.ActionRead).Returns(presets) + })) + s.Run("ClaimPrebuiltWorkspace", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + tpl := testutil.Fake(s.T(), faker, database.Template{CreatedBy: user.ID}) + arg := database.ClaimPrebuiltWorkspaceParams{NewUserID: user.ID, NewName: "", PresetID: uuid.New()} + prow := database.GetPresetByIDRow{ID: arg.PresetID, TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, OrganizationID: tpl.OrganizationID} + + dbm.EXPECT().GetPresetByID(gomock.Any(), arg.PresetID).Return(prow, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().ClaimPrebuiltWorkspace(gomock.Any(), arg).Return(database.ClaimPrebuiltWorkspaceRow{}, sql.ErrNoRows).AnyTimes() + check.Args(arg).Asserts( + rbac.ResourceWorkspace.WithOwner(user.ID.String()).InOrg(tpl.OrganizationID), policy.ActionCreate, + tpl, policy.ActionRead, + tpl, policy.ActionUse, ).Errors(sql.ErrNoRows) })) - s.Run("GetPrebuildMetrics", s.Subtest(func(_ database.Store, check *expects) { - check.Args(). - Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead) + 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("GetPrebuildsSettings", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetPrebuildMetrics", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetPrebuildMetrics(gomock.Any()).Return([]database.GetPrebuildMetricsRow{}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead) + })) + s.Run("GetPrebuildsSettings", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetPrebuildsSettings(gomock.Any()).Return("{}", nil).AnyTimes() check.Args().Asserts() })) - s.Run("UpsertPrebuildsSettings", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertPrebuildsSettings", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertPrebuildsSettings(gomock.Any(), "foo").Return(nil).AnyTimes() check.Args("foo").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("CountInProgressPrebuilds", s.Subtest(func(_ database.Store, check *expects) { - check.Args(). - Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead) - })) - s.Run("GetPresetsAtFailureLimit", s.Subtest(func(_ database.Store, check *expects) { - check.Args(int64(0)). - Asserts(rbac.ResourceTemplate.All(), policy.ActionViewInsights) - })) - s.Run("GetPresetsBackoff", s.Subtest(func(_ database.Store, check *expects) { - check.Args(time.Time{}). - Asserts(rbac.ResourceTemplate.All(), policy.ActionViewInsights) - })) - s.Run("GetRunningPrebuiltWorkspaces", s.Subtest(func(_ database.Store, check *expects) { - check.Args(). - Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead) - })) - s.Run("GetTemplatePresetsWithPrebuilds", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) - check.Args(uuid.NullUUID{UUID: user.ID, Valid: true}). - Asserts(rbac.ResourceTemplate.All(), policy.ActionRead) - })) - s.Run("GetPresetByID", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - user := dbgen.User(s.T(), db, database.User{}) - template := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{ - UUID: template.ID, - Valid: true, - }, - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - preset := dbgen.Preset(s.T(), db, database.InsertPresetParams{ - TemplateVersionID: templateVersion.ID, - }) - check.Args(preset.ID). - Asserts(template, policy.ActionRead). - Returns(database.GetPresetByIDRow{ - ID: preset.ID, - TemplateVersionID: preset.TemplateVersionID, - Name: preset.Name, - CreatedAt: preset.CreatedAt, - TemplateID: uuid.NullUUID{ - UUID: template.ID, - Valid: true, - }, - InvalidateAfterSecs: preset.InvalidateAfterSecs, - OrganizationID: org.ID, - PrebuildStatus: database.PrebuildStatusHealthy, - }) - })) - s.Run("UpdatePresetPrebuildStatus", s.Subtest(func(db database.Store, check *expects) { - org := dbgen.Organization(s.T(), db, database.Organization{}) - user := dbgen.User(s.T(), db, database.User{}) - template := dbgen.Template(s.T(), db, database.Template{ - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{ - UUID: template.ID, - Valid: true, - }, - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - preset := dbgen.Preset(s.T(), db, database.InsertPresetParams{ - TemplateVersionID: templateVersion.ID, - }) - req := database.UpdatePresetPrebuildStatusParams{ - PresetID: preset.ID, - Status: database.PrebuildStatusHealthy, - } - check.Args(req). - Asserts(rbac.ResourceTemplate.WithID(template.ID).InOrg(org.ID), policy.ActionUpdate) + s.Run("CountInProgressPrebuilds", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().CountInProgressPrebuilds(gomock.Any()).Return([]database.CountInProgressPrebuildsRow{}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead) + })) + s.Run("GetPresetsAtFailureLimit", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetPresetsAtFailureLimit(gomock.Any(), int64(0)).Return([]database.GetPresetsAtFailureLimitRow{}, nil).AnyTimes() + check.Args(int64(0)).Asserts(rbac.ResourceTemplate.All(), policy.ActionViewInsights) + })) + s.Run("GetPresetsBackoff", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t0 := time.Time{} + dbm.EXPECT().GetPresetsBackoff(gomock.Any(), t0).Return([]database.GetPresetsBackoffRow{}, nil).AnyTimes() + check.Args(t0).Asserts(rbac.ResourceTemplate.All(), policy.ActionViewInsights) + })) + s.Run("GetRunningPrebuiltWorkspaces", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetRunningPrebuiltWorkspaces(gomock.Any()).Return([]database.GetRunningPrebuiltWorkspacesRow{}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead) + })) + s.Run("GetTemplatePresetsWithPrebuilds", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := uuid.NullUUID{UUID: uuid.New(), Valid: true} + dbm.EXPECT().GetTemplatePresetsWithPrebuilds(gomock.Any(), arg).Return([]database.GetTemplatePresetsWithPrebuildsRow{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceTemplate.All(), policy.ActionRead) + })) + s.Run("GetPresetByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) + tpl := testutil.Fake(s.T(), faker, database.Template{OrganizationID: org.ID}) + presetID := uuid.New() + prow := database.GetPresetByIDRow{ID: presetID, TemplateVersionID: uuid.New(), Name: "test", TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, InvalidateAfterSecs: sql.NullInt32{}, OrganizationID: org.ID, PrebuildStatus: database.PrebuildStatusHealthy} + + dbm.EXPECT().GetPresetByID(gomock.Any(), presetID).Return(prow, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + check.Args(presetID).Asserts(tpl, policy.ActionRead).Returns(prow) + })) + s.Run("UpdatePresetPrebuildStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + org := testutil.Fake(s.T(), faker, database.Organization{}) + tpl := testutil.Fake(s.T(), faker, database.Template{OrganizationID: org.ID}) + presetID := uuid.New() + prow := database.GetPresetByIDRow{ID: presetID, TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, OrganizationID: org.ID} + req := database.UpdatePresetPrebuildStatusParams{PresetID: presetID, Status: database.PrebuildStatusHealthy} + + dbm.EXPECT().GetPresetByID(gomock.Any(), presetID).Return(prow, nil).AnyTimes() + dbm.EXPECT().UpdatePresetPrebuildStatus(gomock.Any(), req).Return(nil).AnyTimes() + // TODO: This does not check the acl list on the template. Should it? + check.Args(req).Asserts(rbac.ResourceTemplate.WithID(tpl.ID).InOrg(org.ID), policy.ActionUpdate) })) } @@ -5877,3 +4194,96 @@ func (s *MethodTestSuite) TestAuthorizePrebuiltWorkspace() { }).Asserts(w, policy.ActionUpdate, w.AsPrebuild(), policy.ActionUpdate) })) } + +func (s *MethodTestSuite) TestUserSecrets() { + 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(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{secret}) + })) + 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(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(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() + })) +} + +func (s *MethodTestSuite) TestUsageEvents() { + s.Run("InsertUsageEvent", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + params := database.InsertUsageEventParams{ + ID: "1", + EventType: "dc_managed_agents_v1", + EventData: []byte("{}"), + CreatedAt: dbtime.Now(), + } + db.EXPECT().InsertUsageEvent(gomock.Any(), params).Return(nil) + check.Args(params).Asserts(rbac.ResourceUsageEvent, policy.ActionCreate) + })) + + s.Run("SelectUsageEventsForPublishing", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + now := dbtime.Now() + db.EXPECT().SelectUsageEventsForPublishing(gomock.Any(), now).Return([]database.UsageEvent{}, nil) + check.Args(now).Asserts(rbac.ResourceUsageEvent, policy.ActionUpdate) + })) + + s.Run("UpdateUsageEventsPostPublish", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + now := dbtime.Now() + params := database.UpdateUsageEventsPostPublishParams{ + Now: now, + IDs: []string{"1", "2"}, + FailureMessages: []string{"error", "error"}, + SetPublishedAts: []bool{false, false}, + } + db.EXPECT().UpdateUsageEventsPostPublish(gomock.Any(), params).Return(nil) + check.Args(params).Asserts(rbac.ResourceUsageEvent, policy.ActionUpdate) + })) + + s.Run("GetTotalUsageDCManagedAgentsV1", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + db.EXPECT().GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).Return(int64(1), nil) + check.Args(database.GetTotalUsageDCManagedAgentsV1Params{ + StartDate: time.Time{}, + EndDate: time.Time{}, + }).Asserts(rbac.ResourceUsageEvent, policy.ActionRead) + })) +} diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index 3fc4b06b7f69d..c9a1b2063d691 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/brianvoe/gofakeit/v7" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" @@ -20,7 +21,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" @@ -28,6 +28,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/rbac/regosql" "github.com/coder/coder/v2/coderd/util/slice" ) @@ -105,11 +106,37 @@ func (s *MethodTestSuite) TearDownSuite() { var testActorID = uuid.New() -// Subtest is a helper function that returns a function that can be passed to +// Mocked runs a subtest with a mocked database. Removing the overhead of a real +// postgres database resulting in much faster tests. +func (s *MethodTestSuite) Mocked(testCaseF func(dmb *dbmock.MockStore, faker *gofakeit.Faker, check *expects)) func() { + t := s.T() + mDB := dbmock.NewMockStore(gomock.NewController(t)) + mDB.EXPECT().Wrappers().Return([]string{}).AnyTimes() + + // Use a constant seed to prevent flakes from random data generation. + faker := gofakeit.New(0) + + // The usual Subtest assumes the test setup will use a real database to populate + // with data. In this mocked case, we want to pass the underlying mocked database + // to the test case instead. + return s.SubtestWithDB(mDB, func(_ database.Store, check *expects) { + testCaseF(mDB, faker, check) + }) +} + +// Subtest starts up a real postgres database for each test case. +// Deprecated: Use 'Mocked' instead for much faster tests. +func (s *MethodTestSuite) Subtest(testCaseF func(db database.Store, check *expects)) func() { + t := s.T() + db, _ := dbtestutil.NewDB(t) + return s.SubtestWithDB(db, testCaseF) +} + +// SubtestWithDB is a helper function that returns a function that can be passed to // s.Run(). This function will run the test case for the method that is being // tested. The check parameter is used to assert the results of the method. // If the caller does not use the `check` parameter, the test will fail. -func (s *MethodTestSuite) Subtest(testCaseF func(db database.Store, check *expects)) func() { +func (s *MethodTestSuite) SubtestWithDB(db database.Store, testCaseF func(db database.Store, check *expects)) func() { return func() { t := s.T() testName := s.T().Name() @@ -117,7 +144,6 @@ func (s *MethodTestSuite) Subtest(testCaseF func(db database.Store, check *expec methodName := names[len(names)-1] s.methodAccounting[methodName]++ - db, _ := dbtestutil.NewDB(t) fakeAuthorizer := &coderdtest.FakeAuthorizer{} rec := &coderdtest.RecordingAuthorizer{ Wrapped: fakeAuthorizer, diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index e7c00255d47c2..6d99005fb3334 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -12,6 +12,9 @@ import ( "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" @@ -43,6 +46,7 @@ type WorkspaceResponse struct { // resources. type WorkspaceBuildBuilder struct { t testing.TB + logger slog.Logger db database.Store ps pubsub.Pubsub ws database.WorkspaceTable @@ -62,7 +66,10 @@ type workspaceBuildDisposition struct { // Omitting the template ID on a workspace will also generate a new template // with a template version. func WorkspaceBuild(t testing.TB, db database.Store, ws database.WorkspaceTable) WorkspaceBuildBuilder { - return WorkspaceBuildBuilder{t: t, db: db, ws: ws} + return WorkspaceBuildBuilder{ + t: t, db: db, ws: ws, + logger: slogtest.Make(t, &slogtest.Options{}).Named("dbfake").Leveled(slog.LevelDebug), + } } func (b WorkspaceBuildBuilder) Pubsub(ps pubsub.Pubsub) WorkspaceBuildBuilder { @@ -131,6 +138,7 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { AgentToken: b.agentToken, } if b.ws.TemplateID == uuid.Nil { + b.logger.Debug(context.Background(), "creating template and version") resp.TemplateVersionResponse = TemplateVersion(b.t, b.db). Resources(b.resources...). Pubsub(b.ps). @@ -145,6 +153,7 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { // If no template version is set assume the active version. if b.seed.TemplateVersionID == uuid.Nil { + b.logger.Debug(context.Background(), "assuming active template version") template, err := b.db.GetTemplateByID(ownerCtx, b.ws.TemplateID) require.NoError(b.t, err) require.NotNil(b.t, template.ActiveVersionID, "active version ID unexpectedly nil") @@ -156,6 +165,9 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { // nolint: revive b.ws = dbgen.Workspace(b.t, b.db, b.ws) resp.Workspace = b.ws + b.logger.Debug(context.Background(), "created workspace", + slog.F("name", resp.Workspace.Name), + slog.F("workspace_id", resp.Workspace.ID)) } b.seed.WorkspaceID = b.ws.ID b.seed.InitiatorID = takeFirst(b.seed.InitiatorID, b.ws.OwnerID) @@ -182,10 +194,12 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { LogsOverflowed: false, }) require.NoError(b.t, err, "insert job") + b.logger.Debug(context.Background(), "inserted provisioner job", slog.F("job_id", job.ID)) if b.dispo.starting { // might need to do this multiple times if we got a template version // import job as well + b.logger.Debug(context.Background(), "looping to acquire provisioner job") for { j, err := b.db.AcquireProvisionerJob(ownerCtx, database.AcquireProvisionerJobParams{ OrganizationID: job.OrganizationID, @@ -202,10 +216,12 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { }) require.NoError(b.t, err, "acquire starting job") if j.ID == job.ID { + b.logger.Debug(context.Background(), "acquired provisioner job", slog.F("job_id", job.ID)) break } } } else { + b.logger.Debug(context.Background(), "completing the provisioner job") err = b.db.UpdateProvisionerJobWithCompleteByID(ownerCtx, database.UpdateProvisionerJobWithCompleteByIDParams{ ID: job.ID, UpdatedAt: dbtime.Now(), @@ -221,11 +237,16 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { } resp.Build = dbgen.WorkspaceBuild(b.t, b.db, b.seed) + b.logger.Debug(context.Background(), "created workspace build", + slog.F("build_id", resp.Build.ID), + slog.F("workspace_id", resp.Workspace.ID), + slog.F("build_number", resp.Build.BuildNumber)) for i := range b.params { b.params[i].WorkspaceBuildID = resp.Build.ID } - _ = dbgen.WorkspaceBuildParameters(b.t, b.db, b.params) + params := dbgen.WorkspaceBuildParameters(b.t, b.db, b.params) + b.logger.Debug(context.Background(), "created workspace build parameters", slog.F("count", len(params))) if b.ws.Deleted { err = b.db.UpdateWorkspaceDeletedByID(ownerCtx, database.UpdateWorkspaceDeletedByIDParams{ @@ -233,6 +254,7 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { Deleted: true, }) require.NoError(b.t, err) + b.logger.Debug(context.Background(), "deleted workspace", slog.F("workspace_id", resp.Workspace.ID)) } if b.ps != nil { @@ -243,6 +265,9 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { require.NoError(b.t, err) err = b.ps.Publish(wspubsub.WorkspaceEventChannel(resp.Workspace.OwnerID), msg) require.NoError(b.t, err) + b.logger.Debug(context.Background(), "published workspace event", + slog.F("owner_id", resp.Workspace.ID), + slog.F("owner_id", resp.Workspace.OwnerID)) } agents, err := b.db.GetWorkspaceAgentsByWorkspaceAndBuildNumber(ownerCtx, database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ @@ -260,7 +285,12 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { err = b.db.DeleteWorkspaceSubAgentByID(ownerCtx, subAgent.ID) require.NoError(b.t, err, "delete workspace agent subagent antagonist") - b.t.Logf("inserted deleted subagent antagonist %s (%v) for workspace agent %s (%v)", subAgent.Name, subAgent.ID, agent.Name, agent.ID) + b.logger.Debug(context.Background(), "inserted deleted subagent antagonist", + slog.F("subagent_name", subAgent.Name), + slog.F("subagent_id", subAgent.ID), + slog.F("agent_name", agent.Name), + slog.F("agent_id", agent.ID), + ) } } @@ -269,6 +299,7 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { type ProvisionerJobResourcesBuilder struct { t testing.TB + logger slog.Logger db database.Store jobID uuid.UUID transition database.WorkspaceTransition @@ -281,6 +312,7 @@ func ProvisionerJobResources( ) ProvisionerJobResourcesBuilder { return ProvisionerJobResourcesBuilder{ t: t, + logger: slogtest.Make(t, &slogtest.Options{}).Named("dbfake").Leveled(slog.LevelDebug).With(slog.F("job_id", jobID)), db: db, jobID: jobID, transition: transition, @@ -292,13 +324,17 @@ func (b ProvisionerJobResourcesBuilder) Do() { b.t.Helper() transition := b.transition if transition == "" { - // Default to start! + b.logger.Debug(context.Background(), "setting default transition to start") transition = database.WorkspaceTransitionStart } for _, resource := range b.resources { //nolint:gocritic // This is only used by tests. err := provisionerdserver.InsertWorkspaceResource(ownerCtx, b.db, b.jobID, transition, resource, &telemetry.Snapshot{}) require.NoError(b.t, err) + b.logger.Debug(context.Background(), "created workspace resource", + slog.F("resource_name", resource.Name), + slog.F("agent_count", len(resource.Agents)), + ) } } @@ -309,6 +345,7 @@ type TemplateVersionResponse struct { type TemplateVersionBuilder struct { t testing.TB + logger slog.Logger db database.Store seed database.TemplateVersion fileID uuid.UUID @@ -326,6 +363,7 @@ type TemplateVersionBuilder struct { func TemplateVersion(t testing.TB, db database.Store) TemplateVersionBuilder { return TemplateVersionBuilder{ t: t, + logger: slogtest.Make(t, &slogtest.Options{}).Named("dbfake").Leveled(slog.LevelDebug), db: db, promote: true, autoCreateTemplate: true, @@ -396,9 +434,16 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse { Valid: true, UUID: resp.Template.ID, } + t.logger.Debug(context.Background(), "created template", + slog.F("organization_id", resp.Template.OrganizationID), + slog.F("template_id", resp.Template.CreatedBy), + ) } version := dbgen.TemplateVersion(t.t, t.db, t.seed) + t.logger.Debug(context.Background(), "created template version", + slog.F("template_version_id", version.ID), + ) if t.promote { err := t.db.UpdateTemplateActiveVersionByID(ownerCtx, database.UpdateTemplateActiveVersionByIDParams{ ID: t.seed.TemplateID.UUID, @@ -406,10 +451,13 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse { UpdatedAt: dbtime.Now(), }) require.NoError(t.t, err) + t.logger.Debug(context.Background(), "promoted template version", + slog.F("template_version_id", t.seed.ID), + ) } for _, preset := range t.presets { - dbgen.Preset(t.t, t.db, database.InsertPresetParams{ + prst := dbgen.Preset(t.t, t.db, database.InsertPresetParams{ ID: preset.ID, TemplateVersionID: version.ID, Name: preset.Name, @@ -421,14 +469,19 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse { Description: preset.Description, Icon: preset.Icon, }) + t.logger.Debug(context.Background(), "added preset", + slog.F("preset_id", prst.ID), + slog.F("preset_name", prst.Name), + ) } for _, presetParam := range t.presetParams { - dbgen.PresetParameter(t.t, t.db, database.InsertPresetParametersParams{ + prm := dbgen.PresetParameter(t.t, t.db, database.InsertPresetParametersParams{ TemplateVersionPresetID: presetParam.TemplateVersionPresetID, Names: []string{presetParam.Name}, Values: []string{presetParam.Value}, }) + t.logger.Debug(context.Background(), "added preset parameter", slog.F("param_name", prm[0].Name)) } payload, err := json.Marshal(provisionerdserver.TemplateVersionImportJob{ @@ -448,6 +501,7 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse { }, FileID: t.fileID, }) + t.logger.Debug(context.Background(), "added template version import job", slog.F("job_id", job.ID)) t.seed.JobID = job.ID diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 81d9efd1cd3e3..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(), })) } @@ -1422,11 +1426,39 @@ func PresetParameter(t testing.TB, db database.Store, seed database.InsertPreset return parameters } -func ClaimPrebuild(t testing.TB, db database.Store, newUserID uuid.UUID, newName string, presetID uuid.UUID) database.ClaimPrebuiltWorkspaceRow { +func UserSecret(t testing.TB, db database.Store, seed database.UserSecret) database.UserSecret { + userSecret, err := db.CreateUserSecret(genCtx, database.CreateUserSecretParams{ + ID: takeFirst(seed.ID, uuid.New()), + UserID: takeFirst(seed.UserID, uuid.New()), + Name: takeFirst(seed.Name, "secret-name"), + Description: takeFirst(seed.Description, "secret description"), + Value: takeFirst(seed.Value, "secret value"), + EnvName: takeFirst(seed.EnvName, "SECRET_ENV_NAME"), + FilePath: takeFirst(seed.FilePath, "~/secret/file/path"), + }) + require.NoError(t, err, "failed to insert user secret") + return userSecret +} + +func ClaimPrebuild( + t testing.TB, + db database.Store, + now time.Time, + newUserID uuid.UUID, + newName string, + presetID uuid.UUID, + autostartSchedule sql.NullString, + nextStartAt sql.NullTime, + ttl sql.NullInt64, +) database.ClaimPrebuiltWorkspaceRow { claimedWorkspace, err := db.ClaimPrebuiltWorkspace(genCtx, database.ClaimPrebuiltWorkspaceParams{ - NewUserID: newUserID, - NewName: newName, - PresetID: presetID, + NewUserID: newUserID, + NewName: newName, + Now: now, + PresetID: presetID, + AutostartSchedule: autostartSchedule, + NextStartAt: nextStartAt, + WorkspaceTtl: ttl, }) require.NoError(t, err, "claim prebuilt workspace") diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 574eeb069e47f..c1943e8e7a40e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -215,6 +215,13 @@ func (m queryMetricsStore) CountUnreadInboxNotificationsByUserID(ctx context.Con return r0, r1 } +func (m queryMetricsStore) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) { + start := time.Now() + r0, r1 := m.s.CreateUserSecret(ctx, arg) + m.queryLatencies.WithLabelValues("CreateUserSecret").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { start := time.Now() r0, r1 := m.s.CustomRoles(ctx, arg) @@ -460,6 +467,13 @@ func (m queryMetricsStore) DeleteTailnetTunnel(ctx context.Context, arg database return r0, r1 } +func (m queryMetricsStore) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteUserSecret(ctx, id) + m.queryLatencies.WithLabelValues("DeleteUserSecret").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { start := time.Now() r0 := m.s.DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx, arg) @@ -551,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) @@ -929,13 +950,6 @@ func (m queryMetricsStore) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Cont return build, err } -func (m queryMetricsStore) GetLatestWorkspaceBuilds(ctx context.Context) ([]database.WorkspaceBuild, error) { - start := time.Now() - builds, err := m.s.GetLatestWorkspaceBuilds(ctx) - m.queryLatencies.WithLabelValues("GetLatestWorkspaceBuilds").Observe(time.Since(start).Seconds()) - return builds, err -} - func (m queryMetricsStore) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceBuild, error) { start := time.Now() builds, err := m.s.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, ids) @@ -964,13 +978,6 @@ func (m queryMetricsStore) GetLogoURL(ctx context.Context) (string, error) { return url, err } -func (m queryMetricsStore) GetManagedAgentCount(ctx context.Context, arg database.GetManagedAgentCountParams) (int64, error) { - start := time.Now() - r0, r1 := m.s.GetManagedAgentCount(ctx, arg) - m.queryLatencies.WithLabelValues("GetManagedAgentCount").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m queryMetricsStore) GetNotificationMessagesByStatus(ctx context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) { start := time.Now() r0, r1 := m.s.GetNotificationMessagesByStatus(ctx, arg) @@ -1342,6 +1349,13 @@ func (m queryMetricsStore) GetQuotaConsumedForUser(ctx context.Context, ownerID return consumed, err } +func (m queryMetricsStore) GetRegularWorkspaceCreateMetrics(ctx context.Context) ([]database.GetRegularWorkspaceCreateMetricsRow, error) { + start := time.Now() + r0, r1 := m.s.GetRegularWorkspaceCreateMetrics(ctx) + m.queryLatencies.WithLabelValues("GetRegularWorkspaceCreateMetrics").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetReplicaByID(ctx context.Context, id uuid.UUID) (database.Replica, error) { start := time.Now() replica, err := m.s.GetReplicaByID(ctx, id) @@ -1524,6 +1538,13 @@ func (m queryMetricsStore) GetTemplateVersionByTemplateIDAndName(ctx context.Con return version, err } +func (m queryMetricsStore) GetTemplateVersionHasAITask(ctx context.Context, id uuid.UUID) (bool, error) { + start := time.Now() + r0, r1 := m.s.GetTemplateVersionHasAITask(ctx, id) + m.queryLatencies.WithLabelValues("GetTemplateVersionHasAITask").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetTemplateVersionParameters(ctx context.Context, templateVersionID uuid.UUID) ([]database.TemplateVersionParameter, error) { start := time.Now() parameters, err := m.s.GetTemplateVersionParameters(ctx, templateVersionID) @@ -1587,6 +1608,13 @@ func (m queryMetricsStore) GetTemplatesWithFilter(ctx context.Context, arg datab return templates, err } +func (m queryMetricsStore) GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg database.GetTotalUsageDCManagedAgentsV1Params) (int64, error) { + start := time.Now() + r0, r1 := m.s.GetTotalUsageDCManagedAgentsV1(ctx, arg) + m.queryLatencies.WithLabelValues("GetTotalUsageDCManagedAgentsV1").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) { start := time.Now() licenses, err := m.s.GetUnexpiredLicenses(ctx) @@ -1657,6 +1685,20 @@ func (m queryMetricsStore) GetUserNotificationPreferences(ctx context.Context, u return r0, r1 } +func (m queryMetricsStore) GetUserSecret(ctx context.Context, id uuid.UUID) (database.UserSecret, error) { + start := time.Now() + r0, r1 := m.s.GetUserSecret(ctx, id) + m.queryLatencies.WithLabelValues("GetUserSecret").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) GetUserSecretByUserIDAndName(ctx context.Context, arg database.GetUserSecretByUserIDAndNameParams) (database.UserSecret, error) { + start := time.Now() + r0, r1 := m.s.GetUserSecretByUserIDAndName(ctx, arg) + m.queryLatencies.WithLabelValues("GetUserSecretByUserIDAndName").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) { start := time.Now() r0, r1 := m.s.GetUserStatusCounts(ctx, arg) @@ -1713,6 +1755,13 @@ func (m queryMetricsStore) GetWebpushVAPIDKeys(ctx context.Context) (database.Ge return r0, r1 } +func (m queryMetricsStore) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceACLByIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceACLByID(ctx, id) + m.queryLatencies.WithLabelValues("GetWorkspaceACLByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, authToken) @@ -2098,13 +2147,6 @@ func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Contex return workspaces, err } -func (m queryMetricsStore) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) { - start := time.Now() - r0, r1 := m.s.HasTemplateVersionsWithAITask(ctx) - m.queryLatencies.WithLabelValues("HasTemplateVersionsWithAITask").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m queryMetricsStore) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { start := time.Now() key, err := m.s.InsertAPIKey(ctx, arg) @@ -2371,6 +2413,13 @@ func (m queryMetricsStore) InsertTemplateVersionWorkspaceTag(ctx context.Context return r0, r1 } +func (m queryMetricsStore) InsertUsageEvent(ctx context.Context, arg database.InsertUsageEventParams) error { + start := time.Now() + r0 := m.s.InsertUsageEvent(ctx, arg) + m.queryLatencies.WithLabelValues("InsertUsageEvent").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) InsertUser(ctx context.Context, arg database.InsertUserParams) (database.User, error) { start := time.Now() user, err := m.s.InsertUser(ctx, arg) @@ -2546,6 +2595,13 @@ func (m queryMetricsStore) ListProvisionerKeysByOrganizationExcludeReserved(ctx return r0, r1 } +func (m queryMetricsStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) { + start := time.Now() + r0, r1 := m.s.ListUserSecrets(ctx, userID) + m.queryLatencies.WithLabelValues("ListUserSecrets").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { start := time.Now() r0, r1 := m.s.ListWorkspaceAgentPortShares(ctx, workspaceID) @@ -2623,6 +2679,13 @@ func (m queryMetricsStore) RevokeDBCryptKey(ctx context.Context, activeKeyDigest return r0 } +func (m queryMetricsStore) SelectUsageEventsForPublishing(ctx context.Context, arg time.Time) ([]database.UsageEvent, error) { + start := time.Now() + r0, r1 := m.s.SelectUsageEventsForPublishing(ctx, arg) + m.queryLatencies.WithLabelValues("SelectUsageEventsForPublishing").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) { start := time.Now() ok, err := m.s.TryAcquireLock(ctx, pgTryAdvisoryXactLock) @@ -2875,13 +2938,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) @@ -2903,6 +2959,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) @@ -2910,6 +2973,13 @@ func (m queryMetricsStore) UpdateTemplateWorkspacesLastUsedAt(ctx context.Contex return r0 } +func (m queryMetricsStore) UpdateUsageEventsPostPublish(ctx context.Context, arg database.UpdateUsageEventsPostPublishParams) error { + start := time.Now() + r0 := m.s.UpdateUsageEventsPostPublish(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUsageEventsPostPublish").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.UpdateUserDeletedByID(ctx, id) @@ -2994,6 +3064,13 @@ func (m queryMetricsStore) UpdateUserRoles(ctx context.Context, arg database.Upd return user, err } +func (m queryMetricsStore) UpdateUserSecret(ctx context.Context, arg database.UpdateUserSecretParams) (database.UserSecret, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserSecret(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserSecret").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { start := time.Now() user, err := m.s.UpdateUserStatus(ctx, arg) @@ -3092,13 +3169,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) @@ -3113,6 +3183,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) @@ -3379,6 +3456,20 @@ func (m queryMetricsStore) UpsertWorkspaceAppAuditSession(ctx context.Context, a return r0, r1 } +func (m queryMetricsStore) ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (database.ValidateGroupIDsRow, error) { + start := time.Now() + r0, r1 := m.s.ValidateGroupIDs(ctx, groupIds) + m.queryLatencies.WithLabelValues("ValidateGroupIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (database.ValidateUserIDsRow, error) { + start := time.Now() + r0, r1 := m.s.ValidateUserIDs(ctx, userIds) + m.queryLatencies.WithLabelValues("ValidateUserIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]database.Template, error) { start := time.Now() templates, err := m.s.GetAuthorizedTemplates(ctx, arg, prepared) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 30589c9fbb8bf..f16d72899c907 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -338,6 +338,21 @@ func (mr *MockStoreMockRecorder) CountUnreadInboxNotificationsByUserID(ctx, user return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountUnreadInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).CountUnreadInboxNotificationsByUserID), ctx, userID) } +// CreateUserSecret mocks base method. +func (m *MockStore) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUserSecret", ctx, arg) + ret0, _ := ret[0].(database.UserSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateUserSecret indicates an expected call of CreateUserSecret. +func (mr *MockStoreMockRecorder) CreateUserSecret(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserSecret", reflect.TypeOf((*MockStore)(nil).CreateUserSecret), ctx, arg) +} + // CustomRoles mocks base method. func (m *MockStore) CustomRoles(ctx context.Context, arg database.CustomRolesParams) ([]database.CustomRole, error) { m.ctrl.T.Helper() @@ -835,6 +850,20 @@ func (mr *MockStoreMockRecorder) DeleteTailnetTunnel(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTailnetTunnel", reflect.TypeOf((*MockStore)(nil).DeleteTailnetTunnel), ctx, arg) } +// DeleteUserSecret mocks base method. +func (m *MockStore) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserSecret", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUserSecret indicates an expected call of DeleteUserSecret. +func (mr *MockStoreMockRecorder) DeleteUserSecret(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserSecret", reflect.TypeOf((*MockStore)(nil).DeleteUserSecret), ctx, id) +} + // DeleteWebpushSubscriptionByUserIDAndEndpoint mocks base method. func (m *MockStore) DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg database.DeleteWebpushSubscriptionByUserIDAndEndpointParams) error { m.ctrl.T.Helper() @@ -1022,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() @@ -1937,21 +1981,6 @@ func (mr *MockStoreMockRecorder) GetLatestWorkspaceBuildByWorkspaceID(ctx, works return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestWorkspaceBuildByWorkspaceID", reflect.TypeOf((*MockStore)(nil).GetLatestWorkspaceBuildByWorkspaceID), ctx, workspaceID) } -// GetLatestWorkspaceBuilds mocks base method. -func (m *MockStore) GetLatestWorkspaceBuilds(ctx context.Context) ([]database.WorkspaceBuild, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetLatestWorkspaceBuilds", ctx) - ret0, _ := ret[0].([]database.WorkspaceBuild) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetLatestWorkspaceBuilds indicates an expected call of GetLatestWorkspaceBuilds. -func (mr *MockStoreMockRecorder) GetLatestWorkspaceBuilds(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestWorkspaceBuilds", reflect.TypeOf((*MockStore)(nil).GetLatestWorkspaceBuilds), ctx) -} - // GetLatestWorkspaceBuildsByWorkspaceIDs mocks base method. func (m *MockStore) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceBuild, error) { m.ctrl.T.Helper() @@ -2012,21 +2041,6 @@ func (mr *MockStoreMockRecorder) GetLogoURL(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogoURL", reflect.TypeOf((*MockStore)(nil).GetLogoURL), ctx) } -// GetManagedAgentCount mocks base method. -func (m *MockStore) GetManagedAgentCount(ctx context.Context, arg database.GetManagedAgentCountParams) (int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetManagedAgentCount", ctx, arg) - ret0, _ := ret[0].(int64) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetManagedAgentCount indicates an expected call of GetManagedAgentCount. -func (mr *MockStoreMockRecorder) GetManagedAgentCount(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetManagedAgentCount", reflect.TypeOf((*MockStore)(nil).GetManagedAgentCount), ctx, arg) -} - // GetNotificationMessagesByStatus mocks base method. func (m *MockStore) GetNotificationMessagesByStatus(ctx context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) { m.ctrl.T.Helper() @@ -2822,6 +2836,21 @@ func (mr *MockStoreMockRecorder) GetQuotaConsumedForUser(ctx, arg any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetQuotaConsumedForUser", reflect.TypeOf((*MockStore)(nil).GetQuotaConsumedForUser), ctx, arg) } +// GetRegularWorkspaceCreateMetrics mocks base method. +func (m *MockStore) GetRegularWorkspaceCreateMetrics(ctx context.Context) ([]database.GetRegularWorkspaceCreateMetricsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRegularWorkspaceCreateMetrics", ctx) + ret0, _ := ret[0].([]database.GetRegularWorkspaceCreateMetricsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRegularWorkspaceCreateMetrics indicates an expected call of GetRegularWorkspaceCreateMetrics. +func (mr *MockStoreMockRecorder) GetRegularWorkspaceCreateMetrics(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRegularWorkspaceCreateMetrics", reflect.TypeOf((*MockStore)(nil).GetRegularWorkspaceCreateMetrics), ctx) +} + // GetReplicaByID mocks base method. func (m *MockStore) GetReplicaByID(ctx context.Context, id uuid.UUID) (database.Replica, error) { m.ctrl.T.Helper() @@ -3242,6 +3271,21 @@ func (mr *MockStoreMockRecorder) GetTemplateVersionByTemplateIDAndName(ctx, arg return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateVersionByTemplateIDAndName", reflect.TypeOf((*MockStore)(nil).GetTemplateVersionByTemplateIDAndName), ctx, arg) } +// GetTemplateVersionHasAITask mocks base method. +func (m *MockStore) GetTemplateVersionHasAITask(ctx context.Context, id uuid.UUID) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTemplateVersionHasAITask", ctx, id) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTemplateVersionHasAITask indicates an expected call of GetTemplateVersionHasAITask. +func (mr *MockStoreMockRecorder) GetTemplateVersionHasAITask(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateVersionHasAITask", reflect.TypeOf((*MockStore)(nil).GetTemplateVersionHasAITask), ctx, id) +} + // GetTemplateVersionParameters mocks base method. func (m *MockStore) GetTemplateVersionParameters(ctx context.Context, templateVersionID uuid.UUID) ([]database.TemplateVersionParameter, error) { m.ctrl.T.Helper() @@ -3377,6 +3421,21 @@ func (mr *MockStoreMockRecorder) GetTemplatesWithFilter(ctx, arg any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplatesWithFilter", reflect.TypeOf((*MockStore)(nil).GetTemplatesWithFilter), ctx, arg) } +// GetTotalUsageDCManagedAgentsV1 mocks base method. +func (m *MockStore) GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg database.GetTotalUsageDCManagedAgentsV1Params) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTotalUsageDCManagedAgentsV1", ctx, arg) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTotalUsageDCManagedAgentsV1 indicates an expected call of GetTotalUsageDCManagedAgentsV1. +func (mr *MockStoreMockRecorder) GetTotalUsageDCManagedAgentsV1(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTotalUsageDCManagedAgentsV1", reflect.TypeOf((*MockStore)(nil).GetTotalUsageDCManagedAgentsV1), ctx, arg) +} + // GetUnexpiredLicenses mocks base method. func (m *MockStore) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) { m.ctrl.T.Helper() @@ -3527,6 +3586,36 @@ func (mr *MockStoreMockRecorder) GetUserNotificationPreferences(ctx, userID any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserNotificationPreferences", reflect.TypeOf((*MockStore)(nil).GetUserNotificationPreferences), ctx, userID) } +// GetUserSecret mocks base method. +func (m *MockStore) GetUserSecret(ctx context.Context, id uuid.UUID) (database.UserSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserSecret", ctx, id) + ret0, _ := ret[0].(database.UserSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserSecret indicates an expected call of GetUserSecret. +func (mr *MockStoreMockRecorder) GetUserSecret(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSecret", reflect.TypeOf((*MockStore)(nil).GetUserSecret), ctx, id) +} + +// GetUserSecretByUserIDAndName mocks base method. +func (m *MockStore) GetUserSecretByUserIDAndName(ctx context.Context, arg database.GetUserSecretByUserIDAndNameParams) (database.UserSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserSecretByUserIDAndName", ctx, arg) + ret0, _ := ret[0].(database.UserSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserSecretByUserIDAndName indicates an expected call of GetUserSecretByUserIDAndName. +func (mr *MockStoreMockRecorder) GetUserSecretByUserIDAndName(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSecretByUserIDAndName", reflect.TypeOf((*MockStore)(nil).GetUserSecretByUserIDAndName), ctx, arg) +} + // GetUserStatusCounts mocks base method. func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) { m.ctrl.T.Helper() @@ -3647,6 +3736,21 @@ func (mr *MockStoreMockRecorder) GetWebpushVAPIDKeys(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWebpushVAPIDKeys", reflect.TypeOf((*MockStore)(nil).GetWebpushVAPIDKeys), ctx) } +// GetWorkspaceACLByID mocks base method. +func (m *MockStore) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceACLByIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceACLByID", ctx, id) + ret0, _ := ret[0].(database.GetWorkspaceACLByIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceACLByID indicates an expected call of GetWorkspaceACLByID. +func (mr *MockStoreMockRecorder) GetWorkspaceACLByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceACLByID", reflect.TypeOf((*MockStore)(nil).GetWorkspaceACLByID), ctx, id) +} + // GetWorkspaceAgentAndLatestBuildByAuthToken mocks base method. func (m *MockStore) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (database.GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) { m.ctrl.T.Helper() @@ -4472,21 +4576,6 @@ func (mr *MockStoreMockRecorder) GetWorkspacesEligibleForTransition(ctx, now any return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesEligibleForTransition", reflect.TypeOf((*MockStore)(nil).GetWorkspacesEligibleForTransition), ctx, now) } -// HasTemplateVersionsWithAITask mocks base method. -func (m *MockStore) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HasTemplateVersionsWithAITask", ctx) - ret0, _ := ret[0].(bool) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// HasTemplateVersionsWithAITask indicates an expected call of HasTemplateVersionsWithAITask. -func (mr *MockStoreMockRecorder) HasTemplateVersionsWithAITask(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasTemplateVersionsWithAITask", reflect.TypeOf((*MockStore)(nil).HasTemplateVersionsWithAITask), ctx) -} - // InTx mocks base method. func (m *MockStore) InTx(arg0 func(database.Store) error, arg1 *database.TxOptions) error { m.ctrl.T.Helper() @@ -5063,6 +5152,20 @@ func (mr *MockStoreMockRecorder) InsertTemplateVersionWorkspaceTag(ctx, arg any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertTemplateVersionWorkspaceTag", reflect.TypeOf((*MockStore)(nil).InsertTemplateVersionWorkspaceTag), ctx, arg) } +// InsertUsageEvent mocks base method. +func (m *MockStore) InsertUsageEvent(ctx context.Context, arg database.InsertUsageEventParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertUsageEvent", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// InsertUsageEvent indicates an expected call of InsertUsageEvent. +func (mr *MockStoreMockRecorder) InsertUsageEvent(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertUsageEvent", reflect.TypeOf((*MockStore)(nil).InsertUsageEvent), ctx, arg) +} + // InsertUser mocks base method. func (m *MockStore) InsertUser(ctx context.Context, arg database.InsertUserParams) (database.User, error) { m.ctrl.T.Helper() @@ -5432,6 +5535,21 @@ func (mr *MockStoreMockRecorder) ListProvisionerKeysByOrganizationExcludeReserve return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProvisionerKeysByOrganizationExcludeReserved", reflect.TypeOf((*MockStore)(nil).ListProvisionerKeysByOrganizationExcludeReserved), ctx, organizationID) } +// ListUserSecrets mocks base method. +func (m *MockStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.UserSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListUserSecrets", ctx, userID) + ret0, _ := ret[0].([]database.UserSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListUserSecrets indicates an expected call of ListUserSecrets. +func (mr *MockStoreMockRecorder) ListUserSecrets(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserSecrets", reflect.TypeOf((*MockStore)(nil).ListUserSecrets), ctx, userID) +} + // ListWorkspaceAgentPortShares mocks base method. func (m *MockStore) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]database.WorkspaceAgentPortShare, error) { m.ctrl.T.Helper() @@ -5623,6 +5741,21 @@ func (mr *MockStoreMockRecorder) RevokeDBCryptKey(ctx, activeKeyDigest any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeDBCryptKey", reflect.TypeOf((*MockStore)(nil).RevokeDBCryptKey), ctx, activeKeyDigest) } +// SelectUsageEventsForPublishing mocks base method. +func (m *MockStore) SelectUsageEventsForPublishing(ctx context.Context, now time.Time) ([]database.UsageEvent, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SelectUsageEventsForPublishing", ctx, now) + ret0, _ := ret[0].([]database.UsageEvent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SelectUsageEventsForPublishing indicates an expected call of SelectUsageEventsForPublishing. +func (mr *MockStoreMockRecorder) SelectUsageEventsForPublishing(ctx, now any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SelectUsageEventsForPublishing", reflect.TypeOf((*MockStore)(nil).SelectUsageEventsForPublishing), ctx, now) +} + // TryAcquireLock mocks base method. func (m *MockStore) TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) { m.ctrl.T.Helper() @@ -6141,20 +6274,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() @@ -6197,6 +6316,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() @@ -6211,6 +6344,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateWorkspacesLastUsedAt(ctx, arg any return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateWorkspacesLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateTemplateWorkspacesLastUsedAt), ctx, arg) } +// UpdateUsageEventsPostPublish mocks base method. +func (m *MockStore) UpdateUsageEventsPostPublish(ctx context.Context, arg database.UpdateUsageEventsPostPublishParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUsageEventsPostPublish", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUsageEventsPostPublish indicates an expected call of UpdateUsageEventsPostPublish. +func (mr *MockStoreMockRecorder) UpdateUsageEventsPostPublish(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUsageEventsPostPublish", reflect.TypeOf((*MockStore)(nil).UpdateUsageEventsPostPublish), ctx, arg) +} + // UpdateUserDeletedByID mocks base method. func (m *MockStore) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() @@ -6387,6 +6534,21 @@ func (mr *MockStoreMockRecorder) UpdateUserRoles(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserRoles", reflect.TypeOf((*MockStore)(nil).UpdateUserRoles), ctx, arg) } +// UpdateUserSecret mocks base method. +func (m *MockStore) UpdateUserSecret(ctx context.Context, arg database.UpdateUserSecretParams) (database.UserSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserSecret", ctx, arg) + ret0, _ := ret[0].(database.UserSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserSecret indicates an expected call of UpdateUserSecret. +func (mr *MockStoreMockRecorder) UpdateUserSecret(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserSecret", reflect.TypeOf((*MockStore)(nil).UpdateUserSecret), ctx, arg) +} + // UpdateUserStatus mocks base method. func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { m.ctrl.T.Helper() @@ -6587,20 +6749,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() @@ -6629,6 +6777,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() @@ -7174,6 +7336,36 @@ func (mr *MockStoreMockRecorder) UpsertWorkspaceAppAuditSession(ctx, arg any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceAppAuditSession", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceAppAuditSession), ctx, arg) } +// ValidateGroupIDs mocks base method. +func (m *MockStore) ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (database.ValidateGroupIDsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidateGroupIDs", ctx, groupIds) + ret0, _ := ret[0].(database.ValidateGroupIDsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ValidateGroupIDs indicates an expected call of ValidateGroupIDs. +func (mr *MockStoreMockRecorder) ValidateGroupIDs(ctx, groupIds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateGroupIDs", reflect.TypeOf((*MockStore)(nil).ValidateGroupIDs), ctx, groupIds) +} + +// ValidateUserIDs mocks base method. +func (m *MockStore) ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (database.ValidateUserIDsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidateUserIDs", ctx, userIds) + ret0, _ := ret[0].(database.ValidateUserIDsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ValidateUserIDs indicates an expected call of ValidateUserIDs. +func (mr *MockStoreMockRecorder) ValidateUserIDs(ctx, userIds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateUserIDs", reflect.TypeOf((*MockStore)(nil).ValidateUserIDs), ctx, userIds) +} + // Wrappers mocks base method. func (m *MockStore) Wrappers() []string { m.ctrl.T.Helper() diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index 135d7f40b05dd..5afa9b4ba2975 100644 --- a/coderd/database/dbpurge/dbpurge.go +++ b/coderd/database/dbpurge/dbpurge.go @@ -12,6 +12,7 @@ import ( "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" "github.com/coder/quartz" ) @@ -38,7 +39,7 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, clk quartz. // Start the ticker with the initial delay. ticker := clk.NewTicker(delay) - doTick := func(start time.Time) { + doTick := func(ctx context.Context, start time.Time) { defer ticker.Reset(delay) // Start a transaction to grab advisory lock, we don't want to run // multiple purges at the same time (multiple replicas). @@ -85,21 +86,21 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, clk quartz. } } - go func() { + pproflabel.Go(ctx, pproflabel.Service(pproflabel.ServiceDBPurge), func(ctx context.Context) { defer close(closed) defer ticker.Stop() // Force an initial tick. - doTick(dbtime.Time(clk.Now()).UTC()) + doTick(ctx, dbtime.Time(clk.Now()).UTC()) for { select { case <-ctx.Done(): return case tick := <-ticker.C: ticker.Stop() - doTick(dbtime.Time(tick).UTC()) + doTick(ctx, dbtime.Time(tick).UTC()) } } - }() + }) return &instance{ cancel: cancelFunc, closed: closed, diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 1d57a87e68f48..b3be0f82631c0 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -15,12 +15,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" + "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/dbgen" + "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbpurge" "github.com/coder/coder/v2/coderd/database/dbrollup" "github.com/coder/coder/v2/coderd/database/dbtestutil" @@ -46,8 +48,9 @@ func TestPurge(t *testing.T) { // We want to make sure dbpurge is actually started so that this test is meaningful. clk := quartz.NewMock(t) done := awaitDoTick(ctx, t, clk) - db, _ := dbtestutil.NewDB(t) - purger := dbpurge.New(context.Background(), testutil.Logger(t), db, clk) + mDB := dbmock.NewMockStore(gomock.NewController(t)) + mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")).Return(nil).Times(2) + purger := dbpurge.New(context.Background(), testutil.Logger(t), mDB, clk) <-done // wait for doTick() to run. require.NoError(t, purger.Close()) } diff --git a/coderd/database/dbtestutil/db.go b/coderd/database/dbtestutil/db.go index f67e3206b09d1..4c7d7dcbf230e 100644 --- a/coderd/database/dbtestutil/db.go +++ b/coderd/database/dbtestutil/db.go @@ -206,7 +206,7 @@ func DumpOnFailure(t testing.TB, connectionURL string) { outPath := filepath.Join(cwd, snakeCaseName+"."+timeSuffix+".test.sql") dump, err := PGDump(connectionURL) if err != nil { - t.Errorf("dump on failure: failed to run pg_dump") + t.Errorf("dump on failure: failed to run pg_dump: %s", err.Error()) return } if err := os.WriteFile(outPath, normalizeDump(dump), 0o600); err != nil { diff --git a/coderd/database/dbtestutil/postgres.go b/coderd/database/dbtestutil/postgres.go index e5aa4b14de83b..1ab80569dedb1 100644 --- a/coderd/database/dbtestutil/postgres.go +++ b/coderd/database/dbtestutil/postgres.go @@ -138,6 +138,7 @@ func initDefaultConnection(t TBSubset) error { type OpenOptions struct { DBFrom *string + LogDSN bool } type OpenOption func(*OpenOptions) @@ -150,9 +151,18 @@ func WithDBFrom(dbFrom string) OpenOption { } } +// WithLogDSN sets whether the DSN should be logged during testing. +// This provides an ergonomic way to connect to test databases during debugging. +func WithLogDSN(logDSN bool) OpenOption { + return func(o *OpenOptions) { + o.LogDSN = logDSN + } +} + // TBSubset is a subset of the testing.TB interface. // It allows to use dbtestutil.Open outside of tests. type TBSubset interface { + Name() string Cleanup(func()) Helper() Logf(format string, args ...any) @@ -227,6 +237,11 @@ func Open(t TBSubset, opts ...OpenOption) (string, error) { Port: port, DBName: dbName, }.DSN() + + // Optionally log the DSN to help connect to the test database. + if openOptions.LogDSN { + _, _ = fmt.Fprintf(os.Stderr, "Connect to the database for %s using: psql '%s'\n", t.Name(), dsn) + } return dsn, nil } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 053b5302d3e38..273ef55b968ea 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -361,6 +361,38 @@ CREATE TYPE workspace_transition AS ENUM ( 'delete' ); +CREATE FUNCTION aggregate_usage_event() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + -- Check for supported event types and throw error for unknown types + IF NEW.event_type NOT IN ('dc_managed_agents_v1') THEN + RAISE EXCEPTION 'Unhandled usage event type in aggregate_usage_event: %', NEW.event_type; + END IF; + + INSERT INTO usage_events_daily (day, event_type, usage_data) + VALUES ( + -- Extract the date from the created_at timestamp, always using UTC for + -- consistency + date_trunc('day', NEW.created_at AT TIME ZONE 'UTC')::date, + NEW.event_type, + NEW.event_data + ) + ON CONFLICT (day, event_type) DO UPDATE SET + usage_data = CASE + -- Handle simple counter events by summing the count + WHEN NEW.event_type IN ('dc_managed_agents_v1') THEN + jsonb_build_object( + 'count', + COALESCE((usage_events_daily.usage_data->>'count')::bigint, 0) + + COALESCE((NEW.event_data->>'count')::bigint, 0) + ) + END; + + RETURN NEW; +END; +$$; + CREATE FUNCTION check_workspace_agent_name_unique() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -942,13 +974,16 @@ CREATE TABLE external_auth_links ( oauth_expiry timestamp with time zone NOT NULL, oauth_access_token_key_id text, oauth_refresh_token_key_id text, - oauth_extra jsonb + oauth_extra jsonb, + oauth_refresh_failure_reason text DEFAULT ''::text NOT NULL ); COMMENT ON COLUMN external_auth_links.oauth_access_token_key_id IS 'The ID of the key used to encrypt the OAuth access token. If this is NULL, the access token is not encrypted'; COMMENT ON COLUMN external_auth_links.oauth_refresh_token_key_id IS 'The ID of the key used to encrypt the OAuth refresh token. If this is NULL, the refresh token is not encrypted'; +COMMENT ON COLUMN external_auth_links.oauth_refresh_failure_reason IS 'This error means the refresh token is invalid. Cached so we can avoid calling the external provider again for the same error.'; + CREATE TABLE files ( hash character varying(64) NOT NULL, created_at timestamp with time zone NOT NULL, @@ -1012,7 +1047,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.'; @@ -1688,7 +1724,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'; @@ -1719,6 +1756,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 @@ -1829,6 +1867,41 @@ CREATE VIEW template_with_names AS COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.'; +CREATE TABLE usage_events ( + id text NOT NULL, + event_type text NOT NULL, + event_data jsonb NOT NULL, + created_at timestamp with time zone NOT NULL, + publish_started_at timestamp with time zone, + published_at timestamp with time zone, + failure_message text, + CONSTRAINT usage_event_type_check CHECK ((event_type = 'dc_managed_agents_v1'::text)) +); + +COMMENT ON TABLE usage_events IS 'usage_events contains usage data that is collected from the product and potentially shipped to the usage collector service.'; + +COMMENT ON COLUMN usage_events.id IS 'For "discrete" event types, this is a random UUID. For "heartbeat" event types, this is a combination of the event type and a truncated timestamp.'; + +COMMENT ON COLUMN usage_events.event_type IS 'The usage event type with version. "dc" means "discrete" (e.g. a single event, for counters), "hb" means "heartbeat" (e.g. a recurring event that contains a total count of usage generated from the database, for gauges).'; + +COMMENT ON COLUMN usage_events.event_data IS 'Event payload. Determined by the matching usage struct for this event type.'; + +COMMENT ON COLUMN usage_events.publish_started_at IS 'Set to a timestamp while the event is being published by a Coder replica to the usage collector service. Used to avoid duplicate publishes by multiple replicas. Timestamps older than 1 hour are considered expired.'; + +COMMENT ON COLUMN usage_events.published_at IS 'Set to a timestamp when the event is successfully (or permanently unsuccessfully) published to the usage collector service. If set, the event should never be attempted to be published again.'; + +COMMENT ON COLUMN usage_events.failure_message IS 'Set to an error message when the event is temporarily or permanently unsuccessfully published to the usage collector service.'; + +CREATE TABLE usage_events_daily ( + day date NOT NULL, + event_type text NOT NULL, + usage_data jsonb NOT NULL +); + +COMMENT ON TABLE usage_events_daily IS 'usage_events_daily is a daily rollup of usage events. It stores the total usage for each event type by day.'; + +COMMENT ON COLUMN usage_events_daily.day IS 'The date of the summed usage events, always in UTC.'; + CREATE TABLE user_configs ( user_id uuid NOT NULL, key character varying(256) NOT NULL, @@ -1861,6 +1934,18 @@ COMMENT ON COLUMN user_links.oauth_refresh_token_key_id IS 'The ID of the key us COMMENT ON COLUMN user_links.claims IS 'Claims from the IDP for the linked user. Includes both id_token and userinfo claims. '; +CREATE TABLE user_secrets ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + name text NOT NULL, + description text NOT NULL, + value text NOT NULL, + env_name text DEFAULT ''::text NOT NULL, + file_path text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + CREATE TABLE user_status_changes ( id uuid DEFAULT gen_random_uuid() NOT NULL, user_id uuid NOT NULL, @@ -2224,7 +2309,9 @@ CREATE TABLE workspace_builds ( template_version_preset_id uuid, has_ai_task boolean, ai_task_sidebar_app_id uuid, - 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)))) + 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))) ); CREATE VIEW workspace_build_with_user AS @@ -2245,6 +2332,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 @@ -2665,6 +2753,12 @@ ALTER TABLE ONLY template_versions ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); +ALTER TABLE ONLY usage_events_daily + ADD CONSTRAINT usage_events_daily_pkey PRIMARY KEY (day, event_type); + +ALTER TABLE ONLY usage_events + ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id); + ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key); @@ -2674,6 +2768,9 @@ ALTER TABLE ONLY user_deleted ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); +ALTER TABLE ONLY user_secrets + ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id); + ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); @@ -2830,6 +2927,8 @@ CREATE INDEX idx_template_versions_has_ai_task ON template_versions USING btree CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id); +CREATE INDEX idx_usage_events_select_for_publishing ON usage_events USING btree (published_at, publish_started_at, created_at); + CREATE INDEX idx_user_deleted_deleted_at ON user_deleted USING btree (deleted_at); CREATE INDEX idx_user_status_changes_changed_at ON user_status_changes USING btree (changed_at); @@ -2862,6 +2961,12 @@ CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree CREATE UNIQUE INDEX user_links_linked_id_login_type_idx ON user_links USING btree (linked_id, login_type) WHERE (linked_id <> ''::text); +CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets USING btree (user_id, env_name) WHERE (env_name <> ''::text); + +CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree (user_id, file_path) WHERE (file_path <> ''::text); + +CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name); + CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false); CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); @@ -2974,6 +3079,8 @@ CREATE TRIGGER tailnet_notify_peer_change AFTER INSERT OR DELETE OR UPDATE ON ta CREATE TRIGGER tailnet_notify_tunnel_change AFTER INSERT OR DELETE OR UPDATE ON tailnet_tunnels FOR EACH ROW EXECUTE FUNCTION tailnet_notify_tunnel_change(); +CREATE TRIGGER trigger_aggregate_usage_event AFTER INSERT ON usage_events FOR EACH ROW EXECUTE FUNCTION aggregate_usage_event(); + CREATE TRIGGER trigger_delete_group_members_on_org_member_delete BEFORE DELETE ON organization_members FOR EACH ROW EXECUTE FUNCTION delete_group_members_on_org_member_delete(); CREATE TRIGGER trigger_delete_oauth2_provider_app_token AFTER DELETE ON oauth2_provider_app_tokens FOR EACH ROW EXECUTE FUNCTION delete_deleted_oauth2_provider_app_token_api_key(); @@ -3167,6 +3274,9 @@ ALTER TABLE ONLY user_links ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY user_secrets + ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); diff --git a/coderd/database/errors.go b/coderd/database/errors.go index 0388ea2cbff49..9d0c3fee7e865 100644 --- a/coderd/database/errors.go +++ b/coderd/database/errors.go @@ -59,6 +59,28 @@ func IsForeignKeyViolation(err error, foreignKeyConstraints ...ForeignKeyConstra return false } +// IsCheckViolation checks if the error is due to a check violation. If one or +// more specific check constraints are given as arguments, the error must be +// caused by one of them. If no constraints are given, this function returns +// true for any check violation. +func IsCheckViolation(err error, checkConstraints ...CheckConstraint) bool { + var pqErr *pq.Error + if errors.As(err, &pqErr) { + if pqErr.Code.Name() == "check_violation" { + if len(checkConstraints) == 0 { + return true + } + for _, cc := range checkConstraints { + if pqErr.Constraint == string(cc) { + return true + } + } + } + } + + return false +} + // IsQueryCanceledError checks if the error is due to a query being canceled. func IsQueryCanceledError(err error) bool { var pqErr *pq.Error diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index c3aaf7342a97c..33aa8edd69032 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -63,6 +63,7 @@ const ( ForeignKeyUserLinksOauthAccessTokenKeyID ForeignKeyConstraint = "user_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "user_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyUserSecretsUserID ForeignKeyConstraint = "user_secrets_user_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentDevcontainersWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_devcontainers_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/gen/dump/main.go b/coderd/database/gen/dump/main.go index f99b69bdaef93..1d84339eecce9 100644 --- a/coderd/database/gen/dump/main.go +++ b/coderd/database/gen/dump/main.go @@ -19,6 +19,10 @@ type mockTB struct { cleanup []func() } +func (*mockTB) Name() string { + return "mockTB" +} + func (t *mockTB) Cleanup(f func()) { t.cleanup = append(t.cleanup, f) } diff --git a/coderd/database/migrations/000356_enforce_deadline_below_max_deadline.down.sql b/coderd/database/migrations/000356_enforce_deadline_below_max_deadline.down.sql new file mode 100644 index 0000000000000..a9b2b6ff7f459 --- /dev/null +++ b/coderd/database/migrations/000356_enforce_deadline_below_max_deadline.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE workspace_builds + DROP CONSTRAINT workspace_builds_deadline_below_max_deadline; diff --git a/coderd/database/migrations/000356_enforce_deadline_below_max_deadline.up.sql b/coderd/database/migrations/000356_enforce_deadline_below_max_deadline.up.sql new file mode 100644 index 0000000000000..00c36ddd0b5dd --- /dev/null +++ b/coderd/database/migrations/000356_enforce_deadline_below_max_deadline.up.sql @@ -0,0 +1,22 @@ +-- New constraint: (deadline IS NOT zero AND deadline <= max_deadline) UNLESS max_deadline is zero. +-- Unfortunately, "zero" here means `time.Time{}`... + +-- Update previous builds that would fail this new constraint. This matches the +-- intended behaviour of the autostop algorithm. +UPDATE + workspace_builds +SET + deadline = max_deadline +WHERE + (deadline = '0001-01-01 00:00:00+00'::timestamptz OR deadline > max_deadline) + AND max_deadline != '0001-01-01 00:00:00+00'::timestamptz; + +-- Add the new constraint. +ALTER TABLE workspace_builds + ADD CONSTRAINT workspace_builds_deadline_below_max_deadline + CHECK ( + -- (deadline is not zero AND deadline <= max_deadline)... + (deadline != '0001-01-01 00:00:00+00'::timestamptz AND deadline <= max_deadline) + -- UNLESS max_deadline is zero. + OR max_deadline = '0001-01-01 00:00:00+00'::timestamptz + ); diff --git a/coderd/database/migrations/000357_add_user_secrets.down.sql b/coderd/database/migrations/000357_add_user_secrets.down.sql new file mode 100644 index 0000000000000..67bd30002e23a --- /dev/null +++ b/coderd/database/migrations/000357_add_user_secrets.down.sql @@ -0,0 +1,7 @@ +-- Drop the unique indexes first (in reverse order of creation) +DROP INDEX IF EXISTS user_secrets_user_file_path_idx; +DROP INDEX IF EXISTS user_secrets_user_env_name_idx; +DROP INDEX IF EXISTS user_secrets_user_name_idx; + +-- Drop the table +DROP TABLE IF EXISTS user_secrets; diff --git a/coderd/database/migrations/000357_add_user_secrets.up.sql b/coderd/database/migrations/000357_add_user_secrets.up.sql new file mode 100644 index 0000000000000..8a4d398f490eb --- /dev/null +++ b/coderd/database/migrations/000357_add_user_secrets.up.sql @@ -0,0 +1,34 @@ +-- Stores encrypted user secrets (global, available across all organizations) +CREATE TABLE user_secrets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL, + + -- The encrypted secret value (base64-encoded encrypted data) + value TEXT NOT NULL, + + -- Auto-injection settings + -- Environment variable name (e.g., "DATABASE_PASSWORD", "API_KEY") + -- Empty string means don't inject as env var + env_name TEXT NOT NULL DEFAULT '', + + -- File path where secret should be written (e.g., "/home/coder/.ssh/id_rsa") + -- Empty string means don't inject as file + file_path TEXT NOT NULL DEFAULT '', + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +-- Unique constraint: user can't have duplicate secret names +CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets(user_id, name); + +-- Unique constraint: user can't have duplicate env names +CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets(user_id, env_name) +WHERE env_name != ''; + +-- Unique constraint: user can't have duplicate file paths +CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets(user_id, file_path) +WHERE file_path != ''; diff --git a/coderd/database/migrations/000358_failed_ext_auth_error.down.sql b/coderd/database/migrations/000358_failed_ext_auth_error.down.sql new file mode 100644 index 0000000000000..72cad82d36a1e --- /dev/null +++ b/coderd/database/migrations/000358_failed_ext_auth_error.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE external_auth_links + DROP COLUMN oauth_refresh_failure_reason +; diff --git a/coderd/database/migrations/000358_failed_ext_auth_error.up.sql b/coderd/database/migrations/000358_failed_ext_auth_error.up.sql new file mode 100644 index 0000000000000..f2030ecbeeca2 --- /dev/null +++ b/coderd/database/migrations/000358_failed_ext_auth_error.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE external_auth_links + ADD COLUMN oauth_refresh_failure_reason TEXT NOT NULL DEFAULT '' +; + +COMMENT ON COLUMN external_auth_links.oauth_refresh_failure_reason IS + 'This error means the refresh token is invalid. Cached so we can avoid calling the external provider again for the same error.' +; diff --git a/coderd/database/migrations/000359_create_usage_events_table.down.sql b/coderd/database/migrations/000359_create_usage_events_table.down.sql new file mode 100644 index 0000000000000..cb86155db10e8 --- /dev/null +++ b/coderd/database/migrations/000359_create_usage_events_table.down.sql @@ -0,0 +1 @@ +DROP TABLE usage_events; diff --git a/coderd/database/migrations/000359_create_usage_events_table.up.sql b/coderd/database/migrations/000359_create_usage_events_table.up.sql new file mode 100644 index 0000000000000..d03d4ad7414c9 --- /dev/null +++ b/coderd/database/migrations/000359_create_usage_events_table.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE usage_events ( + id TEXT PRIMARY KEY, + -- We use a TEXT column with a CHECK constraint rather than an enum because of + -- the limitations with adding new values to an enum and using them in the + -- same transaction. + event_type TEXT NOT NULL CONSTRAINT usage_event_type_check CHECK (event_type IN ('dc_managed_agents_v1')), + event_data JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + publish_started_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + published_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + failure_message TEXT DEFAULT NULL +); + +COMMENT ON TABLE usage_events IS 'usage_events contains usage data that is collected from the product and potentially shipped to the usage collector service.'; +COMMENT ON COLUMN usage_events.id IS 'For "discrete" event types, this is a random UUID. For "heartbeat" event types, this is a combination of the event type and a truncated timestamp.'; +COMMENT ON COLUMN usage_events.event_type IS 'The usage event type with version. "dc" means "discrete" (e.g. a single event, for counters), "hb" means "heartbeat" (e.g. a recurring event that contains a total count of usage generated from the database, for gauges).'; +COMMENT ON COLUMN usage_events.event_data IS 'Event payload. Determined by the matching usage struct for this event type.'; +COMMENT ON COLUMN usage_events.publish_started_at IS 'Set to a timestamp while the event is being published by a Coder replica to the usage collector service. Used to avoid duplicate publishes by multiple replicas. Timestamps older than 1 hour are considered expired.'; +COMMENT ON COLUMN usage_events.published_at IS 'Set to a timestamp when the event is successfully (or permanently unsuccessfully) published to the usage collector service. If set, the event should never be attempted to be published again.'; +COMMENT ON COLUMN usage_events.failure_message IS 'Set to an error message when the event is temporarily or permanently unsuccessfully published to the usage collector service.'; + +-- Create an index with all three fields used by the +-- SelectUsageEventsForPublishing query. +CREATE INDEX idx_usage_events_select_for_publishing + ON usage_events (published_at, publish_started_at, created_at); 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/migrations/000362_aggregate_usage_events.down.sql b/coderd/database/migrations/000362_aggregate_usage_events.down.sql new file mode 100644 index 0000000000000..ca49a1a3a2109 --- /dev/null +++ b/coderd/database/migrations/000362_aggregate_usage_events.down.sql @@ -0,0 +1,3 @@ +DROP TRIGGER IF EXISTS trigger_aggregate_usage_event ON usage_events; +DROP FUNCTION IF EXISTS aggregate_usage_event(); +DROP TABLE IF EXISTS usage_events_daily; diff --git a/coderd/database/migrations/000362_aggregate_usage_events.up.sql b/coderd/database/migrations/000362_aggregate_usage_events.up.sql new file mode 100644 index 0000000000000..58af0398eb766 --- /dev/null +++ b/coderd/database/migrations/000362_aggregate_usage_events.up.sql @@ -0,0 +1,65 @@ +CREATE TABLE usage_events_daily ( + day date NOT NULL, -- always grouped by day in UTC + event_type text NOT NULL, + usage_data jsonb NOT NULL, + PRIMARY KEY (day, event_type) +); + +COMMENT ON TABLE usage_events_daily IS 'usage_events_daily is a daily rollup of usage events. It stores the total usage for each event type by day.'; +COMMENT ON COLUMN usage_events_daily.day IS 'The date of the summed usage events, always in UTC.'; + +-- Function to handle usage event aggregation +CREATE OR REPLACE FUNCTION aggregate_usage_event() +RETURNS TRIGGER AS $$ +BEGIN + -- Check for supported event types and throw error for unknown types + IF NEW.event_type NOT IN ('dc_managed_agents_v1') THEN + RAISE EXCEPTION 'Unhandled usage event type in aggregate_usage_event: %', NEW.event_type; + END IF; + + INSERT INTO usage_events_daily (day, event_type, usage_data) + VALUES ( + -- Extract the date from the created_at timestamp, always using UTC for + -- consistency + date_trunc('day', NEW.created_at AT TIME ZONE 'UTC')::date, + NEW.event_type, + NEW.event_data + ) + ON CONFLICT (day, event_type) DO UPDATE SET + usage_data = CASE + -- Handle simple counter events by summing the count + WHEN NEW.event_type IN ('dc_managed_agents_v1') THEN + jsonb_build_object( + 'count', + COALESCE((usage_events_daily.usage_data->>'count')::bigint, 0) + + COALESCE((NEW.event_data->>'count')::bigint, 0) + ) + END; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger to automatically aggregate usage events +CREATE TRIGGER trigger_aggregate_usage_event + AFTER INSERT ON usage_events + FOR EACH ROW + EXECUTE FUNCTION aggregate_usage_event(); + +-- Populate usage_events_daily with existing data +INSERT INTO + usage_events_daily (day, event_type, usage_data) +SELECT + date_trunc('day', created_at AT TIME ZONE 'UTC')::date AS day, + event_type, + jsonb_build_object('count', SUM((event_data->>'count')::bigint)) AS usage_data +FROM + usage_events +WHERE + -- The only event type we currently support is dc_managed_agents_v1 + event_type = 'dc_managed_agents_v1' +GROUP BY + date_trunc('day', created_at AT TIME ZONE 'UTC')::date, + event_type +ON CONFLICT (day, event_type) DO UPDATE SET + usage_data = EXCLUDED.usage_data; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index f5d84e6532083..f31a3adb0eb3b 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -9,17 +9,20 @@ import ( "slices" "sync" "testing" + "time" "github.com/golang-migrate/migrate/v4" migratepostgres "github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/source" "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/golang-migrate/migrate/v4/source/stub" + "github.com/google/uuid" "github.com/lib/pq" "github.com/stretchr/testify/require" "go.uber.org/goleak" "golang.org/x/sync/errgroup" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/migrations" "github.com/coder/coder/v2/testutil" @@ -363,3 +366,106 @@ func TestMigrateUpWithFixtures(t *testing.T) { }) } } + +// TestMigration000362AggregateUsageEvents tests the migration that aggregates +// usage events into daily rows correctly. +func TestMigration000362AggregateUsageEvents(t *testing.T) { + t.Parallel() + + const migrationVersion = 362 + + // Similarly to the other test, this test will probably time out in CI. + ctx := testutil.Context(t, testutil.WaitSuperLong) + + sqlDB := testSQLDB(t) + db := database.New(sqlDB) + + // Migrate up to the migration before the one that aggregates usage events. + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + for { + version, more, err := next() + require.NoError(t, err) + if !more { + t.Fatalf("migration %d not found", migrationVersion) + } + if version == migrationVersion-1 { + break + } + } + + locSydney, err := time.LoadLocation("Australia/Sydney") + require.NoError(t, err) + + usageEvents := []struct { + // The only possible event type is dc_managed_agents_v1 when this + // migration gets applied. + eventData []byte + createdAt time.Time + }{ + { + eventData: []byte(`{"count": 41}`), + createdAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + eventData: []byte(`{"count": 1}`), + // 2025-01-01 in UTC + createdAt: time.Date(2025, 1, 2, 8, 38, 57, 0, locSydney), + }, + { + eventData: []byte(`{"count": 1}`), + createdAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), + }, + } + expectedDailyRows := []struct { + day time.Time + usageData []byte + }{ + { + day: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + usageData: []byte(`{"count": 42}`), + }, + { + day: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), + usageData: []byte(`{"count": 1}`), + }, + } + + for _, usageEvent := range usageEvents { + err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{ + ID: uuid.New().String(), + EventType: "dc_managed_agents_v1", + EventData: usageEvent.eventData, + CreatedAt: usageEvent.createdAt, + }) + require.NoError(t, err) + } + + // Migrate up to the migration that aggregates usage events. + version, _, err := next() + require.NoError(t, err) + require.EqualValues(t, migrationVersion, version) + + // Get all of the newly created daily rows. This query is not exposed in the + // querier interface intentionally. + rows, err := sqlDB.QueryContext(ctx, "SELECT day, event_type, usage_data FROM usage_events_daily ORDER BY day ASC") + require.NoError(t, err, "perform query") + defer rows.Close() + var out []database.UsageEventsDaily + for rows.Next() { + var row database.UsageEventsDaily + err := rows.Scan(&row.Day, &row.EventType, &row.UsageData) + require.NoError(t, err, "scan row") + out = append(out, row) + } + + // Verify that the daily rows match our expectations. + require.Len(t, out, len(expectedDailyRows)) + for i, row := range out { + require.Equal(t, "dc_managed_agents_v1", row.EventType) + // The read row might be `+0000` rather than `UTC` specifically, so just + // ensure it's within 1 second of the expected time. + require.WithinDuration(t, expectedDailyRows[i].day, row.Day, time.Second) + require.JSONEq(t, string(expectedDailyRows[i].usageData), string(row.UsageData)) + } +} diff --git a/coderd/database/migrations/testdata/fixtures/000357_add_user_secrets.up.sql b/coderd/database/migrations/testdata/fixtures/000357_add_user_secrets.up.sql new file mode 100644 index 0000000000000..a82ceb593b629 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000357_add_user_secrets.up.sql @@ -0,0 +1,18 @@ +INSERT INTO user_secrets ( + id, + user_id, + name, + description, + value, + env_name, + file_path +) +VALUES ( + '4848b19e-b392-4a1b-bc7d-0b7ffb41ef87', + '30095c71-380b-457a-8995-97b8ee6e5307', + 'secret-name', + 'secret description', + 'secret value', + 'SECRET_ENV_NAME', + '~/secret/file/path' +); diff --git a/coderd/database/migrations/testdata/fixtures/000359_create_usage_events_table.up.sql b/coderd/database/migrations/testdata/fixtures/000359_create_usage_events_table.up.sql new file mode 100644 index 0000000000000..aa7c53f5eb94c --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000359_create_usage_events_table.up.sql @@ -0,0 +1,60 @@ +INSERT INTO usage_events ( + id, + event_type, + event_data, + created_at, + publish_started_at, + published_at, + failure_message +) +VALUES +-- Unpublished dc_managed_agents_v1 event. +( + 'event1', + 'dc_managed_agents_v1', + '{"count":1}', + '2023-01-01 00:00:00+00', + NULL, + NULL, + NULL +), +-- Successfully published dc_managed_agents_v1 event. +( + 'event2', + 'dc_managed_agents_v1', + '{"count":2}', + '2023-01-01 00:00:00+00', + NULL, + '2023-01-01 00:00:02+00', + NULL +), +-- Publish in progress dc_managed_agents_v1 event. +( + 'event3', + 'dc_managed_agents_v1', + '{"count":3}', + '2023-01-01 00:00:00+00', + '2023-01-01 00:00:01+00', + NULL, + NULL +), +-- Temporarily failed to publish dc_managed_agents_v1 event. +( + 'event4', + 'dc_managed_agents_v1', + '{"count":4}', + '2023-01-01 00:00:00+00', + NULL, + NULL, + 'publish failed temporarily' +), +-- Permanently failed to publish dc_managed_agents_v1 event. +( + 'event5', + 'dc_managed_agents_v1', + '{"count":5}', + '2023-01-01 00:00:00+00', + NULL, + '2023-01-01 00:00:02+00', + 'publish failed permanently' +) diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index caf7ccce4c6a7..e080c7d7e4217 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -632,3 +632,7 @@ func (m WorkspaceAgentVolumeResourceMonitor) Debounce( return m.DebouncedUntil, false } + +func (s UserSecret) RBACObject() rbac.Object { + return rbac.ResourceUserSecret.WithID(s.ID).WithOwner(s.UserID.String()) +} diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 2a0abbccfdd9b..69bea8d81adab 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -82,6 +82,9 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, + arg.AuthorID, + arg.AuthorUsername, + arg.HasExternalAgent, ) if err != nil { return nil, err @@ -269,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, @@ -319,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 8b13c8a8af057..99107713b080b 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3065,6 +3065,8 @@ type ExternalAuthLink struct { // The ID of the key used to encrypt the OAuth refresh token. If this is NULL, the refresh token is not encrypted OAuthRefreshTokenKeyID sql.NullString `db:"oauth_refresh_token_key_id" json:"oauth_refresh_token_key_id"` OAuthExtra pqtype.NullRawMessage `db:"oauth_extra" json:"oauth_extra"` + // This error means the refresh token is invalid. Cached so we can avoid calling the external provider again for the same error. + OauthRefreshFailureReason string `db:"oauth_refresh_failure_reason" json:"oauth_refresh_failure_reason"` } type File struct { @@ -3632,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"` @@ -3718,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 { @@ -3757,6 +3761,31 @@ type TemplateVersionWorkspaceTag struct { Value string `db:"value" json:"value"` } +// usage_events contains usage data that is collected from the product and potentially shipped to the usage collector service. +type UsageEvent struct { + // For "discrete" event types, this is a random UUID. For "heartbeat" event types, this is a combination of the event type and a truncated timestamp. + ID string `db:"id" json:"id"` + // The usage event type with version. "dc" means "discrete" (e.g. a single event, for counters), "hb" means "heartbeat" (e.g. a recurring event that contains a total count of usage generated from the database, for gauges). + EventType string `db:"event_type" json:"event_type"` + // Event payload. Determined by the matching usage struct for this event type. + EventData json.RawMessage `db:"event_data" json:"event_data"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + // Set to a timestamp while the event is being published by a Coder replica to the usage collector service. Used to avoid duplicate publishes by multiple replicas. Timestamps older than 1 hour are considered expired. + PublishStartedAt sql.NullTime `db:"publish_started_at" json:"publish_started_at"` + // Set to a timestamp when the event is successfully (or permanently unsuccessfully) published to the usage collector service. If set, the event should never be attempted to be published again. + PublishedAt sql.NullTime `db:"published_at" json:"published_at"` + // Set to an error message when the event is temporarily or permanently unsuccessfully published to the usage collector service. + FailureMessage sql.NullString `db:"failure_message" json:"failure_message"` +} + +// usage_events_daily is a daily rollup of usage events. It stores the total usage for each event type by day. +type UsageEventsDaily struct { + // The date of the summed usage events, always in UTC. + Day time.Time `db:"day" json:"day"` + EventType string `db:"event_type" json:"event_type"` + UsageData json.RawMessage `db:"usage_data" json:"usage_data"` +} + type User struct { ID uuid.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` @@ -3812,6 +3841,18 @@ type UserLink struct { Claims UserLinkClaims `db:"claims" json:"claims"` } +type UserSecret struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description string `db:"description" json:"description"` + Value string `db:"value" json:"value"` + EnvName string `db:"env_name" json:"env_name"` + FilePath string `db:"file_path" json:"file_path"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + // Tracks the history of user status changes type UserStatusChange struct { ID uuid.UUID `db:"id" json:"id"` @@ -4142,6 +4183,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"` @@ -4173,6 +4215,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 d812ff1a96de9..f0b5cb6db463a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -71,6 +71,7 @@ type sqlcQuerier interface { // Prebuild considered in-progress if it's in the "starting", "stopping", or "deleting" state. CountInProgressPrebuilds(ctx context.Context) ([]CountInProgressPrebuildsRow, error) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) + CreateUserSecret(ctx context.Context, arg CreateUserSecretParams) (UserSecret, error) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error @@ -118,6 +119,7 @@ type sqlcQuerier interface { DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) + DeleteUserSecret(ctx context.Context, id uuid.UUID) error DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error @@ -135,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) @@ -211,13 +218,10 @@ type sqlcQuerier interface { GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) - GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) GetLicenseByID(ctx context.Context, id int32) (License, error) GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) - // This isn't strictly a license query, but it's related to license enforcement. - GetManagedAgentCount(ctx context.Context, arg GetManagedAgentCountParams) (int64, error) GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, error) // Fetch the notification report generator log indicating recent activity. GetNotificationReportGeneratorLogByTemplate(ctx context.Context, templateID uuid.UUID) (NotificationReportGeneratorLog, error) @@ -300,6 +304,9 @@ type sqlcQuerier interface { GetProvisionerLogsAfterID(ctx context.Context, arg GetProvisionerLogsAfterIDParams) ([]ProvisionerJobLog, error) GetQuotaAllowanceForUser(ctx context.Context, arg GetQuotaAllowanceForUserParams) (int64, error) GetQuotaConsumedForUser(ctx context.Context, arg GetQuotaConsumedForUserParams) (int64, error) + // Count regular workspaces: only those whose first successful 'start' build + // was not initiated by the prebuild system user. + GetRegularWorkspaceCreateMetrics(ctx context.Context) ([]GetRegularWorkspaceCreateMetricsRow, error) GetReplicaByID(ctx context.Context, id uuid.UUID) (Replica, error) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) GetRunningPrebuiltWorkspaces(ctx context.Context) ([]GetRunningPrebuiltWorkspacesRow, error) @@ -353,6 +360,7 @@ type sqlcQuerier interface { GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error) GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error) + GetTemplateVersionHasAITask(ctx context.Context, id uuid.UUID) (bool, error) GetTemplateVersionParameters(ctx context.Context, templateVersionID uuid.UUID) ([]TemplateVersionParameter, error) GetTemplateVersionTerraformValues(ctx context.Context, templateVersionID uuid.UUID) (TemplateVersionTerraformValue, error) GetTemplateVersionVariables(ctx context.Context, templateVersionID uuid.UUID) ([]TemplateVersionVariable, error) @@ -362,6 +370,15 @@ type sqlcQuerier interface { GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error) GetTemplates(ctx context.Context) ([]Template, error) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) + // Gets the total number of managed agents created between two dates. Uses the + // aggregate table to avoid large scans or a complex index on the usage_events + // table. + // + // This has the trade off that we can't count accurately between two exact + // timestamps. The provided timestamps will be converted to UTC and truncated to + // the events that happened on and between the two dates. Both dates are + // inclusive. + GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg GetTotalUsageDCManagedAgentsV1Params) (int64, error) GetUnexpiredLicenses(ctx context.Context) ([]License, error) // GetUserActivityInsights returns the ranking with top active users. // The result can be filtered on template_ids, meaning only user data @@ -383,6 +400,8 @@ type sqlcQuerier interface { GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) + GetUserSecret(ctx context.Context, id uuid.UUID) (UserSecret, error) + GetUserSecretByUserIDAndName(ctx context.Context, arg GetUserSecretByUserIDAndNameParams) (UserSecret, error) // GetUserStatusCounts returns the count of users in each status over time. // The time range is inclusively defined by the start_time and end_time parameters. // @@ -407,6 +426,7 @@ type sqlcQuerier interface { GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) GetWebpushSubscriptionsByUserID(ctx context.Context, userID uuid.UUID) ([]WebpushSubscription, error) GetWebpushVAPIDKeys(ctx context.Context) (GetWebpushVAPIDKeysRow, error) + GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (GetWorkspaceACLByIDRow, error) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error) @@ -473,8 +493,6 @@ type sqlcQuerier interface { GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]GetWorkspacesAndAgentsByOwnerIDRow, error) GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) - // Determines if the template versions table has any rows with has_ai_task = TRUE. - HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) // We use the organization_id as the id // for simplicity since all users is @@ -520,6 +538,9 @@ type sqlcQuerier interface { InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error) InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error) + // Duplicate events are ignored intentionally to allow for multiple replicas to + // publish heartbeat events. + InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error InsertUser(ctx context.Context, arg InsertUserParams) (User, error) // InsertUserGroupsByID adds a user to all provided groups, if they exist. // If there is a conflict, the user is already a member @@ -548,6 +569,7 @@ type sqlcQuerier interface { InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error) ListProvisionerKeysByOrganizationExcludeReserved(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error) + ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]UserSecret, error) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error) MarkAllInboxNotificationsAsRead(ctx context.Context, arg MarkAllInboxNotificationsAsReadParams) error OIDCClaimFieldValues(ctx context.Context, arg OIDCClaimFieldValuesParams) ([]string, error) @@ -565,6 +587,11 @@ type sqlcQuerier interface { RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error RemoveUserFromGroups(ctx context.Context, arg RemoveUserFromGroupsParams) ([]uuid.UUID, error) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error + // Note that this selects from the CTE, not the original table. The CTE is named + // the same as the original table to trick sqlc into reusing the existing struct + // for the table. + // The CTE and the reorder is required because UPDATE doesn't guarantee order. + SelectUsageEventsForPublishing(ctx context.Context, now time.Time) ([]UsageEvent, error) // Non blocking lock. Returns true if the lock was acquired, false otherwise. // // This must be called from within a transaction. The lock will be automatically @@ -606,11 +633,12 @@ 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 UpdateUserGithubComUserID(ctx context.Context, arg UpdateUserGithubComUserIDParams) error UpdateUserHashedOneTimePasscode(ctx context.Context, arg UpdateUserHashedOneTimePasscodeParams) error @@ -623,6 +651,7 @@ type sqlcQuerier interface { UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) + UpdateUserSecret(ctx context.Context, arg UpdateUserSecretParams) (UserSecret, error) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error) UpdateUserThemePreference(ctx context.Context, arg UpdateUserThemePreferenceParams) (UserConfig, error) @@ -637,9 +666,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) @@ -691,6 +720,8 @@ type sqlcQuerier interface { // was started. This means that a new row was inserted (no previous session) or // the updated_at is older than stale interval. UpsertWorkspaceAppAuditSession(ctx context.Context, arg UpsertWorkspaceAppAuditSessionParams) (bool, error) + ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (ValidateGroupIDsRow, error) + ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (ValidateUserIDsRow, error) } var _ sqlcQuerier = (*sqlQuerier)(nil) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 9c88b9b3db679..c7daaaed356d3 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -32,6 +32,7 @@ import ( "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/testutil" @@ -397,6 +398,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 +432,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 +466,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 +479,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 +1780,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") @@ -6003,3 +6234,549 @@ func TestGetRunningPrebuiltWorkspaces(t *testing.T) { require.Len(t, runningPrebuilds, 1, "expected only one running prebuilt workspace") require.Equal(t, runningPrebuild.ID, runningPrebuilds[0].ID, "expected the running prebuilt workspace to be returned") } + +func TestUserSecretsCRUDOperations(t *testing.T) { + t.Parallel() + + // Use raw database without dbauthz wrapper for this test + db, _ := dbtestutil.NewDB(t) + + t.Run("FullCRUDWorkflow", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + // Create a new user for this test + testUser := dbgen.User(t, db, database.User{}) + + // 1. CREATE + secretID := uuid.New() + createParams := database.CreateUserSecretParams{ + ID: secretID, + UserID: testUser.ID, + Name: "workflow-secret", + Description: "Secret for full CRUD workflow", + Value: "workflow-value", + EnvName: "WORKFLOW_ENV", + FilePath: "/workflow/path", + } + + createdSecret, err := db.CreateUserSecret(ctx, createParams) + require.NoError(t, err) + assert.Equal(t, secretID, createdSecret.ID) + + // 2. READ by ID + readSecret, err := db.GetUserSecret(ctx, createdSecret.ID) + require.NoError(t, err) + assert.Equal(t, createdSecret.ID, readSecret.ID) + assert.Equal(t, "workflow-secret", readSecret.Name) + + // 3. READ by UserID and Name + readByNameParams := database.GetUserSecretByUserIDAndNameParams{ + UserID: testUser.ID, + Name: "workflow-secret", + } + readByNameSecret, err := db.GetUserSecretByUserIDAndName(ctx, readByNameParams) + require.NoError(t, err) + assert.Equal(t, createdSecret.ID, readByNameSecret.ID) + + // 4. LIST + secrets, err := db.ListUserSecrets(ctx, testUser.ID) + require.NoError(t, err) + require.Len(t, secrets, 1) + assert.Equal(t, createdSecret.ID, secrets[0].ID) + + // 5. UPDATE + updateParams := database.UpdateUserSecretParams{ + ID: createdSecret.ID, + Description: "Updated workflow description", + Value: "updated-workflow-value", + EnvName: "UPDATED_WORKFLOW_ENV", + FilePath: "/updated/workflow/path", + } + + updatedSecret, err := db.UpdateUserSecret(ctx, updateParams) + require.NoError(t, err) + assert.Equal(t, "Updated workflow description", updatedSecret.Description) + assert.Equal(t, "updated-workflow-value", updatedSecret.Value) + + // 6. DELETE + err = db.DeleteUserSecret(ctx, createdSecret.ID) + require.NoError(t, err) + + // Verify deletion + _, err = db.GetUserSecret(ctx, createdSecret.ID) + require.Error(t, err) + assert.Contains(t, err.Error(), "no rows in result set") + + // Verify list is empty + secrets, err = db.ListUserSecrets(ctx, testUser.ID) + require.NoError(t, err) + assert.Len(t, secrets, 0) + }) + + t.Run("UniqueConstraints", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + // Create a new user for this test + testUser := dbgen.User(t, db, database.User{}) + + // Create first secret + secret1 := dbgen.UserSecret(t, db, database.UserSecret{ + UserID: testUser.ID, + Name: "unique-test", + Description: "First secret", + Value: "value1", + EnvName: "UNIQUE_ENV", + FilePath: "/unique/path", + }) + + // Try to create another secret with the same name (should fail) + _, err := db.CreateUserSecret(ctx, database.CreateUserSecretParams{ + UserID: testUser.ID, + Name: "unique-test", // Same name + Description: "Second secret", + Value: "value2", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate key value") + + // Try to create another secret with the same env_name (should fail) + _, err = db.CreateUserSecret(ctx, database.CreateUserSecretParams{ + UserID: testUser.ID, + Name: "unique-test-2", + Description: "Second secret", + Value: "value2", + EnvName: "UNIQUE_ENV", // Same env_name + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate key value") + + // Try to create another secret with the same file_path (should fail) + _, err = db.CreateUserSecret(ctx, database.CreateUserSecretParams{ + UserID: testUser.ID, + Name: "unique-test-3", + Description: "Second secret", + Value: "value2", + FilePath: "/unique/path", // Same file_path + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate key value") + + // Create secret with empty env_name and file_path (should succeed) + secret2 := dbgen.UserSecret(t, db, database.UserSecret{ + UserID: testUser.ID, + Name: "unique-test-4", + Description: "Second secret", + Value: "value2", + EnvName: "", // Empty env_name + FilePath: "", // Empty file_path + }) + + // Verify both secrets exist + _, err = db.GetUserSecret(ctx, secret1.ID) + require.NoError(t, err) + _, err = db.GetUserSecret(ctx, secret2.ID) + require.NoError(t, err) + }) +} + +func TestUserSecretsAuthorization(t *testing.T) { + t.Parallel() + + // Use raw database and wrap with dbauthz for authorization testing + db, _ := dbtestutil.NewDB(t) + authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()) + authDB := dbauthz.New(db, authorizer, slogtest.Make(t, &slogtest.Options{}), coderdtest.AccessControlStorePointer()) + + // Create test users + user1 := dbgen.User(t, db, database.User{}) + user2 := dbgen.User(t, db, database.User{}) + owner := dbgen.User(t, db, database.User{}) + orgAdmin := dbgen.User(t, db, database.User{}) + + // Create organization for org-scoped roles + org := dbgen.Organization(t, db, database.Organization{}) + + // Create secrets for users + user1Secret := dbgen.UserSecret(t, db, database.UserSecret{ + UserID: user1.ID, + Name: "user1-secret", + Description: "User 1's secret", + Value: "user1-value", + }) + + user2Secret := dbgen.UserSecret(t, db, database.UserSecret{ + UserID: user2.ID, + Name: "user2-secret", + Description: "User 2's secret", + Value: "user2-value", + }) + + testCases := []struct { + name string + subject rbac.Subject + secretID uuid.UUID + expectedAccess bool + }{ + { + name: "UserCanAccessOwnSecrets", + subject: rbac.Subject{ + ID: user1.ID.String(), + Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, + Scope: rbac.ScopeAll, + }, + secretID: user1Secret.ID, + expectedAccess: true, + }, + { + name: "UserCannotAccessOtherUserSecrets", + subject: rbac.Subject{ + ID: user1.ID.String(), + Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, + Scope: rbac.ScopeAll, + }, + secretID: user2Secret.ID, + expectedAccess: false, + }, + { + name: "OwnerCannotAccessUserSecrets", + subject: rbac.Subject{ + ID: owner.ID.String(), + Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, + Scope: rbac.ScopeAll, + }, + secretID: user1Secret.ID, + expectedAccess: false, + }, + { + name: "OrgAdminCannotAccessUserSecrets", + subject: rbac.Subject{ + ID: orgAdmin.ID.String(), + Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgAdmin(org.ID)}, + Scope: rbac.ScopeAll, + }, + secretID: user1Secret.ID, + expectedAccess: false, + }, + } + + for _, tc := range testCases { + tc := tc // capture range variable + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + authCtx := dbauthz.As(ctx, tc.subject) + + // Test GetUserSecret + _, err := authDB.GetUserSecret(authCtx, tc.secretID) + + if tc.expectedAccess { + require.NoError(t, err, "expected access to be granted") + } else { + require.Error(t, err, "expected access to be denied") + assert.True(t, dbauthz.IsNotAuthorizedError(err), "expected authorization error") + } + }) + } +} + +func TestWorkspaceBuildDeadlineConstraint(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + template := dbgen.Template(t, db, database.Template{ + CreatedBy: user.ID, + OrganizationID: org.ID, + }) + templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + TemplateID: template.ID, + Name: "test-workspace", + Deleted: false, + }) + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + InitiatorID: database.PrebuildsSystemUserID, + Provisioner: database.ProvisionerTypeEcho, + Type: database.ProvisionerJobTypeWorkspaceBuild, + StartedAt: sql.NullTime{Time: time.Now().Add(-time.Minute), Valid: true}, + CompletedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) + workspaceBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + TemplateVersionID: templateVersion.ID, + JobID: job.ID, + BuildNumber: 1, + }) + + cases := []struct { + name string + deadline time.Time + maxDeadline time.Time + expectOK bool + }{ + { + name: "no deadline or max_deadline", + deadline: time.Time{}, + maxDeadline: time.Time{}, + expectOK: true, + }, + { + name: "deadline set when max_deadline is not set", + deadline: time.Now().Add(time.Hour), + maxDeadline: time.Time{}, + expectOK: true, + }, + { + name: "deadline before max_deadline", + deadline: time.Now().Add(-time.Hour), + maxDeadline: time.Now().Add(time.Hour), + expectOK: true, + }, + { + name: "deadline is max_deadline", + deadline: time.Now().Add(time.Hour), + maxDeadline: time.Now().Add(time.Hour), + expectOK: true, + }, + + { + name: "deadline after max_deadline", + deadline: time.Now().Add(time.Hour), + maxDeadline: time.Now().Add(-time.Hour), + expectOK: false, + }, + { + name: "deadline is not set when max_deadline is set", + deadline: time.Time{}, + maxDeadline: time.Now().Add(time.Hour), + expectOK: false, + }, + } + + for _, c := range cases { + err := db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ + ID: workspaceBuild.ID, + Deadline: c.deadline, + MaxDeadline: c.maxDeadline, + UpdatedAt: time.Now(), + }) + if c.expectOK { + require.NoError(t, err) + } else { + require.Error(t, err) + require.True(t, database.IsCheckViolation(err, database.CheckWorkspaceBuildsDeadlineBelowMaxDeadline)) + } + } +} + +// TestGetLatestWorkspaceBuildsByWorkspaceIDs populates the database with +// workspaces and builds. It then tests that +// GetLatestWorkspaceBuildsByWorkspaceIDs returns the latest build for some +// subset of the workspaces. +func TestGetLatestWorkspaceBuildsByWorkspaceIDs(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + + org := dbgen.Organization(t, db, database.Organization{}) + admin := dbgen.User(t, db, database.User{}) + + tv := dbfake.TemplateVersion(t, db). + Seed(database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: admin.ID, + }). + Do() + + users := make([]database.User, 5) + wrks := make([][]database.WorkspaceTable, len(users)) + exp := make(map[uuid.UUID]database.WorkspaceBuild) + for i := range users { + users[i] = dbgen.User(t, db, database.User{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: users[i].ID, + OrganizationID: org.ID, + }) + + // Each user gets 2 workspaces. + wrks[i] = make([]database.WorkspaceTable, 2) + for wi := range wrks[i] { + wrks[i][wi] = dbgen.Workspace(t, db, database.WorkspaceTable{ + TemplateID: tv.Template.ID, + OwnerID: users[i].ID, + }) + + // Choose a deterministic number of builds per workspace + // No more than 5 builds though, that would be excessive. + for j := int32(1); int(j) <= (i+wi)%5; j++ { + wb := dbfake.WorkspaceBuild(t, db, wrks[i][wi]). + Seed(database.WorkspaceBuild{ + WorkspaceID: wrks[i][wi].ID, + BuildNumber: j + 1, + }). + Do() + + exp[wrks[i][wi].ID] = wb.Build // Save the final workspace build + } + } + } + + // Only take half the users. And only take 1 workspace per user for the test. + // The others are just noice. This just queries a subset of workspaces and builds + // to make sure the noise doesn't interfere with the results. + assertWrks := wrks[:len(users)/2] + ctx := testutil.Context(t, testutil.WaitLong) + ids := slice.Convert[[]database.WorkspaceTable, uuid.UUID](assertWrks, func(pair []database.WorkspaceTable) uuid.UUID { + return pair[0].ID + }) + + require.Greater(t, len(ids), 0, "expected some workspace ids for test") + builds, err := db.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, ids) + require.NoError(t, err) + for _, b := range builds { + expB, ok := exp[b.WorkspaceID] + require.Truef(t, ok, "unexpected workspace build for workspace id %s", b.WorkspaceID) + require.Equalf(t, expB.ID, b.ID, "unexpected workspace build id for workspace id %s", b.WorkspaceID) + require.Equal(t, expB.BuildNumber, b.BuildNumber, "unexpected build number") + } +} + +func TestUsageEventsTrigger(t *testing.T) { + t.Parallel() + + // This is not exposed in the querier interface intentionally. + getDailyRows := func(ctx context.Context, sqlDB *sql.DB) []database.UsageEventsDaily { + t.Helper() + rows, err := sqlDB.QueryContext(ctx, "SELECT day, event_type, usage_data FROM usage_events_daily ORDER BY day ASC") + require.NoError(t, err, "perform query") + defer rows.Close() + + var out []database.UsageEventsDaily + for rows.Next() { + var row database.UsageEventsDaily + err := rows.Scan(&row.Day, &row.EventType, &row.UsageData) + require.NoError(t, err, "scan row") + out = append(out, row) + } + return out + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + + // Assert there are no daily rows. + rows := getDailyRows(ctx, sqlDB) + require.Len(t, rows, 0) + + // Insert a usage event. + err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{ + ID: "1", + EventType: "dc_managed_agents_v1", + EventData: []byte(`{"count": 41}`), + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + + // Assert there is one daily row that contains the correct data. + rows = getDailyRows(ctx, sqlDB) + require.Len(t, rows, 1) + require.Equal(t, "dc_managed_agents_v1", rows[0].EventType) + require.JSONEq(t, `{"count": 41}`, string(rows[0].UsageData)) + // The read row might be `+0000` rather than `UTC` specifically, so just + // ensure it's within 1 second of the expected time. + require.WithinDuration(t, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), rows[0].Day, time.Second) + + // Insert a new usage event on the same UTC day, should increment the count. + locSydney, err := time.LoadLocation("Australia/Sydney") + require.NoError(t, err) + err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{ + ID: "2", + EventType: "dc_managed_agents_v1", + EventData: []byte(`{"count": 1}`), + // Insert it at a random point during the same day. Sydney is +1000 or + // +1100, so 8am in Sydney is the previous day in UTC. + CreatedAt: time.Date(2025, 1, 2, 8, 38, 57, 0, locSydney), + }) + require.NoError(t, err) + + // There should still be only one daily row with the incremented count. + rows = getDailyRows(ctx, sqlDB) + require.Len(t, rows, 1) + require.Equal(t, "dc_managed_agents_v1", rows[0].EventType) + require.JSONEq(t, `{"count": 42}`, string(rows[0].UsageData)) + require.WithinDuration(t, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), rows[0].Day, time.Second) + + // TODO: when we have a new event type, we should test that adding an + // event with a different event type on the same day creates a new daily + // row. + + // Insert a new usage event on a different day, should create a new daily + // row. + err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{ + ID: "3", + EventType: "dc_managed_agents_v1", + EventData: []byte(`{"count": 1}`), + CreatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + + // There should now be two daily rows. + rows = getDailyRows(ctx, sqlDB) + require.Len(t, rows, 2) + // Output is sorted by day ascending, so the first row should be the + // previous day's row. + require.Equal(t, "dc_managed_agents_v1", rows[0].EventType) + require.JSONEq(t, `{"count": 42}`, string(rows[0].UsageData)) + require.WithinDuration(t, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), rows[0].Day, time.Second) + require.Equal(t, "dc_managed_agents_v1", rows[1].EventType) + require.JSONEq(t, `{"count": 1}`, string(rows[1].UsageData)) + require.WithinDuration(t, time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), rows[1].Day, time.Second) + }) + + t.Run("UnknownEventType", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + + // Relax the usage_events.event_type check constraint to see what + // happens when we insert a usage event that the trigger doesn't know + // about. + _, err := sqlDB.ExecContext(ctx, "ALTER TABLE usage_events DROP CONSTRAINT usage_event_type_check") + require.NoError(t, err) + + // Insert a usage event with an unknown event type. + err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{ + ID: "broken", + EventType: "dean's cool event", + EventData: []byte(`{"my": "cool json"}`), + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + }) + require.ErrorContains(t, err, "Unhandled usage event type in aggregate_usage_event") + + // The event should've been blocked. + var count int + err = sqlDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM usage_events WHERE id = 'broken'").Scan(&count) + require.NoError(t, err) + require.Equal(t, 0, count) + + // We should not have any daily rows. + rows := getDailyRows(ctx, sqlDB) + require.Len(t, rows, 0) + }) +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a7b61d6eabd50..78f61ee59e673 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 ) @@ -1711,7 +1715,7 @@ func (q *sqlQuerier) DeleteExternalAuthLink(ctx context.Context, arg DeleteExter } const getExternalAuthLink = `-- name: GetExternalAuthLink :one -SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, oauth_extra FROM external_auth_links WHERE provider_id = $1 AND user_id = $2 +SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, oauth_extra, oauth_refresh_failure_reason FROM external_auth_links WHERE provider_id = $1 AND user_id = $2 ` type GetExternalAuthLinkParams struct { @@ -1733,12 +1737,13 @@ func (q *sqlQuerier) GetExternalAuthLink(ctx context.Context, arg GetExternalAut &i.OAuthAccessTokenKeyID, &i.OAuthRefreshTokenKeyID, &i.OAuthExtra, + &i.OauthRefreshFailureReason, ) return i, err } const getExternalAuthLinksByUserID = `-- name: GetExternalAuthLinksByUserID :many -SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, oauth_extra FROM external_auth_links WHERE user_id = $1 +SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, oauth_extra, oauth_refresh_failure_reason FROM external_auth_links WHERE user_id = $1 ` func (q *sqlQuerier) GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]ExternalAuthLink, error) { @@ -1761,6 +1766,7 @@ func (q *sqlQuerier) GetExternalAuthLinksByUserID(ctx context.Context, userID uu &i.OAuthAccessTokenKeyID, &i.OAuthRefreshTokenKeyID, &i.OAuthExtra, + &i.OauthRefreshFailureReason, ); err != nil { return nil, err } @@ -1798,7 +1804,7 @@ INSERT INTO external_auth_links ( $8, $9, $10 -) RETURNING provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, oauth_extra +) RETURNING provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, oauth_extra, oauth_refresh_failure_reason ` type InsertExternalAuthLinkParams struct { @@ -1839,6 +1845,7 @@ func (q *sqlQuerier) InsertExternalAuthLink(ctx context.Context, arg InsertExter &i.OAuthAccessTokenKeyID, &i.OAuthRefreshTokenKeyID, &i.OAuthExtra, + &i.OauthRefreshFailureReason, ) return i, err } @@ -1851,8 +1858,12 @@ UPDATE external_auth_links SET oauth_refresh_token = $6, oauth_refresh_token_key_id = $7, oauth_expiry = $8, - oauth_extra = $9 -WHERE provider_id = $1 AND user_id = $2 RETURNING provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, oauth_extra + oauth_extra = $9, + -- Only 'UpdateExternalAuthLinkRefreshToken' supports updating the oauth_refresh_failure_reason. + -- Any updates to the external auth link, will be assumed to change the state and clear + -- any cached errors. + oauth_refresh_failure_reason = '' +WHERE provider_id = $1 AND user_id = $2 RETURNING provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, oauth_extra, oauth_refresh_failure_reason ` type UpdateExternalAuthLinkParams struct { @@ -1891,6 +1902,7 @@ func (q *sqlQuerier) UpdateExternalAuthLink(ctx context.Context, arg UpdateExter &i.OAuthAccessTokenKeyID, &i.OAuthRefreshTokenKeyID, &i.OAuthExtra, + &i.OauthRefreshFailureReason, ) return i, err } @@ -1899,27 +1911,32 @@ const updateExternalAuthLinkRefreshToken = `-- name: UpdateExternalAuthLinkRefre UPDATE external_auth_links SET - oauth_refresh_token = $1, - updated_at = $2 + -- oauth_refresh_failure_reason can be set to cache the failure reason + -- for subsequent refresh attempts. + oauth_refresh_failure_reason = $1, + oauth_refresh_token = $2, + updated_at = $3 WHERE - provider_id = $3 + provider_id = $4 AND - user_id = $4 + user_id = $5 AND -- Required for sqlc to generate a parameter for the oauth_refresh_token_key_id - $5 :: text = $5 :: text + $6 :: text = $6 :: text ` type UpdateExternalAuthLinkRefreshTokenParams struct { - OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ProviderID string `db:"provider_id" json:"provider_id"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - OAuthRefreshTokenKeyID string `db:"oauth_refresh_token_key_id" json:"oauth_refresh_token_key_id"` + OauthRefreshFailureReason string `db:"oauth_refresh_failure_reason" json:"oauth_refresh_failure_reason"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ProviderID string `db:"provider_id" json:"provider_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + OAuthRefreshTokenKeyID string `db:"oauth_refresh_token_key_id" json:"oauth_refresh_token_key_id"` } func (q *sqlQuerier) UpdateExternalAuthLinkRefreshToken(ctx context.Context, arg UpdateExternalAuthLinkRefreshTokenParams) error { _, err := q.db.ExecContext(ctx, updateExternalAuthLinkRefreshToken, + arg.OauthRefreshFailureReason, arg.OAuthRefreshToken, arg.UpdatedAt, arg.ProviderID, @@ -2869,6 +2886,37 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar return i, err } +const validateGroupIDs = `-- name: ValidateGroupIDs :one +WITH input AS ( + SELECT + unnest($1::uuid[]) AS id +) +SELECT + array_agg(input.id)::uuid[] as invalid_group_ids, + COUNT(*) = 0 as ok +FROM + -- Preserve rows where there is not a matching left (groups) row for each + -- right (input) row... + groups + RIGHT JOIN input ON groups.id = input.id +WHERE + -- ...so that we can retain exactly those rows where an input ID does not + -- match an existing group. + groups.id IS NULL +` + +type ValidateGroupIDsRow struct { + InvalidGroupIds []uuid.UUID `db:"invalid_group_ids" json:"invalid_group_ids"` + Ok bool `db:"ok" json:"ok"` +} + +func (q *sqlQuerier) ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (ValidateGroupIDsRow, error) { + row := q.db.QueryRowContext(ctx, validateGroupIDs, pq.Array(groupIds)) + var i ValidateGroupIDsRow + err := row.Scan(pq.Array(&i.InvalidGroupIds), &i.Ok) + return i, err +} + const getTemplateAppInsights = `-- name: GetTemplateAppInsights :many WITH -- Create a list of all unique apps by template, this is used to @@ -4286,44 +4334,6 @@ func (q *sqlQuerier) GetLicenses(ctx context.Context) ([]License, error) { return items, nil } -const getManagedAgentCount = `-- name: GetManagedAgentCount :one -SELECT - COUNT(DISTINCT wb.id) AS count -FROM - workspace_builds AS wb -JOIN - provisioner_jobs AS pj -ON - wb.job_id = pj.id -WHERE - wb.transition = 'start'::workspace_transition - AND wb.has_ai_task = true - -- Only count jobs that are pending, running or succeeded. Other statuses - -- like cancel(ed|ing), failed or unknown are not considered as managed - -- agent usage. These workspace builds are typically unusable anyway. - AND pj.job_status IN ( - 'pending'::provisioner_job_status, - 'running'::provisioner_job_status, - 'succeeded'::provisioner_job_status - ) - -- Jobs are counted at the time they are created, not when they are - -- completed, as pending jobs haven't completed yet. - AND wb.created_at BETWEEN $1::timestamptz AND $2::timestamptz -` - -type GetManagedAgentCountParams struct { - StartTime time.Time `db:"start_time" json:"start_time"` - EndTime time.Time `db:"end_time" json:"end_time"` -} - -// This isn't strictly a license query, but it's related to license enforcement. -func (q *sqlQuerier) GetManagedAgentCount(ctx context.Context, arg GetManagedAgentCountParams) (int64, error) { - row := q.db.QueryRowContext(ctx, getManagedAgentCount, arg.StartTime, arg.EndTime) - var count int64 - err := row.Scan(&count) - return count, err -} - const getUnexpiredLicenses = `-- name: GetUnexpiredLicenses :many SELECT id, uploaded_at, jwt, exp, uuid FROM licenses @@ -6561,16 +6571,19 @@ WHERE organization_id = $1 ELSE true END + -- Filter by system type + AND CASE WHEN $2::bool THEN TRUE ELSE is_system = false END ORDER BY -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. - LOWER(username) ASC OFFSET $2 + LOWER(username) ASC OFFSET $3 LIMIT -- A null limit means "no limit", so 0 means return all - NULLIF($3 :: int, 0) + NULLIF($4 :: int, 0) ` type PaginatedOrganizationMembersParams struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + IncludeSystem bool `db:"include_system" json:"include_system"` OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` LimitOpt int32 `db:"limit_opt" json:"limit_opt"` } @@ -6586,7 +6599,12 @@ type PaginatedOrganizationMembersRow struct { } func (q *sqlQuerier) PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error) { - rows, err := q.db.QueryContext(ctx, paginatedOrganizationMembers, arg.OrganizationID, arg.OffsetOpt, arg.LimitOpt) + rows, err := q.db.QueryContext(ctx, paginatedOrganizationMembers, + arg.OrganizationID, + arg.IncludeSystem, + arg.OffsetOpt, + arg.LimitOpt, + ) if err != nil { return nil, err } @@ -7091,7 +7109,20 @@ const claimPrebuiltWorkspace = `-- name: ClaimPrebuiltWorkspace :one UPDATE workspaces w SET owner_id = $1::uuid, name = $2::text, - updated_at = NOW() + updated_at = $3::timestamptz, + -- Update autostart_schedule, next_start_at and ttl according to template and workspace-level + -- configurations, allowing the workspace to be managed by the lifecycle executor as expected. + autostart_schedule = $4, + next_start_at = $5, + ttl = $6, + -- Update last_used_at during claim to ensure the claimed workspace is treated as recently used. + -- This avoids unintended dormancy caused by prebuilds having stale usage timestamps. + last_used_at = $3::timestamptz, + -- Clear dormant and deletion timestamps as a safeguard to ensure a clean lifecycle state after claim. + -- These fields should not be set on prebuilds, but we defensively reset them here to prevent + -- accidental dormancy or deletion by the lifecycle executor. + dormant_at = NULL, + deleting_at = NULL WHERE w.id IN ( SELECT p.id FROM workspace_prebuilds p @@ -7102,7 +7133,7 @@ WHERE w.id IN ( -- The prebuilds system should never try to claim a prebuild for an inactive template version. -- Nevertheless, this filter is here as a defensive measure: AND b.template_version_id = t.active_version_id - AND p.current_preset_id = $3::uuid + AND p.current_preset_id = $7::uuid AND p.ready AND NOT t.deleted LIMIT 1 FOR UPDATE OF p SKIP LOCKED -- Ensure that a concurrent request will not select the same prebuild. @@ -7111,9 +7142,13 @@ RETURNING w.id, w.name ` type ClaimPrebuiltWorkspaceParams struct { - NewUserID uuid.UUID `db:"new_user_id" json:"new_user_id"` - NewName string `db:"new_name" json:"new_name"` - PresetID uuid.UUID `db:"preset_id" json:"preset_id"` + NewUserID uuid.UUID `db:"new_user_id" json:"new_user_id"` + NewName string `db:"new_name" json:"new_name"` + Now time.Time `db:"now" json:"now"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` + WorkspaceTtl sql.NullInt64 `db:"workspace_ttl" json:"workspace_ttl"` + PresetID uuid.UUID `db:"preset_id" json:"preset_id"` } type ClaimPrebuiltWorkspaceRow struct { @@ -7122,7 +7157,15 @@ type ClaimPrebuiltWorkspaceRow struct { } func (q *sqlQuerier) ClaimPrebuiltWorkspace(ctx context.Context, arg ClaimPrebuiltWorkspaceParams) (ClaimPrebuiltWorkspaceRow, error) { - row := q.db.QueryRowContext(ctx, claimPrebuiltWorkspace, arg.NewUserID, arg.NewName, arg.PresetID) + row := q.db.QueryRowContext(ctx, claimPrebuiltWorkspace, + arg.NewUserID, + arg.NewName, + arg.Now, + arg.AutostartSchedule, + arg.NextStartAt, + arg.WorkspaceTtl, + arg.PresetID, + ) var i ClaimPrebuiltWorkspaceRow err := row.Scan(&i.ID, &i.Name) return i, err @@ -7183,11 +7226,52 @@ 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, tvp.name as preset_name, - o.name as organization_name, + o.name as organization_name, COUNT(*) as created_count, COUNT(*) FILTER (WHERE pj.job_status = 'failed'::provisioner_job_status) as failed_count, COUNT(*) FILTER ( @@ -8149,13 +8233,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, @@ -8222,21 +8306,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 { @@ -8259,10 +8378,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 { @@ -11371,15 +11493,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 { @@ -11459,7 +11583,7 @@ func (q *sqlQuerier) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUI } const updateTailnetPeerStatusByCoordinator = `-- name: UpdateTailnetPeerStatusByCoordinator :exec -UPDATE +UPDATE tailnet_peers SET status = $2 @@ -12059,19 +12183,41 @@ WHERE tv.has_ai_task = $7 :: boolean ELSE true END + -- Filter by author_id + AND CASE + WHEN $8 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + t.created_by = $8 + ELSE true + END + -- Filter by author_username + AND CASE + WHEN $9 :: text != '' THEN + t.created_by = (SELECT id FROM users WHERE lower(users.username) = lower($9) AND deleted = false) + 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"` + 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) { @@ -12083,6 +12229,9 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, + arg.AuthorID, + arg.AuthorUsername, + arg.HasExternalAgent, ) if err != nil { return nil, err @@ -12567,7 +12716,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 @@ -12668,7 +12817,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 @@ -12707,6 +12856,7 @@ func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPrev &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12716,7 +12866,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 @@ -12741,6 +12891,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, @@ -12750,7 +12901,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 @@ -12775,6 +12926,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, @@ -12784,7 +12936,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 @@ -12815,6 +12967,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12822,9 +12975,24 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, return i, err } +const getTemplateVersionHasAITask = `-- name: GetTemplateVersionHasAITask :one +SELECT EXISTS ( + SELECT 1 + FROM template_versions + WHERE id = $1 AND has_ai_task = TRUE +) +` + +func (q *sqlQuerier) GetTemplateVersionHasAITask(ctx context.Context, id uuid.UUID) (bool, error) { + row := q.db.QueryRowContext(ctx, getTemplateVersionHasAITask, id) + var exists bool + err := row.Scan(&exists) + return exists, err +} + 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 @@ -12855,6 +13023,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, @@ -12874,7 +13043,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 @@ -12952,6 +13121,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12970,7 +13140,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) { @@ -12997,6 +13167,7 @@ func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, create &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -13014,18 +13185,6 @@ func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, create return items, nil } -const hasTemplateVersionsWithAITask = `-- name: HasTemplateVersionsWithAITask :one -SELECT EXISTS (SELECT 1 FROM template_versions WHERE has_ai_task = TRUE) -` - -// Determines if the template versions table has any rows with has_ai_task = TRUE. -func (q *sqlQuerier) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) { - row := q.db.QueryRowContext(ctx, hasTemplateVersionsWithAITask) - var exists bool - err := row.Scan(&exists) - return exists, err -} - const insertTemplateVersion = `-- name: InsertTemplateVersion :exec INSERT INTO template_versions ( @@ -13097,27 +13256,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 @@ -13191,6 +13329,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 @@ -13430,6 +13596,195 @@ func (q *sqlQuerier) DisableForeignKeysAndTriggers(ctx context.Context) error { return err } +const getTotalUsageDCManagedAgentsV1 = `-- name: GetTotalUsageDCManagedAgentsV1 :one +SELECT + -- The first cast is necessary since you can't sum strings, and the second + -- cast is necessary to make sqlc happy. + COALESCE(SUM((usage_data->>'count')::bigint), 0)::bigint AS total_count +FROM + usage_events_daily +WHERE + event_type = 'dc_managed_agents_v1' + -- Parentheses are necessary to avoid sqlc from generating an extra + -- argument. + AND day BETWEEN date_trunc('day', ($1::timestamptz) AT TIME ZONE 'UTC')::date AND date_trunc('day', ($2::timestamptz) AT TIME ZONE 'UTC')::date +` + +type GetTotalUsageDCManagedAgentsV1Params struct { + StartDate time.Time `db:"start_date" json:"start_date"` + EndDate time.Time `db:"end_date" json:"end_date"` +} + +// Gets the total number of managed agents created between two dates. Uses the +// aggregate table to avoid large scans or a complex index on the usage_events +// table. +// +// This has the trade off that we can't count accurately between two exact +// timestamps. The provided timestamps will be converted to UTC and truncated to +// the events that happened on and between the two dates. Both dates are +// inclusive. +func (q *sqlQuerier) GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg GetTotalUsageDCManagedAgentsV1Params) (int64, error) { + row := q.db.QueryRowContext(ctx, getTotalUsageDCManagedAgentsV1, arg.StartDate, arg.EndDate) + var total_count int64 + err := row.Scan(&total_count) + return total_count, err +} + +const insertUsageEvent = `-- name: InsertUsageEvent :exec +INSERT INTO + usage_events ( + id, + event_type, + event_data, + created_at, + publish_started_at, + published_at, + failure_message + ) +VALUES + ($1, $2, $3, $4, NULL, NULL, NULL) +ON CONFLICT (id) DO NOTHING +` + +type InsertUsageEventParams struct { + ID string `db:"id" json:"id"` + EventType string `db:"event_type" json:"event_type"` + EventData json.RawMessage `db:"event_data" json:"event_data"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +// Duplicate events are ignored intentionally to allow for multiple replicas to +// publish heartbeat events. +func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error { + _, err := q.db.ExecContext(ctx, insertUsageEvent, + arg.ID, + arg.EventType, + arg.EventData, + arg.CreatedAt, + ) + return err +} + +const selectUsageEventsForPublishing = `-- name: SelectUsageEventsForPublishing :many +WITH usage_events AS ( + UPDATE + usage_events + SET + publish_started_at = $1::timestamptz + WHERE + id IN ( + SELECT + potential_event.id + FROM + usage_events potential_event + WHERE + -- Do not publish events that have already been published or + -- have permanently failed to publish. + potential_event.published_at IS NULL + -- Do not publish events that are already being published by + -- another replica. + AND ( + potential_event.publish_started_at IS NULL + -- If the event has publish_started_at set, it must be older + -- than an hour ago. This is so we can retry publishing + -- events where the replica exited or couldn't update the + -- row. + -- The parentheses around @now::timestamptz are necessary to + -- avoid sqlc from generating an extra argument. + OR potential_event.publish_started_at < ($1::timestamptz) - INTERVAL '1 hour' + ) + -- Do not publish events older than 30 days. Tallyman will + -- always permanently reject these events anyways. This is to + -- avoid duplicate events being billed to customers, as + -- Metronome will only deduplicate events within 34 days. + -- Also, the same parentheses thing here as above. + AND potential_event.created_at > ($1::timestamptz) - INTERVAL '30 days' + ORDER BY potential_event.created_at ASC + FOR UPDATE SKIP LOCKED + LIMIT 100 + ) + RETURNING id, event_type, event_data, created_at, publish_started_at, published_at, failure_message +) +SELECT id, event_type, event_data, created_at, publish_started_at, published_at, failure_message +FROM usage_events +ORDER BY created_at ASC +` + +// Note that this selects from the CTE, not the original table. The CTE is named +// the same as the original table to trick sqlc into reusing the existing struct +// for the table. +// The CTE and the reorder is required because UPDATE doesn't guarantee order. +func (q *sqlQuerier) SelectUsageEventsForPublishing(ctx context.Context, now time.Time) ([]UsageEvent, error) { + rows, err := q.db.QueryContext(ctx, selectUsageEventsForPublishing, now) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UsageEvent + for rows.Next() { + var i UsageEvent + if err := rows.Scan( + &i.ID, + &i.EventType, + &i.EventData, + &i.CreatedAt, + &i.PublishStartedAt, + &i.PublishedAt, + &i.FailureMessage, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUsageEventsPostPublish = `-- name: UpdateUsageEventsPostPublish :exec +UPDATE + usage_events +SET + publish_started_at = NULL, + published_at = CASE WHEN input.set_published_at THEN $1::timestamptz ELSE NULL END, + failure_message = NULLIF(input.failure_message, '') +FROM ( + SELECT + UNNEST($2::text[]) AS id, + UNNEST($3::text[]) AS failure_message, + UNNEST($4::boolean[]) AS set_published_at +) input +WHERE + input.id = usage_events.id + -- If the number of ids, failure messages, and set published ats are not the + -- same, do not do anything. Unfortunately you can't really throw from a + -- query without writing a function or doing some jank like dividing by + -- zero, so this is the best we can do. + AND cardinality($2::text[]) = cardinality($3::text[]) + AND cardinality($2::text[]) = cardinality($4::boolean[]) +` + +type UpdateUsageEventsPostPublishParams struct { + Now time.Time `db:"now" json:"now"` + IDs []string `db:"ids" json:"ids"` + FailureMessages []string `db:"failure_messages" json:"failure_messages"` + SetPublishedAts []bool `db:"set_published_ats" json:"set_published_ats"` +} + +func (q *sqlQuerier) UpdateUsageEventsPostPublish(ctx context.Context, arg UpdateUsageEventsPostPublishParams) error { + _, err := q.db.ExecContext(ctx, updateUsageEventsPostPublish, + arg.Now, + pq.Array(arg.IDs), + pq.Array(arg.FailureMessages), + pq.Array(arg.SetPublishedAts), + ) + return err +} + const getUserLinkByLinkedID = `-- name: GetUserLinkByLinkedID :one SELECT user_links.user_id, user_links.login_type, user_links.linked_id, user_links.oauth_access_token, user_links.oauth_refresh_token, user_links.oauth_expiry, user_links.oauth_access_token_key_id, user_links.oauth_refresh_token_key_id, user_links.claims @@ -13771,6 +14126,196 @@ func (q *sqlQuerier) UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinke return i, err } +const createUserSecret = `-- name: CreateUserSecret :one +INSERT INTO user_secrets ( + id, + user_id, + name, + description, + value, + env_name, + file_path +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at +` + +type CreateUserSecretParams struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description string `db:"description" json:"description"` + Value string `db:"value" json:"value"` + EnvName string `db:"env_name" json:"env_name"` + FilePath string `db:"file_path" json:"file_path"` +} + +func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretParams) (UserSecret, error) { + row := q.db.QueryRowContext(ctx, createUserSecret, + arg.ID, + arg.UserID, + arg.Name, + arg.Description, + arg.Value, + arg.EnvName, + arg.FilePath, + ) + var i UserSecret + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Value, + &i.EnvName, + &i.FilePath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteUserSecret = `-- name: DeleteUserSecret :exec +DELETE FROM user_secrets +WHERE id = $1 +` + +func (q *sqlQuerier) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteUserSecret, id) + return err +} + +const getUserSecret = `-- name: GetUserSecret :one +SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at FROM user_secrets +WHERE id = $1 +` + +func (q *sqlQuerier) GetUserSecret(ctx context.Context, id uuid.UUID) (UserSecret, error) { + row := q.db.QueryRowContext(ctx, getUserSecret, id) + var i UserSecret + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Value, + &i.EnvName, + &i.FilePath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserSecretByUserIDAndName = `-- name: GetUserSecretByUserIDAndName :one +SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at FROM user_secrets +WHERE user_id = $1 AND name = $2 +` + +type GetUserSecretByUserIDAndNameParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) GetUserSecretByUserIDAndName(ctx context.Context, arg GetUserSecretByUserIDAndNameParams) (UserSecret, error) { + row := q.db.QueryRowContext(ctx, getUserSecretByUserIDAndName, arg.UserID, arg.Name) + var i UserSecret + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Value, + &i.EnvName, + &i.FilePath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listUserSecrets = `-- name: ListUserSecrets :many +SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at FROM user_secrets +WHERE user_id = $1 +ORDER BY name ASC +` + +func (q *sqlQuerier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]UserSecret, error) { + rows, err := q.db.QueryContext(ctx, listUserSecrets, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserSecret + for rows.Next() { + var i UserSecret + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Value, + &i.EnvName, + &i.FilePath, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUserSecret = `-- name: UpdateUserSecret :one +UPDATE user_secrets +SET + description = $2, + value = $3, + env_name = $4, + file_path = $5, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at +` + +type UpdateUserSecretParams struct { + ID uuid.UUID `db:"id" json:"id"` + Description string `db:"description" json:"description"` + Value string `db:"value" json:"value"` + EnvName string `db:"env_name" json:"env_name"` + FilePath string `db:"file_path" json:"file_path"` +} + +func (q *sqlQuerier) UpdateUserSecret(ctx context.Context, arg UpdateUserSecretParams) (UserSecret, error) { + row := q.db.QueryRowContext(ctx, updateUserSecret, + arg.ID, + arg.Description, + arg.Value, + arg.EnvName, + arg.FilePath, + ) + var i UserSecret + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Value, + &i.EnvName, + &i.FilePath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const allUserIDs = `-- name: AllUserIDs :many SELECT DISTINCT id FROM USERS WHERE CASE WHEN $1::bool THEN TRUE ELSE is_system = false END @@ -14787,6 +15332,39 @@ func (q *sqlQuerier) UpdateUserThemePreference(ctx context.Context, arg UpdateUs return i, err } +const validateUserIDs = `-- name: ValidateUserIDs :one +WITH input AS ( + SELECT + unnest($1::uuid[]) AS id +) +SELECT + array_agg(input.id)::uuid[] as invalid_user_ids, + COUNT(*) = 0 as ok +FROM + -- Preserve rows where there is not a matching left (users) row for each + -- right (input) row... + users + RIGHT JOIN input ON users.id = input.id +WHERE + -- ...so that we can retain exactly those rows where an input ID does not + -- match an existing user... + users.id IS NULL OR + -- ...or that only matches a user that was deleted. + users.deleted = true +` + +type ValidateUserIDsRow struct { + InvalidUserIds []uuid.UUID `db:"invalid_user_ids" json:"invalid_user_ids"` + Ok bool `db:"ok" json:"ok"` +} + +func (q *sqlQuerier) ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (ValidateUserIDsRow, error) { + row := q.db.QueryRowContext(ctx, validateUserIDs, pq.Array(userIds)) + var i ValidateUserIDsRow + err := row.Scan(pq.Array(&i.InvalidUserIds), &i.Ok) + return i, err +} + const getWorkspaceAgentDevcontainersByAgentID = `-- name: GetWorkspaceAgentDevcontainersByAgentID :many SELECT id, workspace_agent_id, created_at, workspace_folder, config_path, name @@ -15443,7 +16021,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 @@ -15556,6 +16134,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, @@ -18210,7 +18789,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 @@ -18267,6 +18846,7 @@ func (q *sqlQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, t &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18366,7 +18946,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 @@ -18398,6 +18978,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18405,80 +18986,16 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w return i, err } -const getLatestWorkspaceBuilds = `-- name: GetLatestWorkspaceBuilds :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 -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_build_with_user AS workspace_builds - GROUP BY - workspace_id -) m -JOIN - workspace_build_with_user AS wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number -` - -func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) { - rows, err := q.db.QueryContext(ctx, getLatestWorkspaceBuilds) - if err != nil { - return nil, err - } - defer rows.Close() - var items []WorkspaceBuild - for rows.Next() { - var i WorkspaceBuild - if err := rows.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.WorkspaceID, - &i.TemplateVersionID, - &i.BuildNumber, - &i.Transition, - &i.InitiatorID, - &i.ProvisionerState, - &i.JobID, - &i.Deadline, - &i.Reason, - &i.DailyCost, - &i.MaxDeadline, - &i.TemplateVersionPresetID, - &i.HasAITask, - &i.AITaskSidebarAppID, - &i.InitiatorByAvatarUrl, - &i.InitiatorByUsername, - &i.InitiatorByName, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const 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 -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_build_with_user AS workspace_builds - WHERE - workspace_id = ANY($1 :: uuid [ ]) - GROUP BY - workspace_id -) m -JOIN - workspace_build_with_user AS wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number +SELECT + DISTINCT ON (workspace_id) + 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 + workspace_id = ANY($1 :: uuid [ ]) +ORDER BY + workspace_id, build_number DESC -- latest first ` func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) { @@ -18508,6 +19025,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18527,7 +19045,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 @@ -18557,6 +19075,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, @@ -18566,7 +19085,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 @@ -18596,6 +19115,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, @@ -18605,7 +19125,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 @@ -18639,6 +19159,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18715,7 +19236,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 @@ -18788,6 +19309,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18806,7 +19328,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) { @@ -18836,6 +19358,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, created &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18912,33 +19435,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 @@ -18965,7 +19461,15 @@ SET deadline = $1::timestamptz, max_deadline = $2::timestamptz, updated_at = $3::timestamptz -WHERE id = $4::uuid +FROM + workspaces +WHERE + workspace_builds.id = $4::uuid + AND workspace_builds.workspace_id = workspaces.id + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- deadline and max_deadline + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspaceBuildDeadlineByIDParams struct { @@ -18985,6 +19489,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 @@ -19593,6 +20127,97 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy return i, err } +const getRegularWorkspaceCreateMetrics = `-- name: GetRegularWorkspaceCreateMetrics :many +WITH first_success_build AS ( + -- Earliest successful 'start' build per workspace + SELECT DISTINCT ON (wb.workspace_id) + wb.workspace_id, + wb.template_version_preset_id, + wb.initiator_id + FROM workspace_builds wb + JOIN provisioner_jobs pj ON pj.id = wb.job_id + WHERE + wb.transition = 'start'::workspace_transition + AND pj.job_status = 'succeeded'::provisioner_job_status + ORDER BY wb.workspace_id, wb.build_number, wb.id +) +SELECT + t.name AS template_name, + COALESCE(tvp.name, '') AS preset_name, + o.name AS organization_name, + COUNT(*) AS created_count +FROM first_success_build fsb + JOIN workspaces w ON w.id = fsb.workspace_id + JOIN templates t ON t.id = w.template_id + LEFT JOIN template_version_presets tvp ON tvp.id = fsb.template_version_preset_id + JOIN organizations o ON o.id = w.organization_id +WHERE + NOT t.deleted + -- Exclude workspaces whose first successful start was the prebuilds system user + AND fsb.initiator_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid +GROUP BY t.name, COALESCE(tvp.name, ''), o.name +ORDER BY t.name, preset_name, o.name +` + +type GetRegularWorkspaceCreateMetricsRow struct { + TemplateName string `db:"template_name" json:"template_name"` + PresetName string `db:"preset_name" json:"preset_name"` + OrganizationName string `db:"organization_name" json:"organization_name"` + CreatedCount int64 `db:"created_count" json:"created_count"` +} + +// Count regular workspaces: only those whose first successful 'start' build +// was not initiated by the prebuild system user. +func (q *sqlQuerier) GetRegularWorkspaceCreateMetrics(ctx context.Context) ([]GetRegularWorkspaceCreateMetricsRow, error) { + rows, err := q.db.QueryContext(ctx, getRegularWorkspaceCreateMetrics) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetRegularWorkspaceCreateMetricsRow + for rows.Next() { + var i GetRegularWorkspaceCreateMetricsRow + if err := rows.Scan( + &i.TemplateName, + &i.PresetName, + &i.OrganizationName, + &i.CreatedCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getWorkspaceACLByID = `-- name: GetWorkspaceACLByID :one +SELECT + group_acl as groups, + user_acl as users +FROM + workspaces +WHERE + id = $1 +` + +type GetWorkspaceACLByIDRow struct { + Groups WorkspaceACL `db:"groups" json:"groups"` + Users WorkspaceACL `db:"users" json:"users"` +} + +func (q *sqlQuerier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (GetWorkspaceACLByIDRow, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceACLByID, id) + var i GetWorkspaceACLByIDRow + err := row.Scan(&i.Groups, &i.Users) + return i, err +} + const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl, owner_avatar_url, owner_username, owner_name, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description @@ -19952,7 +20577,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 @@ -19965,6 +20591,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, @@ -20205,16 +20832,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 @@ -20223,14 +20856,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. @@ -20274,9 +20907,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 @@ -20284,7 +20918,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 @@ -20312,6 +20946,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_"` @@ -20319,44 +20954,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. @@ -20383,6 +21019,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.LastUsedAfter, arg.UsingActive, arg.HasAITask, + arg.HasExternalAgent, arg.RequesterID, arg.Offset, arg.Limit, @@ -20433,6 +21070,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 @@ -20920,6 +21558,10 @@ SET next_start_at = $3 WHERE id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- autostart_schedule and next_start_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspaceAutostartParams struct { @@ -20976,6 +21618,10 @@ FROM WHERE workspaces.id = $1 AND templates.id = workspaces.template_id + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- dormant_at and deleting_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl ` @@ -21037,6 +21683,10 @@ SET next_start_at = $2 WHERE id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- next_start_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspaceNextStartAtParams struct { @@ -21056,6 +21706,10 @@ SET ttl = $2 WHERE id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- ttl + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspaceTTLParams struct { @@ -21073,14 +21727,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 ` @@ -21134,11 +21791,15 @@ func (q *sqlQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.C const updateWorkspacesTTLByTemplateID = `-- name: UpdateWorkspacesTTLByTemplateID :exec UPDATE - workspaces + workspaces SET - ttl = $2 + 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 ` 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/externalauth.sql b/coderd/database/queries/externalauth.sql index 4368ce56589f0..9ca5cf6f871ad 100644 --- a/coderd/database/queries/externalauth.sql +++ b/coderd/database/queries/externalauth.sql @@ -40,13 +40,20 @@ UPDATE external_auth_links SET oauth_refresh_token = $6, oauth_refresh_token_key_id = $7, oauth_expiry = $8, - oauth_extra = $9 + oauth_extra = $9, + -- Only 'UpdateExternalAuthLinkRefreshToken' supports updating the oauth_refresh_failure_reason. + -- Any updates to the external auth link, will be assumed to change the state and clear + -- any cached errors. + oauth_refresh_failure_reason = '' WHERE provider_id = $1 AND user_id = $2 RETURNING *; -- name: UpdateExternalAuthLinkRefreshToken :exec UPDATE external_auth_links SET + -- oauth_refresh_failure_reason can be set to cache the failure reason + -- for subsequent refresh attempts. + oauth_refresh_failure_reason = @oauth_refresh_failure_reason, oauth_refresh_token = @oauth_refresh_token, updated_at = @updated_at WHERE diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index 48a5ba5c79968..3413e5832e27d 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -8,6 +8,24 @@ WHERE LIMIT 1; +-- name: ValidateGroupIDs :one +WITH input AS ( + SELECT + unnest(@group_ids::uuid[]) AS id +) +SELECT + array_agg(input.id)::uuid[] as invalid_group_ids, + COUNT(*) = 0 as ok +FROM + -- Preserve rows where there is not a matching left (groups) row for each + -- right (input) row... + groups + RIGHT JOIN input ON groups.id = input.id +WHERE + -- ...so that we can retain exactly those rows where an input ID does not + -- match an existing group. + groups.id IS NULL; + -- name: GetGroupByOrgAndName :one SELECT * diff --git a/coderd/database/queries/licenses.sql b/coderd/database/queries/licenses.sql index ac864a94d1792..3512a46514787 100644 --- a/coderd/database/queries/licenses.sql +++ b/coderd/database/queries/licenses.sql @@ -35,28 +35,3 @@ DELETE FROM licenses WHERE id = $1 RETURNING id; - --- name: GetManagedAgentCount :one --- This isn't strictly a license query, but it's related to license enforcement. -SELECT - COUNT(DISTINCT wb.id) AS count -FROM - workspace_builds AS wb -JOIN - provisioner_jobs AS pj -ON - wb.job_id = pj.id -WHERE - wb.transition = 'start'::workspace_transition - AND wb.has_ai_task = true - -- Only count jobs that are pending, running or succeeded. Other statuses - -- like cancel(ed|ing), failed or unknown are not considered as managed - -- agent usage. These workspace builds are typically unusable anyway. - AND pj.job_status IN ( - 'pending'::provisioner_job_status, - 'running'::provisioner_job_status, - 'succeeded'::provisioner_job_status - ) - -- Jobs are counted at the time they are created, not when they are - -- completed, as pending jobs haven't completed yet. - AND wb.created_at BETWEEN @start_time::timestamptz AND @end_time::timestamptz; diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index 9d570bc1c49ee..1c0af011776e3 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -89,6 +89,8 @@ WHERE organization_id = @organization_id ELSE true END + -- Filter by system type + AND CASE WHEN @include_system::bool THEN TRUE ELSE is_system = false END ORDER BY -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. LOWER(username) ASC OFFSET @offset_opt diff --git a/coderd/database/queries/prebuilds.sql b/coderd/database/queries/prebuilds.sql index 37bff9487928e..2ad7f41d41fea 100644 --- a/coderd/database/queries/prebuilds.sql +++ b/coderd/database/queries/prebuilds.sql @@ -2,7 +2,20 @@ UPDATE workspaces w SET owner_id = @new_user_id::uuid, name = @new_name::text, - updated_at = NOW() + updated_at = @now::timestamptz, + -- Update autostart_schedule, next_start_at and ttl according to template and workspace-level + -- configurations, allowing the workspace to be managed by the lifecycle executor as expected. + autostart_schedule = @autostart_schedule, + next_start_at = @next_start_at, + ttl = @workspace_ttl, + -- Update last_used_at during claim to ensure the claimed workspace is treated as recently used. + -- This avoids unintended dormancy caused by prebuilds having stale usage timestamps. + last_used_at = @now::timestamptz, + -- Clear dormant and deletion timestamps as a safeguard to ensure a clean lifecycle state after claim. + -- These fields should not be set on prebuilds, but we defensively reset them here to prevent + -- accidental dormancy or deletion by the lifecycle executor. + dormant_at = NULL, + deleting_at = NULL WHERE w.id IN ( SELECT p.id FROM workspace_prebuilds p @@ -217,7 +230,7 @@ HAVING COUNT(*) = @hard_limit::bigint; SELECT t.name as template_name, tvp.name as preset_name, - o.name as organization_name, + o.name as organization_name, COUNT(*) as created_count, COUNT(*) FILTER (WHERE pj.job_status = 'failed'::provisioner_job_status) as failed_count, COUNT(*) FILTER ( @@ -232,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 4a37bd2d1058b..4bb70c6580503 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -59,6 +59,25 @@ WHERE tv.has_ai_task = sqlc.narg('has_ai_task') :: boolean ELSE true END + -- Filter by author_id + AND CASE + WHEN @author_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + t.created_by = @author_id + ELSE true + END + -- Filter by author_username + AND CASE + WHEN @author_username :: text != '' THEN + t.created_by = (SELECT id FROM users WHERE lower(users.username) = lower(@author_username) AND deleted = false) + 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 4a37413d2f439..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 * @@ -235,6 +226,19 @@ WHERE template_versions.id IN (archived_versions.id) RETURNING template_versions.id; --- name: HasTemplateVersionsWithAITask :one --- Determines if the template versions table has any rows with has_ai_task = TRUE. -SELECT EXISTS (SELECT 1 FROM template_versions WHERE has_ai_task = TRUE); +-- name: GetTemplateVersionHasAITask :one +SELECT EXISTS ( + SELECT 1 + 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/usageevents.sql b/coderd/database/queries/usageevents.sql new file mode 100644 index 0000000000000..291e275c6024d --- /dev/null +++ b/coderd/database/queries/usageevents.sql @@ -0,0 +1,107 @@ +-- name: InsertUsageEvent :exec +-- Duplicate events are ignored intentionally to allow for multiple replicas to +-- publish heartbeat events. +INSERT INTO + usage_events ( + id, + event_type, + event_data, + created_at, + publish_started_at, + published_at, + failure_message + ) +VALUES + (@id, @event_type, @event_data, @created_at, NULL, NULL, NULL) +ON CONFLICT (id) DO NOTHING; + +-- name: SelectUsageEventsForPublishing :many +WITH usage_events AS ( + UPDATE + usage_events + SET + publish_started_at = @now::timestamptz + WHERE + id IN ( + SELECT + potential_event.id + FROM + usage_events potential_event + WHERE + -- Do not publish events that have already been published or + -- have permanently failed to publish. + potential_event.published_at IS NULL + -- Do not publish events that are already being published by + -- another replica. + AND ( + potential_event.publish_started_at IS NULL + -- If the event has publish_started_at set, it must be older + -- than an hour ago. This is so we can retry publishing + -- events where the replica exited or couldn't update the + -- row. + -- The parentheses around @now::timestamptz are necessary to + -- avoid sqlc from generating an extra argument. + OR potential_event.publish_started_at < (@now::timestamptz) - INTERVAL '1 hour' + ) + -- Do not publish events older than 30 days. Tallyman will + -- always permanently reject these events anyways. This is to + -- avoid duplicate events being billed to customers, as + -- Metronome will only deduplicate events within 34 days. + -- Also, the same parentheses thing here as above. + AND potential_event.created_at > (@now::timestamptz) - INTERVAL '30 days' + ORDER BY potential_event.created_at ASC + FOR UPDATE SKIP LOCKED + LIMIT 100 + ) + RETURNING * +) +SELECT * +-- Note that this selects from the CTE, not the original table. The CTE is named +-- the same as the original table to trick sqlc into reusing the existing struct +-- for the table. +FROM usage_events +-- The CTE and the reorder is required because UPDATE doesn't guarantee order. +ORDER BY created_at ASC; + +-- name: UpdateUsageEventsPostPublish :exec +UPDATE + usage_events +SET + publish_started_at = NULL, + published_at = CASE WHEN input.set_published_at THEN @now::timestamptz ELSE NULL END, + failure_message = NULLIF(input.failure_message, '') +FROM ( + SELECT + UNNEST(@ids::text[]) AS id, + UNNEST(@failure_messages::text[]) AS failure_message, + UNNEST(@set_published_ats::boolean[]) AS set_published_at +) input +WHERE + input.id = usage_events.id + -- If the number of ids, failure messages, and set published ats are not the + -- same, do not do anything. Unfortunately you can't really throw from a + -- query without writing a function or doing some jank like dividing by + -- zero, so this is the best we can do. + AND cardinality(@ids::text[]) = cardinality(@failure_messages::text[]) + AND cardinality(@ids::text[]) = cardinality(@set_published_ats::boolean[]); + +-- name: GetTotalUsageDCManagedAgentsV1 :one +-- Gets the total number of managed agents created between two dates. Uses the +-- aggregate table to avoid large scans or a complex index on the usage_events +-- table. +-- +-- This has the trade off that we can't count accurately between two exact +-- timestamps. The provided timestamps will be converted to UTC and truncated to +-- the events that happened on and between the two dates. Both dates are +-- inclusive. +SELECT + -- The first cast is necessary since you can't sum strings, and the second + -- cast is necessary to make sqlc happy. + COALESCE(SUM((usage_data->>'count')::bigint), 0)::bigint AS total_count +FROM + usage_events_daily +WHERE + event_type = 'dc_managed_agents_v1' + -- Parentheses are necessary to avoid sqlc from generating an extra + -- argument. + AND day BETWEEN date_trunc('day', (@start_date::timestamptz) AT TIME ZONE 'UTC')::date AND date_trunc('day', (@end_date::timestamptz) AT TIME ZONE 'UTC')::date; diff --git a/coderd/database/queries/user_secrets.sql b/coderd/database/queries/user_secrets.sql new file mode 100644 index 0000000000000..271b97c9bb13c --- /dev/null +++ b/coderd/database/queries/user_secrets.sql @@ -0,0 +1,40 @@ +-- name: GetUserSecretByUserIDAndName :one +SELECT * FROM user_secrets +WHERE user_id = $1 AND name = $2; + +-- name: GetUserSecret :one +SELECT * FROM user_secrets +WHERE id = $1; + +-- name: ListUserSecrets :many +SELECT * FROM user_secrets +WHERE user_id = $1 +ORDER BY name ASC; + +-- name: CreateUserSecret :one +INSERT INTO user_secrets ( + id, + user_id, + name, + description, + value, + env_name, + file_path +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) RETURNING *; + +-- name: UpdateUserSecret :one +UPDATE user_secrets +SET + description = $2, + value = $3, + env_name = $4, + file_path = $5, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +RETURNING *; + +-- name: DeleteUserSecret :exec +DELETE FROM user_secrets +WHERE id = $1; diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index eece2f96512ea..0b6e52d6bc918 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -25,6 +25,26 @@ WHERE LIMIT 1; +-- name: ValidateUserIDs :one +WITH input AS ( + SELECT + unnest(@user_ids::uuid[]) AS id +) +SELECT + array_agg(input.id)::uuid[] as invalid_user_ids, + COUNT(*) = 0 as ok +FROM + -- Preserve rows where there is not a matching left (users) row for each + -- right (input) row... + users + RIGHT JOIN input ON users.id = input.id +WHERE + -- ...so that we can retain exactly those rows where an input ID does not + -- match an existing user... + users.id IS NULL OR + -- ...or that only matches a user that was deleted. + users.deleted = true; + -- name: GetUsersByIDs :many -- This shouldn't check for deleted, because it's frequently used -- to look up references to actions. eg. a user could build a workspace diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index be76b6642df1f..0736c5514b3f7 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -76,34 +76,16 @@ LIMIT 1; -- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.* -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_build_with_user AS workspace_builds - WHERE - workspace_id = ANY(@ids :: uuid [ ]) - GROUP BY - workspace_id -) m -JOIN - workspace_build_with_user AS wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number; - --- name: GetLatestWorkspaceBuilds :many -SELECT wb.* -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_build_with_user AS workspace_builds - GROUP BY - workspace_id -) m -JOIN - workspace_build_with_user AS wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number; +SELECT + DISTINCT ON (workspace_id) + * +FROM + workspace_build_with_user AS workspace_builds +WHERE + workspace_id = ANY(@ids :: uuid [ ]) +ORDER BY + workspace_id, build_number DESC -- latest first +; -- name: InsertWorkspaceBuild :exec INSERT INTO @@ -141,7 +123,15 @@ SET deadline = @deadline::timestamptz, max_deadline = @max_deadline::timestamptz, updated_at = @updated_at::timestamptz -WHERE id = @id::uuid; +FROM + workspaces +WHERE + workspace_builds.id = @id::uuid + AND workspace_builds.workspace_id = workspaces.id + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- deadline and max_deadline + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspaceBuildProvisionerStateByID :exec UPDATE @@ -151,15 +141,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 ( @@ -253,3 +234,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 b6b4f2de0888f..80d8c7b920d74 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 ( @@ -518,7 +527,11 @@ SET autostart_schedule = $2, next_start_at = $3 WHERE - id = $1; + id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- autostart_schedule and next_start_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspaceNextStartAt :exec UPDATE @@ -526,7 +539,11 @@ UPDATE SET next_start_at = $2 WHERE - id = $1; + id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- next_start_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: BatchUpdateWorkspaceNextStartAt :exec UPDATE @@ -550,15 +567,23 @@ UPDATE SET ttl = $2 WHERE - id = $1; + id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- ttl + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspacesTTLByTemplateID :exec UPDATE - workspaces + workspaces SET - ttl = $2 + 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 @@ -791,6 +816,10 @@ FROM WHERE workspaces.id = $1 AND templates.id = workspaces.template_id + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- dormant_at and deleting_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID RETURNING workspaces.*; @@ -799,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 @@ -874,6 +906,15 @@ GROUP BY workspaces.id, workspaces.name, latest_build.job_status, latest_build.j -- name: GetWorkspacesByTemplateID :many SELECT * FROM workspaces WHERE template_id = $1 AND deleted = false; +-- name: GetWorkspaceACLByID :one +SELECT + group_acl as groups, + user_acl as users +FROM + workspaces +WHERE + id = @id; + -- name: UpdateWorkspaceACLByID :exec UPDATE workspaces @@ -882,3 +923,36 @@ SET user_acl = @user_acl WHERE id = @id; + +-- name: GetRegularWorkspaceCreateMetrics :many +-- Count regular workspaces: only those whose first successful 'start' build +-- was not initiated by the prebuild system user. +WITH first_success_build AS ( + -- Earliest successful 'start' build per workspace + SELECT DISTINCT ON (wb.workspace_id) + wb.workspace_id, + wb.template_version_preset_id, + wb.initiator_id + FROM workspace_builds wb + JOIN provisioner_jobs pj ON pj.id = wb.job_id + WHERE + wb.transition = 'start'::workspace_transition + AND pj.job_status = 'succeeded'::provisioner_job_status + ORDER BY wb.workspace_id, wb.build_number, wb.id +) +SELECT + t.name AS template_name, + COALESCE(tvp.name, '') AS preset_name, + o.name AS organization_name, + COUNT(*) AS created_count +FROM first_success_build fsb + JOIN workspaces w ON w.id = fsb.workspace_id + JOIN templates t ON t.id = w.template_id + LEFT JOIN template_version_presets tvp ON tvp.id = fsb.template_version_preset_id + JOIN organizations o ON o.id = w.organization_id +WHERE + NOT t.deleted + -- Exclude workspaces whose first successful start was the prebuilds system user + AND fsb.initiator_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid +GROUP BY t.name, COALESCE(tvp.name, ''), o.name +ORDER BY t.name, preset_name, o.name; 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/database/unique_constraint.go b/coderd/database/unique_constraint.go index 38c95e67410c9..ddb83a339f0cf 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -67,9 +67,12 @@ const ( UniqueTemplateVersionsPkey UniqueConstraint = "template_versions_pkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_pkey PRIMARY KEY (id); UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); + UniqueUsageEventsDailyPkey UniqueConstraint = "usage_events_daily_pkey" // ALTER TABLE ONLY usage_events_daily ADD CONSTRAINT usage_events_daily_pkey PRIMARY KEY (day, event_type); + UniqueUsageEventsPkey UniqueConstraint = "usage_events_pkey" // ALTER TABLE ONLY usage_events ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id); UniqueUserConfigsPkey UniqueConstraint = "user_configs_pkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key); UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id); UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); + UniqueUserSecretsPkey UniqueConstraint = "user_secrets_pkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id); UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); UniqueWebpushSubscriptionsPkey UniqueConstraint = "webpush_subscriptions_pkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id); @@ -115,6 +118,9 @@ const ( UniqueTemplateUsageStatsStartTimeTemplateIDUserIDIndex UniqueConstraint = "template_usage_stats_start_time_template_id_user_id_idx" // CREATE UNIQUE INDEX template_usage_stats_start_time_template_id_user_id_idx ON template_usage_stats USING btree (start_time, template_id, user_id); UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false); UniqueUserLinksLinkedIDLoginTypeIndex UniqueConstraint = "user_links_linked_id_login_type_idx" // CREATE UNIQUE INDEX user_links_linked_id_login_type_idx ON user_links USING btree (linked_id, login_type) WHERE (linked_id <> ''::text); + UniqueUserSecretsUserEnvNameIndex UniqueConstraint = "user_secrets_user_env_name_idx" // CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets USING btree (user_id, env_name) WHERE (env_name <> ''::text); + UniqueUserSecretsUserFilePathIndex UniqueConstraint = "user_secrets_user_file_path_idx" // CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree (user_id, file_path) WHERE (file_path <> ''::text); + UniqueUserSecretsUserNameIndex UniqueConstraint = "user_secrets_user_name_idx" // CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name); UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false); UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); UniqueWorkspaceAppAuditSessionsUniqueIndex UniqueConstraint = "workspace_app_audit_sessions_unique_index" // CREATE UNIQUE INDEX workspace_app_audit_sessions_unique_index ON workspace_app_audit_sessions USING btree (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); 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.go b/coderd/externalauth/externalauth.go index 9b8b87748e784..24ebe13d03074 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/dustin/go-humanize" "golang.org/x/oauth2" "golang.org/x/xerrors" @@ -28,6 +29,13 @@ import ( "github.com/coder/retry" ) +const ( + // failureReasonLimit is the maximum text length of an error to be cached to the + // database for a failed refresh token. In rare cases, the error could be a large + // HTML payload. + failureReasonLimit = 400 +) + // Config is used for authentication for Git operations. type Config struct { promoauth.InstrumentedOAuth2Config @@ -121,11 +129,12 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu return externalAuthLink, InvalidTokenError("token expired, refreshing is either disabled or refreshing failed and will not be retried") } + refreshToken := externalAuthLink.OAuthRefreshToken + // This is additional defensive programming. Because TokenSource is an interface, // we cannot be sure that the implementation will treat an 'IsZero' time // as "not-expired". The default implementation does, but a custom implementation // might not. Removing the refreshToken will guarantee a refresh will fail. - refreshToken := externalAuthLink.OAuthRefreshToken if c.NoRefresh { refreshToken = "" } @@ -136,15 +145,30 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu Expiry: externalAuthLink.OAuthExpiry, } + // Note: The TokenSource(...) method will make no remote HTTP requests if the + // token is expired and no refresh token is set. This is important to prevent + // spamming the API, consuming rate limits, when the token is known to fail. token, err := c.TokenSource(ctx, existingToken).Token() if err != nil { // TokenSource can fail for numerous reasons. If it fails because of // a bad refresh token, then the refresh token is invalid, and we should // get rid of it. Keeping it around will cause additional refresh // attempts that will fail and cost us api rate limits. + // + // The error message is saved for debugging purposes. if isFailedRefresh(existingToken, err) { + reason := err.Error() + if len(reason) > failureReasonLimit { + // Limit the length of the error message to prevent + // spamming the database with long error messages. + reason = reason[:failureReasonLimit] + } dbExecErr := db.UpdateExternalAuthLinkRefreshToken(ctx, database.UpdateExternalAuthLinkRefreshTokenParams{ - OAuthRefreshToken: "", // It is better to clear the refresh token than to keep retrying. + // Adding a reason will prevent further attempts to try and refresh the token. + OauthRefreshFailureReason: reason, + // Remove the invalid refresh token so it is never used again. The cached + // `reason` can be used to know why this field was zeroed out. + OAuthRefreshToken: "", OAuthRefreshTokenKeyID: externalAuthLink.OAuthRefreshTokenKeyID.String, UpdatedAt: dbtime.Now(), ProviderID: externalAuthLink.ProviderID, @@ -156,12 +180,28 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu } // The refresh token was cleared externalAuthLink.OAuthRefreshToken = "" + externalAuthLink.UpdatedAt = dbtime.Now() } // Unfortunately have to match exactly on the error message string. // Improve the error message to account refresh tokens are deleted if // invalid on our end. + // + // This error messages comes from the oauth2 package on our client side. + // So this check is not against a server generated error message. + // Error source: https://github.com/golang/oauth2/blob/master/oauth2.go#L277 if err.Error() == "oauth2: token expired and refresh token is not set" { + if externalAuthLink.OauthRefreshFailureReason != "" { + // A cached refresh failure error exists. So the refresh token was set, but was invalid, and zeroed out. + // Return this cached error for the original refresh attempt. + return externalAuthLink, InvalidTokenError(fmt.Sprintf("token expired and refreshing failed %s with: %s", + // Do not return the exact time, because then we have to know what timezone the + // user is in. This approximate time is good enough. + humanize.Time(externalAuthLink.UpdatedAt), + externalAuthLink.OauthRefreshFailureReason, + )) + } + return externalAuthLink, InvalidTokenError("token expired, refreshing is either disabled or refreshing failed and will not be retried") } diff --git a/coderd/externalauth/externalauth_test.go b/coderd/externalauth/externalauth_test.go index 81cf5aa1f21e2..8e46566ed2738 100644 --- a/coderd/externalauth/externalauth_test.go +++ b/coderd/externalauth/externalauth_test.go @@ -177,19 +177,25 @@ func TestRefreshToken(t *testing.T) { link.OAuthExpiry = expired // Make the failure a server internal error. Not related to the token + // This should be retried since this error is temporary. refreshErr = &oauth2.RetrieveError{ Response: &http.Response{ StatusCode: http.StatusInternalServerError, }, ErrorCode: "internal_error", } - _, err := config.RefreshToken(ctx, mDB, link) - require.Error(t, err) - require.True(t, externalauth.IsInvalidTokenError(err)) - require.Equal(t, refreshCount, 1) + totalRefreshes := 0 + for i := 0; i < 3; i++ { + // Each loop will hit the temporary error and retry. + _, err := config.RefreshToken(ctx, mDB, link) + require.Error(t, err) + totalRefreshes++ + require.True(t, externalauth.IsInvalidTokenError(err)) + require.Equal(t, refreshCount, totalRefreshes) + } - // Try again with a bad refresh token error - // Expect DB call to remove the refresh token + // Try again with a bad refresh token error. This will invalidate the + // refresh token, and not retry again. Expect DB call to remove the refresh token mDB.EXPECT().UpdateExternalAuthLinkRefreshToken(gomock.Any(), gomock.Any()).Return(nil).Times(1) refreshErr = &oauth2.RetrieveError{ // github error Response: &http.Response{ @@ -197,17 +203,18 @@ func TestRefreshToken(t *testing.T) { }, ErrorCode: "bad_refresh_token", } - _, err = config.RefreshToken(ctx, mDB, link) + _, err := config.RefreshToken(ctx, mDB, link) require.Error(t, err) + totalRefreshes++ require.True(t, externalauth.IsInvalidTokenError(err)) - require.Equal(t, refreshCount, 2) + require.Equal(t, refreshCount, totalRefreshes) // When the refresh token is empty, no api calls should be made link.OAuthRefreshToken = "" // mock'd db, so manually set the token to '' _, err = config.RefreshToken(ctx, mDB, link) require.Error(t, err) require.True(t, externalauth.IsInvalidTokenError(err)) - require.Equal(t, refreshCount, 2) + require.Equal(t, refreshCount, totalRefreshes) }) // ValidateFailure tests if the token is no longer valid with a 401 response. @@ -330,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/httperror/responserror.go b/coderd/httpapi/httperror/responserror.go index be219f538bcf7..000089b6d0bd5 100644 --- a/coderd/httpapi/httperror/responserror.go +++ b/coderd/httpapi/httperror/responserror.go @@ -1,8 +1,12 @@ package httperror import ( + "context" "errors" + "fmt" + "net/http" + "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" ) @@ -17,3 +21,48 @@ func IsResponder(err error) (Responder, bool) { } return nil, false } + +func NewResponseError(status int, resp codersdk.Response) error { + return &responseError{ + status: status, + response: resp, + } +} + +func WriteResponseError(ctx context.Context, rw http.ResponseWriter, err error) { + if responseErr, ok := IsResponder(err); ok { + code, resp := responseErr.Response() + + httpapi.Write(ctx, rw, code, resp) + return + } + + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal server error", + Detail: err.Error(), + }) +} + +type responseError struct { + status int + response codersdk.Response +} + +var ( + _ error = (*responseError)(nil) + _ Responder = (*responseError)(nil) +) + +func (e *responseError) Error() string { + return fmt.Sprintf("%s: %s", e.response.Message, e.response.Detail) +} + +func (e *responseError) Status() int { + return e.status +} + +func (e *responseError) Response() (int, codersdk.Response) { + return e.status, e.response +} + +var ErrResourceNotFound = NewResponseError(http.StatusNotFound, httpapi.ResourceNotFoundResponse) 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/httpmw/loggermw/logger.go b/coderd/httpmw/loggermw/logger.go index 8f21f9aa32123..37e15b3bfcf81 100644 --- a/coderd/httpmw/loggermw/logger.go +++ b/coderd/httpmw/loggermw/logger.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "net/http" + "net/url" + "strconv" + "strings" "sync" "time" @@ -15,6 +18,59 @@ import ( "github.com/coder/coder/v2/coderd/tracing" ) +var ( + safeParams = []string{"page", "limit", "offset"} + countParams = []string{"ids", "template_ids"} +) + +func safeQueryParams(params url.Values) []slog.Field { + if len(params) == 0 { + return nil + } + + fields := make([]slog.Field, 0, len(params)) + for key, values := range params { + // Check if this parameter should be included + for _, pattern := range safeParams { + if strings.EqualFold(key, pattern) { + // Prepend query parameters in the log line to ensure we don't have issues with collisions + // in case any other internal logging fields already log fields with similar names + fieldName := "query_" + key + + // Log the actual values for non-sensitive parameters + if len(values) == 1 { + fields = append(fields, slog.F(fieldName, values[0])) + continue + } + fields = append(fields, slog.F(fieldName, values)) + } + } + // Some query params we just want to log the count of the params length + for _, pattern := range countParams { + if !strings.EqualFold(key, pattern) { + continue + } + count := 0 + + // Prepend query parameters in the log line to ensure we don't have issues with collisions + // in case any other internal logging fields already log fields with similar names + fieldName := "query_" + key + + // Count comma-separated values for CSV format + for _, v := range values { + if strings.Contains(v, ",") { + count += len(strings.Split(v, ",")) + continue + } + count++ + } + // For logging we always want strings + fields = append(fields, slog.F(fieldName+"_count", strconv.Itoa(count))) + } + } + return fields +} + func Logger(log slog.Logger) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { @@ -39,6 +95,11 @@ func Logger(log slog.Logger) func(next http.Handler) http.Handler { slog.F("start", start), ) + // Add safe query parameters to the log + if queryFields := safeQueryParams(r.URL.Query()); len(queryFields) > 0 { + httplog = httplog.With(queryFields...) + } + logContext := NewRequestLogger(httplog, r.Method, start) ctx := WithRequestLogger(r.Context(), logContext) diff --git a/coderd/httpmw/loggermw/logger_internal_test.go b/coderd/httpmw/loggermw/logger_internal_test.go index f372c665fda14..bf090464241a0 100644 --- a/coderd/httpmw/loggermw/logger_internal_test.go +++ b/coderd/httpmw/loggermw/logger_internal_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "net/url" "slices" "strings" "sync" @@ -292,6 +293,76 @@ func TestRequestLogger_RouteParamsLogging(t *testing.T) { } } +func TestSafeQueryParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + params url.Values + expected map[string]interface{} + }{ + { + name: "safe parameters", + params: url.Values{ + "page": []string{"1"}, + "limit": []string{"10"}, + "filter": []string{"active"}, + "sort": []string{"name"}, + "offset": []string{"2"}, + "ids": []string{"some-id,another-id", "second-param"}, + "template_ids": []string{"some-id,another-id", "second-param"}, + }, + expected: map[string]interface{}{ + "query_page": "1", + "query_limit": "10", + "query_offset": "2", + "query_ids_count": "3", + "query_template_ids_count": "3", + }, + }, + { + name: "unknown/sensitive parameters", + params: url.Values{ + "token": []string{"secret-token"}, + "api_key": []string{"secret-key"}, + "coder_signed_app_token": []string{"jwt-token"}, + "coder_application_connect_api_key": []string{"encrypted-key"}, + "client_secret": []string{"oauth-secret"}, + "code": []string{"auth-code"}, + }, + expected: map[string]interface{}{}, + }, + { + name: "mixed parameters", + params: url.Values{ + "page": []string{"1"}, + "token": []string{"secret"}, + "filter": []string{"active"}, + }, + expected: map[string]interface{}{ + "query_page": "1", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fields := safeQueryParams(tt.params) + + // Convert fields to map for easier comparison + result := make(map[string]interface{}) + for _, field := range fields { + result[field.Name] = field.Value + } + + require.Equal(t, tt.expected, result) + }) + } +} + type fakeSink struct { entries []slog.SinkEntry newEntries chan slog.SinkEntry diff --git a/coderd/httpmw/pprof.go b/coderd/httpmw/pprof.go new file mode 100644 index 0000000000000..4c51c1ebe552e --- /dev/null +++ b/coderd/httpmw/pprof.go @@ -0,0 +1,43 @@ +package httpmw + +import ( + "context" + "net/http" + "runtime/pprof" + + "github.com/coder/coder/v2/coderd/pproflabel" +) + +// WithProfilingLabels adds a pprof label to all http request handlers. This is +// primarily used to determine if load is coming from background jobs, or from +// http traffic. +func WithProfilingLabels(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Label to differentiate between http and websocket requests. Websocket requests + // are assumed to be long-lived and more resource consuming. + requestType := "http" + if r.Header.Get("Upgrade") == "websocket" { + requestType = "websocket" + } + + pprof.Do(ctx, pproflabel.Service(pproflabel.ServiceHTTPServer, pproflabel.RequestTypeTag, requestType), func(ctx context.Context) { + r = r.WithContext(ctx) + next.ServeHTTP(rw, r) + }) + }) +} + +func WithStaticProfilingLabels(labels pprof.LabelSet) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + pprof.Do(ctx, labels, func(ctx context.Context) { + r = r.WithContext(ctx) + next.ServeHTTP(rw, r) + }) + }) + } +} 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/mcp/mcp.go b/coderd/mcp/mcp.go index f17ab5ae7cd93..3696beff500a1 100644 --- a/coderd/mcp/mcp.go +++ b/coderd/mcp/mcp.go @@ -67,7 +67,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.streamableServer.ServeHTTP(w, r) } -// RegisterTools registers all available MCP tools with the server +// Register all available MCP tools with the server excluding: +// - ReportTask - which requires dependencies not available in the remote MCP context +// - ChatGPT search and fetch tools, which are redundant with the standard tools. func (s *Server) RegisterTools(client *codersdk.Client) error { if client == nil { return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client") @@ -79,10 +81,36 @@ func (s *Server) RegisterTools(client *codersdk.Client) error { return xerrors.Errorf("failed to initialize tool dependencies: %w", err) } - // Register all available tools, but exclude tools that require dependencies not available in the - // remote MCP context for _, tool := range toolsdk.All { - if tool.Name == toolsdk.ToolNameReportTask { + // the ReportTask tool requires dependencies not available in the remote MCP context + // the ChatGPT search and fetch tools are redundant with the standard tools. + if tool.Name == toolsdk.ToolNameReportTask || + tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch { + continue + } + + s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps)) + } + return nil +} + +// ChatGPT tools are the search and fetch tools as defined in https://platform.openai.com/docs/mcp. +// We do not expose any extra ones because ChatGPT has an undocumented "Safety Scan" feature. +// In my experiments, if I included extra tools in the MCP server, ChatGPT would often - but not always - +// refuse to add Coder as a connector. +func (s *Server) RegisterChatGPTTools(client *codersdk.Client) error { + if client == nil { + return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client") + } + + // Create tool dependencies + toolDeps, err := toolsdk.NewDeps(client) + if err != nil { + return xerrors.Errorf("failed to initialize tool dependencies: %w", err) + } + + for _, tool := range toolsdk.All { + if tool.Name != toolsdk.ToolNameChatGPTSearch && tool.Name != toolsdk.ToolNameChatGPTFetch { continue } diff --git a/coderd/mcp/mcp_e2e_test.go b/coderd/mcp/mcp_e2e_test.go index 248786405fda9..b831d150c2c0d 100644 --- a/coderd/mcp/mcp_e2e_test.go +++ b/coderd/mcp/mcp_e2e_test.go @@ -1215,6 +1215,155 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) { }) } +func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) { + t.Parallel() + + // Setup Coder server with authentication + coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + defer closer.Close() + + user := coderdtest.CreateFirstUser(t, coderClient) + + // Create template and workspace for testing search functionality + version := coderdtest.CreateTemplateVersion(t, coderClient, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, coderClient, version.ID) + template := coderdtest.CreateTemplate(t, coderClient, user.OrganizationID, version.ID) + + // Create MCP client pointing to the ChatGPT endpoint + mcpURL := api.AccessURL.String() + "/api/experimental/mcp/http?toolset=chatgpt" + + // Configure client with authentication headers using RFC 6750 Bearer token + mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL, + transport.WithHTTPHeaders(map[string]string{ + "Authorization": "Bearer " + coderClient.SessionToken(), + })) + require.NoError(t, err) + t.Cleanup(func() { + if closeErr := mcpClient.Close(); closeErr != nil { + t.Logf("Failed to close MCP client: %v", closeErr) + } + }) + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + defer cancel() + + // Start client + err = mcpClient.Start(ctx) + require.NoError(t, err) + + // Initialize connection + initReq := mcp.InitializeRequest{ + Params: mcp.InitializeParams{ + ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION, + ClientInfo: mcp.Implementation{ + Name: "test-chatgpt-client", + Version: "1.0.0", + }, + }, + } + + result, err := mcpClient.Initialize(ctx, initReq) + require.NoError(t, err) + require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name) + require.Equal(t, mcp.LATEST_PROTOCOL_VERSION, result.ProtocolVersion) + require.NotNil(t, result.Capabilities) + + // Test tool listing - should only have search and fetch tools for ChatGPT + tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + require.NoError(t, err) + require.NotEmpty(t, tools.Tools) + + // Verify we have exactly the ChatGPT tools and no others + var foundTools []string + for _, tool := range tools.Tools { + foundTools = append(foundTools, tool.Name) + } + + // ChatGPT endpoint should only expose search and fetch tools + assert.Contains(t, foundTools, toolsdk.ToolNameChatGPTSearch, "Should have ChatGPT search tool") + assert.Contains(t, foundTools, toolsdk.ToolNameChatGPTFetch, "Should have ChatGPT fetch tool") + assert.Len(t, foundTools, 2, "ChatGPT endpoint should only expose search and fetch tools") + + // Should NOT have other tools that are available in the standard endpoint + assert.NotContains(t, foundTools, toolsdk.ToolNameGetAuthenticatedUser, "Should not have authenticated user tool") + assert.NotContains(t, foundTools, toolsdk.ToolNameListWorkspaces, "Should not have list workspaces tool") + + t.Logf("ChatGPT endpoint tools: %v", foundTools) + + // Test search tool - search for templates + var searchTool *mcp.Tool + for _, tool := range tools.Tools { + if tool.Name == toolsdk.ToolNameChatGPTSearch { + searchTool = &tool + break + } + } + require.NotNil(t, searchTool, "Expected to find search tool") + + // Execute search for templates + searchReq := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: searchTool.Name, + Arguments: map[string]any{ + "query": "templates", + }, + }, + } + + searchResult, err := mcpClient.CallTool(ctx, searchReq) + require.NoError(t, err) + require.NotEmpty(t, searchResult.Content) + + // Verify the search result contains our template + assert.Len(t, searchResult.Content, 1) + if textContent, ok := searchResult.Content[0].(mcp.TextContent); ok { + assert.Equal(t, "text", textContent.Type) + assert.Contains(t, textContent.Text, template.ID.String(), "Search result should contain our test template") + t.Logf("Search result: %s", textContent.Text) + } else { + t.Errorf("Expected TextContent type, got %T", searchResult.Content[0]) + } + + // Test fetch tool + var fetchTool *mcp.Tool + for _, tool := range tools.Tools { + if tool.Name == toolsdk.ToolNameChatGPTFetch { + fetchTool = &tool + break + } + } + require.NotNil(t, fetchTool, "Expected to find fetch tool") + + // Execute fetch for the template + fetchReq := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: fetchTool.Name, + Arguments: map[string]any{ + "id": fmt.Sprintf("template:%s", template.ID.String()), + }, + }, + } + + fetchResult, err := mcpClient.CallTool(ctx, fetchReq) + require.NoError(t, err) + require.NotEmpty(t, fetchResult.Content) + + // Verify the fetch result contains template details + assert.Len(t, fetchResult.Content, 1) + if textContent, ok := fetchResult.Content[0].(mcp.TextContent); ok { + assert.Equal(t, "text", textContent.Type) + assert.Contains(t, textContent.Text, template.Name, "Fetch result should contain template name") + assert.Contains(t, textContent.Text, template.ID.String(), "Fetch result should contain template ID") + t.Logf("Fetch result contains template data") + } else { + t.Errorf("Expected TextContent type, got %T", fetchResult.Content[0]) + } + + t.Logf("ChatGPT endpoint E2E test successful: search and fetch tools working correctly") +} + // Helper function to parse URL safely in tests func mustParseURL(t *testing.T, rawURL string) *url.URL { u, err := url.Parse(rawURL) diff --git a/coderd/mcp/mcp_test.go b/coderd/mcp/mcp_test.go index 0c53c899b9830..b7b5a714780d9 100644 --- a/coderd/mcp/mcp_test.go +++ b/coderd/mcp/mcp_test.go @@ -115,7 +115,7 @@ func TestMCPHTTP_ToolRegistration(t *testing.T) { require.Contains(t, err.Error(), "client cannot be nil", "Should reject nil client with appropriate error message") // Test registering tools with valid client should succeed - client := &codersdk.Client{} + client := codersdk.New(testutil.MustURL(t, "http://not-used")) err = server.RegisterTools(client) require.NoError(t, err) diff --git a/coderd/mcp_http.go b/coderd/mcp_http.go index 40aaaa1c40dd5..51082858fe55e 100644 --- a/coderd/mcp_http.go +++ b/coderd/mcp_http.go @@ -1,6 +1,7 @@ package coderd import ( + "fmt" "net/http" "cdr.dev/slog" @@ -11,7 +12,15 @@ import ( "github.com/coder/coder/v2/codersdk" ) +type MCPToolset string + +const ( + MCPToolsetStandard MCPToolset = "standard" + MCPToolsetChatGPT MCPToolset = "chatgpt" +) + // mcpHTTPHandler creates the MCP HTTP transport handler +// It supports a "toolset" query parameter to select the set of tools to register. func (api *API) mcpHTTPHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Create MCP server instance for each request @@ -23,14 +32,30 @@ func (api *API) mcpHTTPHandler() http.Handler { }) return } - authenticatedClient := codersdk.New(api.AccessURL) // Extract the original session token from the request authenticatedClient.SetSessionToken(httpmw.APITokenFromRequest(r)) - // Register tools with authenticated client - if err := mcpServer.RegisterTools(authenticatedClient); err != nil { - api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) + toolset := MCPToolset(r.URL.Query().Get("toolset")) + // Default to standard toolset if no toolset is specified. + if toolset == "" { + toolset = MCPToolsetStandard + } + + switch toolset { + case MCPToolsetStandard: + if err := mcpServer.RegisterTools(authenticatedClient); err != nil { + api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) + } + case MCPToolsetChatGPT: + if err := mcpServer.RegisterChatGPTTools(authenticatedClient); err != nil { + api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) + } + default: + httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Invalid toolset: %s", toolset), + }) + return } // Handle the MCP request diff --git a/coderd/members.go b/coderd/members.go index 0bd5bb1fbc8bd..371b58015b83b 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -203,6 +203,7 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { paginatedMemberRows, err := api.Database.PaginatedOrganizationMembers(ctx, database.PaginatedOrganizationMembersParams{ OrganizationID: organization.ID, + IncludeSystem: false, // #nosec G115 - Pagination limits are small and fit in int32 LimitOpt: int32(paginationParams.Limit), // #nosec G115 - Pagination offsets are small and fit in int32 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.go b/coderd/notifications/manager.go index 11588a09fb797..943306d443265 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -11,12 +11,13 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/quartz" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/notifications/dispatch" + "github.com/coder/coder/v2/coderd/pproflabel" "github.com/coder/coder/v2/codersdk" + "github.com/coder/quartz" ) var ErrInvalidDispatchTimeout = xerrors.New("dispatch timeout must be less than lease period") @@ -145,7 +146,7 @@ func (m *Manager) Run(ctx context.Context) { m.runOnce.Do(func() { // Closes when Stop() is called or context is canceled. - go func() { + pproflabel.Go(ctx, pproflabel.Service(pproflabel.ServiceNotifications), func(ctx context.Context) { err := m.loop(ctx) if err != nil { if xerrors.Is(err, ErrManagerAlreadyClosed) { @@ -154,7 +155,7 @@ func (m *Manager) Run(ctx context.Context) { m.log.Error(ctx, "notification manager stopped with error", slog.Error(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/pproflabel/pproflabel.go b/coderd/pproflabel/pproflabel.go new file mode 100644 index 0000000000000..bde5be1b3630e --- /dev/null +++ b/coderd/pproflabel/pproflabel.go @@ -0,0 +1,43 @@ +package pproflabel + +import ( + "context" + "runtime/pprof" +) + +// Go is just a convince wrapper to set off a labeled goroutine. +func Go(ctx context.Context, labels pprof.LabelSet, f func(context.Context)) { + go pprof.Do(ctx, labels, f) +} + +func Do(ctx context.Context, labels pprof.LabelSet, f func(context.Context)) { + pprof.Do(ctx, labels, f) +} + +const ( + // ServiceTag should not collide with the pyroscope built-in tag "service". + // Use `coder_` to avoid collisions. + ServiceTag = "coder_service" + + ServiceHTTPServer = "http-api" + ServiceLifecycles = "lifecycle-executor" + ServicePrebuildReconciler = "prebuilds-reconciler" + ServiceTerraformProvisioner = "terraform-provisioner" + ServiceDBPurge = "db-purge" + ServiceNotifications = "notifications" + ServiceReplicaSync = "replica-sync" + // ServiceMetricCollector collects metrics from insights in the database and + // exports them in a prometheus collector format. + ServiceMetricCollector = "metrics-collector" + // ServiceAgentMetricAggregator merges agent metrics and exports them in a + // prometheus collector format. + ServiceAgentMetricAggregator = "agent-metrics-aggregator" + // ServiceTallymanPublisher publishes usage events to coder/tallyman. + ServiceTallymanPublisher = "tallyman-publisher" + + RequestTypeTag = "coder_request_type" +) + +func Service(name string, pairs ...string) pprof.LabelSet { + return pprof.Labels(append([]string{ServiceTag, name}, pairs...)...) +} diff --git a/coderd/prebuilds/api.go b/coderd/prebuilds/api.go index 3092d27421d26..1bedeb10130c8 100644 --- a/coderd/prebuilds/api.go +++ b/coderd/prebuilds/api.go @@ -2,6 +2,8 @@ package prebuilds import ( "context" + "database/sql" + "time" "github.com/google/uuid" "golang.org/x/xerrors" @@ -54,6 +56,15 @@ type StateSnapshotter interface { } type Claimer interface { - Claim(ctx context.Context, userID uuid.UUID, name string, presetID uuid.UUID) (*uuid.UUID, error) + Claim( + ctx context.Context, + now time.Time, + userID uuid.UUID, + name string, + presetID uuid.UUID, + autostartSchedule sql.NullString, + nextStartAt sql.NullTime, + ttl sql.NullInt64, + ) (*uuid.UUID, error) Initiator() uuid.UUID } diff --git a/coderd/prebuilds/noop.go b/coderd/prebuilds/noop.go index 3c2dd78a804db..ebb6d6964214e 100644 --- a/coderd/prebuilds/noop.go +++ b/coderd/prebuilds/noop.go @@ -2,6 +2,8 @@ package prebuilds import ( "context" + "database/sql" + "time" "github.com/google/uuid" @@ -28,7 +30,7 @@ var DefaultReconciler ReconciliationOrchestrator = NoopReconciler{} type NoopClaimer struct{} -func (NoopClaimer) Claim(context.Context, uuid.UUID, string, uuid.UUID) (*uuid.UUID, error) { +func (NoopClaimer) Claim(context.Context, time.Time, uuid.UUID, string, uuid.UUID, sql.NullString, sql.NullTime, sql.NullInt64) (*uuid.UUID, error) { // Not entitled to claim prebuilds in AGPL version. return nil, ErrAGPLDoesNotSupportPrebuiltWorkspaces } 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/aggregator.go b/coderd/prometheusmetrics/aggregator.go index 44ade677d5cff..ad51c3e7fa8a7 100644 --- a/coderd/prometheusmetrics/aggregator.go +++ b/coderd/prometheusmetrics/aggregator.go @@ -11,11 +11,11 @@ import ( "github.com/prometheus/common/model" "golang.org/x/xerrors" - "github.com/coder/coder/v2/coderd/agentmetrics" - "cdr.dev/slog" agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/coderd/agentmetrics" + "github.com/coder/coder/v2/coderd/pproflabel" ) const ( @@ -298,7 +298,7 @@ func (ma *MetricsAggregator) Run(ctx context.Context) func() { done := make(chan struct{}) cleanupTicker := time.NewTicker(ma.metricsCleanupInterval) - go func() { + pproflabel.Go(ctx, pproflabel.Service(pproflabel.ServiceAgentMetricAggregator), func(ctx context.Context) { defer close(done) defer cleanupTicker.Stop() @@ -395,7 +395,7 @@ func (ma *MetricsAggregator) Run(ctx context.Context) func() { return } } - }() + }) return func() { cancelFunc() <-done diff --git a/coderd/prometheusmetrics/insights/metricscollector.go b/coderd/prometheusmetrics/insights/metricscollector.go index 41d3a0220f391..a095968526ca8 100644 --- a/coderd/prometheusmetrics/insights/metricscollector.go +++ b/coderd/prometheusmetrics/insights/metricscollector.go @@ -14,6 +14,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/pproflabel" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" ) @@ -158,7 +159,7 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) { }) } - go func() { + pproflabel.Go(ctx, pproflabel.Service(pproflabel.ServiceMetricCollector), func(ctx context.Context) { defer close(done) defer ticker.Stop() for { @@ -170,7 +171,7 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) { doTick() } } - }() + }) return func() { closeFunc() <-done 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 4fd2cfda607ed..ed55e4598dc21 100644 --- a/coderd/prometheusmetrics/prometheusmetrics.go +++ b/coderd/prometheusmetrics/prometheusmetrics.go @@ -150,7 +150,7 @@ func Workspaces(ctx context.Context, logger slog.Logger, registerer prometheus.R Namespace: "coderd", Subsystem: "api", Name: "workspace_latest_build", - Help: "The current number of workspace builds by status.", + Help: "The current number of workspace builds by status for all non-deleted workspaces.", }, []string{"status"}) if err := registerer.Register(workspaceLatestBuildTotals); err != nil { return nil, err @@ -159,68 +159,79 @@ func Workspaces(ctx context.Context, logger slog.Logger, registerer prometheus.R workspaceLatestBuildStatuses := prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "coderd", Name: "workspace_latest_build_status", - Help: "The current workspace statuses by template, transition, and owner.", + Help: "The current workspace statuses by template, transition, and owner for all non-deleted workspaces.", }, []string{"status", "template_name", "template_version", "workspace_owner", "workspace_transition"}) if err := registerer.Register(workspaceLatestBuildStatuses); err != nil { return nil, err } + workspaceCreationTotal := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "coderd", + Name: "workspace_creation_total", + Help: "Total regular (non-prebuilt) workspace creations by organization, template, and preset.", + }, + []string{"organization_name", "template_name", "preset_name"}, + ) + if err := registerer.Register(workspaceCreationTotal); err != nil { + return nil, err + } + ctx, cancelFunc := context.WithCancel(ctx) done := make(chan struct{}) - updateWorkspaceTotals := func() { - builds, err := db.GetLatestWorkspaceBuilds(ctx) + updateWorkspaceMetrics := func() { + ws, err := db.GetWorkspaces(ctx, database.GetWorkspacesParams{ + Deleted: false, + WithSummary: false, + }) if err != nil { if errors.Is(err, sql.ErrNoRows) { - // clear all series if there are no database entries workspaceLatestBuildTotals.Reset() + workspaceLatestBuildStatuses.Reset() } else { - logger.Warn(ctx, "failed to load latest workspace builds", slog.Error(err)) + logger.Warn(ctx, "failed to load active workspaces for metrics", slog.Error(err)) } return } - jobIDs := make([]uuid.UUID, 0, len(builds)) - for _, build := range builds { - jobIDs = append(jobIDs, build.JobID) - } - jobs, err := db.GetProvisionerJobsByIDs(ctx, jobIDs) - if err != nil { - ids := make([]string, 0, len(jobIDs)) - for _, id := range jobIDs { - ids = append(ids, id.String()) - } - - logger.Warn(ctx, "failed to load provisioner jobs", slog.F("ids", ids), slog.Error(err)) - return - } workspaceLatestBuildTotals.Reset() - for _, job := range jobs { - status := codersdk.ProvisionerJobStatus(job.JobStatus) - workspaceLatestBuildTotals.WithLabelValues(string(status)).Add(1) + workspaceLatestBuildStatuses.Reset() + + for _, w := range ws { + status := string(w.LatestBuildStatus) + workspaceLatestBuildTotals.WithLabelValues(status).Add(1) // TODO: deprecated: remove in the future - workspaceLatestBuildTotalsDeprecated.WithLabelValues(string(status)).Add(1) + workspaceLatestBuildTotalsDeprecated.WithLabelValues(status).Add(1) + + workspaceLatestBuildStatuses.WithLabelValues( + status, + w.TemplateName, + w.TemplateVersionName.String, + w.OwnerUsername, + string(w.LatestBuildTransition), + ).Add(1) } - } - updateWorkspaceStatuses := func() { - ws, err := db.GetWorkspaces(ctx, database.GetWorkspacesParams{ - Deleted: false, - WithSummary: false, - }) + // Update regular workspaces (without a prebuild transition) creation counter + regularWorkspaces, err := db.GetRegularWorkspaceCreateMetrics(ctx) if err != nil { if errors.Is(err, sql.ErrNoRows) { - // clear all series if there are no database entries - workspaceLatestBuildStatuses.Reset() + workspaceCreationTotal.Reset() + } else { + logger.Warn(ctx, "failed to load regular workspaces for metrics", slog.Error(err)) } - - logger.Warn(ctx, "failed to load active workspaces", slog.Error(err)) return } - workspaceLatestBuildStatuses.Reset() - for _, w := range ws { - workspaceLatestBuildStatuses.WithLabelValues(string(w.LatestBuildStatus), w.TemplateName, w.TemplateVersionName.String, w.OwnerUsername, string(w.LatestBuildTransition)).Add(1) + workspaceCreationTotal.Reset() + + for _, regularWorkspace := range regularWorkspaces { + workspaceCreationTotal.WithLabelValues( + regularWorkspace.OrganizationName, + regularWorkspace.TemplateName, + regularWorkspace.PresetName, + ).Add(float64(regularWorkspace.CreatedCount)) } } @@ -230,8 +241,7 @@ func Workspaces(ctx context.Context, logger slog.Logger, registerer prometheus.R doTick := func() { defer ticker.Reset(duration) - updateWorkspaceTotals() - updateWorkspaceStatuses() + updateWorkspaceMetrics() } go func() { @@ -351,29 +361,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) @@ -383,7 +388,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)) @@ -408,7 +413,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)) } } @@ -420,7 +425,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/prometheusmetrics/prometheusmetrics_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index 473dbf46bd958..3d8704f92460d 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -247,6 +247,32 @@ func TestWorkspaceLatestBuildTotals(t *testing.T) { codersdk.ProvisionerJobSucceeded: 3, codersdk.ProvisionerJobRunning: 1, }, + }, { + Name: "MultipleWithDeleted", + Database: func() database.Store { + db, _ := dbtestutil.NewDB(t) + u := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + insertCanceled(t, db, u, org) + insertFailed(t, db, u, org) + insertSuccess(t, db, u, org) + insertRunning(t, db, u, org) + + // Verify that deleted workspaces/builds are NOT counted in metrics. + n, err := cryptorand.Intn(5) + require.NoError(t, err) + for range 1 + n { + insertDeleted(t, db, u, org) + } + return db + }, + Total: 4, // Only non-deleted workspaces should be counted + Status: map[codersdk.ProvisionerJobStatus]int{ + codersdk.ProvisionerJobCanceled: 1, + codersdk.ProvisionerJobFailed: 1, + codersdk.ProvisionerJobSucceeded: 1, + codersdk.ProvisionerJobRunning: 1, + }, }} { t.Run(tc.Name, func(t *testing.T) { t.Parallel() @@ -323,6 +349,33 @@ func TestWorkspaceLatestBuildStatuses(t *testing.T) { codersdk.ProvisionerJobSucceeded: 3, codersdk.ProvisionerJobRunning: 1, }, + }, { + Name: "MultipleWithDeleted", + Database: func() database.Store { + db, _ := dbtestutil.NewDB(t) + u := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + insertTemplates(t, db, u, org) + insertCanceled(t, db, u, org) + insertFailed(t, db, u, org) + insertSuccess(t, db, u, org) + insertRunning(t, db, u, org) + + // Verify that deleted workspaces/builds are NOT counted in metrics. + n, err := cryptorand.Intn(5) + require.NoError(t, err) + for range 1 + n { + insertDeleted(t, db, u, org) + } + return db + }, + ExpectedWorkspaces: 4, // Only non-deleted workspaces should be counted + ExpectedStatuses: map[codersdk.ProvisionerJobStatus]int{ + codersdk.ProvisionerJobCanceled: 1, + codersdk.ProvisionerJobFailed: 1, + codersdk.ProvisionerJobSucceeded: 1, + codersdk.ProvisionerJobRunning: 1, + }, }} { t.Run(tc.Name, func(t *testing.T) { t.Parallel() @@ -371,6 +424,107 @@ func TestWorkspaceLatestBuildStatuses(t *testing.T) { } } +func TestWorkspaceCreationTotal(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + Name string + Database func() database.Store + ExpectedWorkspaces int + }{ + { + Name: "None", + Database: func() database.Store { + db, _ := dbtestutil.NewDB(t) + return db + }, + ExpectedWorkspaces: 0, + }, + { + // Should count only the successfully created workspaces + Name: "Multiple", + Database: func() database.Store { + db, _ := dbtestutil.NewDB(t) + u := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + insertTemplates(t, db, u, org) + insertCanceled(t, db, u, org) + insertFailed(t, db, u, org) + insertFailed(t, db, u, org) + insertSuccess(t, db, u, org) + insertSuccess(t, db, u, org) + insertSuccess(t, db, u, org) + insertRunning(t, db, u, org) + return db + }, + ExpectedWorkspaces: 3, + }, + { + // Should not include prebuilt workspaces + Name: "MultipleWithPrebuild", + Database: func() database.Store { + ctx := context.Background() + db, _ := dbtestutil.NewDB(t) + u := dbgen.User(t, db, database.User{}) + prebuildUser, err := db.GetUserByID(ctx, database.PrebuildsSystemUserID) + require.NoError(t, err) + org := dbgen.Organization(t, db, database.Organization{}) + insertTemplates(t, db, u, org) + insertCanceled(t, db, u, org) + insertFailed(t, db, u, org) + insertSuccess(t, db, u, org) + insertSuccess(t, db, prebuildUser, org) + insertRunning(t, db, u, org) + return db + }, + ExpectedWorkspaces: 1, + }, + { + // Should include deleted workspaces + Name: "MultipleWithDeleted", + Database: func() database.Store { + db, _ := dbtestutil.NewDB(t) + u := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + insertTemplates(t, db, u, org) + insertCanceled(t, db, u, org) + insertFailed(t, db, u, org) + insertSuccess(t, db, u, org) + insertRunning(t, db, u, org) + insertDeleted(t, db, u, org) + return db + }, + ExpectedWorkspaces: 2, + }, + } { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + registry := prometheus.NewRegistry() + closeFunc, err := prometheusmetrics.Workspaces(context.Background(), testutil.Logger(t), registry, tc.Database(), testutil.IntervalFast) + require.NoError(t, err) + t.Cleanup(closeFunc) + + require.Eventually(t, func() bool { + metrics, err := registry.Gather() + assert.NoError(t, err) + + sum := 0 + for _, m := range metrics { + if m.GetName() != "coderd_workspace_creation_total" { + continue + } + for _, metric := range m.Metric { + sum += int(metric.GetCounter().GetValue()) + } + } + + t.Logf("count = %d, expected == %d", sum, tc.ExpectedWorkspaces) + return sum == tc.ExpectedWorkspaces + }, testutil.WaitShort, testutil.IntervalFast) + }) + } +} + func TestAgents(t *testing.T) { t.Parallel() @@ -844,6 +998,7 @@ func insertRunning(t *testing.T, db database.Store, u database.User, org databas Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator, TemplateVersionID: templateVersionID, + InitiatorID: u.ID, }) require.NoError(t, err) // This marks the job as started. @@ -907,3 +1062,24 @@ func insertSuccess(t *testing.T, db database.Store, u database.User, org databas }) require.NoError(t, err) } + +func insertDeleted(t *testing.T, db database.Store, u database.User, org database.Organization) { + job := insertRunning(t, db, u, org) + err := db.UpdateProvisionerJobWithCompleteByID(context.Background(), database.UpdateProvisionerJobWithCompleteByIDParams{ + ID: job.ID, + CompletedAt: sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + }, + }) + require.NoError(t, err) + + build, err := db.GetWorkspaceBuildByJobID(context.Background(), job.ID) + require.NoError(t, err) + + err = db.UpdateWorkspaceDeletedByID(context.Background(), database.UpdateWorkspaceDeletedByIDParams{ + ID: build.WorkspaceID, + Deleted: true, + }) + require.NoError(t, err) +} 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/metrics.go b/coderd/provisionerdserver/metrics.go new file mode 100644 index 0000000000000..b1afc10670f22 --- /dev/null +++ b/coderd/provisionerdserver/metrics.go @@ -0,0 +1,158 @@ +package provisionerdserver + +import ( + "context" + "time" + + "github.com/prometheus/client_golang/prometheus" + + "cdr.dev/slog" +) + +type Metrics struct { + logger slog.Logger + workspaceCreationTimings *prometheus.HistogramVec + workspaceClaimTimings *prometheus.HistogramVec +} + +type WorkspaceTimingType int + +const ( + Unsupported WorkspaceTimingType = iota + WorkspaceCreation + PrebuildCreation + PrebuildClaim +) + +const ( + workspaceTypeRegular = "regular" + workspaceTypePrebuild = "prebuild" +) + +type WorkspaceTimingFlags struct { + IsPrebuild bool + IsClaim bool + IsFirstBuild bool +} + +func NewMetrics(logger slog.Logger) *Metrics { + log := logger.Named("provisionerd_server_metrics") + + return &Metrics{ + logger: log, + workspaceCreationTimings: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "coderd", + Name: "workspace_creation_duration_seconds", + Help: "Time to create a workspace by organization, template, preset, and type (regular or prebuild).", + Buckets: []float64{ + 1, // 1s + 10, + 30, + 60, // 1min + 60 * 5, + 60 * 10, + 60 * 30, // 30min + 60 * 60, // 1hr + }, + NativeHistogramBucketFactor: 1.1, + // Max number of native buckets kept at once to bound memory. + NativeHistogramMaxBucketNumber: 100, + // Merge/flush small buckets periodically to control churn. + NativeHistogramMinResetDuration: time.Hour, + // Treat tiny values as zero (helps with noisy near-zero latencies). + NativeHistogramZeroThreshold: 0, + NativeHistogramMaxZeroThreshold: 0, + }, []string{"organization_name", "template_name", "preset_name", "type"}), + workspaceClaimTimings: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "coderd", + Name: "prebuilt_workspace_claim_duration_seconds", + Help: "Time to claim a prebuilt workspace by organization, template, and preset.", + // Higher resolution between 1–5m to show typical prebuild claim times. + // Cap at 5m since longer claims diminish prebuild value. + Buckets: []float64{ + 1, // 1s + 5, + 10, + 20, + 30, + 60, // 1m + 120, // 2m + 180, // 3m + 240, // 4m + 300, // 5m + }, + NativeHistogramBucketFactor: 1.1, + // Max number of native buckets kept at once to bound memory. + NativeHistogramMaxBucketNumber: 100, + // Merge/flush small buckets periodically to control churn. + NativeHistogramMinResetDuration: time.Hour, + // Treat tiny values as zero (helps with noisy near-zero latencies). + NativeHistogramZeroThreshold: 0, + NativeHistogramMaxZeroThreshold: 0, + }, []string{"organization_name", "template_name", "preset_name"}), + } +} + +func (m *Metrics) Register(reg prometheus.Registerer) error { + if err := reg.Register(m.workspaceCreationTimings); err != nil { + return err + } + return reg.Register(m.workspaceClaimTimings) +} + +// getWorkspaceTimingType classifies a workspace build: +// - PrebuildCreation: creation of a prebuilt workspace +// - PrebuildClaim: claim of an existing prebuilt workspace +// - WorkspaceCreation: first build of a regular (non-prebuilt) workspace +// +// Note: order matters. Creating a prebuilt workspace is also a first build +// (IsPrebuild && IsFirstBuild). We check IsPrebuild before IsFirstBuild so +// prebuilds take precedence. This is the only case where two flags can be true. +func getWorkspaceTimingType(flags WorkspaceTimingFlags) WorkspaceTimingType { + switch { + case flags.IsPrebuild: + return PrebuildCreation + case flags.IsClaim: + return PrebuildClaim + case flags.IsFirstBuild: + return WorkspaceCreation + default: + return Unsupported + } +} + +// UpdateWorkspaceTimingsMetrics updates the workspace timing metrics based on the workspace build type +func (m *Metrics) UpdateWorkspaceTimingsMetrics( + ctx context.Context, + flags WorkspaceTimingFlags, + organizationName string, + templateName string, + presetName string, + buildTime float64, +) { + m.logger.Debug(ctx, "update workspace timings metrics", + "organizationName", organizationName, + "templateName", templateName, + "presetName", presetName, + "isPrebuild", flags.IsPrebuild, + "isClaim", flags.IsClaim, + "isWorkspaceFirstBuild", flags.IsFirstBuild) + + workspaceTimingType := getWorkspaceTimingType(flags) + switch workspaceTimingType { + case WorkspaceCreation: + // Regular workspace creation (without prebuild pool) + m.workspaceCreationTimings. + WithLabelValues(organizationName, templateName, presetName, workspaceTypeRegular).Observe(buildTime) + case PrebuildCreation: + // Prebuilt workspace creation duration + m.workspaceCreationTimings. + WithLabelValues(organizationName, templateName, presetName, workspaceTypePrebuild).Observe(buildTime) + case PrebuildClaim: + // Prebuilt workspace claim duration + m.workspaceClaimTimings. + WithLabelValues(organizationName, templateName, presetName).Observe(buildTime) + default: + m.logger.Warn(ctx, "unsupported workspace timing flags") + } +} diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 94173703c467d..4685dad881674 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 @@ -130,6 +129,8 @@ type server struct { heartbeatInterval time.Duration heartbeatFn func(ctx context.Context) error + + metrics *Metrics } // We use the null byte (0x00) in generating a canonical map key for tags, so @@ -174,10 +175,12 @@ 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, prebuildsOrchestrator *atomic.Pointer[prebuilds.ReconciliationOrchestrator], + metrics *Metrics, ) (proto.DRPCProvisionerDaemonServer, error) { // Fail-fast if pointers are nil if lifecycleCtx == nil { @@ -195,6 +198,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 +250,8 @@ func NewServer( heartbeatInterval: options.HeartbeatInterval, heartbeatFn: options.HeartbeatFn, PrebuildsOrchestrator: prebuildsOrchestrator, + UsageInserter: usageInserter, + metrics: metrics, } if s.heartbeatFn == nil { @@ -1183,11 +1191,18 @@ func (s *server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto. if err != nil { return xerrors.Errorf("update workspace build state: %w", err) } + + deadline := build.Deadline + maxDeadline := build.MaxDeadline + if workspace.IsPrebuild() { + deadline = time.Time{} + maxDeadline = time.Time{} + } err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ ID: input.WorkspaceBuildID, UpdatedAt: s.timeNow(), - Deadline: build.Deadline, - MaxDeadline: build.MaxDeadline, + Deadline: deadline, + MaxDeadline: maxDeadline, }) if err != nil { return xerrors.Errorf("update workspace build deadline: %w", err) @@ -1720,16 +1735,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 @@ -1860,37 +1879,47 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro return getWorkspaceError } - templateScheduleStore := *s.TemplateScheduleStore.Load() + // Prebuilt workspaces must not have Deadline or MaxDeadline set, + // as they are managed by the prebuild reconciliation loop, not the lifecycle executor + deadline := time.Time{} + maxDeadline := time.Time{} - autoStop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ - Database: db, - TemplateScheduleStore: templateScheduleStore, - UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(), - Now: now, - Workspace: workspace.WorkspaceTable(), - // Allowed to be the empty string. - WorkspaceAutostart: workspace.AutostartSchedule.String, - }) - if err != nil { - return xerrors.Errorf("calculate auto stop: %w", err) - } + if !workspace.IsPrebuild() { + templateScheduleStore := *s.TemplateScheduleStore.Load() - if workspace.AutostartSchedule.Valid { - templateScheduleOptions, err := templateScheduleStore.Get(ctx, db, workspace.TemplateID) + autoStop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ + Database: db, + TemplateScheduleStore: templateScheduleStore, + UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(), + // `now` is used below to set the build completion time. + WorkspaceBuildCompletedAt: now, + Workspace: workspace.WorkspaceTable(), + // Allowed to be the empty string. + WorkspaceAutostart: workspace.AutostartSchedule.String, + }) if err != nil { - return xerrors.Errorf("get template schedule options: %w", err) + return xerrors.Errorf("calculate auto stop: %w", err) } - nextStartAt, err := schedule.NextAllowedAutostart(now, workspace.AutostartSchedule.String, templateScheduleOptions) - if err == nil { - err = db.UpdateWorkspaceNextStartAt(ctx, database.UpdateWorkspaceNextStartAtParams{ - ID: workspace.ID, - NextStartAt: sql.NullTime{Valid: true, Time: nextStartAt.UTC()}, - }) + if workspace.AutostartSchedule.Valid { + templateScheduleOptions, err := templateScheduleStore.Get(ctx, db, workspace.TemplateID) if err != nil { - return xerrors.Errorf("update workspace next start at: %w", err) + return xerrors.Errorf("get template schedule options: %w", err) + } + + nextStartAt, err := schedule.NextAllowedAutostart(now, workspace.AutostartSchedule.String, templateScheduleOptions) + if err == nil { + err = db.UpdateWorkspaceNextStartAt(ctx, database.UpdateWorkspaceNextStartAtParams{ + ID: workspace.ID, + NextStartAt: sql.NullTime{Valid: true, Time: nextStartAt.UTC()}, + }) + if err != nil { + return xerrors.Errorf("update workspace next start at: %w", err) + } } } + deadline = autoStop.Deadline + maxDeadline = autoStop.MaxDeadline } err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ @@ -1916,20 +1945,24 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro } err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ ID: workspaceBuild.ID, - Deadline: autoStop.Deadline, - MaxDeadline: autoStop.MaxDeadline, + Deadline: deadline, + MaxDeadline: maxDeadline, UpdatedAt: now, }) if err != nil { return xerrors.Errorf("update workspace build deadline: %w", err) } + appIDs := make([]string, 0) agentTimeouts := make(map[time.Duration]bool) // A set of agent timeouts. // This could be a bulk insert to improve performance. for _, protoResource := range jobType.WorkspaceBuild.Resources { for _, protoAgent := range protoResource.Agents { dur := time.Duration(protoAgent.GetConnectionTimeoutSeconds()) * time.Second agentTimeouts[dur] = true + for _, app := range protoAgent.GetApps() { + appIDs = append(appIDs, app.GetId()) + } } err = InsertWorkspaceResource(ctx, db, job.ID, workspaceBuild.Transition, protoResource, telemetrySnapshot) @@ -1944,14 +1977,21 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro } var sidebarAppID uuid.NullUUID - hasAITask := len(jobType.WorkspaceBuild.AiTasks) == 1 - if hasAITask { - task := jobType.WorkspaceBuild.AiTasks[0] - if task.SidebarApp == nil { - return xerrors.Errorf("update ai task: sidebar app is nil") + var hasAITask bool + var warnUnknownSidebarAppID bool + if tasks := jobType.WorkspaceBuild.GetAiTasks(); len(tasks) > 0 { + hasAITask = true + task := tasks[0] + if task == nil || task.GetSidebarApp() == nil || len(task.GetSidebarApp().GetId()) == 0 { + return xerrors.Errorf("update ai task: sidebar app is nil or empty") } - id, err := uuid.Parse(task.SidebarApp.Id) + sidebarTaskID := task.GetSidebarApp().GetId() + if !slices.Contains(appIDs, sidebarTaskID) { + warnUnknownSidebarAppID = true + } + + id, err := uuid.Parse(task.GetSidebarApp().GetId()) if err != nil { return xerrors.Errorf("parse sidebar app id: %w", err) } @@ -1959,19 +1999,114 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro sidebarAppID = uuid.NullUUID{UUID: id, Valid: true} } + // This is a hacky workaround for the issue with tasks 'disappearing' on stop: + // reuse has_ai_task and sidebar_app_id from the previous build. + // This workaround should be removed as soon as possible. + if workspaceBuild.Transition == database.WorkspaceTransitionStop && workspaceBuild.BuildNumber > 1 { + if prevBuild, err := s.Database.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{ + WorkspaceID: workspaceBuild.WorkspaceID, + BuildNumber: workspaceBuild.BuildNumber - 1, + }); err == nil { + hasAITask = prevBuild.HasAITask.Bool + sidebarAppID = prevBuild.AITaskSidebarAppID + warnUnknownSidebarAppID = false + s.Logger.Debug(ctx, "task workaround: reused has_ai_task and sidebar_app_id from previous build to keep track of task", + slog.F("job_id", job.ID.String()), + slog.F("build_number", prevBuild.BuildNumber), + slog.F("workspace_id", workspace.ID), + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("transition", string(workspaceBuild.Transition)), + slog.F("sidebar_app_id", sidebarAppID.UUID), + slog.F("has_ai_task", hasAITask), + ) + } else { + s.Logger.Error(ctx, "task workaround: tracking via has_ai_task and sidebar_app from previous build failed", + slog.Error(err), + slog.F("job_id", job.ID.String()), + slog.F("workspace_id", workspace.ID), + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("transition", string(workspaceBuild.Transition)), + ) + } + } + + if warnUnknownSidebarAppID { + // Ref: https://github.com/coder/coder/issues/18776 + // This can happen for a number of reasons: + // 1. Misconfigured template + // 2. Count=0 on the agent due to stop transition, meaning the associated coder_app was not inserted. + // Failing the build at this point is not ideal, so log a warning instead. + s.Logger.Warn(ctx, "unknown ai_task_sidebar_app_id", + slog.F("ai_task_sidebar_app_id", sidebarAppID.UUID.String()), + slog.F("job_id", job.ID.String()), + slog.F("workspace_id", workspace.ID), + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("transition", string(workspaceBuild.Transition)), + ) + // In order to surface this to the user, we will also insert a warning into the build logs. + if _, err := db.InsertProvisionerJobLogs(ctx, database.InsertProvisionerJobLogsParams{ + JobID: jobID, + CreatedAt: []time.Time{now, now, now, now}, + Source: []database.LogSource{database.LogSourceProvisionerDaemon, database.LogSourceProvisionerDaemon, database.LogSourceProvisionerDaemon, database.LogSourceProvisionerDaemon}, + Level: []database.LogLevel{database.LogLevelWarn, database.LogLevelWarn, database.LogLevelWarn, database.LogLevelWarn}, + Stage: []string{"Cleaning Up", "Cleaning Up", "Cleaning Up", "Cleaning Up"}, + Output: []string{ + fmt.Sprintf("Unknown ai_task_sidebar_app_id %q. This workspace will be unable to run AI tasks. This may be due to a template configuration issue, please check with the template author.", sidebarAppID.UUID.String()), + "Template author: double-check the following:", + " - You have associated the coder_ai_task with a valid coder_app in your template (ref: https://registry.terraform.io/providers/coder/coder/latest/docs/resources/ai_task).", + " - You have associated the coder_agent with at least one other compute resource. Agents with no other associated resources are not inserted into the database.", + }, + }); err != nil { + s.Logger.Error(ctx, "insert provisioner job log for ai task sidebar app id warning", + slog.F("job_id", jobID), + slog.F("workspace_id", workspace.ID), + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("transition", string(workspaceBuild.Transition)), + ) + } + // Important: reset hasAITask and sidebarAppID so that we don't run into a fk constraint violation. + hasAITask = false + 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. - 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, - }) - if err != nil { - return xerrors.Errorf("update workspace build ai tasks flag: %w", err) + }); err != nil { + return xerrors.Errorf("update workspace build ai tasks and external agent flag: %w", err) } // Insert timings inside the transaction now @@ -2150,6 +2285,50 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro } } + // Update workspace (regular and prebuild) timing metrics + if s.metrics != nil { + // Only consider 'start' workspace builds + if workspaceBuild.Transition == database.WorkspaceTransitionStart { + // Get the updated job to report the metrics with correct data + updatedJob, err := s.Database.GetProvisionerJobByID(ctx, jobID) + if err != nil { + s.Logger.Error(ctx, "get updated job from database", slog.Error(err)) + } else + // Only consider 'succeeded' provisioner jobs + if updatedJob.JobStatus == database.ProvisionerJobStatusSucceeded { + presetName := "" + if workspaceBuild.TemplateVersionPresetID.Valid { + preset, err := s.Database.GetPresetByID(ctx, workspaceBuild.TemplateVersionPresetID.UUID) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + s.Logger.Error(ctx, "get preset by ID for workspace timing metrics", slog.Error(err)) + } + } else { + presetName = preset.Name + } + } + + buildTime := updatedJob.CompletedAt.Time.Sub(updatedJob.StartedAt.Time).Seconds() + s.metrics.UpdateWorkspaceTimingsMetrics( + ctx, + WorkspaceTimingFlags{ + // Is a prebuilt workspace creation build + IsPrebuild: input.PrebuiltWorkspaceBuildStage.IsPrebuild(), + // Is a prebuilt workspace claim build + IsClaim: input.PrebuiltWorkspaceBuildStage.IsPrebuiltWorkspaceClaim(), + // Is a regular workspace creation build + // Only consider the first build number for regular workspaces + IsFirstBuild: workspaceBuild.BuildNumber == 1, + }, + workspace.OrganizationName, + workspace.TemplateName, + presetName, + buildTime, + ) + } + } + } + msg, err := json.Marshal(wspubsub.WorkspaceEvent{ Kind: wspubsub.WorkspaceEventKindStateChange, WorkspaceID: workspace.ID, diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index b6f9d82a597e7..914f6dd024193 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 @@ -535,7 +547,10 @@ func TestAcquireJob(t *testing.T) { ctx := context.Background() user := dbgen.User(t, db, database.User{}) - version := dbgen.TemplateVersion(t, db, database.TemplateVersion{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + OrganizationID: pd.OrganizationID, + }) file := dbgen.File(t, db, database.File{CreatedBy: user.ID}) _ = dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ InitiatorID: user.ID, @@ -613,7 +628,10 @@ func TestAcquireJob(t *testing.T) { srv, db, ps, pd := setup(t, false, nil) user := dbgen.User(t, db, database.User{}) - version := dbgen.TemplateVersion(t, db, database.TemplateVersion{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + OrganizationID: pd.OrganizationID, + }) file := dbgen.File(t, db, database.File{CreatedBy: user.ID}) _ = dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ FileID: file.ID, @@ -675,13 +693,22 @@ 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(), + ID: version.JobID, Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeTemplateVersionDryRun, - Input: json.RawMessage("{}"), - Tags: pd.Tags, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: version.ID, + })), + OrganizationID: pd.OrganizationID, + Tags: pd.Tags, }) require.NoError(t, err) _, err = srv.UpdateJob(ctx, &proto.UpdateJobRequest{ @@ -693,13 +720,22 @@ 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(), + ID: version.JobID, Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeTemplateVersionDryRun, - Input: json.RawMessage("{}"), - Tags: pd.Tags, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: version.ID, + })), + OrganizationID: pd.OrganizationID, + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -712,6 +748,7 @@ func TestUpdateJob(t *testing.T) { Time: dbtime.Now(), Valid: true, }, + OrganizationID: pd.OrganizationID, ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) @@ -721,36 +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 uuid.UUID, tags database.StringMap) uuid.UUID { - job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: uuid.New(), - 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, - }, - 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.Tags) + _, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) _, err := srv.UpdateJob(ctx, &proto.UpdateJobRequest{ JobId: job.String(), }) @@ -760,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.Tags) + _, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) published := make(chan struct{}) @@ -785,20 +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.Tags) - versionID := uuid.New() - err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ - ID: versionID, - 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) }) @@ -811,13 +863,7 @@ func TestUpdateJob(t *testing.T) { defer cancel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID, pd.Tags) - versionID := uuid.New() - err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ - ID: versionID, - JobID: job, - }) - require.NoError(t, err) + templateVersionID, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) firstTemplateVariable := &sdkproto.TemplateVariable{ Name: "first", Type: "string", @@ -846,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) @@ -858,13 +904,7 @@ func TestUpdateJob(t *testing.T) { defer cancel() srv, db, _, pd := setup(t, false, &overrides{}) - job := setupJob(t, db, pd.ID, pd.Tags) - versionID := uuid.New() - err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ - ID: versionID, - JobID: job, - }) - require.NoError(t, err) + templateVersionID, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) firstTemplateVariable := &sdkproto.TemplateVariable{ Name: "first", Type: "string", @@ -889,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) @@ -903,15 +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.Tags) - versionID := uuid.New() - err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ - ID: versionID, - JobID: job, - }) - 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", @@ -920,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") @@ -932,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.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 @@ -956,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.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 @@ -997,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.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. @@ -1022,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.Tags) + _, job := setupJob(t, db, pd.ID, pd.OrganizationID, pd.Tags) // First: trigger overflow largeOutput := strings.Repeat("a", 1048577) // 1MB + 1 byte @@ -1085,13 +1119,22 @@ 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(), + ID: version.JobID, Provisioner: database.ProvisionerTypeEcho, StorageMethod: database.ProvisionerStorageMethodFile, Type: database.ProvisionerJobTypeTemplateVersionImport, - Input: json.RawMessage("{}"), - Tags: pd.Tags, + Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{ + TemplateVersionID: version.ID, + })), + OrganizationID: pd.OrganizationID, + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1104,6 +1147,7 @@ func TestFailJob(t *testing.T) { Time: dbtime.Now(), Valid: true, }, + OrganizationID: pd.OrganizationID, ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) @@ -1114,14 +1158,23 @@ 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(), + ID: version.JobID, Provisioner: database.ProvisionerTypeEcho, Type: database.ProvisionerJobTypeTemplateVersionImport, StorageMethod: database.ProvisionerStorageMethodFile, - Input: json.RawMessage("{}"), - Tags: pd.Tags, + Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{ + TemplateVersionID: version.ID, + })), + OrganizationID: pd.OrganizationID, + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1134,6 +1187,7 @@ func TestFailJob(t *testing.T) { Time: dbtime.Now(), Valid: true, }, + OrganizationID: pd.OrganizationID, ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) @@ -1152,19 +1206,20 @@ func TestFailJob(t *testing.T) { }) t.Run("WorkspaceBuild", func(t *testing.T) { t.Parallel() - // Ignore log errors because we get: - // - // (*Server).FailJob audit log - get build {"error": "sql: no rows in result set"} - ignoreLogErrors := true auditor := audit.NewMock() - srv, db, ps, pd := setup(t, ignoreLogErrors, &overrides{ + srv, db, ps, pd := setup(t, false, &overrides{ auditor: auditor, }) org := dbgen.Organization(t, db, database.Organization{}) u := dbgen.User(t, db, database.User{}) - tpl := dbgen.Template(t, db, database.Template{ - OrganizationID: org.ID, + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ CreatedBy: u.ID, + OrganizationID: org.ID, + }) + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: u.ID, + ActiveVersionID: tv.ID, }) workspace, err := db.InsertWorkspace(ctx, database.InsertWorkspaceParams{ ID: uuid.New(), @@ -1181,22 +1236,24 @@ func TestFailJob(t *testing.T) { require.NoError(t, err) job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: uuid.New(), - Input: input, - InitiatorID: workspace.OwnerID, - Provisioner: database.ProvisionerTypeEcho, - Type: database.ProvisionerJobTypeWorkspaceBuild, - StorageMethod: database.ProvisionerStorageMethodFile, - Tags: pd.Tags, + ID: uuid.New(), + Input: input, + InitiatorID: workspace.OwnerID, + OrganizationID: pd.OrganizationID, + Provisioner: database.ProvisionerTypeEcho, + Type: database.ProvisionerJobTypeWorkspaceBuild, + StorageMethod: database.ProvisionerStorageMethodFile, + Tags: pd.Tags, }) require.NoError(t, err) err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ - ID: buildID, - WorkspaceID: workspace.ID, - InitiatorID: workspace.OwnerID, - Transition: database.WorkspaceTransitionStart, - Reason: database.BuildReasonInitiator, - JobID: job.ID, + ID: buildID, + WorkspaceID: workspace.ID, + InitiatorID: workspace.OwnerID, + TemplateVersionID: tpl.ActiveVersionID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + JobID: job.ID, }) require.NoError(t, err) @@ -1210,6 +1267,7 @@ func TestFailJob(t *testing.T) { Time: dbtime.Now(), Valid: true, }, + OrganizationID: pd.OrganizationID, ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) @@ -1279,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{ @@ -1318,7 +1384,9 @@ func TestCompleteJob(t *testing.T) { 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{ + CreatedBy: user.ID, ID: versionID, JobID: jobID, OrganizationID: pd.OrganizationID, @@ -1328,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{ @@ -1376,13 +1446,23 @@ func TestCompleteJob(t *testing.T) { t.Run("TemplateDryRunTransaction", func(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(), - Provisioner: database.ProvisionerTypeEcho, - Type: database.ProvisionerJobTypeTemplateVersionDryRun, - StorageMethod: database.ProvisionerStorageMethodFile, - Input: json.RawMessage("{}"), - Tags: pd.Tags, + ID: uuid.New(), + OrganizationID: org.ID, + Provisioner: database.ProvisionerTypeEcho, + Type: database.ProvisionerJobTypeTemplateVersionDryRun, + StorageMethod: database.ProvisionerStorageMethodFile, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: version.ID, + })), + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -1390,6 +1470,7 @@ func TestCompleteJob(t *testing.T) { UUID: pd.ID, Valid: true, }, + OrganizationID: org.ID, Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, ProvisionerTags: must(json.Marshal(job.Tags)), StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, @@ -1430,6 +1511,7 @@ func TestCompleteJob(t *testing.T) { user := dbgen.User(t, db, database.User{}) template := dbgen.Template(t, db, database.Template{ Name: "template", + CreatedBy: user.ID, Provisioner: database.ProvisionerTypeEcho, OrganizationID: pd.OrganizationID, }) @@ -1441,27 +1523,32 @@ func TestCompleteJob(t *testing.T) { }) version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ OrganizationID: pd.OrganizationID, + CreatedBy: user.ID, TemplateID: uuid.NullUUID{ UUID: template.ID, Valid: true, }, JobID: uuid.New(), }) - build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: workspaceTable.ID, - TemplateVersionID: version.ID, - Transition: database.WorkspaceTransitionStart, - Reason: database.BuildReasonInitiator, - }) + wsBuildID := uuid.New() job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ + ID: uuid.New(), FileID: file.ID, InitiatorID: user.ID, Type: database.ProvisionerJobTypeWorkspaceBuild, Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: build.ID, + WorkspaceBuildID: wsBuildID, })), OrganizationID: pd.OrganizationID, }) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: wsBuildID, + JobID: job.ID, + WorkspaceID: workspaceTable.ID, + TemplateVersionID: version.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + }) _, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ OrganizationID: pd.OrganizationID, WorkerID: uuid.NullUUID{ @@ -1586,23 +1673,49 @@ func TestCompleteJob(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) jobID := uuid.New() - versionID := uuid.New() - err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ - ID: versionID, - JobID: jobID, + user := dbgen.User(t, db, database.User{}) + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, 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{ @@ -1644,18 +1757,22 @@ func TestCompleteJob(t *testing.T) { 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{ + CreatedBy: user.ID, ID: versionID, JobID: jobID, OrganizationID: pd.OrganizationID, }) 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, }) @@ -1708,8 +1825,10 @@ func TestCompleteJob(t *testing.T) { }) jobID := uuid.New() versionID := uuid.New() + user := dbgen.User(t, db, database.User{}) err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ ID: versionID, + CreatedBy: user.ID, JobID: jobID, OrganizationID: pd.OrganizationID, }) @@ -1718,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{ @@ -1896,6 +2017,7 @@ func TestCompleteJob(t *testing.T) { QuietHoursSchedule: c.userQuietHoursSchedule, }) template := dbgen.Template(t, db, database.Template{ + CreatedBy: user.ID, Name: "template", Provisioner: database.ProvisionerTypeEcho, OrganizationID: pd.OrganizationID, @@ -1927,6 +2049,7 @@ func TestCompleteJob(t *testing.T) { OrganizationID: pd.OrganizationID, }) version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, OrganizationID: pd.OrganizationID, TemplateID: uuid.NullUUID{ UUID: template.ID, @@ -1934,22 +2057,25 @@ func TestCompleteJob(t *testing.T) { }, JobID: uuid.New(), }) - build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: workspaceTable.ID, - InitiatorID: user.ID, - TemplateVersionID: version.ID, - Transition: c.transition, - Reason: database.BuildReasonInitiator, - }) + buildID := uuid.New() job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ FileID: file.ID, InitiatorID: user.ID, Type: database.ProvisionerJobTypeWorkspaceBuild, Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: build.ID, + WorkspaceBuildID: buildID, })), OrganizationID: pd.OrganizationID, }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: buildID, + JobID: job.ID, + WorkspaceID: workspaceTable.ID, + InitiatorID: user.ID, + TemplateVersionID: version.ID, + Transition: c.transition, + Reason: database.BuildReasonInitiator, + }) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ OrganizationID: pd.OrganizationID, WorkerID: uuid.NullUUID{ @@ -2038,13 +2164,22 @@ 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(), + ID: version.JobID, Provisioner: database.ProvisionerTypeEcho, Type: database.ProvisionerJobTypeTemplateVersionDryRun, StorageMethod: database.ProvisionerStorageMethodFile, - Input: json.RawMessage("{}"), - Tags: pd.Tags, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: version.ID, + })), + OrganizationID: pd.OrganizationID, + Tags: pd.Tags, }) require.NoError(t, err) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -2057,6 +2192,7 @@ func TestCompleteJob(t *testing.T) { Time: dbtime.Now(), Valid: true, }, + OrganizationID: pd.OrganizationID, ProvisionerTags: must(json.Marshal(job.Tags)), }) require.NoError(t, err) @@ -2136,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, + })), }, }, { @@ -2282,34 +2420,46 @@ func TestCompleteJob(t *testing.T) { if jobParams.Tags == nil { jobParams.Tags = pd.Tags } + if jobParams.OrganizationID == uuid.Nil { + jobParams.OrganizationID = pd.OrganizationID + } user := dbgen.User(t, db, database.User{}) job, err := db.InsertProvisionerJob(ctx, jobParams) + require.NoError(t, err) tpl := dbgen.Template(t, db, database.Template{ + CreatedBy: user.ID, OrganizationID: pd.OrganizationID, }) tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ - TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, - JobID: job.ID, - }) - workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ - TemplateID: tpl.ID, + ID: templateVersionID, + CreatedBy: user.ID, OrganizationID: pd.OrganizationID, - OwnerID: user.ID, - }) - _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - ID: workspaceBuildID, - JobID: job.ID, - WorkspaceID: workspace.ID, - TemplateVersionID: tv.ID, + TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, + JobID: job.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{ WorkerID: uuid.NullUUID{ UUID: pd.ID, Valid: true, }, + OrganizationID: pd.OrganizationID, Types: []database.ProvisionerType{jobParams.Provisioner}, ProvisionerTags: must(json.Marshal(job.Tags)), StartedAt: sql.NullTime{Time: job.CreatedAt, Valid: true}, @@ -2499,6 +2649,7 @@ func TestCompleteJob(t *testing.T) { // Given: a workspace build which simulates claiming a prebuild. user := dbgen.User(t, db, database.User{}) template := dbgen.Template(t, db, database.Template{ + CreatedBy: user.ID, Name: "template", Provisioner: database.ProvisionerTypeEcho, OrganizationID: pd.OrganizationID, @@ -2510,6 +2661,7 @@ func TestCompleteJob(t *testing.T) { OrganizationID: pd.OrganizationID, }) version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, OrganizationID: pd.OrganizationID, TemplateID: uuid.NullUUID{ UUID: template.ID, @@ -2517,23 +2669,26 @@ func TestCompleteJob(t *testing.T) { }, JobID: uuid.New(), }) - build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: workspaceTable.ID, - InitiatorID: user.ID, - TemplateVersionID: version.ID, - Transition: database.WorkspaceTransitionStart, - Reason: database.BuildReasonInitiator, - }) + buildID := uuid.New() job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ FileID: file.ID, InitiatorID: user.ID, Type: database.ProvisionerJobTypeWorkspaceBuild, Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: build.ID, + WorkspaceBuildID: buildID, PrebuiltWorkspaceBuildStage: sdkproto.PrebuiltWorkspaceBuildStage_CLAIM, })), OrganizationID: pd.OrganizationID, }) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: buildID, + JobID: job.ID, + WorkspaceID: workspaceTable.ID, + InitiatorID: user.ID, + TemplateVersionID: version.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + }) _, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ OrganizationID: pd.OrganizationID, WorkerID: uuid.NullUUID{ @@ -2604,17 +2759,23 @@ 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() + templateAdminUser := dbgen.User(t, db, database.User{RBACRoles: []string{codersdk.RoleTemplateAdmin}}) template := dbgen.Template(t, db, database.Template{ Name: "template", + CreatedBy: templateAdminUser.ID, Provisioner: database.ProvisionerTypeEcho, OrganizationID: pd.OrganizationID, }) version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ ID: tvID, + CreatedBy: templateAdminUser.ID, OrganizationID: pd.OrganizationID, TemplateID: uuid.NullUUID{ UUID: template.ID, @@ -2670,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) }) } }) @@ -2677,24 +2842,32 @@ func TestCompleteJob(t *testing.T) { // has_ai_task has a default value of nil, but once the workspace build completes it will have a value; // it is set to "true" if the related template has any coder_ai_task resources defined, and its sidebar app ID // will be set as well in that case. + // HACK(johnstcn): we also set it to "true" if any _previous_ workspace builds ever had it set to "true". + // This is to avoid tasks "disappearing" when you stop them. t.Run("WorkspaceBuild", func(t *testing.T) { type testcase struct { - name string - input *proto.CompletedJob_WorkspaceBuild - expected bool + name string + seedFunc func(context.Context, testing.TB, database.Store) error // If you need to insert other resources + 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{ { @@ -2704,24 +2877,108 @@ func TestCompleteJob(t *testing.T) { }, }, }, + Resources: []*sdkproto.Resource{ + { + Agents: []*sdkproto.Agent{ + { + Id: uuid.NewString(), + Name: "a", + Apps: []*sdkproto.App{ + { + Id: sidebarAppID, + Slug: "test-app", + }, + }, + }, + }, + }, + }, }, - expected: true, + expectHasAiTask: true, + expectUsageEvent: true, + }, + // Checks regression for https://github.com/coder/coder/issues/18776 + { + name: "non-existing app", + transition: database.WorkspaceTransitionStart, + input: &proto.CompletedJob_WorkspaceBuild{ + AiTasks: []*sdkproto.AITask{ + { + Id: uuid.NewString(), + SidebarApp: &sdkproto.AITaskSidebarApp{ + // Non-existing app ID would previously trigger a FK violation. + Id: uuid.NewString(), + }, + }, + }, + }, + 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, + }, + { + name: "current build does not have ai task but previous build did", + seedFunc: seedPreviousWorkspaceStartWithAITask, + transition: database.WorkspaceTransitionStop, + input: &proto.CompletedJob_WorkspaceBuild{ + AiTasks: []*sdkproto.AITask{}, + Resources: []*sdkproto.Resource{}, + }, + 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() + templateUser := dbgen.User(t, db, database.User{RBACRoles: []string{codersdk.RoleTemplateAdmin}}) template := dbgen.Template(t, db, database.Template{ Name: "template", + CreatedBy: templateUser.ID, Provisioner: database.ProvisionerTypeEcho, OrganizationID: pd.OrganizationID, }) version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ ID: tvID, + CreatedBy: templateUser.ID, OrganizationID: pd.OrganizationID, TemplateID: uuid.NullUUID{ UUID: template.ID, @@ -2735,22 +2992,22 @@ func TestCompleteJob(t *testing.T) { OwnerID: user.ID, OrganizationID: pd.OrganizationID, }) - build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: workspaceTable.ID, - TemplateVersionID: version.ID, - InitiatorID: user.ID, - Transition: database.WorkspaceTransitionStart, - }) ctx := testutil.Context(t, testutil.WaitShort) + if tc.seedFunc != nil { + require.NoError(t, tc.seedFunc(ctx, t, db)) + } + + buildJobID := uuid.New() + wsBuildID := uuid.New() job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: importJobID, + ID: buildJobID, CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), OrganizationID: pd.OrganizationID, - InitiatorID: uuid.New(), + InitiatorID: user.ID, Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: build.ID, + WorkspaceBuildID: wsBuildID, LogLevel: "DEBUG", })), Provisioner: database.ProvisionerTypeEcho, @@ -2759,6 +3016,19 @@ func TestCompleteJob(t *testing.T) { Tags: pd.Tags, }) require.NoError(t, err) + var buildNum int32 + if latestBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspaceTable.ID); err == nil { + buildNum = latestBuild.BuildNumber + } + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: wsBuildID, + BuildNumber: buildNum + 1, + JobID: buildJobID, + WorkspaceID: workspaceTable.ID, + TemplateVersionID: version.ID, + InitiatorID: user.ID, + Transition: tc.transition, + }) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ OrganizationID: pd.OrganizationID, @@ -2788,11 +3058,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 && build.Transition != database.WorkspaceTransitionStop { 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) + } }) } }) @@ -3016,22 +3297,21 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("NoAgents", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - job := uuid.New() - err := insert(db, job, &sdkproto.Resource{ + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + err := insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", }) require.NoError(t, err) - resources, err := db.GetWorkspaceResourcesByJobID(ctx, job) + resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID) require.NoError(t, err) require.Len(t, resources, 1) }) t.Run("InvalidAgentToken", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - err := insert(db, uuid.New(), &sdkproto.Resource{ + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + err := insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3046,8 +3326,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("DuplicateApps", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - err := insert(db, uuid.New(), &sdkproto.Resource{ + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + err := insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3062,8 +3342,8 @@ func TestInsertWorkspaceResource(t *testing.T) { require.ErrorContains(t, err, `duplicate app slug, must be unique per template: "a"`) db, _ = dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - err = insert(db, uuid.New(), &sdkproto.Resource{ + job = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + err = insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3083,9 +3363,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("AppSlugInvalid", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - job := uuid.New() - err := insert(db, job, &sdkproto.Resource{ + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + err := insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3096,7 +3375,7 @@ func TestInsertWorkspaceResource(t *testing.T) { }}, }) require.ErrorContains(t, err, `app slug "dev_1" does not match regex`) - err = insert(db, job, &sdkproto.Resource{ + err = insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3107,7 +3386,7 @@ func TestInsertWorkspaceResource(t *testing.T) { }}, }) require.ErrorContains(t, err, `app slug "dev--1" does not match regex`) - err = insert(db, job, &sdkproto.Resource{ + err = insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3122,10 +3401,9 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("DuplicateAgentNames", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - job := uuid.New() + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) // case-insensitive-unique - err := insert(db, job, &sdkproto.Resource{ + err := insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3135,7 +3413,7 @@ func TestInsertWorkspaceResource(t *testing.T) { }}, }) require.ErrorContains(t, err, "duplicate agent name") - err = insert(db, job, &sdkproto.Resource{ + err = insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3149,9 +3427,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("AgentNameInvalid", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - job := uuid.New() - err := insert(db, job, &sdkproto.Resource{ + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + err := insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3159,7 +3436,7 @@ func TestInsertWorkspaceResource(t *testing.T) { }}, }) require.NoError(t, err) // uppercase is still allowed - err = insert(db, job, &sdkproto.Resource{ + err = insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3167,7 +3444,7 @@ func TestInsertWorkspaceResource(t *testing.T) { }}, }) require.ErrorContains(t, err, `agent name "dev_1" contains underscores`) // custom error for underscores - err = insert(db, job, &sdkproto.Resource{ + err = insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3179,9 +3456,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("Success", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - job := uuid.New() - err := insert(db, job, &sdkproto.Resource{ + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + err := insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", DailyCost: 10, @@ -3220,7 +3496,7 @@ func TestInsertWorkspaceResource(t *testing.T) { }}, }) require.NoError(t, err) - resources, err := db.GetWorkspaceResourcesByJobID(ctx, job) + resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID) require.NoError(t, err) require.Len(t, resources, 1) require.EqualValues(t, 10, resources[0].DailyCost) @@ -3249,9 +3525,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("AllDisplayApps", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - job := uuid.New() - err := insert(db, job, &sdkproto.Resource{ + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + err := insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3266,7 +3541,7 @@ func TestInsertWorkspaceResource(t *testing.T) { }}, }) require.NoError(t, err) - resources, err := db.GetWorkspaceResourcesByJobID(ctx, job) + resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID) require.NoError(t, err) require.Len(t, resources, 1) agents, err := db.GetWorkspaceAgentsByResourceIDs(ctx, []uuid.UUID{resources[0].ID}) @@ -3279,9 +3554,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("DisableDefaultApps", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - job := uuid.New() - err := insert(db, job, &sdkproto.Resource{ + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + err := insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3290,7 +3564,7 @@ func TestInsertWorkspaceResource(t *testing.T) { }}, }) require.NoError(t, err) - resources, err := db.GetWorkspaceResourcesByJobID(ctx, job) + resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID) require.NoError(t, err) require.Len(t, resources, 1) agents, err := db.GetWorkspaceAgentsByResourceIDs(ctx, []uuid.UUID{resources[0].ID}) @@ -3305,9 +3579,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("ResourcesMonitoring", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - job := uuid.New() - err := insert(db, job, &sdkproto.Resource{ + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + err := insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3334,7 +3607,7 @@ func TestInsertWorkspaceResource(t *testing.T) { }}, }) require.NoError(t, err) - resources, err := db.GetWorkspaceResourcesByJobID(ctx, job) + resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID) require.NoError(t, err) require.Len(t, resources, 1) agents, err := db.GetWorkspaceAgentsByResourceIDs(ctx, []uuid.UUID{resources[0].ID}) @@ -3358,9 +3631,8 @@ func TestInsertWorkspaceResource(t *testing.T) { t.Run("Devcontainers", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) - job := uuid.New() - err := insert(db, job, &sdkproto.Resource{ + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{}) + err := insert(db, job.ID, &sdkproto.Resource{ Name: "something", Type: "aws_instance", Agents: []*sdkproto.Agent{{ @@ -3372,7 +3644,7 @@ func TestInsertWorkspaceResource(t *testing.T) { }}, }) require.NoError(t, err) - resources, err := db.GetWorkspaceResourcesByJobID(ctx, job) + resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID) require.NoError(t, err) require.Len(t, resources, 1) agents, err := db.GetWorkspaceAgentsByResourceIDs(ctx, []uuid.UUID{resources[0].ID}) @@ -3443,6 +3715,7 @@ func TestNotifications(t *testing.T) { } template := dbgen.Template(t, db, database.Template{ + CreatedBy: user.ID, Name: "template", Provisioner: database.ProvisionerTypeEcho, OrganizationID: pd.OrganizationID, @@ -3456,6 +3729,7 @@ func TestNotifications(t *testing.T) { OrganizationID: pd.OrganizationID, }) version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, OrganizationID: pd.OrganizationID, TemplateID: uuid.NullUUID{ UUID: template.ID, @@ -3463,24 +3737,27 @@ func TestNotifications(t *testing.T) { }, JobID: uuid.New(), }) - build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: workspaceTable.ID, - TemplateVersionID: version.ID, - InitiatorID: initiator.ID, - Transition: database.WorkspaceTransitionDelete, - Reason: tc.deletionReason, - }) + wsBuildID := uuid.New() job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ FileID: file.ID, InitiatorID: initiator.ID, Type: database.ProvisionerJobTypeWorkspaceBuild, Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: build.ID, + WorkspaceBuildID: wsBuildID, })), OrganizationID: pd.OrganizationID, CreatedAt: time.Now(), UpdatedAt: time.Now(), }) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: wsBuildID, + JobID: job.ID, + WorkspaceID: workspaceTable.ID, + TemplateVersionID: version.ID, + InitiatorID: initiator.ID, + Transition: database.WorkspaceTransitionDelete, + Reason: tc.deletionReason, + }) _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ OrganizationID: pd.OrganizationID, WorkerID: uuid.NullUUID{ @@ -3569,6 +3846,7 @@ func TestNotifications(t *testing.T) { initiator := user template := dbgen.Template(t, db, database.Template{ + CreatedBy: user.ID, Name: "template", Provisioner: database.ProvisionerTypeEcho, OrganizationID: pd.OrganizationID, @@ -3580,6 +3858,7 @@ func TestNotifications(t *testing.T) { OrganizationID: pd.OrganizationID, }) version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, OrganizationID: pd.OrganizationID, TemplateID: uuid.NullUUID{ UUID: template.ID, @@ -3587,24 +3866,28 @@ func TestNotifications(t *testing.T) { }, JobID: uuid.New(), }) - build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: workspace.ID, - TemplateVersionID: version.ID, - InitiatorID: initiator.ID, - Transition: database.WorkspaceTransitionDelete, - Reason: tc.buildReason, - }) + wsBuildID := uuid.New() job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ + ID: uuid.New(), FileID: file.ID, InitiatorID: initiator.ID, Type: database.ProvisionerJobTypeWorkspaceBuild, Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: build.ID, + WorkspaceBuildID: wsBuildID, })), OrganizationID: pd.OrganizationID, CreatedAt: time.Now(), UpdatedAt: time.Now(), }) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: wsBuildID, + JobID: job.ID, + WorkspaceID: workspace.ID, + TemplateVersionID: version.ID, + InitiatorID: initiator.ID, + Transition: database.WorkspaceTransitionDelete, + Reason: tc.buildReason, + }) _, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ OrganizationID: pd.OrganizationID, WorkerID: uuid.NullUUID{ @@ -3660,24 +3943,29 @@ func TestNotifications(t *testing.T) { _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: pd.OrganizationID}) template := dbgen.Template(t, db, database.Template{ - Name: "template", DisplayName: "William's Template", Provisioner: database.ProvisionerTypeEcho, OrganizationID: pd.OrganizationID, + CreatedBy: user.ID, + Name: "template", DisplayName: "William's Template", Provisioner: database.ProvisionerTypeEcho, OrganizationID: pd.OrganizationID, }) workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ TemplateID: template.ID, OwnerID: user.ID, OrganizationID: pd.OrganizationID, }) version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, OrganizationID: pd.OrganizationID, TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, JobID: uuid.New(), }) - build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: workspace.ID, TemplateVersionID: version.ID, InitiatorID: user.ID, Transition: database.WorkspaceTransitionDelete, Reason: database.BuildReasonInitiator, - }) + wsBuildID := uuid.New() job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ FileID: dbgen.File(t, db, database.File{CreatedBy: user.ID}).ID, InitiatorID: user.ID, Type: database.ProvisionerJobTypeWorkspaceBuild, - Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{WorkspaceBuildID: build.ID})), + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{WorkspaceBuildID: wsBuildID})), OrganizationID: pd.OrganizationID, }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: wsBuildID, + JobID: job.ID, + WorkspaceID: workspace.ID, TemplateVersionID: version.ID, InitiatorID: user.ID, Transition: database.WorkspaceTransitionDelete, Reason: database.BuildReasonInitiator, + }) _, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ OrganizationID: pd.OrganizationID, WorkerID: uuid.NullUUID{UUID: pd.ID, Valid: true}, @@ -3717,6 +4005,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 @@ -3730,7 +4019,6 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi t.Helper() logger := testutil.Logger(t) db, ps := dbtestutil.NewDB(t) - dbtestutil.DisableForeignKeysAndTriggers(t, db) defOrg, err := db.GetDefaultOrganization(context.Background()) require.NoError(t, err, "default org not found") @@ -3738,13 +4026,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 } @@ -3775,6 +4064,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 } @@ -3812,6 +4110,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(), @@ -3821,7 +4123,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(), @@ -3830,6 +4132,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi auditPtr, tss, uqhss, + usageInserter, deploymentValues, provisionerdserver.Options{ ExternalAuthConfigs: externalAuthConfigs, @@ -3841,6 +4144,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi }, notifEnq, &op, + provisionerdserver.NewMetrics(logger), ) require.NoError(t, err) return srv, db, ps, daemon @@ -3944,3 +4248,82 @@ 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 +} + +func seedPreviousWorkspaceStartWithAITask(ctx context.Context, t testing.TB, db database.Store) error { + t.Helper() + // If the below looks slightly convoluted, that's because it is. + // The workspace doesn't yet have a latest build, so querying all + // workspaces will fail. + tpls, err := db.GetTemplates(ctx) + if err != nil { + return xerrors.Errorf("seedFunc: get template: %w", err) + } + if len(tpls) != 1 { + return xerrors.Errorf("seedFunc: expected exactly one template, got %d", len(tpls)) + } + ws, err := db.GetWorkspacesByTemplateID(ctx, tpls[0].ID) + if err != nil { + return xerrors.Errorf("seedFunc: get workspaces: %w", err) + } + if len(ws) != 1 { + return xerrors.Errorf("seedFunc: expected exactly one workspace, got %d", len(ws)) + } + w := ws[0] + prevJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: w.OrganizationID, + InitiatorID: w.OwnerID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + }) + tvs, err := db.GetTemplateVersionsByTemplateID(ctx, database.GetTemplateVersionsByTemplateIDParams{ + TemplateID: tpls[0].ID, + }) + if err != nil { + return xerrors.Errorf("seedFunc: get template version: %w", err) + } + if len(tvs) != 1 { + return xerrors.Errorf("seedFunc: expected exactly one template version, got %d", len(tvs)) + } + if tpls[0].ActiveVersionID == uuid.Nil { + return xerrors.Errorf("seedFunc: active version id is nil") + } + res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: prevJob.ID, + }) + agt := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: res.ID, + }) + wa := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{ + AgentID: agt.ID, + }) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + BuildNumber: 1, + HasAITask: sql.NullBool{Valid: true, Bool: true}, + AITaskSidebarAppID: uuid.NullUUID{Valid: true, UUID: wa.ID}, + ID: w.ID, + InitiatorID: w.OwnerID, + JobID: prevJob.ID, + TemplateVersionID: tvs[0].ID, + Transition: database.WorkspaceTransitionStart, + WorkspaceID: w.ID, + }) + return nil +} diff --git a/coderd/rbac/acl/updatevalidator.go b/coderd/rbac/acl/updatevalidator.go new file mode 100644 index 0000000000000..9785609f2e33a --- /dev/null +++ b/coderd/rbac/acl/updatevalidator.go @@ -0,0 +1,130 @@ +package acl + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/codersdk" +) + +type UpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole] interface { + // Users should return a map from user UUIDs (as strings) to the role they + // are being assigned. Additionally, it should return a string that will be + // used as the field name for the ValidationErrors returned from Validate. + Users() (map[string]Role, string) + // Groups should return a map from group UUIDs (as strings) to the role they + // are being assigned. Additionally, it should return a string that will be + // used as the field name for the ValidationErrors returned from Validate. + Groups() (map[string]Role, string) + // ValidateRole should return an error that will be used in the + // ValidationError if the role is invalid for the corresponding resource type. + ValidateRole(role Role) error +} + +func Validate[Role codersdk.WorkspaceRole | codersdk.TemplateRole]( + ctx context.Context, + db database.Store, + v UpdateValidator[Role], +) []codersdk.ValidationError { + // nolint:gocritic // Validate requires full read access to users and groups + ctx = dbauthz.AsSystemRestricted(ctx) + var validErrs []codersdk.ValidationError + + groupRoles, groupsField := v.Groups() + groupIDs := make([]uuid.UUID, 0, len(groupRoles)) + for idStr, role := range groupRoles { + // Validate the provided role names + if err := v.ValidateRole(role); err != nil { + validErrs = append(validErrs, codersdk.ValidationError{ + Field: groupsField, + Detail: err.Error(), + }) + } + // Validate that the IDs are UUIDs + id, err := uuid.Parse(idStr) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{ + Field: groupsField, + Detail: fmt.Sprintf("%v is not a valid UUID.", idStr), + }) + continue + } + // Don't check if the ID exists when setting the role to + // WorkspaceRoleDeleted or TemplateRoleDeleted. They might've existing at + // some point and got deleted. If we report that as an error here then they + // can't be removed. + if string(role) == "" { + continue + } + groupIDs = append(groupIDs, id) + } + + // Validate that the groups exist + groupValidation, err := db.ValidateGroupIDs(ctx, groupIDs) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{ + Field: groupsField, + Detail: fmt.Sprintf("failed to validate group IDs: %v", err.Error()), + }) + } + if !groupValidation.Ok { + for _, id := range groupValidation.InvalidGroupIds { + validErrs = append(validErrs, codersdk.ValidationError{ + Field: groupsField, + Detail: fmt.Sprintf("group with ID %v does not exist", id), + }) + } + } + + userRoles, usersField := v.Users() + userIDs := make([]uuid.UUID, 0, len(userRoles)) + for idStr, role := range userRoles { + // Validate the provided role names + if err := v.ValidateRole(role); err != nil { + validErrs = append(validErrs, codersdk.ValidationError{ + Field: usersField, + Detail: err.Error(), + }) + } + // Validate that the IDs are UUIDs + id, err := uuid.Parse(idStr) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{ + Field: usersField, + Detail: fmt.Sprintf("%v is not a valid UUID.", idStr), + }) + continue + } + // Don't check if the ID exists when setting the role to + // WorkspaceRoleDeleted or TemplateRoleDeleted. They might've existing at + // some point and got deleted. If we report that as an error here then they + // can't be removed. + if string(role) == "" { + continue + } + userIDs = append(userIDs, id) + } + + // Validate that the groups exist + userValidation, err := db.ValidateUserIDs(ctx, userIDs) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{ + Field: usersField, + Detail: fmt.Sprintf("failed to validate user IDs: %v", err.Error()), + }) + } + if !userValidation.Ok { + for _, id := range userValidation.InvalidUserIds { + validErrs = append(validErrs, codersdk.ValidationError{ + Field: usersField, + Detail: fmt.Sprintf("user with ID %v does not exist", id), + }) + } + } + + return validErrs +} diff --git a/coderd/rbac/acl/updatevalidator_test.go b/coderd/rbac/acl/updatevalidator_test.go new file mode 100644 index 0000000000000..0e394370b1356 --- /dev/null +++ b/coderd/rbac/acl/updatevalidator_test.go @@ -0,0 +1,91 @@ +package acl_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac/acl" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestOK(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + o := dbgen.Organization(t, db, database.Organization{}) + g := dbgen.Group(t, db, database.Group{OrganizationID: o.ID}) + u := dbgen.User(t, db, database.User{}) + ctx := testutil.Context(t, testutil.WaitShort) + + update := codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + u.ID.String(): codersdk.WorkspaceRoleAdmin, + // An unknown ID is allowed if and only if the specified role is either + // codersdk.WorkspaceRoleDeleted or codersdk.TemplateRoleDeleted. + uuid.NewString(): codersdk.WorkspaceRoleDeleted, + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + g.ID.String(): codersdk.WorkspaceRoleAdmin, + // An unknown ID is allowed if and only if the specified role is either + // codersdk.WorkspaceRoleDeleted or codersdk.TemplateRoleDeleted. + uuid.NewString(): codersdk.WorkspaceRoleDeleted, + }, + } + errors := acl.Validate(ctx, db, coderd.WorkspaceACLUpdateValidator(update)) + require.Empty(t, errors) +} + +func TestDeniesUnknownIDs(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + update := codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + uuid.NewString(): codersdk.WorkspaceRoleAdmin, + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + uuid.NewString(): codersdk.WorkspaceRoleAdmin, + }, + } + errors := acl.Validate(ctx, db, coderd.WorkspaceACLUpdateValidator(update)) + require.Len(t, errors, 2) + require.Equal(t, errors[0].Field, "group_roles") + require.ErrorContains(t, errors[0], "does not exist") + require.Equal(t, errors[1].Field, "user_roles") + require.ErrorContains(t, errors[1], "does not exist") +} + +func TestDeniesUnknownRolesAndInvalidIDs(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + update := codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + "Quifrey": "level 5", + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + "apprentices": "level 2", + }, + } + errors := acl.Validate(ctx, db, coderd.WorkspaceACLUpdateValidator(update)) + require.Len(t, errors, 4) + require.Equal(t, errors[0].Field, "group_roles") + require.ErrorContains(t, errors[0], "role \"level 2\" is not a valid workspace role") + require.Equal(t, errors[1].Field, "group_roles") + require.ErrorContains(t, errors[1], "not a valid UUID") + require.Equal(t, errors[2].Field, "user_roles") + require.ErrorContains(t, errors[2], "role \"level 5\" is not a valid workspace role") + require.Equal(t, errors[3].Field, "user_roles") + require.ErrorContains(t, errors[3], "not a valid UUID") +} diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index fcb6621a34cee..0b48a24aebe83 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -76,6 +76,7 @@ const ( SubjectTypeNotifier SubjectType = "notifier" SubjectTypeSubAgentAPI SubjectType = "sub_agent_api" SubjectTypeFileReader SubjectType = "file_reader" + SubjectTypeUsagePublisher SubjectType = "usage_publisher" ) const ( diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 5fb3cc2bd8a3b..de05dced2693d 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -289,6 +289,15 @@ var ( Type: "template", } + // ResourceUsageEvent + // Valid Actions + // - "ActionCreate" :: create a usage event + // - "ActionRead" :: read usage events + // - "ActionUpdate" :: update usage events + ResourceUsageEvent = Object{ + Type: "usage_event", + } + // ResourceUser // Valid Actions // - "ActionCreate" :: create a new user @@ -301,6 +310,16 @@ var ( Type: "user", } + // ResourceUserSecret + // Valid Actions + // - "ActionCreate" :: create a user secret + // - "ActionDelete" :: delete a user secret + // - "ActionRead" :: read user secret metadata and value + // - "ActionUpdate" :: update user secret metadata and value + ResourceUserSecret = Object{ + Type: "user_secret", + } + // ResourceWebpushSubscription // Valid Actions // - "ActionCreate" :: create webpush subscriptions @@ -402,7 +421,9 @@ func AllResources() []Objecter { ResourceSystem, ResourceTailnetCoordinator, ResourceTemplate, + ResourceUsageEvent, ResourceUser, + ResourceUserSecret, ResourceWebpushSubscription, ResourceWorkspace, ResourceWorkspaceAgentDevcontainers, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 8f05bbdbe544f..25fb87bfc2d94 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -343,4 +343,19 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionCreate: "create workspace agent devcontainers", }, }, + "user_secret": { + Actions: map[Action]ActionDefinition{ + ActionCreate: "create a user secret", + ActionRead: "read user secret metadata and value", + ActionUpdate: "update user secret metadata and value", + ActionDelete: "delete a user secret", + }, + }, + "usage_event": { + Actions: map[Action]ActionDefinition{ + ActionCreate: "create a usage event", + ActionRead: "read usage events", + ActionUpdate: "update usage events", + }, + }, } diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index b8d3f959ce477..c6770f31b0320 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -269,8 +269,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) { DisplayName: "Owner", Site: append( // Workspace dormancy and workspace are omitted. - // Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec - allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace), + // Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec. + // Owners cannot access other users' secrets. + allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUsageEvent), // This adds back in the Workspace permissions. Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ownerWorkspaceActions, @@ -417,7 +418,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { }), Org: map[string][]Permission{ // Org admins should not have workspace exec perms. - organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole), Permissions(map[string][]policy.Action{ + organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret), Permissions(map[string][]policy.Action{ ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent}, ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH), // PrebuiltWorkspaces are a subset of Workspaces. diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 267a99993e642..57a5022392b51 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -858,6 +858,36 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, setOrgNotMe, memberMe, orgMemberMe, templateAdmin, userAdmin}, }, }, + // Only the user themselves can access their own secrets — no one else. + { + Name: "UserSecrets", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceUserSecret.WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {memberMe, orgMemberMe}, + false: { + owner, orgAdmin, + otherOrgAdmin, otherOrgMember, orgAuditor, orgUserAdmin, orgTemplateAdmin, + templateAdmin, userAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + }, + }, + }, + { + Name: "UsageEvents", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, + Resource: rbac.ResourceUsageEvent, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {}, + false: { + owner, + memberMe, orgMemberMe, otherOrgMember, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, } // We expect every permission to be tested above. diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go index f6a01633f3179..25bd043c60975 100644 --- a/coderd/schedule/autostop.go +++ b/coderd/schedule/autostop.go @@ -50,8 +50,19 @@ type CalculateAutostopParams struct { // by autobuild.NextAutostart WorkspaceAutostart string - Now time.Time - Workspace database.WorkspaceTable + // WorkspaceBuildCompletedAt is the time when the workspace build was + // completed. + // + // We always want to calculate using the build completion time, and not just + // the current time, to avoid forcing a workspace build's max_deadline being + // pushed to the next potential cron instance. + // + // E.g. if this function is called for an existing workspace build, which + // currently has a max_deadline within the next 2 hours (see leeway + // above), and the current time is passed into this function, the + // max_deadline will be updated to be much later than expected. + WorkspaceBuildCompletedAt time.Time + Workspace database.WorkspaceTable } type AutostopTime struct { @@ -68,8 +79,8 @@ type AutostopTime struct { // Deadline is the time when the workspace will be stopped, as long as it // doesn't see any new activity (such as SSH, app requests, etc.). When activity // is detected the deadline is bumped by the workspace's TTL (this only happens -// when activity is detected and more than 20% of the TTL has passed to save -// database queries). +// when activity is detected and more than 5% of the TTL has passed to save +// database queries, see the ActivityBumpWorkspace query). // // MaxDeadline is the maximum value for deadline. The deadline cannot be bumped // past this value, so it denotes the absolute deadline that the workspace build @@ -77,55 +88,45 @@ type AutostopTime struct { // requirement" settings and the user's "quiet hours" settings to pick a time // outside of working hours. // -// Deadline is a cost saving measure, while max deadline is a -// compliance/updating measure. +// Note that the deadline is checked at the database level: +// +// (deadline IS NOT zero AND deadline <= max_deadline) UNLESS max_deadline is zero. +// +// Deadline is intended as a cost saving measure, not as a hard policy. It is +// derived from either the workspace's TTL or the template's TTL, depending on +// the template's policy, to ensure workspaces are stopped when they are idle. +// +// MaxDeadline is intended as a compliance policy. It is derived from the +// template's autostop requirement to cap workspace uptime and effectively force +// people to update often. +// +// Note that only the build's CURRENT deadline property influences automation in +// the autobuild package. As stated above, the MaxDeadline property is only used +// to cap the value of a build's deadline. func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (AutostopTime, error) { ctx, span := tracing.StartSpan(ctx, trace.WithAttributes(attribute.String("coder.workspace_id", params.Workspace.ID.String())), trace.WithAttributes(attribute.String("coder.template_id", params.Workspace.TemplateID.String())), ) defer span.End() - defer span.End() var ( - db = params.Database - workspace = params.Workspace - now = params.Now + db = params.Database + workspace = params.Workspace + buildCompletedAt = params.WorkspaceBuildCompletedAt autostop AutostopTime ) - var ttl time.Duration - if workspace.Ttl.Valid { - // When the workspace is made it copies the template's TTL, and the user - // can unset it to disable it (unless the template has - // UserAutoStopEnabled set to false, see below). - ttl = time.Duration(workspace.Ttl.Int64) - } - - if workspace.Ttl.Valid { - // When the workspace is made it copies the template's TTL, and the user - // can unset it to disable it (unless the template has - // UserAutoStopEnabled set to false, see below). - autostop.Deadline = now.Add(time.Duration(workspace.Ttl.Int64)) - } - templateSchedule, err := params.TemplateScheduleStore.Get(ctx, db, workspace.TemplateID) if err != nil { return autostop, xerrors.Errorf("get template schedule options: %w", err) } - if !templateSchedule.UserAutostopEnabled { - // The user is not permitted to set their own TTL, so use the template - // default. - ttl = 0 - if templateSchedule.DefaultTTL > 0 { - ttl = templateSchedule.DefaultTTL - } - } + ttl := workspaceTTL(workspace, templateSchedule) if ttl > 0 { // Only apply non-zero TTLs. - autostop.Deadline = now.Add(ttl) + autostop.Deadline = buildCompletedAt.Add(ttl) if params.WorkspaceAutostart != "" { // If the deadline passes the next autostart, we need to extend the deadline to // autostart + deadline. ActivityBumpWorkspace already covers this case @@ -137,14 +138,14 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut // 3. User starts workspace at 9:45pm. // - The initial deadline is calculated to be 9:45am // - This crosses the autostart deadline, so the deadline is extended to 9pm - nextAutostart, ok := NextAutostart(params.Now, params.WorkspaceAutostart, templateSchedule) + nextAutostart, ok := NextAutostart(params.WorkspaceBuildCompletedAt, params.WorkspaceAutostart, templateSchedule) if ok && autostop.Deadline.After(nextAutostart) { autostop.Deadline = nextAutostart.Add(ttl) } } } - // Otherwise, use the autostop_requirement algorithm. + // Enforce the template autostop requirement if it's configured correctly. if templateSchedule.AutostopRequirement.DaysOfWeek != 0 { // The template has a autostop requirement, so determine the max deadline // of this workspace build. @@ -161,10 +162,10 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut // workspace. if userQuietHoursSchedule.Schedule != nil { loc := userQuietHoursSchedule.Schedule.Location() - now := now.In(loc) + buildCompletedAtInLoc := buildCompletedAt.In(loc) // Add the leeway here so we avoid checking today's quiet hours if // the workspace was started <1h before midnight. - startOfStopDay := truncateMidnight(now.Add(autostopRequirementLeeway)) + startOfStopDay := truncateMidnight(buildCompletedAtInLoc.Add(autostopRequirementLeeway)) // If the template schedule wants to only autostop on n-th weeks // then change the startOfDay to be the Monday of the next @@ -183,7 +184,7 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut // hour of the scheduled stop time will always bounce to the next // stop window). checkSchedule := userQuietHoursSchedule.Schedule.Next(startOfStopDay.Add(autostopRequirementBuffer)) - if checkSchedule.Before(now.Add(autostopRequirementLeeway)) { + if checkSchedule.Before(buildCompletedAtInLoc.Add(autostopRequirementLeeway)) { // Set the first stop day we try to tomorrow because today's // schedule is too close to now or has already passed. startOfStopDay = nextDayMidnight(startOfStopDay) @@ -213,14 +214,17 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut startOfStopDay = nextDayMidnight(startOfStopDay) } - // If the startOfDay is within an hour of now, then we add an hour. + // If the startOfDay is within an hour of the build completion time, + // then we add an hour. checkTime := startOfStopDay - if checkTime.Before(now.Add(time.Hour)) { - checkTime = now.Add(time.Hour) + if checkTime.Before(buildCompletedAtInLoc.Add(time.Hour)) { + checkTime = buildCompletedAtInLoc.Add(time.Hour) } else { - // If it's not within an hour of now, subtract 15 minutes to - // give a little leeway. This prevents skipped stop events - // because autostart perfectly lines up with autostop. + // If it's not within an hour of the build completion time, + // subtract 15 minutes to give a little leeway. This prevents + // skipped stop events because the build time (e.g. autostart + // time) perfectly lines up with the max_deadline minus the + // leeway. checkTime = checkTime.Add(autostopRequirementBuffer) } @@ -238,15 +242,35 @@ func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (Aut autostop.Deadline = autostop.MaxDeadline } - if (!autostop.Deadline.IsZero() && autostop.Deadline.Before(now)) || (!autostop.MaxDeadline.IsZero() && autostop.MaxDeadline.Before(now)) { + if (!autostop.Deadline.IsZero() && autostop.Deadline.Before(buildCompletedAt)) || (!autostop.MaxDeadline.IsZero() && autostop.MaxDeadline.Before(buildCompletedAt)) { // Something went wrong with the deadline calculation, so we should // bail. - return autostop, xerrors.Errorf("deadline calculation error, computed deadline or max deadline is in the past for workspace build: deadline=%q maxDeadline=%q now=%q", autostop.Deadline, autostop.MaxDeadline, now) + return autostop, xerrors.Errorf("deadline calculation error, computed deadline or max deadline is in the past for workspace build: deadline=%q maxDeadline=%q now=%q", autostop.Deadline, autostop.MaxDeadline, buildCompletedAt) } return autostop, nil } +// workspaceTTL returns the TTL to use for a workspace. +// +// If the template forbids custom workspace TTLs, then we always use the +// template's configured TTL (or 0 if the template has no TTL configured). +func workspaceTTL(workspace database.WorkspaceTable, templateSchedule TemplateScheduleOptions) time.Duration { + // If the template forbids custom workspace TTLs, then we always use the + // template's configured TTL (or 0 if the template has no TTL configured). + if !templateSchedule.UserAutostopEnabled { + // This is intentionally a nested if statement because of the else if. + if templateSchedule.DefaultTTL > 0 { + return templateSchedule.DefaultTTL + } + return 0 + } + if workspace.Ttl.Valid { + return time.Duration(workspace.Ttl.Int64) + } + return 0 +} + // truncateMidnight truncates a time to midnight in the time object's timezone. // t.Truncate(24 * time.Hour) truncates based on the internal time and doesn't // factor daylight savings properly. diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index 85cc7b533a6ea..812f549f34dd2 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -76,8 +76,8 @@ func TestCalculateAutoStop(t *testing.T) { t.Log("saturdayMidnightAfterDstOut", saturdayMidnightAfterDstOut) cases := []struct { - name string - now time.Time + name string + buildCompletedAt time.Time wsAutostart string templateAutoStart schedule.TemplateAutostartRequirement @@ -98,7 +98,7 @@ func TestCalculateAutoStop(t *testing.T) { }{ { name: "OK", - now: now, + buildCompletedAt: now, templateAllowAutostop: true, templateDefaultTTL: 0, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, @@ -108,7 +108,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "Delete", - now: now, + buildCompletedAt: now, templateAllowAutostop: true, templateDefaultTTL: 0, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, @@ -118,7 +118,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "WorkspaceTTL", - now: now, + buildCompletedAt: now, templateAllowAutostop: true, templateDefaultTTL: 0, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, @@ -128,7 +128,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "TemplateDefaultTTLIgnored", - now: now, + buildCompletedAt: now, templateAllowAutostop: true, templateDefaultTTL: time.Hour, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, @@ -138,7 +138,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "WorkspaceTTLOverridesTemplateDefaultTTL", - now: now, + buildCompletedAt: now, templateAllowAutostop: true, templateDefaultTTL: 2 * time.Hour, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, @@ -148,7 +148,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "TemplateBlockWorkspaceTTL", - now: now, + buildCompletedAt: now, templateAllowAutostop: false, templateDefaultTTL: 3 * time.Hour, templateAutostopRequirement: schedule.TemplateAutostopRequirement{}, @@ -158,7 +158,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "TemplateAutostopRequirement", - now: wednesdayMidnightUTC, + buildCompletedAt: wednesdayMidnightUTC, templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -172,7 +172,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "TemplateAutostopRequirement1HourSkip", - now: saturdayMidnightSydney.Add(-59 * time.Minute), + buildCompletedAt: saturdayMidnightSydney.Add(-59 * time.Minute), templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -188,7 +188,7 @@ func TestCalculateAutoStop(t *testing.T) { // The next autostop requirement should be skipped if the // workspace is started within 1 hour of it. name: "TemplateAutostopRequirementDaily", - now: fridayEveningSydney, + buildCompletedAt: fridayEveningSydney, templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -202,7 +202,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "TemplateAutostopRequirementFortnightly/Skip", - now: wednesdayMidnightUTC, + buildCompletedAt: wednesdayMidnightUTC, templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -216,7 +216,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "TemplateAutostopRequirementFortnightly/NoSkip", - now: wednesdayMidnightUTC.AddDate(0, 0, 7), + buildCompletedAt: wednesdayMidnightUTC.AddDate(0, 0, 7), templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -230,7 +230,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "TemplateAutostopRequirementTriweekly/Skip", - now: wednesdayMidnightUTC, + buildCompletedAt: wednesdayMidnightUTC, templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -246,7 +246,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "TemplateAutostopRequirementTriweekly/NoSkip", - now: wednesdayMidnightUTC.AddDate(0, 0, 7), + buildCompletedAt: wednesdayMidnightUTC.AddDate(0, 0, 7), templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -262,7 +262,7 @@ func TestCalculateAutoStop(t *testing.T) { name: "TemplateAutostopRequirementOverridesWorkspaceTTL", // now doesn't have to be UTC, but it helps us ensure that // timezones are compared correctly in this test. - now: fridayEveningSydney.In(time.UTC), + buildCompletedAt: fridayEveningSydney.In(time.UTC), templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -276,7 +276,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "TemplateAutostopRequirementOverridesTemplateDefaultTTL", - now: fridayEveningSydney.In(time.UTC), + buildCompletedAt: fridayEveningSydney.In(time.UTC), templateAllowAutostop: true, templateDefaultTTL: 3 * time.Hour, userQuietHoursSchedule: sydneyQuietHours, @@ -293,7 +293,7 @@ func TestCalculateAutoStop(t *testing.T) { // The epoch is 2023-01-02 in each timezone. We set the time to // 1 second before 11pm the previous day, as this is the latest time // we allow due to our 2h leeway logic. - now: time.Date(2023, 1, 1, 21, 59, 59, 0, sydneyLoc), + buildCompletedAt: time.Date(2023, 1, 1, 21, 59, 59, 0, sydneyLoc), templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -306,7 +306,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "DaylightSavings/OK", - now: duringDst, + buildCompletedAt: duringDst, templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -320,7 +320,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "DaylightSavings/SwitchMidWeek/In", - now: beforeDstIn, + buildCompletedAt: beforeDstIn, templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -334,7 +334,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "DaylightSavings/SwitchMidWeek/Out", - now: beforeDstOut, + buildCompletedAt: beforeDstOut, templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: sydneyQuietHours, @@ -348,7 +348,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "DaylightSavings/QuietHoursFallsOnDstSwitch/In", - now: beforeDstIn.Add(-24 * time.Hour), + buildCompletedAt: beforeDstIn.Add(-24 * time.Hour), templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: dstInQuietHours, @@ -362,7 +362,7 @@ func TestCalculateAutoStop(t *testing.T) { }, { name: "DaylightSavings/QuietHoursFallsOnDstSwitch/Out", - now: beforeDstOut.Add(-24 * time.Hour), + buildCompletedAt: beforeDstOut.Add(-24 * time.Hour), templateAllowAutostop: true, templateDefaultTTL: 0, userQuietHoursSchedule: dstOutQuietHours, @@ -382,7 +382,7 @@ func TestCalculateAutoStop(t *testing.T) { // activity on the workspace. name: "AutostopCrossAutostartBorder", // Starting at 9:45pm, with the autostart at 9am. - now: pastDateNight, + buildCompletedAt: pastDateNight, templateAllowAutostop: false, templateDefaultTTL: time.Hour * 12, workspaceTTL: time.Hour * 12, @@ -405,7 +405,7 @@ func TestCalculateAutoStop(t *testing.T) { // Same as AutostopCrossAutostartBorder, but just misses the autostart. name: "AutostopCrossMissAutostartBorder", // Starting at 8:45pm, with the autostart at 9am. - now: time.Date(pastDateNight.Year(), pastDateNight.Month(), pastDateNight.Day(), 20, 30, 0, 0, chicago), + buildCompletedAt: time.Date(pastDateNight.Year(), pastDateNight.Month(), pastDateNight.Day(), 20, 30, 0, 0, chicago), templateAllowAutostop: false, templateDefaultTTL: time.Hour * 12, workspaceTTL: time.Hour * 12, @@ -429,7 +429,7 @@ func TestCalculateAutoStop(t *testing.T) { // The autostop deadline is before the autostart threshold. name: "AutostopCrossAutostartBorderMaxEarlyDeadline", // Starting at 9:45pm, with the autostart at 9am. - now: pastDateNight, + buildCompletedAt: pastDateNight, templateAllowAutostop: false, templateDefaultTTL: time.Hour * 12, workspaceTTL: time.Hour * 12, @@ -459,7 +459,7 @@ func TestCalculateAutoStop(t *testing.T) { // So the deadline is > 12 hours, but stops at the max deadline. name: "AutostopCrossAutostartBorderMaxDeadline", // Starting at 9:45pm, with the autostart at 9am. - now: pastDateNight, + buildCompletedAt: pastDateNight, templateAllowAutostop: false, templateDefaultTTL: time.Hour * 12, workspaceTTL: time.Hour * 12, @@ -571,7 +571,7 @@ func TestCalculateAutoStop(t *testing.T) { Database: db, TemplateScheduleStore: templateScheduleStore, UserQuietHoursScheduleStore: userQuietHoursScheduleStore, - Now: c.now, + WorkspaceBuildCompletedAt: c.buildCompletedAt, Workspace: workspace, WorkspaceAutostart: c.wsAutostart, }) diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index d35f3c94b5ff7..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 { @@ -263,7 +264,7 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder return filter, parser.Errors } -func Templates(ctx context.Context, db database.Store, query string) (database.GetTemplatesWithFilterParams, []codersdk.ValidationError) { +func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query string) (database.GetTemplatesWithFilterParams, []codersdk.ValidationError) { // Always lowercase for all searches. query = strings.ToLower(query) values, errors := searchTerms(query, func(term string, values url.Values) error { @@ -277,13 +278,21 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G parser := httpapi.NewQueryParamParser() filter := database.GetTemplatesWithFilterParams{ - Deleted: parser.Boolean(values, false, "deleted"), - 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"), - OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), - HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"), + 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 { + filter.AuthorID = actorID + filter.AuthorUsername = "" } parser.ErrorExcessParams(values) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 4744b57edff4a..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 { @@ -640,6 +670,7 @@ func TestSearchUsers(t *testing.T) { func TestSearchTemplates(t *testing.T) { t.Parallel() + userID := uuid.New() testCases := []struct { Name string Query string @@ -688,6 +719,44 @@ 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", + Expected: database.GetTemplatesWithFilterParams{ + AuthorUsername: "", + AuthorID: userID, + }, + }, } for _, c := range testCases { @@ -696,7 +765,7 @@ func TestSearchTemplates(t *testing.T) { // Do not use a real database, this is only used for an // organization lookup. db, _ := dbtestutil.NewDB(t) - values, errs := searchquery.Templates(context.Background(), db, c.Query) + values, errs := searchquery.Templates(context.Background(), db, userID, c.Query) if c.ExpectedErrorContains != "" { require.True(t, len(errs) > 0, "expect some errors") var s strings.Builder 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..734c23eb3dd76 --- /dev/null +++ b/coderd/taskname/taskname.go @@ -0,0 +1,179 @@ +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 27 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 + } + + taskName := acc.Messages()[0].Content + if taskName == "task-unnamed" { + return "", ErrNoNameGenerated + } + + // We append a suffix to the end of the task name to reduce + // the chance of collisions. We truncate the task name to + // to a maximum of 27 bytes, so that when we append the + // 5 byte suffix (`-` and 4 byte hex slug), it should + // remain within the 32 byte workspace name limit. + taskName = taskName[:min(len(taskName), 27)] + taskName = fmt.Sprintf("%s-%s", taskName, generateSuffix()) + if err := codersdk.NameValid(taskName); err != nil { + return "", xerrors.Errorf("generated name %v not valid: %w", taskName, err) + } + + return taskName, 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 ac836317b680e..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) @@ -403,7 +463,6 @@ func TestTelemetryItem(t *testing.T) { func TestPrebuiltWorkspacesTelemetry(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitMedium) db, _ := dbtestutil.NewDB(t) cases := []struct { @@ -435,6 +494,7 @@ func TestPrebuiltWorkspacesTelemetry(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) deployment, snapshot := collectSnapshot(ctx, t, db, func(opts telemetry.Options) telemetry.Options { opts.Database = tc.storeFn(db) diff --git a/coderd/templates.go b/coderd/templates.go index 694bb90b86a4d..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 @@ -544,9 +544,10 @@ func (api *API) templatesByOrganization() http.HandlerFunc { func (api *API) fetchTemplates(mutate func(r *http.Request, arg *database.GetTemplatesWithFilterParams)) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + key := httpmw.APIKey(r) queryStr := r.URL.Query().Get("q") - filter, errs := searchquery.Templates(ctx, api.Database, queryStr) + filter, errs := searchquery.Templates(ctx, api.Database, key.UserID, queryStr) if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid template search query.", @@ -628,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) { @@ -770,12 +773,16 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { classicTemplateFlow = *req.UseClassicParameterFlow } + displayName := ptr.NilToDefault(req.DisplayName, template.DisplayName) + description := ptr.NilToDefault(req.Description, template.Description) + icon := ptr.NilToDefault(req.Icon, template.Icon) + var updated database.Template err = api.Database.InTx(func(tx database.Store) error { if req.Name == template.Name && - req.Description == template.Description && - req.DisplayName == template.DisplayName && - req.Icon == template.Icon && + description == template.Description && + displayName == template.DisplayName && + icon == template.Icon && req.AllowUserAutostart == template.AllowUserAutostart && req.AllowUserAutostop == template.AllowUserAutostop && req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs && @@ -826,9 +833,9 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { ID: template.ID, UpdatedAt: dbtime.Now(), Name: name, - DisplayName: req.DisplayName, - Description: req.Description, - Icon: req.Icon, + DisplayName: displayName, + Description: description, + Icon: icon, AllowUserCancelWorkspaceJobs: req.AllowUserCancelWorkspaceJobs, GroupACL: groupACL, MaxPortSharingLevel: maxPortShareLevel, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 0858ce83325cc..c470dd17c664a 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -814,6 +814,46 @@ func TestTemplatesByOrganization(t *testing.T) { require.False(t, templates[0].Deprecated) require.Empty(t, templates[0].DeprecationMessage) }) + + t.Run("ListByAuthor", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + adminAlpha, adminAlphaData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + adminBravo, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + adminCharlie, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + + versionA := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + versionB := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + versionC := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + foo := coderdtest.CreateTemplate(t, adminAlpha, owner.OrganizationID, versionA.ID, func(request *codersdk.CreateTemplateRequest) { + request.Name = "foo" + }) + bar := coderdtest.CreateTemplate(t, adminBravo, owner.OrganizationID, versionB.ID, func(request *codersdk.CreateTemplateRequest) { + request.Name = "bar" + }) + _ = coderdtest.CreateTemplate(t, adminCharlie, owner.OrganizationID, versionC.ID, func(request *codersdk.CreateTemplateRequest) { + request.Name = "baz" + }) + + ctx := testutil.Context(t, testutil.WaitLong) + + // List alpha + alpha, err := client.Templates(ctx, codersdk.TemplateFilter{ + AuthorUsername: adminAlphaData.Username, + }) + require.NoError(t, err) + require.Len(t, alpha, 1) + require.Equal(t, foo.ID, alpha[0].ID) + + // List bravo + bravo, err := adminBravo.Templates(ctx, codersdk.TemplateFilter{ + AuthorUsername: codersdk.Me, + }) + require.NoError(t, err) + require.Len(t, bravo, 1) + require.Equal(t, bar.ID, bravo[0].ID) + }) } func TestTemplateByOrganizationAndName(t *testing.T) { @@ -861,9 +901,9 @@ func TestPatchTemplateMeta(t *testing.T) { req := codersdk.UpdateTemplateMeta{ Name: "new-template-name", - DisplayName: "Displayed Name 456", - Description: "lorem ipsum dolor sit amet et cetera", - Icon: "/icon/new-icon.png", + DisplayName: ptr.Ref("Displayed Name 456"), + Description: ptr.Ref("lorem ipsum dolor sit amet et cetera"), + Icon: ptr.Ref("/icon/new-icon.png"), DefaultTTLMillis: 12 * time.Hour.Milliseconds(), ActivityBumpMillis: 3 * time.Hour.Milliseconds(), AllowUserCancelWorkspaceJobs: false, @@ -878,9 +918,9 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.Greater(t, updated.UpdatedAt, template.UpdatedAt) assert.Equal(t, req.Name, updated.Name) - assert.Equal(t, req.DisplayName, updated.DisplayName) - assert.Equal(t, req.Description, updated.Description) - assert.Equal(t, req.Icon, updated.Icon) + assert.Equal(t, *req.DisplayName, updated.DisplayName) + assert.Equal(t, *req.Description, updated.Description) + assert.Equal(t, *req.Icon, updated.Icon) assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis) assert.Equal(t, req.ActivityBumpMillis, updated.ActivityBumpMillis) assert.False(t, req.AllowUserCancelWorkspaceJobs) @@ -890,9 +930,9 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.Greater(t, updated.UpdatedAt, template.UpdatedAt) assert.Equal(t, req.Name, updated.Name) - assert.Equal(t, req.DisplayName, updated.DisplayName) - assert.Equal(t, req.Description, updated.Description) - assert.Equal(t, req.Icon, updated.Icon) + assert.Equal(t, *req.DisplayName, updated.DisplayName) + assert.Equal(t, *req.Description, updated.Description) + assert.Equal(t, *req.Icon, updated.Icon) assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis) assert.Equal(t, req.ActivityBumpMillis, updated.ActivityBumpMillis) assert.False(t, req.AllowUserCancelWorkspaceJobs) @@ -1127,9 +1167,9 @@ func TestPatchTemplateMeta(t *testing.T) { got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, DefaultTTLMillis: 0, AutostopRequirement: &template.AutostopRequirement, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, @@ -1162,9 +1202,9 @@ func TestPatchTemplateMeta(t *testing.T) { got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, DefaultTTLMillis: template.DefaultTTLMillis, AutostopRequirement: &template.AutostopRequirement, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, @@ -1223,9 +1263,9 @@ func TestPatchTemplateMeta(t *testing.T) { allowAutostop.Store(false) got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, DefaultTTLMillis: template.DefaultTTLMillis, AutostopRequirement: &template.AutostopRequirement, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, @@ -1254,9 +1294,9 @@ func TestPatchTemplateMeta(t *testing.T) { got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, // Increase the default TTL to avoid error "not modified". DefaultTTLMillis: template.DefaultTTLMillis + 1, AutostopRequirement: &template.AutostopRequirement, @@ -1286,8 +1326,8 @@ func TestPatchTemplateMeta(t *testing.T) { req := codersdk.UpdateTemplateMeta{ Name: template.Name, - Description: template.Description, - Icon: template.Icon, + Description: &template.Description, + Icon: &template.Icon, DefaultTTLMillis: template.DefaultTTLMillis, ActivityBumpMillis: template.ActivityBumpMillis, AutostopRequirement: nil, @@ -1347,7 +1387,7 @@ func TestPatchTemplateMeta(t *testing.T) { ctr.Icon = "/icon/code.png" }) req := codersdk.UpdateTemplateMeta{ - Icon: "", + Icon: ptr.Ref(""), } ctx := testutil.Context(t, testutil.WaitLong) @@ -1402,9 +1442,9 @@ func TestPatchTemplateMeta(t *testing.T) { require.EqualValues(t, 1, template.AutostopRequirement.Weeks) req := codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, DefaultTTLMillis: time.Hour.Milliseconds(), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ @@ -1479,9 +1519,9 @@ func TestPatchTemplateMeta(t *testing.T) { require.EqualValues(t, 2, template.AutostopRequirement.Weeks) req := codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, DefaultTTLMillis: time.Hour.Milliseconds(), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ @@ -1516,9 +1556,9 @@ func TestPatchTemplateMeta(t *testing.T) { require.EqualValues(t, 1, template.AutostopRequirement.Weeks) req := codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, DefaultTTLMillis: time.Hour.Milliseconds(), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ @@ -1578,6 +1618,106 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.False(t, updated.UseClassicParameterFlow, "expected false") }) + + t.Run("SupportEmptyOrDefaultFields", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + displayName := "Test Display Name" + description := "test-description" + icon := "/icon/icon.png" + defaultTTLMillis := 10 * time.Hour.Milliseconds() + + reference := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DisplayName = displayName + ctr.Description = description + ctr.Icon = icon + ctr.DefaultTTLMillis = ptr.Ref(defaultTTLMillis) + }) + require.Equal(t, displayName, reference.DisplayName) + require.Equal(t, description, reference.Description) + require.Equal(t, icon, reference.Icon) + + restoreReq := codersdk.UpdateTemplateMeta{ + DisplayName: &displayName, + Description: &description, + Icon: &icon, + DefaultTTLMillis: defaultTTLMillis, + } + + type expected struct { + displayName string + description string + icon string + defaultTTLMillis int64 + } + + type testCase struct { + name string + req codersdk.UpdateTemplateMeta + expected expected + } + + tests := []testCase{ + { + name: "Only update default_ttl_ms", + req: codersdk.UpdateTemplateMeta{DefaultTTLMillis: 99 * time.Hour.Milliseconds()}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: 99 * time.Hour.Milliseconds()}, + }, + { + name: "Clear display name", + req: codersdk.UpdateTemplateMeta{DisplayName: ptr.Ref("")}, + expected: expected{displayName: "", description: reference.Description, icon: reference.Icon, defaultTTLMillis: 0}, + }, + { + name: "Clear description", + req: codersdk.UpdateTemplateMeta{Description: ptr.Ref("")}, + expected: expected{displayName: reference.DisplayName, description: "", icon: reference.Icon, defaultTTLMillis: 0}, + }, + { + name: "Clear icon", + req: codersdk.UpdateTemplateMeta{Icon: ptr.Ref("")}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: "", defaultTTLMillis: 0}, + }, + { + name: "Nil display name defaults to reference display name", + req: codersdk.UpdateTemplateMeta{DisplayName: nil}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: 0}, + }, + { + name: "Nil description defaults to reference description", + req: codersdk.UpdateTemplateMeta{Description: nil}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: 0}, + }, + { + name: "Nil icon defaults to reference icon", + req: codersdk.UpdateTemplateMeta{Icon: nil}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: 0}, + }, + } + + for _, tc := range tests { + //nolint:tparallel,paralleltest + t.Run(tc.name, func(t *testing.T) { + defer func() { + ctx := testutil.Context(t, testutil.WaitLong) + // Restore reference after each test case + _, err := client.UpdateTemplateMeta(ctx, reference.ID, restoreReq) + require.NoError(t, err) + }() + ctx := testutil.Context(t, testutil.WaitLong) + updated, err := client.UpdateTemplateMeta(ctx, reference.ID, tc.req) + require.NoError(t, err) + assert.Equal(t, tc.expected.displayName, updated.DisplayName) + assert.Equal(t, tc.expected.description, updated.Description) + assert.Equal(t, tc.expected.icon, updated.Icon) + assert.Equal(t, tc.expected.defaultTTLMillis, updated.DefaultTTLMillis) + }) + } + }) } func TestDeleteTemplate(t *testing.T) { @@ -1875,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/inserter.go b/coderd/usage/inserter.go new file mode 100644 index 0000000000000..7a0f42daf4724 --- /dev/null +++ b/coderd/usage/inserter.go @@ -0,0 +1,32 @@ +package usage + +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. + // 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. +type AGPLInserter struct{} + +var _ Inserter = AGPLInserter{} + +func NewAGPLInserter() Inserter { + return AGPLInserter{} +} + +// InsertDiscreteUsageEvent is a no-op implementation of +// InsertDiscreteUsageEvent. +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.go b/coderd/users.go index 7fbb8e7d04cdf..d38d40a1fc826 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -148,7 +148,7 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { err = userpassword.Validate(createUser.Password) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Password not strong enough!", + Message: "Password is invalid", Validations: []codersdk.ValidationError{{ Field: "password", Detail: err.Error(), @@ -448,7 +448,7 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { err = userpassword.Validate(req.Password) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Password not strong enough!", + Message: "Password is invalid", Validations: []codersdk.ValidationError{{ Field: "password", Detail: err.Error(), @@ -542,7 +542,10 @@ func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) { return } - workspaces, err := api.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{ + // This query is ONLY done to get the workspace count, so we use a system + // context to return ALL workspaces. Not just workspaces the user can view. + // nolint:gocritic + workspaces, err := api.Database.GetWorkspaces(dbauthz.AsSystemRestricted(ctx), database.GetWorkspacesParams{ OwnerID: user.ID, }) if err != nil { diff --git a/coderd/users_test.go b/coderd/users_test.go index 9d695f37c9906..22c9fad5eebea 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -377,6 +377,43 @@ func TestDeleteUser(t *testing.T) { require.ErrorAs(t, err, &apiErr, "should be a coderd error") require.Equal(t, http.StatusForbidden, apiErr.StatusCode(), "should be forbidden") }) + t.Run("CountCheckIncludesAllWorkspaces", func(t *testing.T) { + t.Parallel() + client, _ := coderdtest.NewWithProvisionerCloser(t, nil) + firstUser := coderdtest.CreateFirstUser(t, client) + + // Create a target user who will own a workspace + targetUserClient, targetUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + + // Create a User Admin who should not have permission to see the target user's workspace + userAdminClient, userAdmin := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + + // Grant User Admin role to the userAdmin + userAdmin, err := client.UpdateUserRoles(context.Background(), userAdmin.ID.String(), codersdk.UpdateRoles{ + Roles: []string{rbac.RoleUserAdmin().String()}, + }) + require.NoError(t, err) + + // Create a template and workspace owned by the target user + version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID) + _ = coderdtest.CreateWorkspace(t, targetUserClient, template.ID) + + workspaces, err := userAdminClient.Workspaces(context.Background(), codersdk.WorkspaceFilter{ + Owner: targetUser.Username, + }) + require.NoError(t, err) + require.Len(t, workspaces.Workspaces, 0) + + // Attempt to delete the target user - this should fail because the + // user has a workspace not visible to the deleting user. + err = userAdminClient.DeleteUser(context.Background(), targetUser.ID) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusExpectationFailed, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "has workspaces") + }) } func TestNotifyUserStatusChanged(t *testing.T) { @@ -1507,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)), @@ -1535,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), @@ -1557,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), @@ -1578,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), @@ -1875,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.go b/coderd/workspaceagents.go index d600eff6ecfec..f2ee1ac18e823 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -896,7 +896,11 @@ func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Re case <-ctx.Done(): return - case containers := <-containersCh: + case containers, ok := <-containersCh: + if !ok { + return + } + if err := encoder.Encode(containers); err != nil { api.Logger.Error(ctx, "encode containers", slog.Error(err)) return 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 30859cb6391e6..6a817966f4ff5 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 @@ -1389,169 +1387,147 @@ func TestWorkspaceAgentContainers(t *testing.T) { func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { t.Parallel() - var ( - ctx = testutil.Context(t, testutil.WaitLong) - 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() + t.Run("OK", func(t *testing.T) { + t.Parallel() - fakeContainer1 = codersdk.WorkspaceAgentContainer{ - ID: "container1", - CreatedAt: dbtime.Now(), - FriendlyName: "container1", - Image: "busybox:latest", - Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project1", - agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project1/.devcontainer/devcontainer.json", - }, - Running: true, - Status: "running", - } + var ( + ctx = testutil.Context(t, testutil.WaitLong) + 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() + + fakeContainer1 = codersdk.WorkspaceAgentContainer{ + ID: "container1", + CreatedAt: dbtime.Now(), + FriendlyName: "container1", + Image: "busybox:latest", + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project1", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project1/.devcontainer/devcontainer.json", + }, + Running: true, + Status: "running", + } - fakeContainer2 = codersdk.WorkspaceAgentContainer{ - ID: "container1", - CreatedAt: dbtime.Now(), - FriendlyName: "container2", - Image: "busybox:latest", - Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project2", - agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project2/.devcontainer/devcontainer.json", - }, - Running: true, - Status: "running", - } - ) + fakeContainer2 = codersdk.WorkspaceAgentContainer{ + ID: "container1", + CreatedAt: dbtime.Now(), + FriendlyName: "container2", + Image: "busybox:latest", + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project2", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project2/.devcontainer/devcontainer.json", + }, + Running: true, + Status: "running", + } + ) - stages := []struct { - containers []codersdk.WorkspaceAgentContainer - expected codersdk.WorkspaceAgentListContainersResponse - }{ - { - containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, - expected: codersdk.WorkspaceAgentListContainersResponse{ - Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, - Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ - { - Name: "project1", - WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], - ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], - Status: "running", - Container: &fakeContainer1, + stages := []struct { + containers []codersdk.WorkspaceAgentContainer + expected codersdk.WorkspaceAgentListContainersResponse + }{ + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "project1", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer1, + }, }, }, }, - }, - { - containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, - expected: codersdk.WorkspaceAgentListContainersResponse{ - Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, - Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ - { - Name: "project1", - WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], - ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], - Status: "running", - Container: &fakeContainer1, - }, - { - Name: "project2", - WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], - ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], - Status: "running", - Container: &fakeContainer2, + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "project1", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer1, + }, + { + Name: "project2", + WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer2, + }, }, }, }, - }, - { - containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, - expected: codersdk.WorkspaceAgentListContainersResponse{ - Containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, - Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ - { - Name: "", - WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], - ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], - Status: "stopped", - Container: nil, - }, - { - Name: "project2", - WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], - ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], - Status: "running", - Container: &fakeContainer2, + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "stopped", + Container: nil, + }, + { + Name: "project2", + WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer2, + }, }, }, }, - }, - } - - // Set up initial state for immediate send on connection - mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stages[0].containers}, 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 + // Set up initial state for immediate send on connection + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stages[0].containers}, nil) + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("", nil).AnyTimes() - updaterTickerTrap.MustWait(ctx).MustRelease(ctx) - defer updaterTickerTrap.Close() - - containers, closer, err := client.WatchWorkspaceAgentContainers(ctx, agentID) - require.NoError(t, err) - defer func() { - closer.Close() - }() + _ = 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()), + } + }) - // Read initial state sent immediately on connection - var got codersdk.WorkspaceAgentListContainersResponse - select { - case <-ctx.Done(): - case got = <-containers: - } - require.NoError(t, ctx.Err()) - - require.Equal(t, stages[0].expected.Containers, got.Containers) - require.Len(t, got.Devcontainers, len(stages[0].expected.Devcontainers)) - for j, expectedDev := range stages[0].expected.Devcontainers { - gotDev := got.Devcontainers[j] - require.Equal(t, expectedDev.Name, gotDev.Name) - require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) - require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath) - require.Equal(t, expectedDev.Status, gotDev.Status) - require.Equal(t, expectedDev.Container, gotDev.Container) - } + 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 - // Process remaining stages through updater loop - for i, stage := range stages[1:] { - mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil) + updaterTickerTrap.MustWait(ctx).MustRelease(ctx) + defer updaterTickerTrap.Close() - _, aw := mClock.AdvanceNext() - aw.MustWait(ctx) + containers, closer, err := client.WatchWorkspaceAgentContainers(ctx, agentID) + require.NoError(t, err) + defer func() { + closer.Close() + }() + // Read initial state sent immediately on connection var got codersdk.WorkspaceAgentListContainersResponse select { case <-ctx.Done(): @@ -1559,9 +1535,9 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { } require.NoError(t, ctx.Err()) - require.Equal(t, stages[i+1].expected.Containers, got.Containers) - require.Len(t, got.Devcontainers, len(stages[i+1].expected.Devcontainers)) - for j, expectedDev := range stages[i+1].expected.Devcontainers { + require.Equal(t, stages[0].expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stages[0].expected.Devcontainers)) + for j, expectedDev := range stages[0].expected.Devcontainers { gotDev := got.Devcontainers[j] require.Equal(t, expectedDev.Name, gotDev.Name) require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) @@ -1569,7 +1545,33 @@ func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { require.Equal(t, expectedDev.Status, gotDev.Status) require.Equal(t, expectedDev.Container, gotDev.Container) } - } + + // Process remaining stages through updater loop + for i, stage := range stages[1:] { + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil) + + _, aw := mClock.AdvanceNext() + aw.MustWait(ctx) + + var got codersdk.WorkspaceAgentListContainersResponse + select { + case <-ctx.Done(): + case got = <-containers: + } + require.NoError(t, ctx.Err()) + + require.Equal(t, stages[i+1].expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stages[i+1].expected.Devcontainers)) + for j, expectedDev := range stages[i+1].expected.Devcontainers { + gotDev := got.Devcontainers[j] + require.Equal(t, expectedDev.Name, gotDev.Name) + require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) + require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath) + require.Equal(t, expectedDev.Status, gotDev.Status) + require.Equal(t, expectedDev.Container, gotDev.Container) + } + } + }) } func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { @@ -1608,63 +1610,77 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { ) for _, tc := range []struct { - name string - devcontainerID string - setupDevcontainers []codersdk.WorkspaceAgentDevcontainer - setupMock func(mccli *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) (status int) + name string + devcontainerID string + devcontainers []codersdk.WorkspaceAgentDevcontainer + containers []codersdk.WorkspaceAgentContainer + expectRecreate bool + expectErrorCode int }{ { - name: "Recreate", - devcontainerID: devcontainerID.String(), - setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{devcontainer}, - setupMock: func(mccli *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) int { - mccli.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ - Containers: []codersdk.WorkspaceAgentContainer{devContainer}, - }, nil).AnyTimes() - // DetectArchitecture always returns "" for this test to disable agent injection. - mccli.EXPECT().DetectArchitecture(gomock.Any(), devContainer.ID).Return("", nil).AnyTimes() - mdccli.EXPECT().ReadConfig(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return(agentcontainers.DevcontainerConfig{}, nil).AnyTimes() - mdccli.EXPECT().Up(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return("someid", nil).Times(1) - return 0 - }, + name: "Recreate", + devcontainerID: devcontainerID.String(), + devcontainers: []codersdk.WorkspaceAgentDevcontainer{devcontainer}, + containers: []codersdk.WorkspaceAgentContainer{devContainer}, + expectRecreate: true, }, { - name: "Devcontainer does not exist", - devcontainerID: uuid.NewString(), - setupDevcontainers: nil, - setupMock: func(mccli *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) int { - mccli.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, nil).AnyTimes() - return http.StatusNotFound - }, + name: "Devcontainer does not exist", + devcontainerID: uuid.NewString(), + devcontainers: nil, + containers: []codersdk.WorkspaceAgentContainer{}, + expectErrorCode: http.StatusNotFound, }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - ctrl := gomock.NewController(t) - mccli := acmock.NewMockContainerCLI(ctrl) - mdccli := acmock.NewMockDevcontainerCLI(ctrl) - wantStatus := tc.setupMock(mccli, mdccli) - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - 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() + var ( + ctx = testutil.Context(t, testutil.WaitLong) + mCtrl = gomock.NewController(t) + mCCLI = acmock.NewMockContainerCLI(mCtrl) + mDCCLI = acmock.NewMockDevcontainerCLI(mCtrl) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + 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() + ) + + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: tc.containers, + }, nil).AnyTimes() + + var upCalled chan struct{} + + if tc.expectRecreate { + upCalled = make(chan struct{}) + + // DetectArchitecture always returns "" for this test to disable agent injection. + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), devContainer.ID).Return("", nil).AnyTimes() + mDCCLI.EXPECT().ReadConfig(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return(agentcontainers.DevcontainerConfig{}, nil).AnyTimes() + mDCCLI.EXPECT().Up(gomock.Any(), workspaceFolder, configFile, gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { + close(upCalled) + + return "someid", nil + }).Times(1) + } devcontainerAPIOptions := []agentcontainers.Option{ - agentcontainers.WithContainerCLI(mccli), - agentcontainers.WithDevcontainerCLI(mdccli), + agentcontainers.WithContainerCLI(mCCLI), + agentcontainers.WithDevcontainerCLI(mDCCLI), agentcontainers.WithWatcher(watcher.NewNoop()), } - if tc.setupDevcontainers != nil { + if tc.devcontainers != nil { devcontainerAPIOptions = append(devcontainerAPIOptions, - agentcontainers.WithDevcontainers(tc.setupDevcontainers, nil)) + agentcontainers.WithDevcontainers(tc.devcontainers, nil)) } _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { @@ -1677,15 +1693,14 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { require.Len(t, resources[0].Agents, 1, "expected one agent") agentID := resources[0].Agents[0].ID - ctx := testutil.Context(t, testutil.WaitLong) - _, err := client.WorkspaceAgentRecreateDevcontainer(ctx, agentID, tc.devcontainerID) - if wantStatus > 0 { + if tc.expectErrorCode > 0 { cerr, ok := codersdk.AsError(err) require.True(t, ok, "expected error to be a coder error") - assert.Equal(t, wantStatus, cerr.StatusCode()) + assert.Equal(t, tc.expectErrorCode, cerr.StatusCode()) } else { require.NoError(t, err, "failed to recreate devcontainer") + testutil.TryReceive(ctx, t, upCalled) } }) } @@ -2417,7 +2432,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. @@ -2458,7 +2473,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) @@ -2475,7 +2490,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/workspaceagentsrpc.go b/coderd/workspaceagentsrpc.go index 0806118f2a832..8dacbe9812ca9 100644 --- a/coderd/workspaceagentsrpc.go +++ b/coderd/workspaceagentsrpc.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "runtime/pprof" "sync" "sync/atomic" "time" @@ -348,16 +347,14 @@ func (m *agentConnectionMonitor) init() { func (m *agentConnectionMonitor) start(ctx context.Context) { ctx, m.cancel = context.WithCancel(ctx) m.wg.Add(2) - go pprof.Do(ctx, pprof.Labels("agent", m.workspaceAgent.ID.String()), - func(ctx context.Context) { - defer m.wg.Done() - m.sendPings(ctx) - }) - go pprof.Do(ctx, pprof.Labels("agent", m.workspaceAgent.ID.String()), - func(ctx context.Context) { - defer m.wg.Done() - m.monitor(ctx) - }) + go func(ctx context.Context) { + defer m.wg.Done() + m.sendPings(ctx) + }(ctx) + go func(ctx context.Context) { + defer m.wg.Done() + m.monitor(ctx) + }(ctx) } func (m *agentConnectionMonitor) monitor(ctx context.Context) { diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index d9862ab1f9db9..22669d568b0e1 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -255,6 +255,7 @@ func Test_ResolveRequest(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) // Try resolving a request for each app as the owner, without a // token, then use the token to resolve each app. @@ -589,6 +590,7 @@ func Test_ResolveRequest(t *testing.T) { t.Run("TokenDoesNotMatchRequest", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) badToken := workspaceapps.SignedToken{ Request: (workspaceapps.Request{ 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..2fdb40a1e4661 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -329,13 +329,44 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - workspace := httpmw.WorkspaceParam(r) var createBuild codersdk.CreateWorkspaceBuildRequest if !httpapi.Read(ctx, rw, r, &createBuild) { return } + apiBuild, err := api.postWorkspaceBuildsInternal( + ctx, + apiKey, + workspace, + createBuild, + func(action policy.Action, object rbac.Objecter) bool { + return api.Authorize(r, action, object) + }, + audit.WorkspaceBuildBaggageFromRequest(r), + ) + if err != nil { + httperror.WriteWorkspaceBuildError(ctx, rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusCreated, apiBuild) +} + +// postWorkspaceBuildsInternal handles the internal logic for creating +// workspace builds, can be called by other handlers and must not +// reference httpmw. +func (api *API) postWorkspaceBuildsInternal( + ctx context.Context, + apiKey database.APIKey, + workspace database.Workspace, + createBuild codersdk.CreateWorkspaceBuildRequest, + authorize func(action policy.Action, object rbac.Objecter) bool, + workspaceBuildBaggage audit.WorkspaceBuildBaggage, +) ( + codersdk.WorkspaceBuild, + error, +) { transition := database.WorkspaceTransition(createBuild.Transition) builder := wsbuilder.New(workspace, transition, *api.BuildUsageChecker.Load()). Initiator(apiKey.UserID). @@ -362,11 +393,10 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { previousWorkspaceBuild, err = tx.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { api.Logger.Error(ctx, "failed fetching previous workspace build", slog.F("workspace_id", workspace.ID), slog.Error(err)) - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching previous workspace build", Detail: err.Error(), }) - return nil } if createBuild.TemplateVersionID != uuid.Nil { @@ -375,16 +405,14 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { if createBuild.Orphan { if createBuild.Transition != codersdk.WorkspaceTransitionDelete { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: "Orphan is only permitted when deleting a workspace.", }) - return nil } if len(createBuild.ProvisionerState) > 0 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.", }) - return nil } builder = builder.Orphan() } @@ -397,24 +425,23 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { tx, api.FileCache, func(action policy.Action, object rbac.Objecter) bool { - if auth := api.Authorize(r, action, object); auth { + if auth := authorize(action, object); auth { return true } // Special handling for prebuilt workspace deletion if action == policy.ActionDelete { if workspaceObj, ok := object.(database.PrebuiltWorkspaceResource); ok && workspaceObj.IsPrebuild() { - return api.Authorize(r, action, workspaceObj.AsPrebuild()) + return authorize(action, workspaceObj.AsPrebuild()) } } return false }, - audit.WorkspaceBuildBaggageFromRequest(r), + workspaceBuildBaggage, ) return err }, nil) if err != nil { - httperror.WriteWorkspaceBuildError(ctx, rw, err) - return + return codersdk.WorkspaceBuild{}, err } var queuePos database.GetProvisionerJobsByIDsWithQueuePositionRow @@ -478,11 +505,13 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { provisionerDaemons, ) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error converting workspace build.", - Detail: err.Error(), - }) - return + return codersdk.WorkspaceBuild{}, httperror.NewResponseError( + http.StatusInternalServerError, + codersdk.Response{ + Message: "Internal error converting workspace build.", + Detail: err.Error(), + }, + ) } // If this workspace build has a different template version ID to the previous build @@ -509,7 +538,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { WorkspaceID: workspace.ID, }) - httpapi.Write(ctx, rw, http.StatusCreated, apiBuild) + return apiBuild, nil } func (api *API) notifyWorkspaceUpdated( @@ -1157,6 +1186,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 +1219,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..994411a8b3817 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") @@ -579,8 +577,12 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) { build, err = client.WorkspaceBuild(ctx, workspace.LatestBuild.ID) return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning }, testutil.WaitShort, testutil.IntervalFast) - err := client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{}) - require.NoError(t, err) + + require.Eventually(t, func() bool { + err := client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{}) + return assert.NoError(t, err) + }, testutil.WaitShort, testutil.IntervalMedium) + require.Eventually(t, func() bool { var err error build, err = client.WorkspaceBuild(ctx, build.ID) @@ -1638,6 +1640,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 +1649,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 +1670,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 2080926b44089..3b8e35c003682 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -32,12 +32,14 @@ import ( "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/acl" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule/cron" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" @@ -45,8 +47,8 @@ import ( ) var ( - ttlMin = time.Minute //nolint:revive // min here means 'minimum' not 'minutes' - ttlMax = 30 * 24 * time.Hour + ttlMinimum = time.Minute + ttlMaximum = 30 * 24 * time.Hour errTTLMin = xerrors.New("time until shutdown must be at least one minute") errTTLMax = xerrors.New("time until shutdown must be less than 30 days") @@ -137,7 +139,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 @@ -386,7 +388,13 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req AvatarURL: member.AvatarURL, } - createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, rw, r) + w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, r) + if err != nil { + httperror.WriteResponseError(ctx, rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusCreated, w) } // Create a new workspace for the currently authenticated user. @@ -440,8 +448,9 @@ func (api *API) postUserWorkspaces(rw http.ResponseWriter, r *http.Request) { // This can be optimized. It exists as it is now for code simplicity. // The most common case is to create a workspace for 'Me'. Which does // not enter this code branch. - template, ok := requestTemplate(ctx, rw, req, api.Database) - if !ok { + template, err := requestTemplate(ctx, req, api.Database) + if err != nil { + httperror.WriteResponseError(ctx, rw, err) return } @@ -474,7 +483,14 @@ func (api *API) postUserWorkspaces(rw http.ResponseWriter, r *http.Request) { }) defer commitAudit() - createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, rw, r) + + w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, r) + if err != nil { + httperror.WriteResponseError(ctx, rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusCreated, w) } type workspaceOwner struct { @@ -490,12 +506,11 @@ func createWorkspace( api *API, owner workspaceOwner, req codersdk.CreateWorkspaceRequest, - rw http.ResponseWriter, r *http.Request, -) { - template, ok := requestTemplate(ctx, rw, req, api.Database) - if !ok { - return +) (codersdk.Workspace, error) { + template, err := requestTemplate(ctx, req, api.Database) + if err != nil { + return codersdk.Workspace{}, err } // This is a premature auth check to avoid doing unnecessary work if the user @@ -504,14 +519,12 @@ func createWorkspace( rbac.ResourceWorkspace.InOrg(template.OrganizationID).WithOwner(owner.ID.String())) { // If this check fails, return a proper unauthorized error to the user to indicate // what is going on. - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{ Message: "Unauthorized to create workspace.", Detail: "You are unable to create a workspace in this organization. " + "It is possible to have access to the template, but not be able to create a workspace. " + "Please contact an administrator about your permissions if you feel this is an error.", - Validations: nil, }) - return } // Update audit log's organization @@ -521,49 +534,42 @@ func createWorkspace( // would be wasted. if !api.Authorize(r, policy.ActionCreate, rbac.ResourceWorkspace.InOrg(template.OrganizationID).WithOwner(owner.ID.String())) { - httpapi.ResourceNotFound(rw) - return + return codersdk.Workspace{}, httperror.ErrResourceNotFound } // The user also needs permission to use the template. At this point they have // read perms, but not necessarily "use". This is also checked in `db.InsertWorkspace`. // Doing this up front can save some work below if the user doesn't have permission. if !api.Authorize(r, policy.ActionUse, template) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{ Message: fmt.Sprintf("Unauthorized access to use the template %q.", template.Name), Detail: "Although you are able to view the template, you are unable to create a workspace using it. " + "Please contact an administrator about your permissions if you feel this is an error.", - Validations: nil, }) - return } templateAccessControl := (*(api.AccessControlStore.Load())).GetTemplateAccessControl(template) if templateAccessControl.IsDeprecated() { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("Template %q has been deprecated, and cannot be used to create a new workspace.", template.Name), // Pass the deprecated message to the user. - Detail: templateAccessControl.Deprecated, - Validations: nil, + Detail: templateAccessControl.Deprecated, }) - return } dbAutostartSchedule, err := validWorkspaceSchedule(req.AutostartSchedule) if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: "Invalid Autostart Schedule.", Validations: []codersdk.ValidationError{{Field: "schedule", Detail: err.Error()}}, }) - return } templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, template.ID) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching template schedule.", Detail: err.Error(), }) - return } nextStartAt := sql.NullTime{} @@ -576,11 +582,10 @@ func createWorkspace( dbTTL, err := validWorkspaceTTLMillis(req.TTLMillis, templateSchedule.DefaultTTL) if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: "Invalid Workspace Time to Shutdown.", Validations: []codersdk.ValidationError{{Field: "ttl_ms", Detail: err.Error()}}, }) - return } // back-compatibility: default to "never" if not included. @@ -588,11 +593,10 @@ func createWorkspace( if req.AutomaticUpdates != "" { dbAU, err = validWorkspaceAutomaticUpdates(req.AutomaticUpdates) if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: "Invalid Workspace Automatic Updates setting.", Validations: []codersdk.ValidationError{{Field: "automatic_updates", Detail: err.Error()}}, }) - return } } @@ -605,20 +609,18 @@ func createWorkspace( }) if err == nil { // If the workspace already exists, don't allow creation. - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusConflict, codersdk.Response{ Message: fmt.Sprintf("Workspace %q already exists.", req.Name), Validations: []codersdk.ValidationError{{ Field: "name", Detail: "This value is already in use and should be unique.", }}, }) - return } else if !errors.Is(err, sql.ErrNoRows) { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: fmt.Sprintf("Internal error fetching workspace by name %q.", req.Name), Detail: err.Error(), }) - return } var ( @@ -634,10 +636,38 @@ func createWorkspace( claimedWorkspace *database.Workspace ) - // If a template preset was chosen, try claim a prebuilt workspace. - if req.TemplateVersionPresetID != uuid.Nil { + // Use injected Clock to allow time mocking in tests + now := dbtime.Time(api.Clock.Now()) + + templateVersionPresetID := req.TemplateVersionPresetID + + // 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. - claimedWorkspace, err = claimPrebuild(ctx, prebuildsClaimer, db, api.Logger, req, owner) + // 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.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 { @@ -646,7 +676,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 { @@ -665,7 +695,6 @@ func createWorkspace( // No prebuild found; regular flow. if claimedWorkspace == nil { - now := dbtime.Now() // Workspaces are created without any versions. minimumWorkspace, err := db.InsertWorkspace(ctx, database.InsertWorkspaceParams{ ID: uuid.New(), @@ -680,7 +709,7 @@ func createWorkspace( Ttl: dbTTL, // The workspaces page will sort by last used at, and it's useful to // have the newly created workspace at the top of the list! - LastUsedAt: dbtime.Now(), + LastUsedAt: now, AutomaticUpdates: dbAU, }) if err != nil { @@ -711,8 +740,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() @@ -730,8 +759,7 @@ func createWorkspace( return err }, nil) if err != nil { - httperror.WriteWorkspaceBuildError(ctx, rw, err) - return + return codersdk.Workspace{}, err } err = provisionerjobs.PostJob(api.Pubsub, *provisionerJob) @@ -780,11 +808,10 @@ func createWorkspace( provisionerDaemons, ) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error converting workspace build.", Detail: err.Error(), }) - return } w, err := convertWorkspace( @@ -796,40 +823,38 @@ func createWorkspace( codersdk.WorkspaceAppStatus{}, ) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error converting workspace.", Detail: err.Error(), }) - return } - httpapi.Write(ctx, rw, http.StatusCreated, w) + + return w, nil } -func requestTemplate(ctx context.Context, rw http.ResponseWriter, req codersdk.CreateWorkspaceRequest, db database.Store) (database.Template, bool) { +func requestTemplate(ctx context.Context, req codersdk.CreateWorkspaceRequest, db database.Store) (database.Template, error) { // If we were given a `TemplateVersionID`, we need to determine the `TemplateID` from it. templateID := req.TemplateID if templateID == uuid.Nil { templateVersion, err := db.GetTemplateVersionByID(ctx, req.TemplateVersionID) if httpapi.Is404Error(err) { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + return database.Template{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("Template version %q doesn't exist.", req.TemplateVersionID), Validations: []codersdk.ValidationError{{ Field: "template_version_id", Detail: "template not found", }}, }) - return database.Template{}, false } if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching template version.", Detail: err.Error(), }) - return database.Template{}, false } if templateVersion.Archived { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Archived template versions cannot be used to make a workspace.", Validations: []codersdk.ValidationError{ { @@ -838,7 +863,6 @@ func requestTemplate(ctx context.Context, rw http.ResponseWriter, req codersdk.C }, }, }) - return database.Template{}, false } templateID = templateVersion.TemplateID.UUID @@ -846,33 +870,42 @@ func requestTemplate(ctx context.Context, rw http.ResponseWriter, req codersdk.C template, err := db.GetTemplateByID(ctx, templateID) if httpapi.Is404Error(err) { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + return database.Template{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("Template %q doesn't exist.", templateID), Validations: []codersdk.ValidationError{{ Field: "template_id", Detail: "template not found", }}, }) - return database.Template{}, false } if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching template.", Detail: err.Error(), }) - return database.Template{}, false } if template.Deleted { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + return database.Template{}, httperror.NewResponseError(http.StatusNotFound, codersdk.Response{ Message: fmt.Sprintf("Template %q has been deleted!", template.Name), }) - return database.Template{}, false } - return template, true + return template, nil } -func claimPrebuild(ctx context.Context, claimer prebuilds.Claimer, db database.Store, logger slog.Logger, req codersdk.CreateWorkspaceRequest, owner workspaceOwner) (*database.Workspace, error) { - claimedID, err := claimer.Claim(ctx, owner.ID, req.Name, req.TemplateVersionPresetID) +func claimPrebuild( + ctx context.Context, + claimer prebuilds.Claimer, + db database.Store, + logger slog.Logger, + now time.Time, + 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, 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) @@ -1071,6 +1104,17 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { return } + // Autostart configuration is not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild() { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Autostart is not supported for prebuilt workspaces", + Detail: "Prebuilt workspace scheduling is configured per preset at the template level. Workspace-level overrides are not supported.", + }) + return + } + dbSched, err := validWorkspaceSchedule(req.Schedule) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -1097,12 +1141,20 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { return } + // Use injected Clock to allow time mocking in tests + now := api.Clock.Now() + nextStartAt := sql.NullTime{} if dbSched.Valid { - next, err := schedule.NextAllowedAutostart(dbtime.Now(), dbSched.String, templateSchedule) - if err == nil { - nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())} + next, err := schedule.NextAllowedAutostart(now, dbSched.String, templateSchedule) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error calculating workspace autostart schedule.", + Detail: err.Error(), + }) + return } + nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())} } err = api.Database.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{ @@ -1155,6 +1207,17 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return } + // TTL updates are not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with TTL behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild() { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "TTL updates are not supported for prebuilt workspaces", + Detail: "Prebuilt workspace TTL is configured per preset at the template level. Workspace-level overrides are not supported.", + }) + return + } + var dbTTL sql.NullInt64 err := api.Database.InTx(func(s database.Store) error { @@ -1180,6 +1243,9 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("update workspace time until shutdown: %w", err) } + // Use injected Clock to allow time mocking in tests + now := api.Clock.Now() + // If autostop has been disabled, we want to remove the deadline from the // existing workspace build (if there is one). if !dbTTL.Valid { @@ -1190,10 +1256,24 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { if build.Transition == database.WorkspaceTransitionStart { if err = s.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ - ID: build.ID, - Deadline: time.Time{}, + ID: build.ID, + // Use the max_deadline as the new build deadline. It will + // either be zero (our target), or a non-zero value that we + // need to abide by anyway due to template policy. + // + // Previously, we would always set the deadline to zero, + // which was incorrect behavior. When max_deadline is + // non-zero, deadline must be set to a non-zero value that + // is less than max_deadline. + // + // Disabling TTL autostop (at a workspace or template level) + // does not trump the template's autostop requirement. + // + // Refer to the comments on schedule.CalculateAutostop for + // more information. + Deadline: build.MaxDeadline, MaxDeadline: build.MaxDeadline, - UpdatedAt: dbtime.Time(api.Clock.Now()), + UpdatedAt: dbtime.Time(now), }); err != nil { return xerrors.Errorf("update workspace build deadline: %w", err) } @@ -1257,17 +1337,30 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { return } + // Dormancy configuration is not supported for prebuilt workspaces. + // Prebuilds are managed by the reconciliation loop and are not subject to dormancy. + if oldWorkspace.IsPrebuild() { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Dormancy updates are not supported for prebuilt workspaces", + Detail: "Prebuilt workspaces are not subject to dormancy. Dormancy behavior is only applicable to regular workspaces", + }) + return + } + // If the workspace is already in the desired state do nothing! if oldWorkspace.DormantAt.Valid == req.Dormant { rw.WriteHeader(http.StatusNotModified) return } + // Use injected Clock to allow time mocking in tests + now := api.Clock.Now() + dormantAt := sql.NullTime{ Valid: req.Dormant, } if req.Dormant { - dormantAt.Time = dbtime.Now() + dormantAt.Time = dbtime.Time(now) } newWorkspace, err := api.Database.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{ @@ -1307,7 +1400,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { } if initiatorErr == nil && tmplErr == nil { - dormantTime := dbtime.Now().Add(time.Duration(tmpl.TimeTilDormant)) + dormantTime := dbtime.Time(now).Add(time.Duration(tmpl.TimeTilDormant)) _, err = api.NotificationsEnqueuer.Enqueue( // nolint:gocritic // Need notifier actor to enqueue notifications dbauthz.AsNotifier(ctx), @@ -1401,6 +1494,17 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { return } + // Deadline extensions are not supported for prebuilt workspaces. + // Prebuilds are managed by the reconciliation loop and must always have + // Deadline and MaxDeadline unset. + if workspace.IsPrebuild() { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Deadline extension is not supported for prebuilt workspaces", + Detail: "Prebuilt workspaces do not support user deadline modifications. Deadline extension is only applicable to regular workspaces", + }) + return + } + code := http.StatusOK resp := codersdk.Response{} @@ -1437,8 +1541,11 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("workspace shutdown is manual") } + // Use injected Clock to allow time mocking in tests + now := api.Clock.Now() + newDeadline := req.Deadline.UTC() - if err := validWorkspaceDeadline(job.CompletedAt.Time, newDeadline); err != nil { + if err := validWorkspaceDeadline(now, job.CompletedAt.Time, newDeadline); err != nil { // NOTE(Cian): Putting the error in the Message field on request from the FE folks. // Normally, we would put the validation error in Validations, but this endpoint is // not tied to a form or specific named user input on the FE. @@ -1454,7 +1561,7 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { if err := s.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ ID: build.ID, - UpdatedAt: dbtime.Now(), + UpdatedAt: dbtime.Time(now), Deadline: newDeadline, MaxDeadline: build.MaxDeadline, }); err != nil { @@ -2041,6 +2148,110 @@ func (api *API) workspaceTimings(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, timings) } +// @Summary Get workspace ACLs +// @ID get-workspace-acls +// @Security CoderSessionToken +// @Produce json +// @Tags Workspaces +// @Param workspace path string true "Workspace ID" format(uuid) +// @Success 200 {object} codersdk.WorkspaceACL +// @Router /workspaces/{workspace}/acl [get] +func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + workspace = httpmw.WorkspaceParam(r) + ) + + // Fetch the ACL data. + workspaceACL, err := api.Database.GetWorkspaceACLByID(ctx, workspace.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + // This is largely based on the template ACL implementation, and is far from + // ideal. Usually, when we use the System context it's because we need to + // run some query that won't actually be exposed to the user. That is not + // the case here. This data goes directly to an unauthorized user. We are + // just straight up breaking security promises. + // + // Fine for now while behind the shared-workspaces experiment, but needs to + // be fixed before GA. + + // Fetch all of the users and their organization memberships + userIDs := make([]uuid.UUID, 0, len(workspaceACL.Users)) + for userID := range workspaceACL.Users { + id, err := uuid.Parse(userID) + if err != nil { + api.Logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID)) + continue + } + userIDs = append(userIDs, id) + } + // For context see https://github.com/coder/coder/pull/19375 + // nolint:gocritic + dbUsers, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), userIDs) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + + // Convert the db types to the codersdk.WorkspaceUser type + users := make([]codersdk.WorkspaceUser, 0, len(dbUsers)) + for _, it := range dbUsers { + users = append(users, codersdk.WorkspaceUser{ + MinimalUser: db2sdk.MinimalUser(it), + Role: convertToWorkspaceRole(workspaceACL.Users[it.ID.String()].Permissions), + }) + } + + // Fetch all of the groups + groupIDs := make([]uuid.UUID, 0, len(workspaceACL.Groups)) + for groupID := range workspaceACL.Groups { + id, err := uuid.Parse(groupID) + if err != nil { + api.Logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID)) + continue + } + groupIDs = append(groupIDs, id) + } + // For context see https://github.com/coder/coder/pull/19375 + // nolint:gocritic + dbGroups, err := api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs}) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + + groups := make([]codersdk.WorkspaceGroup, 0, len(dbGroups)) + for _, it := range dbGroups { + var members []database.GroupMember + // For context see https://github.com/coder/coder/pull/19375 + // nolint:gocritic + members, err = api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersByGroupIDParams{ + GroupID: it.Group.ID, + IncludeSystem: false, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + groups = append(groups, codersdk.WorkspaceGroup{ + Group: db2sdk.Group(database.GetGroupsRow{ + Group: it.Group, + OrganizationName: it.OrganizationName, + OrganizationDisplayName: it.OrganizationDisplayName, + }, members, len(members)), + Role: convertToWorkspaceRole(workspaceACL.Groups[it.Group.ID.String()].Permissions), + }) + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceACL{ + Users: users, + Groups: groups, + }) +} + // @Summary Update workspace ACL // @ID update-workspace-acl // @Security CoderSessionToken @@ -2072,17 +2283,10 @@ func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) { return } - validErrs := validateWorkspaceACLPerms(ctx, api.Database, req.UserRoles, "user_roles") - validErrs = append(validErrs, validateWorkspaceACLPerms( - ctx, - api.Database, - req.GroupRoles, - "group_roles", - )...) - + validErrs := acl.Validate(ctx, api.Database, WorkspaceACLUpdateValidator(req)) if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid request to update template metadata!", + Message: "Invalid request to update workspace ACL", Validations: validErrs, }) return @@ -2391,11 +2595,11 @@ func validWorkspaceTTLMillis(millis *int64, templateDefault time.Duration) (sql. dur := time.Duration(*millis) * time.Millisecond truncated := dur.Truncate(time.Minute) - if truncated < ttlMin { + if truncated < ttlMinimum { return sql.NullInt64{}, errTTLMin } - if truncated > ttlMax { + if truncated > ttlMaximum { return sql.NullInt64{}, errTTLMax } @@ -2416,8 +2620,8 @@ func validWorkspaceAutomaticUpdates(updates codersdk.AutomaticUpdates) (database return dbAU, nil } -func validWorkspaceDeadline(startedAt, newDeadline time.Time) error { - soon := time.Now().Add(29 * time.Minute) +func validWorkspaceDeadline(now, startedAt, newDeadline time.Time) error { + soon := now.Add(29 * time.Minute) if newDeadline.Before(soon) { return errDeadlineTooSoon } @@ -2478,63 +2682,40 @@ func (api *API) publishWorkspaceAgentLogsUpdate(ctx context.Context, workspaceAg } } -func validateWorkspaceACLPerms(ctx context.Context, db database.Store, perms map[string]codersdk.WorkspaceRole, field string) []codersdk.ValidationError { - // nolint:gocritic // Validate requires full read access to users and groups - ctx = dbauthz.AsSystemRestricted(ctx) - var validErrs []codersdk.ValidationError - for idStr, role := range perms { - if err := validateWorkspaceRole(role); err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: err.Error()}) - continue - } +type WorkspaceACLUpdateValidator codersdk.UpdateWorkspaceACL - id, err := uuid.Parse(idStr) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: idStr + "is not a valid UUID."}) - continue - } +var ( + workspaceACLUpdateUsersFieldName = "user_roles" + workspaceACLUpdateGroupsFieldName = "group_roles" +) - switch field { - case "user_roles": - // TODO(lilac): put this back after Kirby button shenanigans are over - // This could get slow if we get a ton of user perm updates. - // _, err = db.GetUserByID(ctx, id) - // if err != nil { - // validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) - // continue - // } - case "group_roles": - // This could get slow if we get a ton of group perm updates. - _, err = db.GetGroupByID(ctx, id) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) - continue - } - default: - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: "invalid field"}) - } - } +// WorkspaceACLUpdateValidator implements acl.UpdateValidator[codersdk.WorkspaceRole] +var _ acl.UpdateValidator[codersdk.WorkspaceRole] = WorkspaceACLUpdateValidator{} - return validErrs +func (w WorkspaceACLUpdateValidator) Users() (map[string]codersdk.WorkspaceRole, string) { + return w.UserRoles, workspaceACLUpdateUsersFieldName } -func validateWorkspaceRole(role codersdk.WorkspaceRole) error { +func (w WorkspaceACLUpdateValidator) Groups() (map[string]codersdk.WorkspaceRole, string) { + return w.GroupRoles, workspaceACLUpdateGroupsFieldName +} + +func (WorkspaceACLUpdateValidator) ValidateRole(role codersdk.WorkspaceRole) error { actions := db2sdk.WorkspaceRoleActions(role) if len(actions) == 0 && role != codersdk.WorkspaceRoleDeleted { - return xerrors.Errorf("role %q is not a valid Workspace role", role) + return xerrors.Errorf("role %q is not a valid workspace role", role) } return nil } -// TODO: This will go here -// func convertToWorkspaceRole(actions []policy.Action) codersdk.TemplateRole { -// switch { -// case len(actions) == 2 && slice.SameElements(actions, []policy.Action{policy.ActionUse, policy.ActionRead}): -// return codersdk.TemplateRoleUse -// case len(actions) == 1 && actions[0] == policy.WildcardSymbol: -// return codersdk.TemplateRoleAdmin -// } - -// return "" -// } +func convertToWorkspaceRole(actions []policy.Action) codersdk.WorkspaceRole { + switch { + case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleAdmin)): + return codersdk.WorkspaceRoleAdmin + case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleUse)): + return codersdk.WorkspaceRoleUse + } + + return codersdk.WorkspaceRoleDeleted +} diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 9fe066aae6284..4beebc9d1337c 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{ @@ -1800,6 +1799,7 @@ func TestWorkspaceFilter(t *testing.T) { for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) workspaces, err := client.Workspaces(ctx, c.Filter) require.NoError(t, err, "fetch workspaces") @@ -2214,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), @@ -2678,8 +2675,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { // ensure test invariant: new workspaces have no autostart schedule. require.Empty(t, workspace.AutostartSchedule, "expected newly-minted workspace to have no autostart schedule") - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ Schedule: ptr.Ref("CRON_TZ=Europe/Dublin 30 9 * * 1-5"), @@ -2698,8 +2694,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { } ) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) err := client.UpdateWorkspaceAutostart(ctx, wsid, req) require.IsType(t, err, &codersdk.Error{}, "expected codersdk.Error") @@ -2897,6 +2892,56 @@ func TestWorkspaceUpdateTTL(t *testing.T) { } }) + t.Run("RemoveAutostopWithRunningWorkspaceWithMaxDeadline", func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitLong) + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + deadline = 8 * time.Hour + maxDeadline = 10 * time.Hour + workspace = coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTLMillis = ptr.Ref(deadline.Milliseconds()) + }) + build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + ) + + // 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) + require.NoError(t, err) + 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{ + ID: build.ID, + Deadline: initialDeadline, + MaxDeadline: expectedMaxDeadline, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + // Remove autostop. + err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: nil, + }) + require.NoError(t, err) + + // Expect that the deadline is set to the max_deadline. + build, err = client.WorkspaceBuild(ctx, build.ID) + require.NoError(t, err) + require.True(t, build.Deadline.Valid) + require.WithinDuration(t, build.Deadline.Time, expectedMaxDeadline, time.Second) + require.True(t, build.MaxDeadline.Valid) + require.WithinDuration(t, build.MaxDeadline.Time, expectedMaxDeadline, time.Second) + }) + t.Run("CustomAutostopDisabledByTemplate", func(t *testing.T) { t.Parallel() var ( @@ -4458,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, @@ -4554,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}, @@ -4757,9 +4799,399 @@ 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) require.Contains(t, pj.Error.String, "only one 'coder_ai_task' resource can be provisioned per template") } + +func TestUpdateWorkspaceACL(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + adminClient := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dv, + }) + adminUser := coderdtest.CreateFirstUser(t, adminClient) + orgID := adminUser.OrganizationID + client, _ := coderdtest.CreateAnotherUser(t, adminClient, orgID) + _, friend := coderdtest.CreateAnotherUser(t, adminClient, orgID) + + tv := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, tv.ID) + template := coderdtest.CreateTemplate(t, adminClient, orgID, tv.ID) + + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := client.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + friend.ID.String(): codersdk.WorkspaceRoleAdmin, + }, + }) + require.NoError(t, err) + + workspaceACL, err := client.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + require.Len(t, workspaceACL.Users, 1) + require.Equal(t, workspaceACL.Users[0].ID, friend.ID) + require.Equal(t, workspaceACL.Users[0].Role, codersdk.WorkspaceRoleAdmin) + }) + + t.Run("UnknownUserID", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + adminClient := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dv, + }) + adminUser := coderdtest.CreateFirstUser(t, adminClient) + orgID := adminUser.OrganizationID + client, _ := coderdtest.CreateAnotherUser(t, adminClient, orgID) + + tv := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, tv.ID) + template := coderdtest.CreateTemplate(t, adminClient, orgID, tv.ID) + + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := client.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + uuid.NewString(): codersdk.WorkspaceRoleAdmin, + }, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Len(t, cerr.Validations, 1) + require.Equal(t, cerr.Validations[0].Field, "user_roles") + }) + + t.Run("DeletedUser", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + adminClient := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dv, + }) + adminUser := coderdtest.CreateFirstUser(t, adminClient) + orgID := adminUser.OrganizationID + client, _ := coderdtest.CreateAnotherUser(t, adminClient, orgID) + _, mike := coderdtest.CreateAnotherUser(t, adminClient, orgID) + + tv := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, tv.ID) + template := coderdtest.CreateTemplate(t, adminClient, orgID, tv.ID) + + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := adminClient.DeleteUser(ctx, mike.ID) + require.NoError(t, err) + err = client.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + mike.ID.String(): codersdk.WorkspaceRoleAdmin, + }, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Len(t, cerr.Validations, 1) + 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 89ca9c948f272..1ca1016f28ea8 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -3,8 +3,10 @@ package codersdk import ( "context" "encoding/json" + "fmt" "net/http" "strings" + "time" "github.com/google/uuid" @@ -44,3 +46,162 @@ func (c *ExperimentalClient) AITaskPrompts(ctx context.Context, buildIDs []uuid. var prompts AITasksPromptsResponse return prompts, json.NewDecoder(res.Body).Decode(&prompts) } + +type CreateTaskRequest struct { + TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid"` + TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"` + Prompt string `json:"prompt"` +} + +func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, request CreateTaskRequest) (Task, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s", user), request) + if err != nil { + return Task{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusCreated { + return Task{}, ReadBodyAsError(res) + } + + var task Task + if err := json.NewDecoder(res.Body).Decode(&task); err != nil { + return Task{}, err + } + + return task, 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" table:"id"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"` + OwnerID uuid.UUID `json:"owner_id" format:"uuid" table:"owner id"` + OwnerName string `json:"owner_name" table:"owner name"` + Name string `json:"name" table:"name,default_sort"` + TemplateID uuid.UUID `json:"template_id" format:"uuid" table:"template id"` + TemplateName string `json:"template_name" table:"template name"` + TemplateDisplayName string `json:"template_display_name" table:"template display name"` + TemplateIcon string `json:"template_icon" table:"template icon"` + WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid" table:"workspace id"` + WorkspaceAgentID uuid.NullUUID `json:"workspace_agent_id" format:"uuid" table:"workspace agent id"` + WorkspaceAgentLifecycle *WorkspaceAgentLifecycle `json:"workspace_agent_lifecycle" table:"workspace agent lifecycle"` + WorkspaceAgentHealth *WorkspaceAgentHealth `json:"workspace_agent_health" table:"workspace agent health"` + InitialPrompt string `json:"initial_prompt" table:"initial prompt"` + Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted" table:"status"` + CurrentState *TaskStateEntry `json:"current_state" table:"cs,recursive_inline"` + CreatedAt time.Time `json:"created_at" format:"date-time" table:"created at"` + UpdatedAt time.Time `json:"updated_at" format:"date-time" table:"updated at"` +} + +// 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" table:"-"` + State TaskState `json:"state" enum:"working,idle,completed,failed" table:"state"` + Message string `json:"message" table:"message"` + URI string `json:"uri" table:"-"` +} + +// 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"` + // Status is a task status. + Status string `json:"status,omitempty" typescript:"-"` + // Offset is the number of tasks to skip before returning results. + Offset int `json:"offset,omitempty" typescript:"-"` + // Limit is a limit on the number of tasks returned. + Limit int `json:"limit,omitempty" typescript:"-"` +} + +// 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{} + } + + var wsFilter WorkspaceFilter + wsFilter.Owner = filter.Owner + wsFilter.Status = filter.Status + page := Pagination{ + Offset: filter.Offset, + Limit: filter.Limit, + } + + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/tasks", nil, wsFilter.asRequestOption(), page.asRequestOption()) + 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 +} + +// DeleteTask deletes a task by its ID. +// +// Experimental: This method is experimental and may change in the future. +func (c *ExperimentalClient) DeleteTask(ctx context.Context, user string, id uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/tasks/%s/%s", user, id.String()), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusAccepted { + return ReadBodyAsError(res) + } + return nil +} diff --git a/codersdk/client.go b/codersdk/client.go index 105c8437f841b..b6f10465e3a07 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -108,8 +108,9 @@ var loggableMimeTypes = map[string]struct{}{ // New creates a Coder client for the provided URL. func New(serverURL *url.URL) *Client { return &Client{ - URL: serverURL, - HTTPClient: &http.Client{}, + URL: serverURL, + HTTPClient: &http.Client{}, + SessionTokenProvider: FixedSessionTokenProvider{}, } } @@ -118,18 +119,14 @@ func New(serverURL *url.URL) *Client { type Client struct { // mu protects the fields sessionToken, logger, and logBodies. These // need to be safe for concurrent access. - mu sync.RWMutex - sessionToken string - logger slog.Logger - logBodies bool + mu sync.RWMutex + SessionTokenProvider SessionTokenProvider + logger slog.Logger + logBodies bool HTTPClient *http.Client URL *url.URL - // SessionTokenHeader is an optional custom header to use for setting tokens. By - // default 'Coder-Session-Token' is used. - SessionTokenHeader string - // PlainLogger may be set to log HTTP traffic in a human-readable form. // It uses the LogBodies option. PlainLogger io.Writer @@ -176,14 +173,20 @@ func (c *Client) SetLogBodies(logBodies bool) { func (c *Client) SessionToken() string { c.mu.RLock() defer c.mu.RUnlock() - return c.sessionToken + return c.SessionTokenProvider.GetSessionToken() } -// SetSessionToken returns the currently set token for the client. +// SetSessionToken sets a fixed token for the client. +// Deprecated: Create a new client instead of changing the token after creation. func (c *Client) SetSessionToken(token string) { + c.SetSessionTokenProvider(FixedSessionTokenProvider{SessionToken: token}) +} + +// SetSessionTokenProvider sets the session token provider for the client. +func (c *Client) SetSessionTokenProvider(provider SessionTokenProvider) { c.mu.Lock() defer c.mu.Unlock() - c.sessionToken = token + c.SessionTokenProvider = provider } func prefixLines(prefix, s []byte) []byte { @@ -199,6 +202,14 @@ func prefixLines(prefix, s []byte) []byte { // Request performs a HTTP request with the body provided. The caller is // responsible for closing the response body. func (c *Client) Request(ctx context.Context, method, path string, body interface{}, opts ...RequestOption) (*http.Response, error) { + opts = append([]RequestOption{c.SessionTokenProvider.AsRequestOption()}, opts...) + return c.RequestWithoutSessionToken(ctx, method, path, body, opts...) +} + +// RequestWithoutSessionToken performs a HTTP request. It is similar to Request, but does not set +// the session token in the request header, nor does it make a call to the SessionTokenProvider. +// This allows session token providers to call this method without causing reentrancy issues. +func (c *Client) RequestWithoutSessionToken(ctx context.Context, method, path string, body interface{}, opts ...RequestOption) (*http.Response, error) { if ctx == nil { return nil, xerrors.Errorf("context should not be nil") } @@ -248,12 +259,6 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac return nil, xerrors.Errorf("create request: %w", err) } - tokenHeader := c.SessionTokenHeader - if tokenHeader == "" { - tokenHeader = SessionTokenHeader - } - req.Header.Set(tokenHeader, c.SessionToken()) - if r != nil { req.Header.Set("Content-Type", "application/json") } @@ -345,20 +350,10 @@ func (c *Client) Dial(ctx context.Context, path string, opts *websocket.DialOpti return nil, err } - tokenHeader := c.SessionTokenHeader - if tokenHeader == "" { - tokenHeader = SessionTokenHeader - } - if opts == nil { opts = &websocket.DialOptions{} } - if opts.HTTPHeader == nil { - opts.HTTPHeader = http.Header{} - } - if opts.HTTPHeader.Get(tokenHeader) == "" { - opts.HTTPHeader.Set(tokenHeader, c.SessionToken()) - } + c.SessionTokenProvider.SetDialOption(opts) conn, resp, err := websocket.Dial(ctx, u.String(), opts) if resp != nil && resp.Body != nil { diff --git a/codersdk/credentials.go b/codersdk/credentials.go new file mode 100644 index 0000000000000..06dc8cc22a114 --- /dev/null +++ b/codersdk/credentials.go @@ -0,0 +1,55 @@ +package codersdk + +import ( + "net/http" + + "github.com/coder/websocket" +) + +// SessionTokenProvider provides the session token to access the Coder service (coderd). +// @typescript-ignore SessionTokenProvider +type SessionTokenProvider interface { + // AsRequestOption returns a request option that attaches the session token to an HTTP request. + AsRequestOption() RequestOption + // SetDialOption sets the session token on a websocket request via DialOptions + SetDialOption(options *websocket.DialOptions) + // GetSessionToken returns the session token as a string. + GetSessionToken() string +} + +// FixedSessionTokenProvider provides a given, fixed, session token. E.g. one read from file or environment variable +// at the program start. +// @typescript-ignore FixedSessionTokenProvider +type FixedSessionTokenProvider struct { + SessionToken string + // SessionTokenHeader is an optional custom header to use for setting tokens. By + // default, 'Coder-Session-Token' is used. + SessionTokenHeader string +} + +func (f FixedSessionTokenProvider) AsRequestOption() RequestOption { + return func(req *http.Request) { + tokenHeader := f.SessionTokenHeader + if tokenHeader == "" { + tokenHeader = SessionTokenHeader + } + req.Header.Set(tokenHeader, f.SessionToken) + } +} + +func (f FixedSessionTokenProvider) GetSessionToken() string { + return f.SessionToken +} + +func (f FixedSessionTokenProvider) SetDialOption(opts *websocket.DialOptions) { + tokenHeader := f.SessionTokenHeader + if tokenHeader == "" { + tokenHeader = SessionTokenHeader + } + if opts.HTTPHeader == nil { + opts.HTTPHeader = http.Header{} + } + if opts.HTTPHeader.Get(tokenHeader) == "" { + opts.HTTPHeader.Set(tokenHeader, f.SessionToken) + } +} 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 86bc47bce2375..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)) } @@ -541,6 +553,7 @@ type TemplateFilter struct { OrganizationID uuid.UUID `typescript:"-"` ExactName string `typescript:"-"` FuzzyName string `typescript:"-"` + AuthorUsername string `typescript:"-"` SearchQuery string `json:"q,omitempty"` } @@ -562,6 +575,11 @@ func (f TemplateFilter) asRequestOption() RequestOption { if f.FuzzyName != "" { params = append(params, fmt.Sprintf("name:%q", f.FuzzyName)) } + + if f.AuthorUsername != "" { + params = append(params, fmt.Sprintf("author:%q", f.AuthorUsername)) + } + if f.SearchQuery != "" { params = append(params, f.SearchQuery) } 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/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 3e22d29c73297..54532106a6fd1 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -35,7 +35,9 @@ const ( ResourceSystem RBACResource = "system" ResourceTailnetCoordinator RBACResource = "tailnet_coordinator" ResourceTemplate RBACResource = "template" + ResourceUsageEvent RBACResource = "usage_event" ResourceUser RBACResource = "user" + ResourceUserSecret RBACResource = "user_secret" ResourceWebpushSubscription RBACResource = "webpush_subscription" ResourceWorkspace RBACResource = "workspace" ResourceWorkspaceAgentDevcontainers RBACResource = "workspace_agent_devcontainers" @@ -99,7 +101,9 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceSystem: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceTailnetCoordinator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionUse, ActionViewInsights}, + ResourceUsageEvent: {ActionCreate, ActionRead, ActionUpdate}, ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, + ResourceUserSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceWebpushSubscription: {ActionCreate, ActionDelete, ActionRead}, ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionCreateAgent, ActionDelete, ActionDeleteAgent, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, ResourceWorkspaceAgentDevcontainers: {ActionCreate}, diff --git a/codersdk/templates.go b/codersdk/templates.go index 2e77d999003ed..49c1f9e7c57f9 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -193,10 +193,13 @@ type TemplateUser struct { } type UpdateTemplateACL struct { - // UserPerms 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. + // UserPerms is a mapping from valid user UUIDs to the template role they + // should be granted. To remove a user from the template, use "" as the role + // (available as a constant named codersdk.TemplateRoleDeleted) UserPerms map[string]TemplateRole `json:"user_perms,omitempty" example:":admin,4df59e74-c027-470b-ab4d-cbba8963a5e9:use"` - // GroupPerms should be a mapping of group id to role. + // GroupPerms is a mapping from valid group UUIDs to the template role they + // should be granted. To remove a group from the template, use "" as the role + // (available as a constant named codersdk.TemplateRoleDeleted) GroupPerms map[string]TemplateRole `json:"group_perms,omitempty" example:":admin,8bd26b20-f3e8-48be-a903-46bb920cf671:use"` } @@ -208,11 +211,11 @@ type ACLAvailable struct { } type UpdateTemplateMeta struct { - Name string `json:"name,omitempty" validate:"omitempty,template_name"` - DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"` + Name string `json:"name,omitempty" validate:"omitempty,template_name"` + DisplayName *string `json:"display_name,omitempty" validate:"omitempty,template_display_name"` + Description *string `json:"description,omitempty"` + Icon *string `json:"icon,omitempty"` + DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"` // ActivityBumpMillis 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. 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/toolsdk/bash_test.go b/codersdk/toolsdk/bash_test.go index 0656b2d8786e6..caf54109688ea 100644 --- a/codersdk/toolsdk/bash_test.go +++ b/codersdk/toolsdk/bash_test.go @@ -2,6 +2,7 @@ package toolsdk_test import ( "context" + "runtime" "testing" "github.com/stretchr/testify/require" @@ -14,6 +15,9 @@ import ( func TestWorkspaceBash(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("Skipping on Windows: Workspace MCP bash tools rely on a Unix-like shell (bash) and POSIX/SSH semantics. Use Linux/macOS or WSL for these tests.") + } t.Run("ValidateArgs", func(t *testing.T) { t.Parallel() @@ -97,6 +101,9 @@ func TestWorkspaceBash(t *testing.T) { func TestNormalizeWorkspaceInput(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("Skipping on Windows: Workspace MCP bash tools rely on a Unix-like shell (bash) and POSIX/SSH semantics. Use Linux/macOS or WSL for these tests.") + } testCases := []struct { name string @@ -151,6 +158,9 @@ func TestNormalizeWorkspaceInput(t *testing.T) { func TestAllToolsIncludesBash(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("Skipping on Windows: Workspace MCP bash tools rely on a Unix-like shell (bash) and POSIX/SSH semantics. Use Linux/macOS or WSL for these tests.") + } // Verify that WorkspaceBash is included in the All slice found := false @@ -169,6 +179,9 @@ func TestAllToolsIncludesBash(t *testing.T) { func TestWorkspaceBashTimeout(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("Skipping on Windows: Workspace MCP bash tools rely on a Unix-like shell (bash) and POSIX/SSH semantics. Use Linux/macOS or WSL for these tests.") + } t.Run("TimeoutDefaultValue", func(t *testing.T) { t.Parallel() @@ -251,6 +264,9 @@ func TestWorkspaceBashTimeout(t *testing.T) { func TestWorkspaceBashTimeoutIntegration(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("Skipping on Windows: Workspace MCP bash tools rely on a Unix-like shell (bash) and POSIX/SSH semantics. Use Linux/macOS or WSL for these tests.") + } t.Run("ActualTimeoutBehavior", func(t *testing.T) { t.Parallel() @@ -338,6 +354,9 @@ func TestWorkspaceBashTimeoutIntegration(t *testing.T) { func TestWorkspaceBashBackgroundIntegration(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("Skipping on Windows: Workspace MCP bash tools rely on a Unix-like shell (bash) and POSIX/SSH semantics. Use Linux/macOS or WSL for these tests.") + } t.Run("BackgroundCommandCapturesOutput", func(t *testing.T) { t.Parallel() diff --git a/codersdk/toolsdk/chatgpt.go b/codersdk/toolsdk/chatgpt.go new file mode 100644 index 0000000000000..c4bf5b5d4c174 --- /dev/null +++ b/codersdk/toolsdk/chatgpt.go @@ -0,0 +1,436 @@ +package toolsdk + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "golang.org/x/xerrors" + + "github.com/google/uuid" + + "github.com/coder/aisdk-go" + "github.com/coder/coder/v2/codersdk" +) + +type ObjectType string + +const ( + ObjectTypeTemplate ObjectType = "template" + ObjectTypeWorkspace ObjectType = "workspace" +) + +type ObjectID struct { + Type ObjectType + ID string +} + +func (o ObjectID) String() string { + return fmt.Sprintf("%s:%s", o.Type, o.ID) +} + +func parseObjectID(id string) (ObjectID, error) { + parts := strings.Split(id, ":") + if len(parts) != 2 || (parts[0] != "template" && parts[0] != "workspace") { + return ObjectID{}, xerrors.Errorf("invalid ID: %s", id) + } + return ObjectID{ + Type: ObjectType(parts[0]), + ID: parts[1], + }, nil +} + +func createObjectID(objectType ObjectType, id string) ObjectID { + return ObjectID{ + Type: objectType, + ID: id, + } +} + +func searchTemplates(ctx context.Context, deps Deps, query string) ([]SearchResultItem, error) { + serverURL := deps.ServerURL() + templates, err := deps.coderClient.Templates(ctx, codersdk.TemplateFilter{ + SearchQuery: query, + }) + if err != nil { + return nil, err + } + results := make([]SearchResultItem, len(templates)) + for i, template := range templates { + results[i] = SearchResultItem{ + ID: createObjectID(ObjectTypeTemplate, template.ID.String()).String(), + Title: template.DisplayName, + Text: template.Description, + URL: fmt.Sprintf("%s/templates/%s/%s", serverURL, template.OrganizationName, template.Name), + } + } + return results, nil +} + +func searchWorkspaces(ctx context.Context, deps Deps, query string) ([]SearchResultItem, error) { + serverURL := deps.ServerURL() + workspaces, err := deps.coderClient.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: query, + }) + if err != nil { + return nil, err + } + results := make([]SearchResultItem, len(workspaces.Workspaces)) + for i, workspace := range workspaces.Workspaces { + results[i] = SearchResultItem{ + ID: createObjectID(ObjectTypeWorkspace, workspace.ID.String()).String(), + Title: workspace.Name, + Text: fmt.Sprintf("Owner: %s\nTemplate: %s\nLatest transition: %s", workspace.OwnerName, workspace.TemplateDisplayName, workspace.LatestBuild.Transition), + URL: fmt.Sprintf("%s/%s/%s", serverURL, workspace.OwnerName, workspace.Name), + } + } + return results, nil +} + +type SearchQueryType string + +const ( + SearchQueryTypeTemplates SearchQueryType = "templates" + SearchQueryTypeWorkspaces SearchQueryType = "workspaces" +) + +type SearchQuery struct { + Type SearchQueryType + Query string +} + +func parseSearchQuery(query string) (SearchQuery, error) { + parts := strings.Split(query, "/") + queryType := SearchQueryType(parts[0]) + if !(queryType == SearchQueryTypeTemplates || queryType == SearchQueryTypeWorkspaces) { + return SearchQuery{}, xerrors.Errorf("invalid query: %s", query) + } + queryString := "" + if len(parts) > 1 { + queryString = strings.Join(parts[1:], "/") + } + return SearchQuery{ + Type: queryType, + Query: queryString, + }, nil +} + +type SearchArgs struct { + Query string `json:"query"` +} + +type SearchResultItem struct { + ID string `json:"id"` + Title string `json:"title"` + Text string `json:"text"` + URL string `json:"url"` +} + +type SearchResult struct { + Results []SearchResultItem `json:"results"` +} + +// Implements the "search" tool as described in https://platform.openai.com/docs/mcp#search-tool. +// From my experiments with ChatGPT, it has access to the description that is provided in the +// tool definition. This is in contrast to the "fetch" tool, where ChatGPT does not have access +// to the description. +var ChatGPTSearch = Tool[SearchArgs, SearchResult]{ + Tool: aisdk.Tool{ + Name: ToolNameChatGPTSearch, + // Note: the queries are passed directly to the list workspaces and list templates + // endpoints. The list of accepted parameters below is not exhaustive - some are omitted + // because they are not as useful in ChatGPT. + Description: `Search for templates, workspaces, and files in workspaces. + +To pick what you want to search for, use the following query formats: + +- ` + "`" + `templates/` + "`" + `: List templates. The query accepts the following, optional parameters delineated by whitespace: + - "name:" - Fuzzy search by template name (substring matching). Example: "name:docker" + - "organization:" - Filter by organization ID or name. Example: "organization:coder" + - "deprecated:" - Filter by deprecated status. Example: "deprecated:true" + - "deleted:" - Filter by deleted status. Example: "deleted:true" + - "has-ai-task:" - Filter by whether the template has an AI task. Example: "has-ai-task:true" +- ` + "`" + `workspaces/` + "`" + `: List workspaces. The query accepts the following, optional parameters delineated by whitespace: + - "owner:" - Filter by workspace owner (username or "me"). Example: "owner:alice" or "owner:me" + - "template:" - Filter by template name. Example: "template:web-development" + - "name:" - Filter by workspace name (substring matching). Example: "name:project" + - "organization:" - Filter by organization ID or name. Example: "organization:engineering" + - "status:" - Filter by workspace/build status. Values: starting, stopping, deleting, deleted, stopped, started, running, pending, canceling, canceled, failed. Example: "status:running" + - "has-agent:" - Filter by agent connectivity status. Values: connecting, connected, disconnected, timeout. Example: "has-agent:connected" + - "dormant:" - Filter dormant workspaces. Example: "dormant:true" + - "outdated:" - Filter workspaces using outdated template versions. Example: "outdated:true" + - "last_used_after:" - Filter workspaces last used after a specific date. Example: "last_used_after:2023-12-01T00:00:00Z" + - "last_used_before:" - Filter workspaces last used before a specific date. Example: "last_used_before:2023-12-31T23:59:59Z" + - "has-ai-task:" - Filter workspaces with AI tasks. Example: "has-ai-task:true" + - "param:" or "param:=" - Match workspaces by build parameters. Example: "param:environment=production" or "param:gpu" + +# Examples + +## Listing templates + +List all templates without any filters. + +` + "```" + `json +{ + "query": "templates" +} +` + "```" + ` + +List all templates with a "docker" substring in the name. + +` + "```" + `json +{ + "query": "templates/name:docker" +} +` + "```" + ` + +List templates in a specific organization. + +` + "```" + `json +{ + "query": "templates/organization:engineering" +} +` + "```" + ` + +List deprecated templates. + +` + "```" + `json +{ + "query": "templates/deprecated:true" +} +` + "```" + ` + +List templates that have AI tasks. + +` + "```" + `json +{ + "query": "templates/has-ai-task:true" +} +` + "```" + ` + +List templates with multiple filters - non-deprecated templates with "web" in the name. + +` + "```" + `json +{ + "query": "templates/name:web deprecated:false" +} +` + "```" + ` + +List deleted templates (requires appropriate permissions). + +` + "```" + `json +{ + "query": "templates/deleted:true" +} +` + "```" + ` + +## Listing workspaces + +List all workspaces belonging to the current user. + +` + "```" + `json +{ + "query": "workspaces/owner:me" +} +` + "```" + ` + +or + +` + "```" + `json +{ + "query": "workspaces" +} +` + "```" + ` + +List all workspaces belonging to a user with username "josh". + +` + "```" + `json +{ + "query": "workspaces/owner:josh" +} +` + "```" + ` + +List all running workspaces. + +` + "```" + `json +{ + "query": "workspaces/status:running" +} +` + "```" + ` + +List workspaces using a specific template. + +` + "```" + `json +{ + "query": "workspaces/template:web-development" +} +` + "```" + ` + +List dormant workspaces. + +` + "```" + `json +{ + "query": "workspaces/dormant:true" +} +` + "```" + ` + +List workspaces with connected agents. + +` + "```" + `json +{ + "query": "workspaces/has-agent:connected" +} +` + "```" + ` + +List workspaces with multiple filters - running workspaces owned by "alice". + +` + "```" + `json +{ + "query": "workspaces/owner:alice status:running" +} +` + "```" + ` +`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "query": map[string]any{ + "type": "string", + }, + }, + Required: []string{"query"}, + }, + }, + Handler: func(ctx context.Context, deps Deps, args SearchArgs) (SearchResult, error) { + query, err := parseSearchQuery(args.Query) + if err != nil { + return SearchResult{}, err + } + switch query.Type { + case SearchQueryTypeTemplates: + results, err := searchTemplates(ctx, deps, query.Query) + if err != nil { + return SearchResult{}, err + } + return SearchResult{Results: results}, nil + case SearchQueryTypeWorkspaces: + searchQuery := query.Query + if searchQuery == "" { + searchQuery = "owner:me" + } + results, err := searchWorkspaces(ctx, deps, searchQuery) + if err != nil { + return SearchResult{}, err + } + return SearchResult{Results: results}, nil + } + return SearchResult{}, xerrors.Errorf("reached unreachable code with query: %s", args.Query) + }, +} + +func fetchWorkspace(ctx context.Context, deps Deps, workspaceID string) (FetchResult, error) { + parsedID, err := uuid.Parse(workspaceID) + if err != nil { + return FetchResult{}, xerrors.Errorf("invalid workspace ID, must be a valid UUID: %w", err) + } + workspace, err := deps.coderClient.Workspace(ctx, parsedID) + if err != nil { + return FetchResult{}, err + } + workspaceJSON, err := json.Marshal(workspace) + if err != nil { + return FetchResult{}, xerrors.Errorf("failed to marshal workspace: %w", err) + } + return FetchResult{ + ID: workspace.ID.String(), + Title: workspace.Name, + Text: string(workspaceJSON), + URL: fmt.Sprintf("%s/%s/%s", deps.ServerURL(), workspace.OwnerName, workspace.Name), + }, nil +} + +func fetchTemplate(ctx context.Context, deps Deps, templateID string) (FetchResult, error) { + parsedID, err := uuid.Parse(templateID) + if err != nil { + return FetchResult{}, xerrors.Errorf("invalid template ID, must be a valid UUID: %w", err) + } + template, err := deps.coderClient.Template(ctx, parsedID) + if err != nil { + return FetchResult{}, err + } + templateJSON, err := json.Marshal(template) + if err != nil { + return FetchResult{}, xerrors.Errorf("failed to marshal template: %w", err) + } + return FetchResult{ + ID: template.ID.String(), + Title: template.DisplayName, + Text: string(templateJSON), + URL: fmt.Sprintf("%s/templates/%s/%s", deps.ServerURL(), template.OrganizationName, template.Name), + }, nil +} + +type FetchArgs struct { + ID string `json:"id"` +} + +type FetchResult struct { + ID string `json:"id"` + Title string `json:"title"` + Text string `json:"text"` + URL string `json:"url"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Implements the "fetch" tool as described in https://platform.openai.com/docs/mcp#fetch-tool. +// From my experiments with ChatGPT, it seems that it does not see the description that is +// provided in the tool definition. ChatGPT sees "fetch" as a very simple tool that can take +// an ID returned by the "search" tool and return the full details of the object. +var ChatGPTFetch = Tool[FetchArgs, FetchResult]{ + Tool: aisdk.Tool{ + Name: ToolNameChatGPTFetch, + Description: `Fetch a template or workspace. + + ID is a unique identifier for the template or workspace. It is a combination of the type and the ID. + + # Examples + + Fetch a template with ID "56f13b5e-be0f-4a17-bdb2-aaacc3353ea7". + + ` + "```" + `json + { + "id": "template:56f13b5e-be0f-4a17-bdb2-aaacc3353ea7" + } + ` + "```" + ` + + Fetch a workspace with ID "fcb6fc42-ba88-4175-9508-88e6a554a61a". + + ` + "```" + `json + { + "id": "workspace:fcb6fc42-ba88-4175-9508-88e6a554a61a" + } + ` + "```" + ` + `, + + Schema: aisdk.Schema{ + Properties: map[string]any{ + "id": map[string]any{ + "type": "string", + }, + }, + Required: []string{"id"}, + }, + }, + Handler: func(ctx context.Context, deps Deps, args FetchArgs) (FetchResult, error) { + objectID, err := parseObjectID(args.ID) + if err != nil { + return FetchResult{}, err + } + switch objectID.Type { + case ObjectTypeTemplate: + return fetchTemplate(ctx, deps, objectID.ID) + case ObjectTypeWorkspace: + return fetchWorkspace(ctx, deps, objectID.ID) + } + return FetchResult{}, xerrors.Errorf("reached unreachable code with object ID: %s", args.ID) + }, +} diff --git a/codersdk/toolsdk/chatgpt_test.go b/codersdk/toolsdk/chatgpt_test.go new file mode 100644 index 0000000000000..c8a05ba41411b --- /dev/null +++ b/codersdk/toolsdk/chatgpt_test.go @@ -0,0 +1,566 @@ +// nolint:gocritic // This is a test package, so database types do not end up in the build +package toolsdk_test + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/toolsdk" +) + +func TestChatGPTSearch_TemplateSearch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + setupTemplates int + expectError bool + errorContains string + }{ + { + name: "ValidTemplatesQuery_MultipleTemplates", + query: "templates", + setupTemplates: 3, + expectError: false, + }, + { + name: "ValidTemplatesQuery_NoTemplates", + query: "templates", + setupTemplates: 0, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + // Create templates as needed + var expectedTemplates []database.Template + for i := 0; i < tt.setupTemplates; i++ { + template := dbfake.TemplateVersion(t, store). + Seed(database.TemplateVersion{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + }).Do() + expectedTemplates = append(expectedTemplates, template.Template) + } + + // Create tool dependencies + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + // Execute tool + args := toolsdk.SearchArgs{Query: tt.query} + result, err := testTool(t, toolsdk.ChatGPTSearch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + return + } + + require.NoError(t, err) + require.Len(t, result.Results, tt.setupTemplates) + + // Validate result format for each template + templateIDsFound := make(map[string]bool) + for _, item := range result.Results { + require.NotEmpty(t, item.ID) + require.Contains(t, item.ID, "template:") + require.NotEmpty(t, item.Title) + require.Contains(t, item.URL, "/templates/") + + // Track that we found this template ID + templateIDsFound[item.ID] = true + } + + // Verify all expected templates are present + for _, expectedTemplate := range expectedTemplates { + expectedID := "template:" + expectedTemplate.ID.String() + require.True(t, templateIDsFound[expectedID], "Expected template %s not found in results", expectedID) + } + }) + } +} + +func TestChatGPTSearch_TemplateMultipleFilters(t *testing.T) { + t.Parallel() + + // Setup + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + org2 := dbgen.Organization(t, store, database.Organization{ + Name: "org2", + }) + + dbgen.Template(t, store, database.Template{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + Name: "docker-development", // Name contains "docker" + DisplayName: "Docker Development", + Description: "A Docker-based development template", + }) + + // Create another template that doesn't contain "docker" + dbgen.Template(t, store, database.Template{ + OrganizationID: org2.ID, + CreatedBy: owner.UserID, + Name: "python-web", // Name doesn't contain "docker" + DisplayName: "Python Web", + Description: "A Python web development template", + }) + + // Create third template with "docker" in name + dockerTemplate2 := dbgen.Template(t, store, database.Template{ + OrganizationID: org2.ID, + CreatedBy: owner.UserID, + Name: "old-docker-template", // Name contains "docker" + DisplayName: "Old Docker Template", + Description: "An old Docker template", + }) + + // Create tool dependencies + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + args := toolsdk.SearchArgs{Query: "templates/name:docker organization:org2"} + result, err := testTool(t, toolsdk.ChatGPTSearch, deps, args) + + // Verify results + require.NoError(t, err) + require.Len(t, result.Results, 1, "Should match only the docker template in org2") + + expectedID := "template:" + dockerTemplate2.ID.String() + require.Equal(t, expectedID, result.Results[0].ID, "Should match the docker template in org2") +} + +func TestChatGPTSearch_WorkspaceSearch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + setupOwner string // "self" or "other" + setupWorkspace bool + expectError bool + errorContains string + }{ + { + name: "ValidWorkspacesQuery_CurrentUser", + query: "workspaces", + setupOwner: "self", + setupWorkspace: true, + expectError: false, + }, + { + name: "ValidWorkspacesQuery_CurrentUserMe", + query: "workspaces/owner:me", + setupOwner: "self", + setupWorkspace: true, + expectError: false, + }, + { + name: "ValidWorkspacesQuery_NoWorkspaces", + query: "workspaces", + setupOwner: "self", + setupWorkspace: false, + expectError: false, + }, + { + name: "ValidWorkspacesQuery_SpecificUser", + query: "workspaces/owner:otheruser", + setupOwner: "other", + setupWorkspace: true, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + var workspaceOwnerID uuid.UUID + var workspaceClient *codersdk.Client + if tt.setupOwner == "self" { + workspaceOwnerID = owner.UserID + workspaceClient = client + } else { + var workspaceOwner codersdk.User + workspaceClient, workspaceOwner = coderdtest.CreateAnotherUserMutators(t, client, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { + r.Username = "otheruser" + }) + workspaceOwnerID = workspaceOwner.ID + } + + // Create workspace if needed + var expectedWorkspace database.WorkspaceTable + if tt.setupWorkspace { + workspace := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + Name: "test-workspace", + OrganizationID: owner.OrganizationID, + OwnerID: workspaceOwnerID, + }).Do() + expectedWorkspace = workspace.Workspace + } + + // Create tool dependencies + deps, err := toolsdk.NewDeps(workspaceClient) + require.NoError(t, err) + + // Execute tool + args := toolsdk.SearchArgs{Query: tt.query} + result, err := testTool(t, toolsdk.ChatGPTSearch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + return + } + + require.NoError(t, err) + + if tt.setupWorkspace { + require.Len(t, result.Results, 1) + item := result.Results[0] + require.NotEmpty(t, item.ID) + require.Contains(t, item.ID, "workspace:") + require.Equal(t, expectedWorkspace.Name, item.Title) + require.Contains(t, item.Text, "Owner:") + require.Contains(t, item.Text, "Template:") + require.Contains(t, item.Text, "Latest transition:") + require.Contains(t, item.URL, expectedWorkspace.Name) + } else { + require.Len(t, result.Results, 0) + } + }) + } +} + +func TestChatGPTSearch_QueryParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query string + expectError bool + errorMsg string + }{ + { + name: "ValidTemplatesQuery", + query: "templates", + expectError: false, + }, + { + name: "ValidWorkspacesQuery", + query: "workspaces", + expectError: false, + }, + { + name: "ValidWorkspacesMeQuery", + query: "workspaces/owner:me", + expectError: false, + }, + { + name: "ValidWorkspacesUserQuery", + query: "workspaces/owner:testuser", + expectError: false, + }, + { + name: "InvalidQueryType", + query: "users", + expectError: true, + errorMsg: "invalid query", + }, + { + name: "EmptyQuery", + query: "", + expectError: true, + errorMsg: "invalid query", + }, + { + name: "MalformedQuery", + query: "invalidtype/somequery", + expectError: true, + errorMsg: "invalid query", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup minimal environment + client, _ := coderdtest.NewWithDatabase(t, nil) + coderdtest.CreateFirstUser(t, client) + + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + // Execute tool + args := toolsdk.SearchArgs{Query: tt.query} + _, err = testTool(t, toolsdk.ChatGPTSearch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestChatGPTFetch_TemplateFetch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupTemplate bool + objectID string // if empty, will use real template ID + expectError bool + errorContains string + }{ + { + name: "ValidTemplateFetch", + setupTemplate: true, + expectError: false, + }, + { + name: "NonExistentTemplateID", + setupTemplate: false, + objectID: "template:" + uuid.NewString(), + expectError: true, + errorContains: "Resource not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + var templateID string + var expectedTemplate database.Template + if tt.setupTemplate { + template := dbfake.TemplateVersion(t, store). + Seed(database.TemplateVersion{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + }).Do() + expectedTemplate = template.Template + templateID = "template:" + template.Template.ID.String() + } else if tt.objectID != "" { + templateID = tt.objectID + } + + // Create tool dependencies + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + // Execute tool + args := toolsdk.FetchArgs{ID: templateID} + result, err := testTool(t, toolsdk.ChatGPTFetch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + return + } + + require.NoError(t, err) + require.Equal(t, expectedTemplate.ID.String(), result.ID) + require.Equal(t, expectedTemplate.DisplayName, result.Title) + require.NotEmpty(t, result.Text) + require.Contains(t, result.URL, "/templates/") + require.Contains(t, result.URL, expectedTemplate.Name) + + // Validate JSON marshaling + var templateData codersdk.Template + err = json.Unmarshal([]byte(result.Text), &templateData) + require.NoError(t, err) + require.Equal(t, expectedTemplate.ID, templateData.ID) + }) + } +} + +func TestChatGPTFetch_WorkspaceFetch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupWorkspace bool + objectID string // if empty, will use real workspace ID + expectError bool + errorContains string + }{ + { + name: "ValidWorkspaceFetch", + setupWorkspace: true, + expectError: false, + }, + { + name: "NonExistentWorkspaceID", + setupWorkspace: false, + objectID: "workspace:" + uuid.NewString(), + expectError: true, + errorContains: "Resource not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup + client, store := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + var workspaceID string + var expectedWorkspace database.WorkspaceTable + if tt.setupWorkspace { + workspace := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: owner.UserID, + }).Do() + expectedWorkspace = workspace.Workspace + workspaceID = "workspace:" + workspace.Workspace.ID.String() + } else if tt.objectID != "" { + workspaceID = tt.objectID + } + + // Create tool dependencies + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + // Execute tool + args := toolsdk.FetchArgs{ID: workspaceID} + result, err := testTool(t, toolsdk.ChatGPTFetch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + return + } + + require.NoError(t, err) + require.Equal(t, expectedWorkspace.ID.String(), result.ID) + require.Equal(t, expectedWorkspace.Name, result.Title) + require.NotEmpty(t, result.Text) + require.Contains(t, result.URL, expectedWorkspace.Name) + + // Validate JSON marshaling + var workspaceData codersdk.Workspace + err = json.Unmarshal([]byte(result.Text), &workspaceData) + require.NoError(t, err) + require.Equal(t, expectedWorkspace.ID, workspaceData.ID) + }) + } +} + +func TestChatGPTFetch_ObjectIDParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + objectID string + expectError bool + errorMsg string + }{ + { + name: "ValidTemplateID", + objectID: "template:" + uuid.NewString(), + expectError: false, + }, + { + name: "ValidWorkspaceID", + objectID: "workspace:" + uuid.NewString(), + expectError: false, + }, + { + name: "MissingColon", + objectID: "template" + uuid.NewString(), + expectError: true, + errorMsg: "invalid ID", + }, + { + name: "InvalidUUID", + objectID: "template:invalid-uuid", + expectError: true, + errorMsg: "invalid template ID, must be a valid UUID", + }, + { + name: "UnsupportedType", + objectID: "user:" + uuid.NewString(), + expectError: true, + errorMsg: "invalid ID", + }, + { + name: "EmptyID", + objectID: "", + expectError: true, + errorMsg: "invalid ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Setup minimal environment + client, _ := coderdtest.NewWithDatabase(t, nil) + coderdtest.CreateFirstUser(t, client) + + deps, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + // Execute tool + args := toolsdk.FetchArgs{ID: tt.objectID} + _, err = testTool(t, toolsdk.ChatGPTFetch, deps, args) + + // Verify results + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + // For valid formats, we expect it to fail on API call since IDs don't exist + // but parsing should succeed + require.Error(t, err) + require.Contains(t, err.Error(), "Resource not found") + } + }) + } +} diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index c6c37821e5234..7cb8cecb25234 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -36,6 +36,8 @@ const ( ToolNameCreateTemplate = "coder_create_template" ToolNameDeleteTemplate = "coder_delete_template" ToolNameWorkspaceBash = "coder_workspace_bash" + ToolNameChatGPTSearch = "search" + ToolNameChatGPTFetch = "fetch" ) func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) { @@ -56,6 +58,13 @@ type Deps struct { report func(ReportTaskArgs) error } +func (d Deps) ServerURL() string { + serverURLCopy := *d.coderClient.URL + serverURLCopy.Path = "" + serverURLCopy.RawQuery = "" + return serverURLCopy.String() +} + func WithTaskReporter(fn func(ReportTaskArgs) error) func(*Deps) { return func(d *Deps) { d.report = fn @@ -194,6 +203,8 @@ var All = []GenericTool{ UploadTarFile.Generic(), UpdateTemplateActiveVersion.Generic(), WorkspaceBash.Generic(), + ChatGPTSearch.Generic(), + ChatGPTFetch.Generic(), } type ReportTaskArgs struct { diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index 13e475c80609a..fb321e90e7dee 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "runtime" "sort" "sync" "testing" @@ -397,6 +398,9 @@ func TestTools(t *testing.T) { }) t.Run("WorkspaceSSHExec", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("WorkspaceSSHExec is not supported on Windows") + } // Setup workspace exactly like main SSH tests client, workspace, agentToken := setupWorkspaceForAgent(t) @@ -661,6 +665,10 @@ func TestMain(m *testing.M) { var untested []string for _, tool := range toolsdk.All { if tested, ok := testedTools.Load(tool.Name); !ok || !tested.(bool) { + // Test is skipped on Windows + if runtime.GOOS == "windows" && tool.Name == "coder_workspace_bash" { + continue + } untested = append(untested, tool.Name) } } diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 1eb37bb07c989..4f3faedb534fc 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -550,7 +550,9 @@ func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid }}) conn, res, err := websocket.Dial(ctx, reqURL.String(), &websocket.DialOptions{ - CompressionMode: websocket.CompressionDisabled, + // We want `NoContextTakeover` compression to balance improving + // bandwidth cost/latency with minimal memory usage overhead. + CompressionMode: websocket.CompressionNoContextTakeover, HTTPClient: &http.Client{ Jar: jar, Transport: c.HTTPClient.Transport, @@ -563,6 +565,12 @@ func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid return nil, nil, ReadBodyAsError(res) } + // When a workspace has a few devcontainers running, or a single devcontainer + // has a large amount of apps, then each payload can easily exceed 32KiB. + // We up the limit to 4MiB to give us plenty of headroom for workspaces that + // have lots of dev containers with lots of apps. + conn.SetReadLimit(1 << 22) // 4MiB + d := wsjson.NewDecoder[WorkspaceAgentListContainersResponse](conn, websocket.MessageText, c.logger) return d.Chan(), d, nil } 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..a38cca8bbe9a9 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -663,11 +663,19 @@ func (c *Client) WorkspaceTimings(ctx context.Context, id uuid.UUID) (WorkspaceB return timings, json.NewDecoder(res.Body).Decode(&timings) } -type UpdateWorkspaceACL struct { - // Keys must be valid UUIDs. To remove a user/group from the ACL use "" as the - // role name (available as a constant named `codersdk.WorkspaceRoleDeleted`) - UserRoles map[string]WorkspaceRole `json:"user_roles,omitempty"` - GroupRoles map[string]WorkspaceRole `json:"group_roles,omitempty"` +type WorkspaceACL struct { + Users []WorkspaceUser `json:"users"` + Groups []WorkspaceGroup `json:"group"` +} + +type WorkspaceGroup struct { + Group + Role WorkspaceRole `json:"role" enums:"admin,use"` +} + +type WorkspaceUser struct { + MinimalUser + Role WorkspaceRole `json:"role" enums:"admin,use"` } type WorkspaceRole string @@ -678,6 +686,30 @@ const ( WorkspaceRoleDeleted WorkspaceRole = "" ) +func (c *Client) WorkspaceACL(ctx context.Context, workspaceID uuid.UUID) (WorkspaceACL, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/acl", workspaceID), nil) + if err != nil { + return WorkspaceACL{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceACL{}, ReadBodyAsError(res) + } + var acl WorkspaceACL + return acl, json.NewDecoder(res.Body).Decode(&acl) +} + +type UpdateWorkspaceACL struct { + // UserRoles is a mapping from valid user UUIDs to the workspace role they + // should be granted. To remove a user from the workspace, use "" as the role + // (available as a constant named codersdk.WorkspaceRoleDeleted) + UserRoles map[string]WorkspaceRole `json:"user_roles,omitempty"` + // GroupRoles is a mapping from valid group UUIDs to the workspace role they + // should be granted. To remove a group from the workspace, use "" as the role + // (available as a constant named codersdk.WorkspaceRoleDeleted) + GroupRoles map[string]WorkspaceRole `json:"group_roles,omitempty"` +} + func (c *Client) UpdateWorkspaceACL(ctx context.Context, workspaceID uuid.UUID, req UpdateWorkspaceACL) error { res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/workspaces/%s/acl", workspaceID), req) if err != nil { @@ -689,3 +721,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 ce66d5e1b8a70..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() @@ -400,6 +431,10 @@ func (c *AgentConn) WatchContainers(ctx context.Context, logger slog.Logger) (<- conn, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ HTTPClient: c.apiClient(), + + // We want `NoContextTakeover` compression to balance improving + // bandwidth cost/latency with minimal memory usage overhead. + CompressionMode: websocket.CompressionNoContextTakeover, }) if err != nil { if res == nil { @@ -411,13 +446,19 @@ func (c *AgentConn) WatchContainers(ctx context.Context, logger slog.Logger) (<- defer res.Body.Close() } + // When a workspace has a few devcontainers running, or a single devcontainer + // has a large amount of apps, then each payload can easily exceed 32KiB. + // We up the limit to 4MiB to give us plenty of headroom for workspaces that + // have lots of dev containers with lots of apps. + conn.SetReadLimit(1 << 22) // 4MiB + d := wsjson.NewDecoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText, logger) return d.Chan(), d, nil } // 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) @@ -436,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() @@ -453,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 @@ -494,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/dialer.go b/codersdk/workspacesdk/dialer.go index 71cac0c5f04b1..39d02931e6ae1 100644 --- a/codersdk/workspacesdk/dialer.go +++ b/codersdk/workspacesdk/dialer.go @@ -24,6 +24,10 @@ var permanentErrorStatuses = []int{ http.StatusBadRequest, // returned if API mismatch http.StatusNotFound, // returned if user doesn't have permission or agent doesn't exist http.StatusInternalServerError, // returned if database is not reachable, + http.StatusForbidden, // returned if user is not authorized + // StatusUnauthorized is only a permanent error if the error is not due to + // an invalid resume token. See `checkResumeTokenFailure`. + http.StatusUnauthorized, } type WebsocketDialer struct { @@ -39,6 +43,24 @@ type WebsocketDialer struct { isFirst bool } +// checkResumeTokenFailure checks if the parsed error indicates a resume token failure +// and updates the resumeTokenFailed flag accordingly. Returns true if a resume token +// failure was detected. +func (w *WebsocketDialer) checkResumeTokenFailure(ctx context.Context, sdkErr *codersdk.Error) bool { + if sdkErr == nil { + return false + } + + for _, v := range sdkErr.Validations { + if v.Field == "resume_token" { + w.logger.Warn(ctx, "failed to dial tailnet v2+ API: server replied invalid resume token; unsetting for next connection attempt") + w.resumeTokenFailed = true + return true + } + } + return false +} + type WebsocketDialerOption func(*WebsocketDialer) func WithWorkspaceUpdates(req *proto.WorkspaceUpdatesRequest) WebsocketDialerOption { @@ -82,9 +104,14 @@ func (w *WebsocketDialer) Dial(ctx context.Context, r tailnet.ResumeTokenControl if w.isFirst { if res != nil && slices.Contains(permanentErrorStatuses, res.StatusCode) { err = codersdk.ReadBodyAsError(res) - // A bit more human-readable help in the case the API version was rejected var sdkErr *codersdk.Error if xerrors.As(err, &sdkErr) { + // Check for resume token failure first + if w.checkResumeTokenFailure(ctx, sdkErr) { + return tailnet.ControlProtocolClients{}, err + } + + // A bit more human-readable help in the case the API version was rejected if sdkErr.Message == AgentAPIMismatchMessage && sdkErr.StatusCode() == http.StatusBadRequest { sdkErr.Helper = fmt.Sprintf( @@ -107,13 +134,8 @@ func (w *WebsocketDialer) Dial(ctx context.Context, r tailnet.ResumeTokenControl bodyErr := codersdk.ReadBodyAsError(res) var sdkErr *codersdk.Error if xerrors.As(bodyErr, &sdkErr) { - for _, v := range sdkErr.Validations { - if v.Field == "resume_token" { - // Unset the resume token for the next attempt - w.logger.Warn(ctx, "failed to dial tailnet v2+ API: server replied invalid resume token; unsetting for next connection attempt") - w.resumeTokenFailed = true - return tailnet.ControlProtocolClients{}, err - } + if w.checkResumeTokenFailure(ctx, sdkErr) { + return tailnet.ControlProtocolClients{}, err } } if !errors.Is(err, context.Canceled) { diff --git a/codersdk/workspacesdk/dialer_test.go b/codersdk/workspacesdk/dialer_test.go index dbe351e4e492c..227299d43afda 100644 --- a/codersdk/workspacesdk/dialer_test.go +++ b/codersdk/workspacesdk/dialer_test.go @@ -270,6 +270,46 @@ func TestWebsocketDialer_ResumeTokenFailure(t *testing.T) { require.Error(t, err) } +func TestWebsocketDialer_UnauthenticatedFailFast(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{ + IgnoreErrors: true, + }).Leveled(slog.LevelDebug) + + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{}) + })) + defer svr.Close() + svrURL, err := url.Parse(svr.URL) + require.NoError(t, err) + + uut := workspacesdk.NewWebsocketDialer(logger, svrURL, &websocket.DialOptions{}) + + _, err = uut.Dial(ctx, nil) + require.Error(t, err) +} + +func TestWebsocketDialer_UnauthorizedFailFast(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{ + IgnoreErrors: true, + }).Leveled(slog.LevelDebug) + + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{}) + })) + defer svr.Close() + svrURL, err := url.Parse(svr.URL) + require.NoError(t, err) + + uut := workspacesdk.NewWebsocketDialer(logger, svrURL, &websocket.DialOptions{}) + + _, err = uut.Dial(ctx, nil) + require.Error(t, err) +} + func TestWebsocketDialer_UplevelVersion(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) diff --git a/codersdk/workspacesdk/workspacesdk.go b/codersdk/workspacesdk/workspacesdk.go index 9f587cf5267a8..29ddbd1f53094 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{} } @@ -215,12 +215,12 @@ func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options * options.BlockEndpoints = true } - headers := make(http.Header) - tokenHeader := codersdk.SessionTokenHeader - if c.client.SessionTokenHeader != "" { - tokenHeader = c.client.SessionTokenHeader + wsOptions := &websocket.DialOptions{ + HTTPClient: c.client.HTTPClient, + // Need to disable compression to avoid a data-race. + CompressionMode: websocket.CompressionDisabled, } - headers.Set(tokenHeader, c.client.SessionToken()) + c.client.SessionTokenProvider.SetDialOption(wsOptions) // New context, separate from dialCtx. We don't want to cancel the // connection if dialCtx is canceled. @@ -236,12 +236,7 @@ func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options * return nil, xerrors.Errorf("parse url: %w", err) } - dialer := NewWebsocketDialer(options.Logger, coordinateURL, &websocket.DialOptions{ - HTTPClient: c.client.HTTPClient, - HTTPHeader: headers, - // Need to disable compression to avoid a data-race. - CompressionMode: websocket.CompressionDisabled, - }) + dialer := NewWebsocketDialer(options.Logger, coordinateURL, wsOptions) clk := quartz.NewReal() controller := tailnet.NewController(options.Logger, dialer) controller.ResumeTokenCtrl = tailnet.NewBasicResumeTokenController(options.Logger, clk) diff --git a/docker-compose.yaml b/compose.yaml similarity index 99% rename from docker-compose.yaml rename to compose.yaml index b5ab4cf0227ff..409ecda158c1b 100644 --- a/docker-compose.yaml +++ b/compose.yaml @@ -1,4 +1,3 @@ -version: "3.9" services: coder: # This MUST be stable for our documentation and 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/_redirects b/docs/_redirects new file mode 100644 index 0000000000000..fdfc401f098f9 --- /dev/null +++ b/docs/_redirects @@ -0,0 +1,6 @@ +# Redirect old offline deployments URL to new airgap URL +/install/offline /install/airgap 301 + +# Redirect old offline anchor fragments to new airgap anchors +/install/offline#offline-docs /install/airgap#airgap-docs 301 +/install/offline#offline-container-images /install/airgap#airgap-container-images 301 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/about/contributing/modules.md b/docs/about/contributing/modules.md index b824fa209e77a..05d06e9299fa4 100644 --- a/docs/about/contributing/modules.md +++ b/docs/about/contributing/modules.md @@ -369,7 +369,7 @@ Use the version bump script to update versions: ## Get help -- **Examples**: Review existing modules like [`code-server`](https://registry.coder.com/modules/coder/code-server), [`git-clone`](https://registry.coder.com/modules/coder/git-clone), and [`jetbrains-gateway`](https://registry.coder.com/modules/coder/jetbrains-gateway) +- **Examples**: Review existing modules like [`code-server`](https://registry.coder.com/modules/coder/code-server), [`git-clone`](https://registry.coder.com/modules/coder/git-clone), and [`jetbrains`](https://registry.coder.com/modules/coder/jetbrains) - **Issues**: Open an issue at [github.com/coder/registry](https://github.com/coder/registry/issues) - **Community**: Join the [Coder Discord](https://discord.gg/coder) for questions - **Documentation**: Check the [Coder docs](https://coder.com/docs) for help on Coder. diff --git a/docs/admin/infrastructure/scale-utility.md b/docs/admin/infrastructure/scale-utility.md index b66e7fca41394..6945b54bf559e 100644 --- a/docs/admin/infrastructure/scale-utility.md +++ b/docs/admin/infrastructure/scale-utility.md @@ -44,7 +44,7 @@ environments. > for your users. > To avoid potential outages and orphaned resources, we recommend that you run > scale tests on a secondary "staging" environment or a dedicated -> [Kubernetes playground cluster](https://github.com/coder/coder/tree/main/scaletest/terraform). +> Kubernetes playground cluster. > > Run it against a production environment at your own risk. diff --git a/docs/admin/integrations/index.md b/docs/admin/integrations/index.md index 900925bd2dfd0..3a1a11f2448df 100644 --- a/docs/admin/integrations/index.md +++ b/docs/admin/integrations/index.md @@ -13,6 +13,6 @@ our [installation guides](../../install/index.md). The following resources may help as you're deploying Coder. - [Coder packages: one-click install on cloud providers](https://github.com/coder/packages) -- [Deploy Coder offline](../../install/offline.md) +- [Deploy Coder Air-gapped](../../install/airgap.md) - [Supported resources (Terraform registry)](https://registry.terraform.io) - [Writing custom templates](../templates/index.md) diff --git a/docs/admin/integrations/jfrog-artifactory.md b/docs/admin/integrations/jfrog-artifactory.md index 702bce2599266..06f0bc670fad8 100644 --- a/docs/admin/integrations/jfrog-artifactory.md +++ b/docs/admin/integrations/jfrog-artifactory.md @@ -129,9 +129,9 @@ To set this up, follow these steps: If you don't want to use the official modules, you can read through the [example template](https://github.com/coder/coder/tree/main/examples/jfrog/docker), which uses Docker as the underlying compute. The same concepts apply to all compute types. -## Offline Deployments +## Air-Gapped Deployments -See the [offline deployments](../templates/extending-templates/modules.md#offline-installations) section for instructions on how to use Coder modules in an offline environment with Artifactory. +See the [air-gapped deployments](../templates/extending-templates/modules.md#offline-installations) section for instructions on how to use Coder modules in an offline environment with Artifactory. ## Next Steps diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md new file mode 100644 index 0000000000000..e5264904293f7 --- /dev/null +++ b/docs/admin/integrations/oauth2-provider.md @@ -0,0 +1,236 @@ +# OAuth2 Provider (Experimental) + +> [!WARNING] +> The OAuth2 provider functionality is currently **experimental and unstable**. This feature: +> +> - Is subject to breaking changes without notice +> - May have incomplete functionality +> - Is not recommended for production use +> - Requires the `oauth2` experiment flag to be enabled +> +> Use this feature for development and testing purposes only. + +Coder can act as an OAuth2 authorization server, allowing third-party applications to authenticate users through Coder and access the Coder API on their behalf. This enables integrations where external applications can leverage Coder's authentication and user management. + +## Requirements + +- Admin privileges in Coder +- OAuth2 experiment flag enabled +- HTTPS recommended for production deployments + +## Enable OAuth2 Provider + +Add the `oauth2` experiment flag to your Coder server: + +```bash +coder server --experiments oauth2 +``` + +Or set the environment variable: + +```env +CODER_EXPERIMENTS=oauth2 +``` + +## Creating OAuth2 Applications + +### Method 1: Web UI + +1. Navigate to **Deployment Settings** → **OAuth2 Applications** +2. Click **Create Application** +3. Fill in the application details: + - **Name**: Your application name + - **Callback URL**: `https://yourapp.example.com/callback` + - **Icon**: Optional icon URL + +### Method 2: Management API + +Create an application using the Coder API: + +```bash +curl -X POST \ + -H "Authorization: Bearer $CODER_SESSION_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "My Application", + "callback_url": "https://myapp.example.com/callback", + "icon": "https://myapp.example.com/icon.png" + }' \ + "$CODER_URL/api/v2/oauth2-provider/apps" +``` + +Generate a client secret: + +```bash +curl -X POST \ + -H "Authorization: Bearer $CODER_SESSION_TOKEN" \ + "$CODER_URL/api/v2/oauth2-provider/apps/$APP_ID/secrets" +``` + +## Integration Patterns + +### Standard OAuth2 Flow + +1. **Authorization Request**: Redirect users to Coder's authorization endpoint: + + ```url + https://coder.example.com/oauth2/authorize? + client_id=your-client-id& + response_type=code& + redirect_uri=https://yourapp.example.com/callback& + state=random-string + ``` + +2. **Token Exchange**: Exchange the authorization code for an access token: + + ```bash + curl -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "code=$AUTH_CODE" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" \ + -d "redirect_uri=https://yourapp.example.com/callback" \ + "$CODER_URL/oauth2/tokens" + ``` + +3. **API Access**: Use the access token to call Coder's API: + + ```bash + curl -H "Authorization: Bearer $ACCESS_TOKEN" \ + "$CODER_URL/api/v2/users/me" + ``` + +### PKCE Flow (Public Clients) + +For mobile apps and single-page applications, use PKCE for enhanced security: + +1. Generate a code verifier and challenge: + + ```bash + CODE_VERIFIER=$(openssl rand -base64 96 | tr -d "=+/" | cut -c1-128) + CODE_CHALLENGE=$(echo -n $CODE_VERIFIER | openssl dgst -sha256 -binary | base64 | tr -d "=+/" | cut -c1-43) + ``` + +2. Include PKCE parameters in the authorization request: + + ```url + https://coder.example.com/oauth2/authorize? + client_id=your-client-id& + response_type=code& + code_challenge=$CODE_CHALLENGE& + code_challenge_method=S256& + redirect_uri=https://yourapp.example.com/callback + ``` + +3. Include the code verifier in the token exchange: + + ```bash + curl -X POST \ + -d "grant_type=authorization_code" \ + -d "code=$AUTH_CODE" \ + -d "client_id=$CLIENT_ID" \ + -d "code_verifier=$CODE_VERIFIER" \ + "$CODER_URL/oauth2/tokens" + ``` + +## Discovery Endpoints + +Coder provides OAuth2 discovery endpoints for programmatic integration: + +- **Authorization Server Metadata**: `GET /.well-known/oauth-authorization-server` +- **Protected Resource Metadata**: `GET /.well-known/oauth-protected-resource` + +These endpoints return server capabilities and endpoint URLs according to [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) and [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728). + +## Token Management + +### Refresh Tokens + +Refresh an expired access token: + +```bash +curl -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token" \ + -d "refresh_token=$REFRESH_TOKEN" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" \ + "$CODER_URL/oauth2/tokens" +``` + +### Revoke Access + +Revoke all tokens for an application: + +```bash +curl -X DELETE \ + -H "Authorization: Bearer $CODER_SESSION_TOKEN" \ + "$CODER_URL/oauth2/tokens?client_id=$CLIENT_ID" +``` + +## Testing and Development + +Coder provides comprehensive test scripts for OAuth2 development: + +```bash +# Navigate to the OAuth2 test scripts +cd scripts/oauth2/ + +# Run the full automated test suite +./test-mcp-oauth2.sh + +# Create a test application for manual testing +eval $(./setup-test-app.sh) + +# Run an interactive browser-based test +./test-manual-flow.sh + +# Clean up when done +./cleanup-test-app.sh +``` + +For more details on testing, see the [OAuth2 test scripts README](../../../scripts/oauth2/README.md). + +## Common Issues + +### "OAuth2 experiment not enabled" + +Add `oauth2` to your experiment flags: `coder server --experiments oauth2` + +### "Invalid redirect_uri" + +Ensure the redirect URI in your request exactly matches the one registered for your application. + +### "PKCE verification failed" + +Verify that the `code_verifier` used in the token request matches the one used to generate the `code_challenge`. + +## Security Considerations + +- **Use HTTPS**: Always use HTTPS in production to protect tokens in transit +- **Implement PKCE**: Use PKCE for all public clients (mobile apps, SPAs) +- **Validate redirect URLs**: Only register trusted redirect URIs for your applications +- **Rotate secrets**: Periodically rotate client secrets using the management API + +## Limitations + +As an experimental feature, the current implementation has limitations: + +- No scope system - all tokens have full API access +- No client credentials grant support +- Limited to opaque access tokens (no JWT support) + +## Standards Compliance + +This implementation follows established OAuth2 standards including [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) (OAuth2 core), [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) (PKCE), and related specifications for discovery and client registration. + +## Next Steps + +- Review the [API Reference](../../reference/api/index.md) for complete endpoint documentation +- Check [External Authentication](../external-auth/index.md) for configuring Coder as an OAuth2 client +- See [Security Best Practices](../security/index.md) for deployment security guidance + +## Feedback + +This is an experimental feature under active development. Please report issues and feedback through [GitHub Issues](https://github.com/coder/coder/issues) with the `oauth2` label. diff --git a/docs/admin/integrations/prometheus.md b/docs/admin/integrations/prometheus.md index ac88c8c5beda7..47fbc575c7c2e 100644 --- a/docs/admin/integrations/prometheus.md +++ b/docs/admin/integrations/prometheus.md @@ -143,9 +143,12 @@ deployment. They will always be available from the agent. | `coderd_oauth2_external_requests_rate_limit_total` | gauge | DEPRECATED: use coderd_oauth2_external_requests_rate_limit instead | `name` `resource` | | `coderd_oauth2_external_requests_rate_limit_used` | gauge | The number of requests made in this interval. | `name` `resource` | | `coderd_oauth2_external_requests_total` | counter | The total number of api calls made to external oauth2 providers. 'status_code' will be 0 if the request failed with no response. | `name` `source` `status_code` | +| `coderd_prebuilt_workspace_claim_duration_seconds` | histogram | Time to claim a prebuilt workspace by organization, template, and preset. | `organization_name` `preset_name` `template_name` | | `coderd_provisionerd_job_timings_seconds` | histogram | The provisioner job time duration in seconds. | `provisioner` `status` | | `coderd_provisionerd_jobs_current` | gauge | The number of currently running provisioner jobs. | `provisioner` | | `coderd_workspace_builds_total` | counter | The number of workspaces started, updated, or deleted. | `action` `owner_email` `status` `template_name` `template_version` `workspace_name` | +| `coderd_workspace_creation_duration_seconds` | histogram | Time to create a workspace by organization, template, preset, and type (regular or prebuild). | `organization_name` `preset_name` `template_name` `type` | +| `coderd_workspace_creation_total` | counter | Total regular (non-prebuilt) workspace creations by organization, template, and preset. | `organization_name` `preset_name` `template_name` | | `coderd_workspace_latest_build_status` | gauge | The current workspace statuses by template, transition, and owner. | `status` `template_name` `template_version` `workspace_owner` `workspace_transition` | | `go_gc_duration_seconds` | summary | A summary of the pause duration of garbage collection cycles. | | | `go_goroutines` | gauge | Number of goroutines that currently exist. | | @@ -185,3 +188,19 @@ deployment. They will always be available from the agent. | `promhttp_metric_handler_requests_total` | counter | Total number of scrapes by HTTP status code. | `code` | + +### Note on Prometheus native histogram support + +The following metrics support native histograms: + +* `coderd_workspace_creation_duration_seconds` +* `coderd_prebuilt_workspace_claim_duration_seconds` + +Native histograms are an **experimental** Prometheus feature that removes the need to predefine bucket boundaries and allows higher-resolution buckets that adapt to deployment characteristics. +Whether a metric is exposed as classic or native depends entirely on the Prometheus server configuration (see [Prometheus docs](https://prometheus.io/docs/specs/native_histograms/) for details): + +* If native histograms are enabled, Prometheus ingests the high-resolution histogram. +* If not, it falls back to the predefined buckets. + +⚠️ Important: classic and native histograms cannot be aggregated together. If Prometheus is switched from classic to native at a certain point in time, dashboards may need to account for that transition. +For this reason, it’s recommended to follow [Prometheus’ migration guidelines](https://prometheus.io/docs/specs/native_histograms/#migration-considerations) when moving from classic to native histograms. diff --git a/docs/admin/monitoring/logs.md b/docs/admin/monitoring/logs.md index 02e175795ae1f..8b9f5e747d5fd 100644 --- a/docs/admin/monitoring/logs.md +++ b/docs/admin/monitoring/logs.md @@ -17,7 +17,7 @@ machine/VM. options. - To only display certain types of logs, use the[`CODER_LOG_FILTER`](../../reference/cli/server.md#-l---log-filter) server - config. + config. Using `.*` will result in the `DEBUG` log level being used. Events such as server errors, audit logs, user activities, and SSO & OpenID Connect logs are all captured in the `coderd` logs. diff --git a/docs/admin/networking/index.md b/docs/admin/networking/index.md index 4ab3352b2c19f..87cbcd7775c93 100644 --- a/docs/admin/networking/index.md +++ b/docs/admin/networking/index.md @@ -116,12 +116,12 @@ If a direct connection is not available (e.g. client or server is behind NAT), Coder will use a relayed connection. By default, [Coder uses Google's public STUN server](../../reference/cli/server.md#--derp-server-stun-addresses), but this can be disabled or changed for -[offline deployments](../../install/offline.md). +[Air-gapped deployments](../../install/airgap.md). ### Relayed connections By default, your Coder server also runs a built-in DERP relay which can be used -for both public and [offline deployments](../../install/offline.md). +for both public and [Air-gapped deployments](../../install/airgap.md). However, our Wireguard integration through Tailscale has graciously allowed us to use @@ -135,7 +135,7 @@ coder server --derp-config-url https://controlplane.tailscale.com/derpmap/defaul #### Custom Relays If you want lower latency than what Tailscale offers or want additional DERP -relays for offline deployments, you may run custom DERP servers. Refer to +relays for air-gapped deployments, you may run custom DERP servers. Refer to [Tailscale's documentation](https://tailscale.com/kb/1118/custom-derp-servers/#why-run-your-own-derp-server) to learn how to set them up. diff --git a/docs/admin/networking/workspace-proxies.md b/docs/admin/networking/workspace-proxies.md index 3cabea87ebae9..5760b3e1a8177 100644 --- a/docs/admin/networking/workspace-proxies.md +++ b/docs/admin/networking/workspace-proxies.md @@ -178,7 +178,7 @@ regular Coder server. #### Docker Compose Change the provided -[`docker-compose.yml`](https://github.com/coder/coder/blob/main/docker-compose.yaml) +[`compose.yml`](https://github.com/coder/coder/blob/main/compose.yaml) file to include a custom entrypoint: ```diff 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/dynamic-parameters.md b/docs/admin/templates/extending-templates/dynamic-parameters.md index d676c3bcf3148..8ca1f4efd4149 100644 --- a/docs/admin/templates/extending-templates/dynamic-parameters.md +++ b/docs/admin/templates/extending-templates/dynamic-parameters.md @@ -38,11 +38,11 @@ They allow you to set resource guardrails by referencing Coder identity in the ` ## How to enable Dynamic Parameters -In Coder v2.24.0, you can opt-in to Dynamic Parameters on a per-template basis. +In Coder v2.25.0, Dynamic Parameters are automatically enabled for new templates. You can opt-in to Dynamic Parameters for individual existing templates via template settings. 1. Go to your template's settings and enable the **Enable dynamic parameters for workspace creation** option. - ![Enable dynamic parameters for workspace creation](../../../images/admin/templates/extend-templates/dyn-params/enable-dynamic-parameters.png) + ![Enable dynamic parameters for workspace creation](../../../images/admin/templates/extend-templates/dyn-params/dynamic-parameters-ga-settings.png) 1. Update your template to use version >=2.4.0 of the Coder provider with the following Terraform block. @@ -784,9 +784,9 @@ data "coder_parameter" "your_groups" { ## Troubleshooting -Dynamic Parameters is still in Beta as we continue to polish and improve the workflow. +Dynamic Parameters is now in general availability. We're tracking a list of known issues [here in Github](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20label%3Aparameters) as we continue to polish and improve the workflow. If you have any issues during upgrade, please file an issue in our -[GitHub repository](https://github.com/coder/coder/issues/new?labels=parameters) and include a +[GitHub repository](https://github.com/coder/coder/issues/new?labels=parameters) with the `parameters` label and include a [Playground link](https://playground.coder.app/parameters) where applicable. We appreciate the feedback and look forward to what the community creates with this system! @@ -798,7 +798,7 @@ You can share anything you build with Dynamic Parameters in our [Discord](https: Ensure that the following version requirements are met: -- `coder/coder`: >= [v2.24.0](https://github.com/coder/coder/releases/tag/v2.24.0) +- `coder/coder`: >= [v2.25.0](https://github.com/coder/coder/releases/tag/v2.25.0) - `coder/terraform-provider-coder`: >= [v2.5.3](https://github.com/coder/terraform-provider-coder/releases/tag/v2.5.3) Enabling Dynamic Parameters on an existing template requires administrators to publish a new template version. @@ -818,10 +818,9 @@ To revert Dynamic Parameters on a template: ### Template variables not showing up -In beta, template variables are not supported in Dynamic Parameters. +Dynamic Parameters is GA as of [v2.25.0](https://github.com/coder/coder/releases/tag/v2.25.0), and this issue has been resolved. In beta ([v2.24.0](https://github.com/coder/coder/releases/tag/v2.24.0)), template variables were not supported in Dynamic Parameters. -This issue will be resolved by the next minor release of `coder/coder`. -If this is issue is blocking your usage of Dynamic Parameters, please let us know in [this thread](https://github.com/coder/coder/issues/18671). +If you are experiencing issues with template variables, try upgrading to the latest version with dynamic parameters in GA. Otherwise, please file an issue in our Github. ### Can I use registry modules with Dynamic Parameters? diff --git a/docs/admin/templates/extending-templates/modules.md b/docs/admin/templates/extending-templates/modules.md index 1495dfce1f2da..887704f098e93 100644 --- a/docs/admin/templates/extending-templates/modules.md +++ b/docs/admin/templates/extending-templates/modules.md @@ -44,7 +44,7 @@ across templates. Some of the modules we publish are, [`vscode-web`](https://registry.coder.com/modules/coder/vscode-web) 2. [`git-clone`](https://registry.coder.com/modules/coder/git-clone) 3. [`dotfiles`](https://registry.coder.com/modules/coder/dotfiles) -4. [`jetbrains-gateway`](https://registry.coder.com/modules/coder/jetbrains-gateway) +4. [`jetbrains`](https://registry.coder.com/modules/coder/jetbrains) 5. [`jfrog-oauth`](https://registry.coder.com/modules/coder/jfrog-oauth) and [`jfrog-token`](https://registry.coder.com/modules/coder/jfrog-token) 6. [`vault-github`](https://registry.coder.com/modules/coder/vault-github) diff --git a/docs/admin/templates/extending-templates/parameters.md b/docs/admin/templates/extending-templates/parameters.md index 5b380645c1b36..43a477632e7db 100644 --- a/docs/admin/templates/extending-templates/parameters.md +++ b/docs/admin/templates/extending-templates/parameters.md @@ -391,7 +391,7 @@ parameters in one of two ways: Or set the [environment variable](../../setup/index.md), `CODER_EXPERIMENTS=auto-fill-parameters` -## Dynamic Parameters (beta) +## Dynamic Parameters Coder v2.24.0 introduces [Dynamic Parameters](./dynamic-parameters.md) to extend the existing parameter system with conditional form controls, enriched input types, and user identity awareness. diff --git a/docs/admin/templates/extending-templates/prebuilt-workspaces.md b/docs/admin/templates/extending-templates/prebuilt-workspaces.md index 8e61687ce0f01..61734679d4c7d 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. @@ -235,12 +233,18 @@ The system always maintains the desired number of prebuilt workspaces for the ac ### Managing resource quotas -Prebuilt workspaces can be used in conjunction with [resource quotas](../../users/quotas.md). +To help prevent unexpected infrastructure costs, prebuilt workspaces can be used in conjunction with [resource quotas](../../users/quotas.md). Because unclaimed prebuilt workspaces are owned by the `prebuilds` user, you can: 1. Configure quotas for any group that includes this user. 1. Set appropriate limits to balance prebuilt workspace availability with resource constraints. +When prebuilt workspaces are configured for an organization, Coder creates a "prebuilds" group in that organization and adds the prebuilds user to it. This group has a default quota allowance of 0, which you should adjust based on your needs: + +- **Set a quota allowance** on the "prebuilds" group to control how many prebuilt workspaces can be provisioned +- **Monitor usage** to ensure the quota is appropriate for your desired number of prebuilt instances +- **Adjust as needed** based on your template costs and desired prebuilt workspace pool size + If a quota is exceeded, the prebuilt workspace will fail provisioning the same way other workspaces do. ### Template configuration best practices @@ -284,23 +288,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 @@ -313,6 +300,7 @@ Coder provides several metrics to monitor your prebuilt workspaces: - `coderd_prebuilt_workspaces_desired` (gauge): Target number of prebuilt workspaces that should be available. - `coderd_prebuilt_workspaces_running` (gauge): Current number of prebuilt workspaces in a `running` state. - `coderd_prebuilt_workspaces_eligible` (gauge): Current number of prebuilt workspaces eligible to be claimed. +- `coderd_prebuilt_workspace_claim_duration_seconds` ([_native histogram_](https://prometheus.io/docs/specs/native_histograms) support): Time to claim a prebuilt workspace from the prebuild pool. #### Logs diff --git a/docs/admin/templates/index.md b/docs/admin/templates/index.md index cc9a08cf26a25..e5b0314120371 100644 --- a/docs/admin/templates/index.md +++ b/docs/admin/templates/index.md @@ -61,5 +61,6 @@ needs of different teams. changes are reviewed and tested. - [Permissions and Policies](./template-permissions.md): Control who may access and modify your template. +- [External Workspaces](./managing-templates/external-workspaces.md): Learn how to connect your existing infrastructure to Coder workspaces. diff --git a/docs/admin/templates/managing-templates/external-workspaces.md b/docs/admin/templates/managing-templates/external-workspaces.md new file mode 100644 index 0000000000000..25a97db468867 --- /dev/null +++ b/docs/admin/templates/managing-templates/external-workspaces.md @@ -0,0 +1,131 @@ +# External Workspaces + +External workspaces allow you to seamlessly connect externally managed infrastructure as Coder workspaces. This enables you to integrate existing servers, on-premises systems, or any capable machine with the Coder environment, ensuring a smooth and efficient development workflow without requiring Coder to provision additional compute resources. + +## Prerequisites + +- Access to external compute resources that can run the Coder agent: + - **Windows**: amd64 or arm64 architecture + - **Linux**: amd64, arm64, or armv7 architecture + - **macOS**: amd64 or arm64 architecture + - **Examples**: VMs, bare-metal servers, Kubernetes nodes, or any machine meeting the above requirements. +- Networking access to your Coder deployment. +- A workspace template that includes a [`coder_external_agent`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/external_agent) resource. + +We provide an example template on how to set up external workspaces in the [Coder Registry](https://registry.coder.com/templates/coder-labs/externally-managed-workspace) + +## Benefits + +External workspaces offer flexibility and control in complex environments: + +- **Incremental adoption of Coder** + + Integrate with existing infrastructure gradually without needing to migrate everything at once. This is particularly useful when gradually migrating worklods to Coder without refactoring current infrastructure. + +- **Flexibility** + + Attach cloud, hybrid, or on-premises machines as developer workspaces. This enables connecting existing on-premises GPU servers for ML development or bringing manually provisioned VMs in restricted networks under Coder's workspace management. + +- **Separation of concerns** + + Provision compute resources externally (using your existing IaC or manual processes) while managing workspace configuration (apps, scripts) with Terraform. This approach is ideal for running agents in CI pipelines to provision short-lived, externally managed workspaces for testing or build automation. + +## Known limitations + +- **Lifecycle control** + + Start/stop/restart actions in the Coder UI are disabled for external workspaces. +- **No automatic deprovisioning** + + Deleting an external workspace in Coder removes the agent token and record, but does not delete the underlying compute resource. +- **Manual agent management** + + Administrators are responsible for deploying and maintaining agents on external resources. +- **Limited UI indicators** + + External workspaces are marked in the UI, but underlying infrastructure health is not monitored by Coder. + +## When to use it? + +Use external workspaces if: + +- You have compute resources provisioned outside of Coder’s Terraform flows. +- You want to connect specialized or legacy systems to your Coder deployment. +- You are migrating incrementally to Coder and need hybrid support. +- You need finer control over how and where agents run, while still benefiting from Coder’s workspace experience. + +## How to use it? + +You can create and manage external workspaces using either the **CLI** or the **UI**. + +
+ +## CLI + +1. **Create an external workspace** + + ```bash + coder external-workspaces create hello-world \ + --template=externally-managed-workspace -y + ``` + + - Validates that the template includes a `coder_external_agent` resource. + - Once created, the workspace is registered in Coder but marked as requiring an external agent. + +2. **List external workspaces** + + ```bash + coder external-workspaces list + ``` + + Example output: + + ```bash + WORKSPACE TEMPLATE STATUS HEALTHY LAST BUILT CURRENT VERSION OUTDATED + hello-world externally-managed-workspace Started true 15m happy_mendel9 false + ``` + +3. **Retrieve agent connection instructions** + + Use this command to query the script you must run on the external machine: + + ```bash + coder external-workspaces agent-instructions hello-world + ``` + + Example: + + ```bash + Please run the following command to attach external agent to the workspace hello-world: + + curl -fsSL "https:///api/v2/init-script/linux/amd64" | CODER_AGENT_TOKEN="" sh + ``` + + You can also output JSON for automation: + + ```bash + coder external-workspaces agent-instructions hello-world --output=json + ``` + + ```json + { + "workspace_name": "hello-world", + "agent_name": "main", + "auth_type": "token", + "auth_token": "", + "init_script": "curl -fsSL \"https:///api/v2/init-script/linux/arm64\" | CODER_AGENT_TOKEN=\"\" sh" + } + ``` + +## UI + +1. Import the external workspace template (see prerequisites). +2. In the Coder UI, go to **Workspaces → New workspace** and select the imported template. +3. Once the workspace is created, Coder will display **connection details** with the command users need to run on the external machine to start the agent. +4. The workspace will appear in the dashboard, but with the following differences: + - **Start**, **Stop**, and **Restart** actions are disabled. + - Users are provided with instructions for launching the agent manually on the external machine. + +![External Workspace View](../../../images/admin/templates/external-workspace.png) + +
diff --git a/docs/admin/users/index.md b/docs/admin/users/index.md index e86d40a5a1b1f..4f6f5049d34ee 100644 --- a/docs/admin/users/index.md +++ b/docs/admin/users/index.md @@ -173,7 +173,7 @@ coder reset-password ### Resetting a password on Kubernetes ```shell -kubectl exec -it deployment/coder /bin/bash -n coder +kubectl exec -it deployment/coder -n coder -- /bin/bash coder reset-password ``` diff --git a/docs/admin/users/oidc-auth/google.md b/docs/admin/users/oidc-auth/google.md new file mode 100644 index 0000000000000..298497b27bebc --- /dev/null +++ b/docs/admin/users/oidc-auth/google.md @@ -0,0 +1,62 @@ +# Google authentication (OIDC) + +This guide shows how to configure Coder to authenticate users with Google using OpenID Connect (OIDC). + +## Prerequisites + +- A Google Cloud project with the OAuth consent screen configured +- Permission to create OAuth 2.0 Client IDs in Google Cloud + +## Step 1: Create an OAuth client in Google Cloud + +1. Open Google Cloud Console → APIs & Services → Credentials → Create Credentials → OAuth client ID. +2. Application type: Web application. +3. Authorized redirect URIs: add your Coder callback URL: + - `https://coder.example.com/api/v2/users/oidc/callback` +4. Save and note the Client ID and Client secret. + +## Step 2: Configure Coder OIDC for Google + +Set the following environment variables on your Coder deployment and restart Coder: + +```env +CODER_OIDC_ISSUER_URL=https://accounts.google.com +CODER_OIDC_CLIENT_ID= +CODER_OIDC_CLIENT_SECRET= +# Restrict to one or more email domains (comma-separated) +CODER_OIDC_EMAIL_DOMAIN="example.com" +# Standard OIDC scopes for Google +CODER_OIDC_SCOPES=openid,profile,email +# Optional: customize the login button +CODER_OIDC_SIGN_IN_TEXT="Sign in with Google" +CODER_OIDC_ICON_URL=/icon/google.svg +``` + +> [!NOTE] +> The redirect URI must exactly match what you configured in Google Cloud. + +## Enable refresh tokens (recommended) + +Google uses auth URL parameters to issue refresh tokens. Configure: + +```env +# Keep standard scopes +CODER_OIDC_SCOPES=openid,profile,email +# Add Google-specific auth URL params +CODER_OIDC_AUTH_URL_PARAMS='{"access_type": "offline", "prompt": "consent"}' +``` + +After changing settings, users must log out and back in once to obtain refresh tokens. + +Learn more in [Configure OIDC refresh tokens](./refresh-tokens.md). + +## Troubleshooting + +- "invalid redirect_uri": ensure the redirect URI in Google Cloud matches `https:///api/v2/users/oidc/callback`. +- Domain restriction: if users from unexpected domains can log in, verify `CODER_OIDC_EMAIL_DOMAIN`. +- Claims: to inspect claims returned by Google, see guidance in the [OIDC overview](./index.md#oidc-claims). + +## See also + +- [OIDC overview](./index.md) +- [Configure OIDC refresh tokens](./refresh-tokens.md) diff --git a/docs/ai-coder/custom-agents.md b/docs/ai-coder/custom-agents.md index 6709251049efa..6ab68d949a69b 100644 --- a/docs/ai-coder/custom-agents.md +++ b/docs/ai-coder/custom-agents.md @@ -1,6 +1,6 @@ # Custom Agents -Custom agents beyond the ones listed in the [Coder registry](https://registry.coder.com/modules?tag=agent) can be used with Coder Tasks. +Custom agents beyond the ones listed in the [Coder registry](https://registry.coder.com/modules?search=tag%3Aagent) can be used with Coder Tasks. ## Prerequisites diff --git a/docs/ai-coder/mcp-server.md b/docs/ai-coder/mcp-server.md index 29d602030ab58..fdfadb4117d36 100644 --- a/docs/ai-coder/mcp-server.md +++ b/docs/ai-coder/mcp-server.md @@ -1,6 +1,6 @@ # MCP Server -Power users can configure Claude Desktop, Cursor, or other external agents to interact with Coder in order to: +Power users can configure [claude.ai](https://claude.ai), Claude Desktop, Cursor, or other external agents to interact with Coder in order to: - List workspaces - Create/start/stop workspaces @@ -12,6 +12,8 @@ Power users can configure Claude Desktop, Cursor, or other external agents to in In this model, any custom agent could interact with a remote Coder workspace, or Coder can be used in a remote pipeline or a larger workflow. +## Local MCP server + The Coder CLI has options to automatically configure MCP servers for you. On your local machine, run the following command: ```sh @@ -30,4 +32,27 @@ coder exp mcp server ``` > [!NOTE] -> The MCP server is authenticated with the same identity as your Coder CLI and can perform any action on the user's behalf. Fine-grained permissions and a remote MCP server are in development. [Contact us](https://coder.com/contact) if this use case is important to you. +> The MCP server is authenticated with the same identity as your Coder CLI and can perform any action on the user's behalf. Fine-grained permissions are in development. [Contact us](https://coder.com/contact) if this use case is important to you. + +## Remote MCP server + +Coder can expose an MCP server via HTTP. This is useful for connecting web-based agents, like https://claude.ai/, to Coder. This is an experimental feature and is subject to change. + +To enable this feature, activate the `oauth2` and `mcp-server-http` experiments using an environment variable or a CLI flag: + +```sh +CODER_EXPERIMENTS="oauth2,mcp-server-http" coder server +# or +coder server --experiments=oauth2,mcp-server-http +``` + +The Coder server will expose the MCP server at: + +```txt +https://coder.example.com/api/experimental/mcp/http +``` + +> [!NOTE] +> At this time, the remote MCP server is not compatible with web-based ChatGPT. + +Users can authenticate applications to use the remote MCP server with [OAuth2](../admin/integrations/oauth2-provider.md). An authenticated application can perform any action on the user's behalf. Fine-grained permissions are in development. diff --git a/docs/ai-coder/tasks.md b/docs/ai-coder/tasks.md index 43c4becdf8be1..ef47a6b3fb874 100644 --- a/docs/ai-coder/tasks.md +++ b/docs/ai-coder/tasks.md @@ -82,6 +82,10 @@ If a workspace app has the special `"preview"` slug, a navbar will appear above We plan to introduce more customization options in future releases. +## Automatically name your tasks + +Coder can automatically generate a name your tasks if you set the `ANTHROPIC_API_KEY` environment variable on the Coder server. Otherwise, tasks will be given randomly generated names. + ## Opting out of Tasks If you tried Tasks and decided you don't want to use it, you can hide the Tasks tab by starting `coder server` with the `CODER_HIDE_AI_TASKS=true` environment variable or the `--hide-ai-tasks` flag. diff --git a/docs/changelogs/v0.27.0.md b/docs/changelogs/v0.27.0.md index a37997f942f23..5e06e5a028c3c 100644 --- a/docs/changelogs/v0.27.0.md +++ b/docs/changelogs/v0.27.0.md @@ -25,7 +25,7 @@ Agent logs can be pushed after a workspace has started (#8528) - Template version messages (#8435) 252772262-087f1338-f1e2-49fb-81f2-358070a46484 - TTL and max TTL validation increased to 30 days (#8258) -- [Self-hosted docs](https://coder.com/docs/install/offline#offline-docs): +- [Self-hosted docs](https://coder.com/docs/install/airgap#airgap-docs): Host your own copy of Coder's documentation in your own environment (#8527) (#8601) - Add custom coder bin path for `config-ssh` (#8425) diff --git a/docs/changelogs/v2.8.0.md b/docs/changelogs/v2.8.0.md index e7804ab57b3db..1b17ba3a7343f 100644 --- a/docs/changelogs/v2.8.0.md +++ b/docs/changelogs/v2.8.0.md @@ -83,7 +83,7 @@ ### Documentation -- Using coder modules in offline deployments (#11788) (@matifali) +- Using coder modules in air-gapped deployments (#11788) (@matifali) - Simplify JFrog integration docs (#11787) (@matifali) - Add guide for azure federation (#11864) (@ericpaulsen) - Fix example template README 404s and semantics (#11903) (@ericpaulsen) diff --git a/docs/images/admin/templates/extend-templates/dyn-params/dynamic-parameters-ga-settings.png b/docs/images/admin/templates/extend-templates/dyn-params/dynamic-parameters-ga-settings.png new file mode 100644 index 0000000000000..14e84ccdef6dc Binary files /dev/null and b/docs/images/admin/templates/extend-templates/dyn-params/dynamic-parameters-ga-settings.png differ diff --git a/docs/images/admin/templates/external-workspace.png b/docs/images/admin/templates/external-workspace.png new file mode 100644 index 0000000000000..73f26f403925e Binary files /dev/null and b/docs/images/admin/templates/external-workspace.png differ diff --git a/docs/images/architecture-multi-region.png b/docs/images/architecture-multi-region.png index 41205f401b64c..904b769d64237 100644 Binary files a/docs/images/architecture-multi-region.png and b/docs/images/architecture-multi-region.png differ diff --git a/docs/images/architecture-single-region.png b/docs/images/architecture-single-region.png index 3400a6ebc2809..cdca579fa5e12 100644 Binary files a/docs/images/architecture-single-region.png and b/docs/images/architecture-single-region.png differ 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/install/offline.md b/docs/install/airgap.md similarity index 97% rename from docs/install/offline.md rename to docs/install/airgap.md index 289780526f76a..cb2f2340a63cd 100644 --- a/docs/install/offline.md +++ b/docs/install/airgap.md @@ -1,12 +1,10 @@ -# Offline Deployments - -All Coder features are supported in offline / behind firewalls / in air-gapped -environments. However, some changes to your configuration are necessary. +# Air-gapped Deployments +All Coder features are supported in air-gapped / behind firewalls / disconnected / offline. This is a general comparison. Keep reading for a full tutorial running Coder -offline with Kubernetes or Docker. +air-gapped with Kubernetes or Docker. -| | Public deployments | Offline deployments | +| | Public deployments | Air-gapped deployments | |--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Terraform binary | By default, Coder downloads Terraform binary from [releases.hashicorp.com](https://releases.hashicorp.com) | Terraform binary must be included in `PATH` for the VM or container image. [Supported versions](https://github.com/coder/coder/blob/main/provisioner/terraform/install.go#L23-L24) | | Terraform registry | Coder templates will attempt to download providers from [registry.terraform.io](https://registry.terraform.io) or [custom source addresses](https://developer.hashicorp.com/terraform/language/providers/requirements#source-addresses) specified in each template | [Custom source addresses](https://developer.hashicorp.com/terraform/language/providers/requirements#source-addresses) can be specified in each Coder template, or a custom registry/mirror can be used. More details below | @@ -16,7 +14,7 @@ offline with Kubernetes or Docker. | Telemetry | Telemetry is on by default, and [can be disabled](../reference/cli/server.md#--telemetry) | Telemetry [can be disabled](../reference/cli/server.md#--telemetry) | | Update check | By default, Coder checks for updates from [GitHub releases](https://github.com/coder/coder/releases) | Update checks [can be disabled](../reference/cli/server.md#--update-check) | -## Offline container images +## Air-gapped container images The following instructions walk you through how to build a custom Coder server image for Docker or Kubernetes @@ -214,9 +212,9 @@ coder:
-## Offline docs +## Air-gapped docs -Coder also provides offline documentation in case you want to host it on your +Coder also provides air-gapped documentation in case you want to host it on your own server. The docs are exported as static files that you can host on any web server, as demonstrated in the example below: diff --git a/docs/install/cloud/compute-engine.md b/docs/install/cloud/compute-engine.md index 49572059afc60..671a890125392 100644 --- a/docs/install/cloud/compute-engine.md +++ b/docs/install/cloud/compute-engine.md @@ -10,9 +10,12 @@ Google Cloud Platform project. ## Launch a Coder instance from the Google Cloud Marketplace -We publish an Ubuntu 22.04 VM image with Coder and Docker pre-installed. Search -for `Coder v2` in the GCP Marketplace or -[use direct link](https://console.cloud.google.com/marketplace/product/coder-enterprise-market-public/coder-v2). +We publish an Ubuntu 22.04 VM image with Coder and Docker pre-installed. + +Two SKU's are available via the Google Cloud Marketplace: + +1. [License purchase via Google Cloud Marketplace](https://console.cloud.google.com/marketplace/product/coder-enterprise-market-public/coder-gcmp?inv=1&invt=Ab45rg&project=secret-beacon-468405-p5) +2. [A solution to deploy VM's on GCP (Bring Your Own License)](https://console.cloud.google.com/marketplace/product/workspan-public-422119/coder?inv=1&invt=Ab45rg&project=secret-beacon-468405-p5) ![Coder on GCP Marketplace](../../images/platforms/gcp/marketplace.png) diff --git a/docs/install/docker.md b/docs/install/docker.md index 042d28e25e5a5..de9799ef210bf 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -50,14 +50,14 @@ docker run --rm -it \ ## Install Coder via `docker compose` Coder's publishes a -[docker-compose example](https://github.com/coder/coder/blob/main/docker-compose.yaml) +[docker compose example](https://github.com/coder/coder/blob/main/compose.yaml) which includes an PostgreSQL container and volume. 1. Make sure you have [Docker Compose](https://docs.docker.com/compose/install/) installed. 1. Download the - [`docker-compose.yaml`](https://github.com/coder/coder/blob/main/docker-compose.yaml) + [`docker-compose.yaml`](https://github.com/coder/coder/blob/main/compose.yaml) file. 1. Update `group_add:` in `docker-compose.yaml` with the `gid` of `docker` diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index 72c51e0da3e8c..0b9c506878517 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -135,9 +135,9 @@ We support two release channels: mainline and stable - read the helm install coder coder-v2/coder \ --namespace coder \ --values values.yaml \ - --version 2.23.1 + --version 2.25.0 ``` - + - **OCI Registry** @@ -146,7 +146,7 @@ We support two release channels: mainline and stable - read the helm install coder oci://ghcr.io/coder/chart/coder \ --namespace coder \ --values values.yaml \ - --version 2.23.1 + --version 2.25.0 ``` - **Stable** Coder release: @@ -159,9 +159,9 @@ We support two release channels: mainline and stable - read the helm install coder coder-v2/coder \ --namespace coder \ --values values.yaml \ - --version 2.22.1 + --version 2.25.0 ``` - + - **OCI Registry** @@ -170,7 +170,7 @@ We support two release channels: mainline and stable - read the helm install coder oci://ghcr.io/coder/chart/coder \ --namespace coder \ --values values.yaml \ - --version 2.22.1 + --version 2.25.0 ``` You can watch Coder start up by running `kubectl get pods -n coder`. Once Coder diff --git a/docs/install/releases/index.md b/docs/install/releases/index.md index 577939c05dde9..83efc16aefe17 100644 --- a/docs/install/releases/index.md +++ b/docs/install/releases/index.md @@ -55,15 +55,15 @@ pages. ## Release schedule -| Release name | Release Date | Status | Latest Release | -|------------------------------------------------|-------------------|------------------|----------------------------------------------------------------| -| [2.19](https://coder.com/changelog/coder-2-19) | February 04, 2025 | Not Supported | [v2.19.3](https://github.com/coder/coder/releases/tag/v2.19.3) | -| [2.20](https://coder.com/changelog/coder-2-20) | March 04, 2025 | Not Supported | [v2.20.3](https://github.com/coder/coder/releases/tag/v2.20.3) | -| [2.21](https://coder.com/changelog/coder-2-21) | April 02, 2025 | Not Supported | [v2.21.3](https://github.com/coder/coder/releases/tag/v2.21.3) | -| [2.22](https://coder.com/changelog/coder-2-22) | May 16, 2025 | Security Support | [v2.22.1](https://github.com/coder/coder/releases/tag/v2.22.1) | -| [2.23](https://coder.com/changelog/coder-2-23) | June 03, 2025 | Stable | [v2.23.2](https://github.com/coder/coder/releases/tag/v2.23.2) | -| [2.24](https://coder.com/changelog/coder-2-24) | July 01, 2025 | Mainline | [v2.24.1](https://github.com/coder/coder/releases/tag/v2.24.1) | -| 2.25 | | Not Released | N/A | +| Release name | Release Date | Status | Latest Release | +|------------------------------------------------|-----------------|------------------|----------------------------------------------------------------| +| [2.20](https://coder.com/changelog/coder-2-20) | March 04, 2025 | Not Supported | [v2.20.3](https://github.com/coder/coder/releases/tag/v2.20.3) | +| [2.21](https://coder.com/changelog/coder-2-21) | April 02, 2025 | Not Supported | [v2.21.3](https://github.com/coder/coder/releases/tag/v2.21.3) | +| [2.22](https://coder.com/changelog/coder-2-22) | May 16, 2025 | Security Support | [v2.22.1](https://github.com/coder/coder/releases/tag/v2.22.1) | +| [2.23](https://coder.com/changelog/coder-2-23) | June 03, 2025 | Security Support | [v2.23.2](https://github.com/coder/coder/releases/tag/v2.23.4) | +| [2.24](https://coder.com/changelog/coder-2-24) | July 01, 2025 | Stable | [v2.24.2](https://github.com/coder/coder/releases/tag/v2.24.2) | +| [2.24](https://coder.com/changelog/coder-2-24) | August 05, 2025 | Mainline | [v2.25.0](https://github.com/coder/coder/releases/tag/v2.25.0) | +| 2.25 | | Not Released | N/A | > [!TIP] diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md index 7a94b22b25f6c..c04bd6e9c2723 100644 --- a/docs/install/uninstall.md +++ b/docs/install/uninstall.md @@ -74,17 +74,17 @@ performing the following step or copying the directory to another location.
-## macOS +## Linux ```shell -rm -rf ~/Library/Application\ Support/coderv2 +rm -rf ~/.config/coderv2 +rm -rf ~/.cache/coder ``` -## Linux +## macOS ```shell -rm -rf ~/.config/coderv2 -rm -rf ~/.cache/coder +rm -rf ~/Library/Application\ Support/coderv2 ``` ## Windows diff --git a/docs/manifest.json b/docs/manifest.json index 189614f9191d1..9359fb6f1da33 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" } ] } @@ -148,9 +154,9 @@ ] }, { - "title": "Offline Deployments", - "description": "Run Coder in offline / air-gapped environments", - "path": "./install/offline.md", + "title": "Air-gapped Deployments", + "description": "Run Coder in air-gapped / disconnected / offline environments", + "path": "./install/airgap.md", "icon_path": "./images/icons/lan.svg" }, { @@ -416,6 +422,11 @@ "description": "Configure OpenID Connect authentication with identity providers like Okta or Active Directory", "path": "./admin/users/oidc-auth/index.md", "children": [ + { + "title": "Google", + "description": "Configure Google as an OIDC provider", + "path": "./admin/users/oidc-auth/google.md" + }, { "title": "Configure OIDC refresh tokens", "description": "How to configure OIDC refresh tokens", @@ -526,6 +537,12 @@ "title": "Workspace Scheduling", "description": "Learn how to control how workspaces are started and stopped", "path": "./admin/templates/managing-templates/schedule.md" + }, + { + "title": "External Workspaces", + "description": "Learn how to manage external workspaces", + "path": "./admin/templates/managing-templates/external-workspaces.md", + "state": ["premium", "early access"] } ] }, @@ -547,8 +564,7 @@ { "title": "Dynamic Parameters", "description": "Conditional, identity-aware parameter syntax for advanced users.", - "path": "./admin/templates/extending-templates/dynamic-parameters.md", - "state": ["beta"] + "path": "./admin/templates/extending-templates/dynamic-parameters.md" }, { "title": "Prebuilt workspaces", @@ -710,14 +726,19 @@ "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" }, { "title": "Hashicorp Vault", "description": "Integrate Coder with Hashicorp Vault", "path": "./admin/integrations/vault.md" + }, + { + "title": "OAuth2 Provider", + "description": "Use Coder as an OAuth2 provider", + "path": "./admin/integrations/oauth2-provider.md" } ] }, @@ -1162,6 +1183,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/members.md b/docs/reference/api/members.md index 4b0adbf45e338..5a6bd2c861bac 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -213,7 +213,9 @@ Status Code **200** | `resource_type` | `system` | | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | +| `resource_type` | `usage_event` | | `resource_type` | `user` | +| `resource_type` | `user_secret` | | `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | @@ -383,7 +385,9 @@ Status Code **200** | `resource_type` | `system` | | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | +| `resource_type` | `usage_event` | | `resource_type` | `user` | +| `resource_type` | `user_secret` | | `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | @@ -553,7 +557,9 @@ Status Code **200** | `resource_type` | `system` | | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | +| `resource_type` | `usage_event` | | `resource_type` | `user` | +| `resource_type` | `user_secret` | | `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | @@ -692,7 +698,9 @@ Status Code **200** | `resource_type` | `system` | | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | +| `resource_type` | `usage_event` | | `resource_type` | `user` | +| `resource_type` | `user_secret` | | `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | @@ -1053,7 +1061,9 @@ Status Code **200** | `resource_type` | `system` | | `resource_type` | `tailnet_coordinator` | | `resource_type` | `template` | +| `resource_type` | `usage_event` | | `resource_type` | `user` | +| `resource_type` | `user_secret` | | `resource_type` | `webpush_subscription` | | `resource_type` | `workspace` | | `resource_type` | `workspace_agent_devcontainers` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 581743ea7cc22..99e852b3fe4b9 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 @@ -6378,7 +6394,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `system` | | `tailnet_coordinator` | | `template` | +| `usage_event` | | `user` | +| `user_secret` | | `webpush_subscription` | | `workspace` | | `workspace_agent_devcontainers` | @@ -7612,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": [ @@ -7676,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 | | | @@ -8060,12 +8080,77 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------| -| `group_perms` | object | false | | Group perms should be a mapping of group ID to role. | -| » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | -| `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 | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `group_perms` | object | false | | Group perms is a mapping from valid group UUIDs to the template role they should be granted. To remove a group from the template, use "" as the role (available as a constant named codersdk.TemplateRoleDeleted) | +| » `[any property]` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +| `user_perms` | object | false | | User perms is a mapping from valid user UUIDs to the template role they should be granted. To remove a user from the template, use "" as the role (available as a constant named codersdk.TemplateRoleDeleted) | +| » `[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 @@ -8166,12 +8251,12 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|--------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| -| `group_roles` | object | false | | | -| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | -| `user_roles` | object | false | | Keys must be valid UUIDs. To remove a user/group from the ACL use "" as the role name (available as a constant named `codersdk.WorkspaceRoleDeleted`) | -| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|--------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `group_roles` | object | false | | Group roles is a mapping from valid group UUIDs to the workspace role they should be granted. To remove a group from the workspace, use "" as the role (available as a constant named codersdk.WorkspaceRoleDeleted) | +| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| `user_roles` | object | false | | User roles is a mapping from valid user UUIDs to the workspace role they should be granted. To remove a user from the workspace, use "" as the role (available as a constant named codersdk.WorkspaceRoleDeleted) | +| » `[any property]` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | ## codersdk.UpdateWorkspaceAutomaticUpdatesRequest @@ -8811,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", @@ -9072,6 +9158,58 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `automatic_updates` | `always` | | `automatic_updates` | `never` | +## codersdk.WorkspaceACL + +```json +{ + "group": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "role": "admin", + "source": "user", + "total_member_count": 0 + } + ], + "users": [ + { + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "role": "admin", + "username": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|-------------------------------------------------------------|----------|--------------|-------------| +| `group` | array of [codersdk.WorkspaceGroup](#codersdkworkspacegroup) | false | | | +| `users` | array of [codersdk.WorkspaceUser](#codersdkworkspaceuser) | false | | | + ## codersdk.WorkspaceAgent ```json @@ -9921,6 +10059,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", @@ -10130,6 +10269,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 | | | @@ -10281,6 +10421,63 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `stopped` | integer | false | | | | `tx_bytes` | integer | false | | | +## codersdk.WorkspaceGroup + +```json +{ + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "role": "admin", + "source": "user", + "total_member_count": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------------------|-------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `avatar_url` | string | false | | | +| `display_name` | string | false | | | +| `id` | string | false | | | +| `members` | array of [codersdk.ReducedUser](#codersdkreduceduser) | false | | | +| `name` | string | false | | | +| `organization_display_name` | string | false | | | +| `organization_id` | string | false | | | +| `organization_name` | string | false | | | +| `quota_allowance` | integer | false | | | +| `role` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | | +| `total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. | + +#### Enumerated Values + +| Property | Value | +|----------|---------| +| `role` | `admin` | +| `role` | `use` | + ## codersdk.WorkspaceHealth ```json @@ -10627,6 +10824,33 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `stop` | | `delete` | +## codersdk.WorkspaceUser + +```json +{ + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "role": "admin", + "username": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|--------------------------------------------------|----------|--------------|-------------| +| `avatar_url` | string | false | | | +| `id` | string | true | | | +| `role` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | | +| `username` | string | true | | | + +#### Enumerated Values + +| Property | Value | +|----------|---------| +| `role` | `admin` | +| `role` | `use` | + ## codersdk.WorkspacesResponse ```json @@ -10669,6 +10893,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/users.md b/docs/reference/api/users.md index 43842fde6539b..bef79ddaad4e3 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -919,10 +919,10 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ ### Parameters -| Name | In | Type | Required | Description | -|---------|------|--------------|----------|----------------------| -| `user` | path | string | true | User ID, name, or me | -| `keyid` | path | string(uuid) | true | Key ID | +| Name | In | Type | Required | Description | +|---------|------|----------------|----------|----------------------| +| `user` | path | string | true | User ID, name, or me | +| `keyid` | path | string(string) | true | Key ID | ### Example responses @@ -965,10 +965,10 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ ### Parameters -| Name | In | Type | Required | Description | -|---------|------|--------------|----------|----------------------| -| `user` | path | string | true | User ID, name, or me | -| `keyid` | path | string(uuid) | true | Key ID | +| Name | In | Type | Required | Description | +|---------|------|----------------|----------|----------------------| +| `user` | path | string | true | User ID, name, or me | +| `keyid` | path | string(string) | true | Key ID | ### Responses diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 70338fdeb1814..01e9aee949b4f 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", @@ -1514,6 +1519,80 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaces/{workspace} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get workspace ACLs + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/acl \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaces/{workspace}/acl` + +### Parameters + +| Name | In | Type | Required | Description | +|-------------|------|--------------|----------|--------------| +| `workspace` | path | string(uuid) | true | Workspace ID | + +### Example responses + +> 200 Response + +```json +{ + "group": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "role": "admin", + "source": "user", + "total_member_count": 0 + } + ], + "users": [ + { + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "role": "admin", + "username": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceACL](schemas.md#codersdkworkspaceacl) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Update workspace ACL ### Code samples @@ -1699,6 +1778,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/reference/cli/schedule_extend.md b/docs/reference/cli/schedule_extend.md index e4b696ad5c4a7..aa4540b4d7d31 100644 --- a/docs/reference/cli/schedule_extend.md +++ b/docs/reference/cli/schedule_extend.md @@ -16,7 +16,7 @@ coder schedule extend ## Description ```console - +Extends the workspace deadline. * The new stop time is calculated from *now*. * The new stop time must be at least 30 minutes in the future. * The workspace template may restrict the maximum workspace runtime. diff --git a/docs/tutorials/faqs.md b/docs/tutorials/faqs.md index bd386f81288a8..a2f350b45a734 100644 --- a/docs/tutorials/faqs.md +++ b/docs/tutorials/faqs.md @@ -332,7 +332,7 @@ References: ## Can I run Coder in an air-gapped or offline mode? (no Internet)? Yes, Coder can be deployed in -[air-gapped or offline mode](../install/offline.md). +[air-gapped or offline mode](../install/airgap.md). Our product bundles with the Terraform binary so assume access to terraform.io during installation. The docs outline rebuilding the Coder container with diff --git a/docs/tutorials/reverse-proxy-caddy.md b/docs/tutorials/reverse-proxy-caddy.md index d915687cad428..741f3842f10fb 100644 --- a/docs/tutorials/reverse-proxy-caddy.md +++ b/docs/tutorials/reverse-proxy-caddy.md @@ -6,12 +6,12 @@ certificates, you'll need a domain name that resolves to your Caddy server. ## Getting started -### With docker-compose +### With `docker compose` 1. [Install Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/) -2. Create a `docker-compose.yaml` file and add the following: +2. Create a `compose.yaml` file and add the following: ```yaml services: @@ -212,7 +212,7 @@ Caddy modules. - Docker: [Build an custom Caddy image](https://github.com/docker-library/docs/tree/master/caddy#adding-custom-caddy-modules) with the module for your DNS provider. Be sure to reference the new image - in the `docker-compose.yaml`. + in the `compose.yaml`. - Standalone: [Download a custom Caddy build](https://caddyserver.com/download) with the 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/docs/user-guides/desktop/index.md b/docs/user-guides/desktop/index.md index 116f7d4d6de69..d5f5e5aabb3c2 100644 --- a/docs/user-guides/desktop/index.md +++ b/docs/user-guides/desktop/index.md @@ -8,12 +8,6 @@ Coder Desktop requires a Coder deployment running [v2.20.0](https://github.com/c ## Install Coder Desktop -> [!IMPORTANT] -> Coder Desktop can't connect through a corporate VPN. -> -> Due to a [known issue](#coder-desktop-cant-connect-through-another-vpn), -> if your Coder deployment requires that you connect through a corporate VPN, Desktop will timeout when it tries to connect. -
You can install Coder Desktop on macOS or Windows. @@ -144,9 +138,7 @@ To avoid system VPN configuration conflicts, only one copy of `Coder Desktop.app If the logged in Coder deployment requires a corporate VPN to connect, Coder Connect can't establish communication through the VPN, and will time out. -This is due to known issues with [macOS](https://github.com/coder/coder-desktop-macos/issues/201) and -[Windows](https://github.com/coder/coder-desktop-windows/issues/147) networking. -A resolution is in progress. +This issue has been fixed in Coder v2.24.3 and later. For macOS clients, Coder Desktop v0.8.0 or later is also required. ## Next Steps diff --git a/dogfood/coder-envbuilder/main.tf b/dogfood/coder-envbuilder/main.tf index fb57bebffa9a1..cd316100fea8e 100644 --- a/dogfood/coder-envbuilder/main.tf +++ b/dogfood/coder-envbuilder/main.tf @@ -110,20 +110,20 @@ data "coder_workspace_owner" "me" {} module "slackme" { source = "dev.registry.coder.com/coder/slackme/coder" - version = "1.0.30" + version = "1.0.31" agent_id = coder_agent.dev.id auth_provider_id = "slack" } module "dotfiles" { source = "dev.registry.coder.com/coder/dotfiles/coder" - version = "1.2.0" + version = "1.2.1" agent_id = coder_agent.dev.id } module "personalize" { source = "dev.registry.coder.com/coder/personalize/coder" - version = "1.0.30" + version = "1.0.31" agent_id = coder_agent.dev.id } @@ -135,26 +135,24 @@ module "code-server" { auto_install_extensions = true } -module "jetbrains_gateway" { - source = "dev.registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.1.1" - agent_id = coder_agent.dev.id - agent_name = "dev" - folder = local.repo_dir - jetbrains_ides = ["GO", "WS"] - default = "GO" - latest = true +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "dev.registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" + agent_id = coder_agent.dev.id + agent_name = "dev" + folder = local.repo_dir } module "filebrowser" { source = "dev.registry.coder.com/coder/filebrowser/coder" - version = "1.1.1" + version = "1.1.2" agent_id = coder_agent.dev.id } module "coder-login" { source = "dev.registry.coder.com/coder/coder-login/coder" - version = "1.0.30" + version = "1.1.0" agent_id = coder_agent.dev.id } @@ -448,4 +446,4 @@ resource "coder_metadata" "container_info" { key = "region" value = data.coder_parameter.region.option[index(data.coder_parameter.region.option.*.value, data.coder_parameter.region.value)].name } -} +} \ No newline at end of file diff --git a/dogfood/coder/Dockerfile b/dogfood/coder/Dockerfile index dbafcd7add427..b0e0e4b3f0cfd 100644 --- a/dogfood/coder/Dockerfile +++ b/dogfood/coder/Dockerfile @@ -4,14 +4,15 @@ FROM rust:slim@sha256:3f391b0678a6e0c88fd26f13e399c9c515ac47354e3cadfee7daee3b21 ENV CARGO_INSTALL_ROOT=/tmp/ # Use more reliable mirrors for Debian packages RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.edge.kernel.org/debian|g' /etc/apt/sources.list && \ - apt-get update || true + apt-get update || true RUN apt-get update && apt-get install -y libssl-dev openssl pkg-config build-essential RUN cargo install jj-cli typos-cli watchexec-cli FROM ubuntu:jammy@sha256:0e5e4a57c2499249aafc3b40fcd541e9a456aab7296681a3994d631587203f97 AS go # Install Go manually, so that we can control the version -ARG GO_VERSION=1.24.4 +ARG GO_VERSION=1.24.6 +ARG GO_CHECKSUM="bbca37cc395c974ffa4893ee35819ad23ebb27426df87af92e93a9ec66ef8712" # Boring Go is needed to build FIPS-compliant binaries. RUN apt-get update && \ @@ -19,6 +20,7 @@ RUN apt-get update && \ curl --silent --show-error --location \ "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \ -o /usr/local/go.tar.gz && \ + echo "$GO_CHECKSUM /usr/local/go.tar.gz" | sha256sum -c && \ rm -rf /var/lib/apt/lists/* ENV PATH=$PATH:/usr/local/go/bin @@ -29,6 +31,7 @@ RUN apt-get update && \ mkdir --parents /usr/local/go && \ tar --extract --gzip --directory=/usr/local/go --file=/usr/local/go.tar.gz --strip-components=1 && \ mkdir --parents "$GOPATH" && \ + go env -w GOSUMDB=sum.golang.org && \ # moq for Go tests. go install github.com/matryer/moq@v0.2.3 && \ # swag for Swagger doc generation @@ -38,7 +41,7 @@ RUN apt-get update && \ # goimports for updating imports go install golang.org/x/tools/cmd/goimports@v0.31.0 && \ # protoc-gen-go is needed to build sysbox from source - go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 && \ + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30.0 && \ # drpc support for v2 go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34 && \ # migrate for migration support for v2 @@ -123,8 +126,8 @@ RUN mkdir -p /etc/sudoers.d && \ # Use more reliable mirrors for Ubuntu packages RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/ubuntu/|g' /etc/apt/sources.list && \ - sed -i 's|http://security.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/ubuntu/|g' /etc/apt/sources.list && \ - apt-get update --quiet && apt-get install --yes \ + sed -i 's|http://security.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/ubuntu/|g' /etc/apt/sources.list && \ + apt-get update --quiet && apt-get install --yes \ ansible \ apt-transport-https \ apt-utils \ @@ -206,7 +209,7 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u # NOTE: In scripts/Dockerfile.base we specifically install Terraform version 1.12.2. # Installing the same version here to match. -RUN wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.12.2/terraform_1.12.2_linux_amd64.zip" && \ +RUN wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.13.0/terraform_1.13.0_linux_amd64.zip" && \ unzip /tmp/terraform.zip -d /usr/local/bin && \ rm -f /tmp/terraform.zip && \ chmod +x /usr/local/bin/terraform && \ @@ -242,7 +245,7 @@ RUN DOCTL_VERSION=$(curl -s "https://api.github.com/repos/digitalocean/doctl/rel ARG NVM_INSTALL_SHA=bdea8c52186c4dd12657e77e7515509cda5bf9fa5a2f0046bce749e62645076d # Install frontend utilities ENV NVM_DIR=/usr/local/nvm -ENV NODE_VERSION=20.16.0 +ENV NODE_VERSION=20.19.4 RUN mkdir -p $NVM_DIR RUN curl -o nvm_install.sh https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh && \ echo "${NVM_INSTALL_SHA} nvm_install.sh" | sha256sum -c && \ @@ -252,9 +255,9 @@ RUN source $NVM_DIR/nvm.sh && \ nvm install $NODE_VERSION && \ nvm use $NODE_VERSION ENV PATH=$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH -# Allow patch updates for npm and pnpm -RUN npm install -g npm@10.8.1 --integrity=sha512-Dp1C6SvSMYQI7YHq/y2l94uvI+59Eqbu1EpuKQHQ8p16txXRuRit5gH3Lnaagk2aXDIjg/Iru9pd05bnneKgdw== -RUN npm install -g pnpm@9.15.1 --integrity=sha512-GstWXmGT7769p3JwKVBGkVDPErzHZCYudYfnHRncmKQj3/lTblfqRMSb33kP9pToPCe+X6oj1n4MAztYO+S/zw== +RUN corepack enable && \ + corepack prepare npm@10.8.1 --activate && \ + corepack prepare pnpm@10.14.0 --activate RUN pnpx playwright@1.47.0 install --with-deps chromium diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf index 3531257565f6b..40f02764da46d 100644 --- a/dogfood/coder/main.tf +++ b/dogfood/coder/main.tf @@ -38,6 +38,7 @@ locals { repo_base_dir = data.coder_parameter.repo_base_dir.value == "~" ? "/home/coder" : replace(data.coder_parameter.repo_base_dir.value, "/^~\\//", "/home/coder/") repo_dir = replace(try(module.git-clone[0].repo_dir, ""), "/^~\\//", "/home/coder/") container_name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + has_ai_prompt = data.coder_parameter.ai_prompt.value != "" } data "coder_workspace_preset" "cpt" { @@ -150,6 +151,13 @@ data "coder_parameter" "image_type" { } } +variable "anthropic_api_key" { + type = string + description = "The API key used to authenticate with the Anthropic API." + default = "" + sensitive = true +} + locals { default_regions = { // keys should match group names @@ -167,7 +175,6 @@ locals { ], ["us-pittsburgh"])[0] } - data "coder_parameter" "region" { type = "string" name = "Region" @@ -242,6 +249,14 @@ data "coder_parameter" "devcontainer_autostart" { mutable = true } +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Prompt for Claude Code" + mutable = true // Workaround for issue with claiming a prebuild from a preset that does not include this parameter. +} + provider "docker" { host = lookup(local.docker_host, data.coder_parameter.region.value) } @@ -261,10 +276,78 @@ 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" - version = "1.0.30" + version = "1.0.31" agent_id = coder_agent.dev.id auth_provider_id = "slack" } @@ -272,14 +355,21 @@ module "slackme" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "dev.registry.coder.com/coder/dotfiles/coder" - version = "1.2.0" + version = "1.2.1" + 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" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.dev.id url = "https://github.com/coder/coder" base_dir = local.repo_base_dir @@ -288,12 +378,12 @@ module "git-clone" { module "personalize" { count = data.coder_workspace.me.start_count source = "dev.registry.coder.com/coder/personalize/coder" - version = "1.0.30" + version = "1.0.31" agent_id = coder_agent.dev.id } 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 @@ -303,9 +393,9 @@ 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" + version = "1.4.1" agent_id = coder_agent.dev.id folder = local.repo_dir extensions = ["github.copilot"] @@ -315,9 +405,9 @@ 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.0" + version = "1.0.3" agent_id = coder_agent.dev.id agent_name = "dev" folder = local.repo_dir @@ -327,7 +417,7 @@ module "jetbrains" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "dev.registry.coder.com/coder/filebrowser/coder" - version = "1.1.1" + version = "1.1.2" agent_id = coder_agent.dev.id agent_name = "dev" } @@ -335,37 +425,37 @@ module "filebrowser" { module "coder-login" { count = data.coder_workspace.me.start_count source = "dev.registry.coder.com/coder/coder-login/coder" - version = "1.0.30" + version = "1.1.0" agent_id = coder_agent.dev.id } 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.2.1" + version = "1.3.2" agent_id = coder_agent.dev.id folder = local.repo_dir } 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" + version = "1.2.0" agent_id = coder_agent.dev.id folder = local.repo_dir } 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.0.0" + version = "1.1.0" agent_id = coder_agent.dev.id agent_name = "dev" folder = local.repo_dir } 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 @@ -380,6 +470,24 @@ module "devcontainers-cli" { agent_id = coder_agent.dev.id } +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.2.0" + agent_id = coder_agent.dev.id + folder = local.repo_dir + install_claude_code = true + claude_code_version = "latest" + order = 999 + + experiment_report_tasks = true + experiment_post_install_script = <<-EOT + claude mcp add playwright npx -- @playwright/mcp@latest --headless --isolated --no-sandbox + claude mcp add desktop-commander npx -- @wonderwhy-er/desktop-commander@latest + EOT +} + + resource "coder_agent" "dev" { arch = "amd64" os = "linux" @@ -389,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. @@ -629,6 +742,8 @@ resource "docker_container" "workspace" { name, hostname, labels, + env, + entrypoint ] } count = data.coder_workspace.me.start_count @@ -710,4 +825,130 @@ resource "coder_metadata" "container_info" { key = "region" value = data.coder_parameter.region.option[index(data.coder_parameter.region.option.*.value, data.coder_parameter.region.value)].name } + item { + key = "ai_task" + value = local.has_ai_prompt ? "yes" : "no" + } +} + +resource "coder_env" "claude_system_prompt" { + count = local.has_ai_prompt ? data.coder_workspace.me.start_count : 0 + agent_id = coder_agent.dev.id + name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT" + value = <<-EOT + + -- Framing -- + You are a helpful Coding assistant. Aim to autonomously investigate + and solve issues the user gives you and test your work, whenever possible. + + Avoid shortcuts like mocking tests. When you get stuck, you can ask the user + but opt for autonomy. + + -- Tool Selection -- + - coder_report_task: providing status updates or requesting user input. + - playwright: previewing your changes after you made them + to confirm it worked as expected + - desktop-commander - use only for commands that keep running + (servers, dev watchers, GUI apps). + - Built-in tools - use for everything else: + (file operations, git commands, builds & installs, one-off shell commands) + + Remember this decision rule: + - Stays running? → desktop-commander + - Finishes immediately? → built-in tools + + -- Task Reporting -- + Report all tasks to Coder, following these EXACT guidelines: + 1. Be granular. If you are investigating with multiple steps, report each step + to coder. + 2. IMMEDIATELY report status after receiving ANY user message + 3. Use "state": "working" when actively processing WITHOUT needing + additional user input + 4. Use "state": "complete" only when finished with a task + 5. Use "state": "failure" when you need ANY user input, lack sufficient + details, or encounter blockers + + In your summary: + - Be specific about what you're doing + - Clearly indicate what information you need from the user when in + "failure" state + - Keep it under 160 characters + - Make it actionable + + -- Context -- + There is an existing application in the current directory. + Be sure to read CLAUDE.md before making any changes. + + This is a real-world production application. As such, make sure to think carefully, use TODO lists, and plan carefully before making changes. + + EOT +} + +resource "coder_env" "claude_task_prompt" { + count = local.has_ai_prompt ? data.coder_workspace.me.start_count : 0 + agent_id = coder_agent.dev.id + name = "CODER_MCP_CLAUDE_TASK_PROMPT" + value = data.coder_parameter.ai_prompt.value +} + +# 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 = "CLAUDE_API_KEY" + value = var.anthropic_api_key +} + +resource "coder_app" "develop_sh" { + count = local.has_ai_prompt ? data.coder_workspace.me.start_count : 0 + agent_id = coder_agent.dev.id + slug = "develop-sh" + display_name = "develop.sh" + icon = "${data.coder_workspace.me.access_url}/emojis/1f4bb.png" // 💻 + command = "screen -x develop_sh" + share = "authenticated" + subdomain = true + open_in = "tab" + order = 0 +} + +resource "coder_script" "develop_sh" { + count = local.has_ai_prompt ? data.coder_workspace.me.start_count : 0 + display_name = "develop.sh" + agent_id = coder_agent.dev.id + run_on_start = true + start_blocks_login = false + icon = "${data.coder_workspace.me.access_url}/emojis/1f4bb.png" // 💻 + script = <<-EOT + #!/usr/bin/env bash + set -eux -o pipefail + + # Wait for the agent startup script to finish. + for attempt in {1..60}; do + if [[ -f /tmp/.coder-startup-script.done ]]; then + break + fi + echo "Waiting for agent startup script to finish... ($attempt/60)" + sleep 10 + done + cd "${local.repo_dir}" && screen -dmS develop_sh /bin/sh -c 'while true; do ./scripts/develop.sh --; echo "develop.sh exited with code $? restarting in 30s"; sleep 30; done' + EOT +} + +resource "coder_app" "preview" { + count = local.has_ai_prompt ? data.coder_workspace.me.start_count : 0 + agent_id = coder_agent.dev.id + slug = "preview" + display_name = "Preview" + icon = "${data.coder_workspace.me.access_url}/emojis/1f50e.png" // 🔎 + url = "http://localhost:8080" + share = "authenticated" + subdomain = true + open_in = "tab" + order = 1 + healthcheck { + url = "http://localhost:8080/healthz" + interval = 5 + threshold = 15 + } } diff --git a/dogfood/main.tf b/dogfood/main.tf index 72cd868f61645..c79e950efadf4 100644 --- a/dogfood/main.tf +++ b/dogfood/main.tf @@ -33,6 +33,13 @@ variable "CODER_TEMPLATE_MESSAGE" { type = string } +variable "CODER_DOGFOOD_ANTHROPIC_API_KEY" { + type = string + description = "The API key that workspaces will use to authenticate with the Anthropic API." + default = "" + sensitive = true +} + resource "coderd_template" "dogfood" { name = var.CODER_TEMPLATE_NAME display_name = "Write Coder on Coder" @@ -45,6 +52,12 @@ resource "coderd_template" "dogfood" { message = var.CODER_TEMPLATE_MESSAGE directory = var.CODER_TEMPLATE_DIR active = true + tf_vars = [ + { + name = "anthropic_api_key" + value = var.CODER_DOGFOOD_ANTHROPIC_API_KEY + } + ] } ] acl = { 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 b5960436edcfb..cf0c74105020c 100644 --- a/enterprise/cli/prebuilds_test.go +++ b/enterprise/cli/prebuilds_test.go @@ -2,17 +2,30 @@ package cli_test import ( "bytes" + "database/sql" "net/http" "testing" + "time" + "github.com/google/uuid" "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/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/dbtime" + "github.com/coder/coder/v2/coderd/util/ptr" "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/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" ) func TestPrebuildsPause(t *testing.T) { @@ -341,3 +354,138 @@ func TestPrebuildsSettingsAPI(t *testing.T) { assert.False(t, settings.ReconciliationPaused) }) } + +// TestSchedulePrebuilds verifies the CLI schedule command when used with prebuilds. +// Running the command on an unclaimed prebuild fails, but after the prebuild is +// claimed (becoming a regular workspace) it succeeds as expected. +func TestSchedulePrebuilds(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + cliErrorMsg string + cmdArgs func(string) []string + }{ + { + name: "AutostartPrebuildError", + cliErrorMsg: "autostart configuration is not supported for prebuilt workspaces", + cmdArgs: func(workspaceName string) []string { + return []string{"schedule", "start", workspaceName, "7:30AM", "Mon-Fri", "Europe/Lisbon"} + }, + }, + { + name: "AutostopPrebuildError", + cliErrorMsg: "autostop configuration is not supported for prebuilt workspaces", + cmdArgs: func(workspaceName string) []string { + return []string{"schedule", "stop", workspaceName, "8h30m"} + }, + }, + { + name: "ExtendPrebuildError", + cliErrorMsg: "extend configuration is not supported for prebuilt workspaces", + cmdArgs: func(workspaceName string) []string { + return []string{"schedule", "extend", workspaceName, "90m"} + }, + }, + } + + 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 + 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) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + dbgen.Preset(t, db, database.InsertPresetParams{ + ID: presetID, + TemplateVersionID: version.ID, + DesiredInstances: sql.NullInt32{Int32: 1, Valid: true}, + }) + workspaceBuild := 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, + }, + }).WithAgent(func(agent []*proto.Agent) []*proto.Agent { + return agent + }).Do() + + // Mark the prebuilt workspace's agent as ready so the prebuild can be claimed + ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong)) + agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, uuid.MustParse(workspaceBuild.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 + prebuild := coderdtest.MustWorkspace(t, client, workspaceBuild.Workspace.ID) + + // When: running the schedule command over a prebuilt workspace + inv, root := clitest.New(t, tc.cmdArgs(prebuild.OwnerName+"/"+prebuild.Name)...) + clitest.SetupConfig(t, client, root) + ptytest.New(t).Attach(inv) + doneChan := make(chan struct{}) + var runErr error + go func() { + defer close(doneChan) + runErr = inv.Run() + }() + <-doneChan + + // Then: an error should be returned, with an error message specific to the lifecycle parameter + require.Error(t, runErr) + require.Contains(t, runErr.Error(), tc.cliErrorMsg) + + // 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), + // The 'extend' command requires the workspace to have an existing deadline. + // To ensure this, we set the workspace's TTL to 1 hour. + TTLMillis: ptr.Ref[int64](time.Hour.Milliseconds()), + }) + 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) + + // When: running the schedule command over the claimed workspace + inv, root = clitest.New(t, tc.cmdArgs(workspace.OwnerName+"/"+workspace.Name)...) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: the updated schedule should be shown + pty.ExpectMatch(workspace.OwnerName + "/" + workspace.Name) + }) + } +} 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/templateedit_test.go b/enterprise/cli/templateedit_test.go index fbff3e75dffcf..01d4784fd3c1e 100644 --- a/enterprise/cli/templateedit_test.go +++ b/enterprise/cli/templateedit_test.go @@ -219,9 +219,9 @@ func TestTemplateEdit(t *testing.T) { template, err := ownerClient.UpdateTemplateMeta(ctx, dbtemplate.ID, codersdk.UpdateTemplateMeta{ Name: expectedName, - DisplayName: expectedDisplayName, - Description: expectedDescription, - Icon: expectedIcon, + DisplayName: &expectedDisplayName, + Description: &expectedDescription, + Icon: &expectedIcon, DefaultTTLMillis: expectedDefaultTTLMillis, AllowUserAutostop: expectedAllowAutostop, AllowUserAutostart: expectedAllowAutostart, @@ -267,9 +267,9 @@ func TestTemplateEdit(t *testing.T) { template, err = ownerClient.UpdateTemplateMeta(ctx, dbtemplate.ID, codersdk.UpdateTemplateMeta{ Name: expectedName, - DisplayName: expectedDisplayName, - Description: expectedDescription, - Icon: expectedIcon, + DisplayName: &expectedDisplayName, + Description: &expectedDescription, + Icon: &expectedIcon, DefaultTTLMillis: expectedDefaultTTLMillis, AllowUserAutostop: expectedAllowAutostop, AllowUserAutostart: expectedAllowAutostart, 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 9583e14cd7fd3..0d276eef8604e 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -10,22 +10,25 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" - "github.com/coder/quartz" - "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/appearance" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/idpsync" agplportsharing "github.com/coder/coder/v2/coderd/portsharing" + "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" "tailscale.com/tailcfg" @@ -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 { @@ -903,7 +922,9 @@ func (api *API) updateEntitlements(ctx context.Context) error { } api.AGPL.PrebuildsReconciler.Store(&reconciler) - go reconciler.Run(context.Background()) + // TODO: Should this context be the api.ctx context? To cancel when + // the API (and entire app) is closed via shutdown? + pproflabel.Go(context.Background(), pproflabel.Service(pproflabel.ServicePrebuildReconciler), reconciler.Run) api.AGPL.PrebuildsClaimer.Store(&claimer) } @@ -918,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 }) @@ -937,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. @@ -949,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 usage events. + managedAgentCount, err := store.GetTotalUsageDCManagedAgentsV1(agpldbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{ + StartDate: managedAgentLimit.UsagePeriod.Start, + EndDate: 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/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 47d248335dda1..ce9050992eb92 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "crypto/rand" "crypto/tls" + "database/sql" "io" "net/http" "os/exec" @@ -23,10 +24,13 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/prebuilds" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/drpcsdk" "github.com/coder/coder/v2/enterprise/coderd" "github.com/coder/coder/v2/enterprise/coderd/license" + entprebuilds "github.com/coder/coder/v2/enterprise/coderd/prebuilds" "github.com/coder/coder/v2/enterprise/dbcrypt" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisioner/terraform" @@ -105,7 +109,7 @@ func NewWithAPI(t *testing.T, options *Options) ( AuditLogging: options.AuditLogging, BrowserOnly: options.BrowserOnly, SCIMAPIKey: options.SCIMAPIKey, - DERPServerRelayAddress: oop.AccessURL.String(), + DERPServerRelayAddress: serverURL.String(), DERPServerRegionID: oop.BaseDERPMap.RegionIDs()[0], ReplicaSyncUpdateInterval: options.ReplicaSyncUpdateInterval, ReplicaErrorGracePeriod: options.ReplicaErrorGracePeriod, @@ -161,12 +165,13 @@ func NewWithAPI(t *testing.T, options *Options) ( // LicenseOptions is used to generate a license for testing. // It supports the builder pattern for easy customization. type LicenseOptions struct { - AccountType string - AccountID string - DeploymentIDs []string - Trial bool - FeatureSet codersdk.FeatureSet - AllFeatures bool + AccountType string + AccountID string + DeploymentIDs []string + Trial bool + FeatureSet codersdk.FeatureSet + AllFeatures bool + PublishUsageData bool // GraceAt is the time at which the license will enter the grace period. GraceAt time.Time // ExpiresAt is the time at which the license will hard expire. @@ -271,6 +276,13 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { issuedAt = time.Now().Add(-time.Minute) } + if options.AccountType == "" { + options.AccountType = license.AccountTypeSalesforce + } + if options.AccountID == "" { + options.AccountID = "test-account-id" + } + c := &license.Claims{ RegisteredClaims: jwt.RegisteredClaims{ ID: uuid.NewString(), @@ -279,15 +291,16 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { NotBefore: jwt.NewNumericDate(options.NotBefore), IssuedAt: jwt.NewNumericDate(issuedAt), }, - LicenseExpires: jwt.NewNumericDate(options.GraceAt), - AccountType: options.AccountType, - AccountID: options.AccountID, - DeploymentIDs: options.DeploymentIDs, - Trial: options.Trial, - Version: license.CurrentVersion, - AllFeatures: options.AllFeatures, - FeatureSet: options.FeatureSet, - Features: options.Features, + LicenseExpires: jwt.NewNumericDate(options.GraceAt), + AccountType: options.AccountType, + AccountID: options.AccountID, + DeploymentIDs: options.DeploymentIDs, + Trial: options.Trial, + Version: license.CurrentVersion, + AllFeatures: options.AllFeatures, + FeatureSet: options.FeatureSet, + Features: options.Features, + PublishUsageData: options.PublishUsageData, } return GenerateLicenseRaw(t, c) } @@ -437,3 +450,98 @@ func newExternalProvisionerDaemon(t testing.TB, client *codersdk.Client, org uui return closer } + +func GetRunningPrebuilds( + ctx context.Context, + t *testing.T, + db database.Store, + desiredPrebuilds int, +) []database.GetRunningPrebuiltWorkspacesRow { + t.Helper() + + var runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow + testutil.Eventually(ctx, t, func(context.Context) bool { + prebuiltWorkspaces, err := db.GetRunningPrebuiltWorkspaces(ctx) + assert.NoError(t, err, "failed to get running prebuilds") + + for _, prebuild := range prebuiltWorkspaces { + runningPrebuilds = append(runningPrebuilds, prebuild) + + agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, prebuild.ID) + assert.NoError(t, err, "failed to get agents") + + // Manually mark all agents as ready since tests don't have real agent processes + // that would normally report their lifecycle state. Prebuilt workspaces are only + // eligible for claiming when their agents reach the "ready" state. + for _, agent := range agents { + err = db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: agent.ID, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + StartedAt: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true}, + ReadyAt: sql.NullTime{Time: time.Now().Add(-1 * time.Hour), Valid: true}, + }) + assert.NoError(t, err, "failed to update agent") + } + } + + t.Logf("found %d running prebuilds so far, want %d", len(runningPrebuilds), desiredPrebuilds) + return len(runningPrebuilds) == desiredPrebuilds + }, testutil.IntervalSlow, "found %d running prebuilds, expected %d", len(runningPrebuilds), desiredPrebuilds) + + return runningPrebuilds +} + +func MustRunReconciliationLoopForPreset( + ctx context.Context, + t *testing.T, + db database.Store, + reconciler *entprebuilds.StoreReconciler, + preset codersdk.Preset, +) []*prebuilds.ReconciliationActions { + t.Helper() + + state, err := reconciler.SnapshotState(ctx, db) + require.NoError(t, err) + ps, err := state.FilterByPreset(preset.ID) + require.NoError(t, err) + require.NotNil(t, ps) + actions, err := reconciler.CalculateActions(ctx, *ps) + require.NoError(t, err) + require.NotNil(t, actions) + require.NoError(t, reconciler.ReconcilePreset(ctx, *ps)) + + return actions +} + +func MustClaimPrebuild( + ctx context.Context, + t *testing.T, + client *codersdk.Client, + userClient *codersdk.Client, + username string, + version codersdk.TemplateVersion, + presetID uuid.UUID, + autostartSchedule ...string, +) codersdk.Workspace { + t.Helper() + + var startSchedule string + if len(autostartSchedule) > 0 { + startSchedule = autostartSchedule[0] + } + + workspaceName := strings.ReplaceAll(testutil.GetRandomName(t), "_", "-") + userWorkspace, err := userClient.CreateUserWorkspace(ctx, username, codersdk.CreateWorkspaceRequest{ + TemplateVersionID: version.ID, + Name: workspaceName, + TemplateVersionPresetID: presetID, + AutostartSchedule: ptr.Ref(startSchedule), + }) + require.NoError(t, err) + build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, userWorkspace.LatestBuild.ID) + require.Equal(t, build.Job.Status, codersdk.ProvisionerJobSucceeded) + workspace := coderdtest.MustWorkspace(t, client, userWorkspace.ID) + require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) + + return workspace +} diff --git a/enterprise/coderd/coderdenttest/proxytest.go b/enterprise/coderd/coderdenttest/proxytest.go index 5aaaf4a88a725..c4e5ed6019f61 100644 --- a/enterprise/coderd/coderdenttest/proxytest.go +++ b/enterprise/coderd/coderdenttest/proxytest.go @@ -109,7 +109,7 @@ func NewWorkspaceProxyReplica(t *testing.T, coderdAPI *coderd.API, owner *coders serverURL, err := url.Parse(srv.URL) require.NoError(t, err) - serverURL.Host = fmt.Sprintf("localhost:%d", tcpAddr.Port) + serverURL.Host = fmt.Sprintf("127.0.0.1:%d", tcpAddr.Port) accessURL := options.ProxyURL if accessURL == nil { 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 bc5c174d9fc3a..5d0fc9b9fb2b2 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -3,8 +3,10 @@ package license import ( "context" "crypto/ed25519" + "database/sql" "fmt" "math" + "sort" "time" "github.com/golang-jwt/jwt/v4" @@ -94,15 +96,48 @@ 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) { + // This is not super accurate, as the start and end times will be + // truncated to the date in UTC timezone. This is an optimization + // so we can use an aggregate table instead of scanning the usage + // events table. + // + // High accuracy is not super necessary, as we give buffers in our + // licenses (e.g. higher hard limit) to account for additional + // usage. + // // nolint:gocritic // Requires permission to read all workspaces to read managed agent count. - return db.GetManagedAgentCount(dbauthz.AsSystemRestricted(ctx), database.GetManagedAgentCountParams{ - StartTime: startTime, - EndTime: endTime, + return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{ + StartDate: startTime, + EndDate: endTime, }) }, }) @@ -114,9 +149,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. @@ -165,6 +202,13 @@ func LicensesEntitlements( }) } + // nextLicenseValidityPeriod holds the current or next contiguous period + // where there will be at least one active license. This is used for + // generating license expiry warnings. Previously we would generate licenses + // expiry warnings for each license, but it means that the warning will show + // even if you've loaded up a new license that doesn't have any gap. + nextLicenseValidityPeriod := &licenseValidityPeriod{} + // TODO: License specific warnings and errors should be tied to the license, not the // 'Entitlements' group as a whole. for _, license := range licenses { @@ -174,6 +218,17 @@ func LicensesEntitlements( // The license isn't valid yet. We don't consider any entitlements contained in it, but // it's also not an error. Just skip it silently. This can happen if an administrator // uploads a license for a new term that hasn't started yet. + // + // We still want to factor this into our validity period, though. + // This ensures we can suppress license expiry warnings for expiring + // licenses while a new license is ready to take its place. + // + // claims is nil, so reparse the claims with the IgnoreNbf function. + claims, err = ParseClaimsIgnoreNbf(license.JWT, keys) + if err != nil { + continue + } + nextLicenseValidityPeriod.ApplyClaims(claims) continue } if err != nil { @@ -182,6 +237,10 @@ func LicensesEntitlements( continue } + // Obviously, valid licenses should be considered for the license + // validity period. + nextLicenseValidityPeriod.ApplyClaims(claims) + usagePeriodStart := claims.NotBefore.Time // checked not-nil when validating claims usagePeriodEnd := claims.ExpiresAt.Time // checked not-nil when validating claims if usagePeriodStart.After(usagePeriodEnd) { @@ -210,10 +269,6 @@ func LicensesEntitlements( entitlement = codersdk.EntitlementGracePeriod } - // Will add a warning if the license is expiring soon. - // This warning can be raised multiple times if there is more than 1 license. - licenseExpirationWarning(&entitlements, now, claims) - // 'claims.AllFeature' is the legacy way to set 'claims.FeatureSet = codersdk.FeatureSetEnterprise' // If both are set, ignore the legacy 'claims.AllFeature' if claims.AllFeatures && claims.FeatureSet == "" { @@ -378,6 +433,10 @@ func LicensesEntitlements( // Now the license specific warnings and errors are added to the entitlements. + // Add a single warning if we are currently in the license validity period + // and it's expiring soon. + nextLicenseValidityPeriod.LicenseExpirationWarning(&entitlements, now) + // If HA is enabled, ensure the feature is entitled. if featureArguments.ReplicaCount > 1 { feature := entitlements.Features[codersdk.FeatureHighAvailability] @@ -418,6 +477,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. @@ -584,6 +667,7 @@ type Claims struct { Version uint64 `json:"version"` Features Features `json:"features"` RequireTelemetry bool `json:"require_telemetry,omitempty"` + PublishUsageData bool `json:"publish_usage_data,omitempty"` } var _ jwt.Claims = &Claims{} @@ -690,10 +774,85 @@ func keyFunc(keys map[string]ed25519.PublicKey) func(*jwt.Token) (interface{}, e } } -// licenseExpirationWarning adds a warning message if the license is expiring soon. -func licenseExpirationWarning(entitlements *codersdk.Entitlements, now time.Time, claims *Claims) { - // Add warning if license is expiring soon - daysToExpire := int(math.Ceil(claims.LicenseExpires.Sub(now).Hours() / 24)) +// licenseValidityPeriod keeps track of all license validity periods, and +// generates warnings over contiguous periods across multiple licenses. +// +// Note: this does not track the actual entitlements of each license to ensure +// newer licenses cover the same features as older licenses before merging. It +// is assumed that all licenses cover the same features. +type licenseValidityPeriod struct { + // parts contains all tracked license periods prior to merging. + parts [][2]time.Time +} + +// ApplyClaims tracks a license validity period. This should only be called with +// valid (including not-yet-valid), unexpired licenses. +func (p *licenseValidityPeriod) ApplyClaims(claims *Claims) { + if claims == nil || claims.NotBefore == nil || claims.LicenseExpires == nil { + // Bad data + return + } + p.Apply(claims.NotBefore.Time, claims.LicenseExpires.Time) +} + +// Apply adds a license validity period. +func (p *licenseValidityPeriod) Apply(start, end time.Time) { + if end.Before(start) { + // Bad data + return + } + p.parts = append(p.parts, [2]time.Time{start, end}) +} + +// merged merges the license validity periods into contiguous blocks, and sorts +// the merged blocks. +func (p *licenseValidityPeriod) merged() [][2]time.Time { + if len(p.parts) == 0 { + return nil + } + + // Sort the input periods by start time. + sorted := make([][2]time.Time, len(p.parts)) + copy(sorted, p.parts) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i][0].Before(sorted[j][0]) + }) + + out := make([][2]time.Time, 0, len(sorted)) + cur := sorted[0] + for i := 1; i < len(sorted); i++ { + next := sorted[i] + + // If the current period's end time is before or equal to the next + // period's start time, they should be merged. + if !next[0].After(cur[1]) { + // Pick the maximum end time. + if next[1].After(cur[1]) { + cur[1] = next[1] + } + continue + } + + // They don't overlap, so commit the current period and start a new one. + out = append(out, cur) + cur = next + } + // Commit the final period. + out = append(out, cur) + return out +} + +// LicenseExpirationWarning adds a warning message if we are currently in the +// license validity period and it's expiring soon. +func (p *licenseValidityPeriod) LicenseExpirationWarning(entitlements *codersdk.Entitlements, now time.Time) { + merged := p.merged() + if len(merged) == 0 { + // No licenses + return + } + end := merged[0][1] + + daysToExpire := int(math.Ceil(end.Sub(now).Hours() / 24)) showWarningDays := 30 isTrial := entitlements.Trial if isTrial { diff --git a/enterprise/coderd/license/license_internal_test.go b/enterprise/coderd/license/license_internal_test.go new file mode 100644 index 0000000000000..616f0b5b989b9 --- /dev/null +++ b/enterprise/coderd/license/license_internal_test.go @@ -0,0 +1,140 @@ +package license + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNextLicenseValidityPeriod(t *testing.T) { + t.Parallel() + + t.Run("Apply", func(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + + licensePeriods [][2]time.Time + expectedPeriods [][2]time.Time + }{ + { + name: "None", + licensePeriods: [][2]time.Time{}, + expectedPeriods: [][2]time.Time{}, + }, + { + name: "One", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "TwoOverlapping", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "TwoNonOverlapping", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "ThreeOverlapping", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "ThreeNonOverlapping", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "PeriodContainsAnotherPeriod", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 8, 0, 0, 0, 0, time.UTC)}, + {time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: [][2]time.Time{ + {time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 8, 0, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "EndBeforeStart", + licensePeriods: [][2]time.Time{ + {time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, + }, + expectedPeriods: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Test with all possible permutations of the periods to ensure + // consistency regardless of the order. + ps := permutations(tc.licensePeriods) + for _, p := range ps { + t.Logf("permutation: %v", p) + period := &licenseValidityPeriod{} + for _, times := range p { + t.Logf("applying %v", times) + period.Apply(times[0], times[1]) + } + assert.Equal(t, tc.expectedPeriods, period.merged(), "merged") + } + }) + } + }) +} + +func permutations[T any](arr []T) [][]T { + var res [][]T + var helper func([]T, int) + helper = func(a []T, i int) { + if i == len(a)-1 { + // make a copy before appending + tmp := make([]T, len(a)) + copy(tmp, a) + res = append(res, tmp) + return + } + for j := i; j < len(a); j++ { + a[i], a[j] = a[j], a[i] + helper(a, i+1) + a[i], a[j] = a[j], a[i] // backtrack + } + } + helper(arr, 0) + return res +} diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index d8203117039cb..1889cb7105e7e 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -180,6 +180,121 @@ func TestEntitlements(t *testing.T) { ) }) + t.Run("Expiration warning suppressed if new license covers gap", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + // Insert the expiring license + graceDate := dbtime.Now().AddDate(0, 0, 1) + _, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureUserLimit: 100, + codersdk.FeatureAuditLog: 1, + }, + + FeatureSet: codersdk.FeatureSetPremium, + GraceAt: graceDate, + ExpiresAt: dbtime.Now().AddDate(0, 0, 5), + }), + Exp: time.Now().AddDate(0, 0, 5), + }) + require.NoError(t, err) + + // Warning should be generated. + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.False(t, entitlements.Trial) + require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[codersdk.FeatureAuditLog].Entitlement) + require.Len(t, entitlements.Warnings, 1) + require.Contains(t, entitlements.Warnings, "Your license expires in 1 day.") + + // Insert the new, not-yet-valid license that starts BEFORE the expiring + // license expires. + _, err = db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureUserLimit: 100, + codersdk.FeatureAuditLog: 1, + }, + + FeatureSet: codersdk.FeatureSetPremium, + NotBefore: graceDate.Add(-time.Hour), // contiguous, and also in the future + GraceAt: dbtime.Now().AddDate(1, 0, 0), + ExpiresAt: dbtime.Now().AddDate(1, 0, 5), + }), + Exp: dbtime.Now().AddDate(1, 0, 5), + }) + require.NoError(t, err) + + // Warning should be suppressed. + entitlements, err = license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.False(t, entitlements.Trial) + require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[codersdk.FeatureAuditLog].Entitlement) + require.Len(t, entitlements.Warnings, 0) // suppressed + }) + + t.Run("Expiration warning not suppressed if new license has gap", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + // Insert the expiring license + graceDate := dbtime.Now().AddDate(0, 0, 1) + _, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureUserLimit: 100, + codersdk.FeatureAuditLog: 1, + }, + + FeatureSet: codersdk.FeatureSetPremium, + GraceAt: graceDate, + ExpiresAt: dbtime.Now().AddDate(0, 0, 5), + }), + Exp: time.Now().AddDate(0, 0, 5), + }) + require.NoError(t, err) + + // Should generate a warning. + entitlements, err := license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.False(t, entitlements.Trial) + require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[codersdk.FeatureAuditLog].Entitlement) + require.Len(t, entitlements.Warnings, 1) + require.Contains(t, entitlements.Warnings, "Your license expires in 1 day.") + + // Insert the new, not-yet-valid license that starts AFTER the expiring + // license expires (e.g. there's a gap) + _, err = db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureUserLimit: 100, + codersdk.FeatureAuditLog: 1, + }, + + FeatureSet: codersdk.FeatureSetPremium, + NotBefore: graceDate.Add(time.Minute), // gap of 1 second! + GraceAt: dbtime.Now().AddDate(1, 0, 0), + ExpiresAt: dbtime.Now().AddDate(1, 0, 5), + }), + Exp: dbtime.Now().AddDate(1, 0, 5), + }) + require.NoError(t, err) + + // Warning should still be generated. + entitlements, err = license.Entitlements(context.Background(), db, 1, 1, coderdenttest.Keys, all) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.False(t, entitlements.Trial) + require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[codersdk.FeatureAuditLog].Entitlement) + require.Len(t, entitlements.Warnings, 1) + require.Contains(t, entitlements.Warnings, "Your license expires in 1 day.") + }) + t.Run("Expiration warning for trials", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -712,17 +827,28 @@ func TestEntitlements(t *testing.T) { GetActiveUserCount(gomock.Any(), false). Return(int64(1), nil) mDB.EXPECT(). - GetManagedAgentCount(gomock.Any(), gomock.Cond(func(params database.GetManagedAgentCountParams) bool { - // gomock doesn't seem to compare times very nicely. - if !assert.WithinDuration(t, licenseOpts.NotBefore, params.StartTime, time.Second) { + GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Cond(func(params database.GetTotalUsageDCManagedAgentsV1Params) bool { + // gomock doesn't seem to compare times very nicely, so check + // them manually. + // + // The query truncates these times to the date in UTC timezone, + // but we still check that we're passing in the correct + // timestamp in the first place. + if !assert.WithinDuration(t, licenseOpts.NotBefore, params.StartDate, time.Second) { return false } - if !assert.WithinDuration(t, licenseOpts.ExpiresAt, params.EndTime, time.Second) { + if !assert.WithinDuration(t, licenseOpts.ExpiresAt, params.EndDate, time.Second) { return false } 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 +892,7 @@ func TestLicenseEntitlements(t *testing.T) { codersdk.FeatureUserRoleManagement: true, codersdk.FeatureAccessControl: true, codersdk.FeatureControlSharedPorts: true, + codersdk.FeatureWorkspaceExternalAgent: true, } legacyLicense := func() *coderdenttest.LicenseOptions { @@ -1109,6 +1236,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/claim.go b/enterprise/coderd/prebuilds/claim.go index b6a85ae1fc094..daea281d38d60 100644 --- a/enterprise/coderd/prebuilds/claim.go +++ b/enterprise/coderd/prebuilds/claim.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "time" "github.com/google/uuid" "golang.org/x/xerrors" @@ -24,14 +25,22 @@ func NewEnterpriseClaimer(store database.Store) *EnterpriseClaimer { func (c EnterpriseClaimer) Claim( ctx context.Context, + now time.Time, userID uuid.UUID, name string, presetID uuid.UUID, + autostartSchedule sql.NullString, + nextStartAt sql.NullTime, + ttl sql.NullInt64, ) (*uuid.UUID, error) { result, err := c.store.ClaimPrebuiltWorkspace(ctx, database.ClaimPrebuiltWorkspaceParams{ - NewUserID: userID, - NewName: name, - PresetID: presetID, + NewUserID: userID, + NewName: name, + Now: now, + PresetID: presetID, + AutostartSchedule: autostartSchedule, + NextStartAt: nextStartAt, + WorkspaceTtl: ttl, }) if err != nil { switch { diff --git a/enterprise/coderd/prebuilds/claim_test.go b/enterprise/coderd/prebuilds/claim_test.go index 01195e3485016..9ed7e9ffd19e0 100644 --- a/enterprise/coderd/prebuilds/claim_test.go +++ b/enterprise/coderd/prebuilds/claim_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/quartz" @@ -132,7 +134,9 @@ func TestClaimPrebuild(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - // Setup. + // Setup + clock := quartz.NewMock(t) + clock.Set(dbtime.Now()) ctx := testutil.Context(t, testutil.WaitSuperLong) db, pubsub := dbtestutil.NewDB(t) @@ -144,6 +148,7 @@ func TestClaimPrebuild(t *testing.T) { Options: &coderdtest.Options{ Database: spy, Pubsub: pubsub, + Clock: clock, }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ @@ -238,6 +243,7 @@ func TestClaimPrebuild(t *testing.T) { // When: a user creates a new workspace with a preset for which prebuilds are configured. workspaceName := strings.ReplaceAll(testutil.GetRandomName(t), "_", "-") params := database.ClaimPrebuiltWorkspaceParams{ + Now: clock.Now(), NewUserID: user.ID, NewName: workspaceName, PresetID: presets[0].ID, diff --git a/enterprise/coderd/prebuilds/membership.go b/enterprise/coderd/prebuilds/membership.go index 079711bcbcc49..f843d33f7f106 100644 --- a/enterprise/coderd/prebuilds/membership.go +++ b/enterprise/coderd/prebuilds/membership.go @@ -12,6 +12,11 @@ import ( "github.com/coder/quartz" ) +const ( + PrebuiltWorkspacesGroupName = "coderprebuiltworkspaces" + PrebuiltWorkspacesGroupDisplayName = "Prebuilt Workspaces" +) + // StoreMembershipReconciler encapsulates the responsibility of ensuring that the prebuilds system user is a member of all // organizations for which prebuilt workspaces are requested. This is necessary because our data model requires that such // prebuilt workspaces belong to a member of the organization of their eventual claimant. @@ -27,11 +32,16 @@ func NewStoreMembershipReconciler(store database.Store, clock quartz.Clock) Stor } } -// ReconcileAll compares the current membership of a user to the membership required in order to create prebuilt workspaces. -// If the user in question is not yet a member of an organization that needs prebuilt workspaces, ReconcileAll will create -// the membership required. +// ReconcileAll compares the current organization and group memberships of a user to the memberships required +// in order to create prebuilt workspaces. If the user in question is not yet a member of an organization that +// needs prebuilt workspaces, ReconcileAll will create the membership required. +// +// To facilitate quota management, ReconcileAll will ensure: +// * the existence of a group (defined by PrebuiltWorkspacesGroupName) in each organization that needs prebuilt workspaces +// * that the prebuilds system user belongs to the group in each organization that needs prebuilt workspaces +// * that the group has a quota of 0 by default, which users can adjust based on their needs. // -// This method does not have an opinion on transaction or lock management. These responsibilities are left to the caller. +// ReconcileAll does not have an opinion on transaction or lock management. These responsibilities are left to the caller. func (s StoreMembershipReconciler) ReconcileAll(ctx context.Context, userID uuid.UUID, presets []database.GetTemplatePresetsWithPrebuildsRow) error { organizationMemberships, err := s.store.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ UserID: userID, @@ -44,37 +54,80 @@ func (s StoreMembershipReconciler) ReconcileAll(ctx context.Context, userID uuid return xerrors.Errorf("determine prebuild organization membership: %w", err) } - systemUserMemberships := make(map[uuid.UUID]struct{}, 0) + orgMemberships := make(map[uuid.UUID]struct{}, 0) defaultOrg, err := s.store.GetDefaultOrganization(ctx) if err != nil { return xerrors.Errorf("get default organization: %w", err) } - systemUserMemberships[defaultOrg.ID] = struct{}{} + orgMemberships[defaultOrg.ID] = struct{}{} for _, o := range organizationMemberships { - systemUserMemberships[o.ID] = struct{}{} + orgMemberships[o.ID] = struct{}{} } var membershipInsertionErrors error for _, preset := range presets { - _, alreadyMember := systemUserMemberships[preset.OrganizationID] - if alreadyMember { - continue + _, alreadyOrgMember := orgMemberships[preset.OrganizationID] + if !alreadyOrgMember { + // Add the organization to our list of memberships regardless of potential failure below + // to avoid a retry that will probably be doomed anyway. + orgMemberships[preset.OrganizationID] = struct{}{} + + // Insert the missing membership + _, err = s.store.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ + OrganizationID: preset.OrganizationID, + UserID: userID, + CreatedAt: s.clock.Now(), + UpdatedAt: s.clock.Now(), + Roles: []string{}, + }) + if err != nil { + membershipInsertionErrors = errors.Join(membershipInsertionErrors, xerrors.Errorf("insert membership for prebuilt workspaces: %w", err)) + continue + } } - // Add the organization to our list of memberships regardless of potential failure below - // to avoid a retry that will probably be doomed anyway. - systemUserMemberships[preset.OrganizationID] = struct{}{} - // Insert the missing membership - _, err = s.store.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ + // determine whether the org already has a prebuilds group + prebuildsGroupExists := true + prebuildsGroup, err := s.store.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{ OrganizationID: preset.OrganizationID, - UserID: userID, - CreatedAt: s.clock.Now(), - UpdatedAt: s.clock.Now(), - Roles: []string{}, + Name: PrebuiltWorkspacesGroupName, + }) + if err != nil { + if !xerrors.Is(err, sql.ErrNoRows) { + membershipInsertionErrors = errors.Join(membershipInsertionErrors, xerrors.Errorf("get prebuilds group: %w", err)) + continue + } + prebuildsGroupExists = false + } + + // if the prebuilds group does not exist, create it + if !prebuildsGroupExists { + // create a "prebuilds" group in the organization and add the system user to it + // this group will have a quota of 0 by default, which users can adjust based on their needs + prebuildsGroup, err = s.store.InsertGroup(ctx, database.InsertGroupParams{ + ID: uuid.New(), + Name: PrebuiltWorkspacesGroupName, + DisplayName: PrebuiltWorkspacesGroupDisplayName, + OrganizationID: preset.OrganizationID, + AvatarURL: "", + QuotaAllowance: 0, // Default quota of 0, users should set this based on their needs + }) + if err != nil { + membershipInsertionErrors = errors.Join(membershipInsertionErrors, xerrors.Errorf("create prebuilds group: %w", err)) + continue + } + } + + // add the system user to the prebuilds group + err = s.store.InsertGroupMember(ctx, database.InsertGroupMemberParams{ + GroupID: prebuildsGroup.ID, + UserID: userID, }) if err != nil { - membershipInsertionErrors = errors.Join(membershipInsertionErrors, xerrors.Errorf("insert membership for prebuilt workspaces: %w", err)) - continue + // ignore unique violation errors as the user might already be in the group + if !database.IsUniqueViolation(err) { + membershipInsertionErrors = errors.Join(membershipInsertionErrors, xerrors.Errorf("add system user to prebuilds group: %w", err)) + } } } return membershipInsertionErrors diff --git a/enterprise/coderd/prebuilds/membership_test.go b/enterprise/coderd/prebuilds/membership_test.go index 82d2abf92a4d8..80e2f907349ae 100644 --- a/enterprise/coderd/prebuilds/membership_test.go +++ b/enterprise/coderd/prebuilds/membership_test.go @@ -1,18 +1,23 @@ package prebuilds_test import ( - "context" + "database/sql" + "errors" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" + "tailscale.com/types/ptr" "github.com/coder/quartz" + "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/enterprise/coderd/prebuilds" + "github.com/coder/coder/v2/testutil" ) // TestReconcileAll verifies that StoreMembershipReconciler correctly updates membership @@ -20,7 +25,6 @@ import ( func TestReconcileAll(t *testing.T) { t.Parallel() - ctx := context.Background() clock := quartz.NewMock(t) // Helper to build a minimal Preset row belonging to a given org. @@ -32,87 +36,171 @@ func TestReconcileAll(t *testing.T) { } tests := []struct { - name string - includePreset bool - preExistingMembership bool + name string + includePreset []bool + preExistingOrgMembership []bool + preExistingGroup []bool + preExistingGroupMembership []bool + // Expected outcomes + expectOrgMembershipExists *bool + expectGroupExists *bool + expectUserInGroup *bool }{ - // The StoreMembershipReconciler acts based on the provided agplprebuilds.GlobalSnapshot. - // These test cases must therefore trust any valid snapshot, so the only relevant functional test cases are: - - // No presets to act on and the prebuilds user does not belong to any organizations. - // Reconciliation should be a no-op - {name: "no presets, no memberships", includePreset: false, preExistingMembership: false}, - // If we have a preset that requires prebuilds, but the prebuilds user is not a member of - // that organization, then we should add the membership. - {name: "preset, but no membership", includePreset: true, preExistingMembership: false}, - // If the prebuilds system user is already a member of the organization to which a preset belongs, - // then reconciliation should be a no-op: - {name: "preset, but already a member", includePreset: true, preExistingMembership: true}, - // If the prebuilds system user is a member of an organization that doesn't have need any prebuilds, - // then it must have required prebuilds in the past. The membership is not currently necessary, but - // the reconciler won't remove it, because there's little cost to keeping it and prebuilds might be - // enabled again. - {name: "member, but no presets", includePreset: false, preExistingMembership: true}, + { + name: "if there are no presets, membership reconciliation is a no-op", + includePreset: []bool{false}, + preExistingOrgMembership: []bool{true, false}, + preExistingGroup: []bool{true, false}, + preExistingGroupMembership: []bool{true, false}, + expectOrgMembershipExists: ptr.To(false), + expectGroupExists: ptr.To(false), + }, + { + name: "if there is a preset, then we should enforce org and group membership in all cases", + includePreset: []bool{true}, + preExistingOrgMembership: []bool{true, false}, + preExistingGroup: []bool{true, false}, + preExistingGroupMembership: []bool{true, false}, + expectOrgMembershipExists: ptr.To(true), + expectGroupExists: ptr.To(true), + expectUserInGroup: ptr.To(true), + }, } for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - db, _ := dbtestutil.NewDB(t) - - defaultOrg, err := db.GetDefaultOrganization(ctx) - require.NoError(t, err) - - // introduce an unrelated organization to ensure that the membership reconciler don't interfere with it. - unrelatedOrg := dbgen.Organization(t, db, database.Organization{}) - targetOrg := dbgen.Organization(t, db, database.Organization{}) - - if !dbtestutil.WillUsePostgres() { - // dbmem doesn't ensure membership to the default organization - dbgen.OrganizationMember(t, db, database.OrganizationMember{ - OrganizationID: defaultOrg.ID, - UserID: database.PrebuildsSystemUserID, - }) - } - - dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: unrelatedOrg.ID, UserID: database.PrebuildsSystemUserID}) - if tc.preExistingMembership { - // System user already a member of both orgs. - dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: targetOrg.ID, UserID: database.PrebuildsSystemUserID}) + tc := tc + for _, includePreset := range tc.includePreset { + includePreset := includePreset + for _, preExistingOrgMembership := range tc.preExistingOrgMembership { + preExistingOrgMembership := preExistingOrgMembership + for _, preExistingGroup := range tc.preExistingGroup { + preExistingGroup := preExistingGroup + for _, preExistingGroupMembership := range tc.preExistingGroupMembership { + preExistingGroupMembership := preExistingGroupMembership + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // nolint:gocritic // Reconciliation happens as prebuilds system user, not a human user. + ctx := dbauthz.AsPrebuildsOrchestrator(testutil.Context(t, testutil.WaitLong)) + _, db := coderdtest.NewWithDatabase(t, nil) + + defaultOrg, err := db.GetDefaultOrganization(ctx) + require.NoError(t, err) + + // introduce an unrelated organization to ensure that the membership reconciler doesn't interfere with it. + unrelatedOrg := dbgen.Organization(t, db, database.Organization{}) + targetOrg := dbgen.Organization(t, db, database.Organization{}) + + if !dbtestutil.WillUsePostgres() { + // dbmem doesn't ensure membership to the default organization + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: defaultOrg.ID, + UserID: database.PrebuildsSystemUserID, + }) + } + + // Ensure membership to unrelated org. + dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: unrelatedOrg.ID, UserID: database.PrebuildsSystemUserID}) + + if preExistingOrgMembership { + // System user already a member of both orgs. + dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: targetOrg.ID, UserID: database.PrebuildsSystemUserID}) + } + + // Create pre-existing prebuilds group if required by test case + var prebuildsGroup database.Group + if preExistingGroup { + prebuildsGroup = dbgen.Group(t, db, database.Group{ + Name: prebuilds.PrebuiltWorkspacesGroupName, + DisplayName: prebuilds.PrebuiltWorkspacesGroupDisplayName, + OrganizationID: targetOrg.ID, + QuotaAllowance: 0, + }) + + // Add the system user to the group if preExistingGroupMembership is true + if preExistingGroupMembership { + dbgen.GroupMember(t, db, database.GroupMemberTable{ + GroupID: prebuildsGroup.ID, + UserID: database.PrebuildsSystemUserID, + }) + } + } + + presets := []database.GetTemplatePresetsWithPrebuildsRow{newPresetRow(unrelatedOrg.ID)} + if includePreset { + presets = append(presets, newPresetRow(targetOrg.ID)) + } + + // Verify memberships before reconciliation. + preReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ + UserID: database.PrebuildsSystemUserID, + }) + require.NoError(t, err) + expectedMembershipsBefore := []uuid.UUID{defaultOrg.ID, unrelatedOrg.ID} + if preExistingOrgMembership { + expectedMembershipsBefore = append(expectedMembershipsBefore, targetOrg.ID) + } + require.ElementsMatch(t, expectedMembershipsBefore, extractOrgIDs(preReconcileMemberships)) + + // Reconcile + reconciler := prebuilds.NewStoreMembershipReconciler(db, clock) + require.NoError(t, reconciler.ReconcileAll(ctx, database.PrebuildsSystemUserID, presets)) + + // Verify memberships after reconciliation. + postReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ + UserID: database.PrebuildsSystemUserID, + }) + require.NoError(t, err) + expectedMembershipsAfter := expectedMembershipsBefore + if !preExistingOrgMembership && tc.expectOrgMembershipExists != nil && *tc.expectOrgMembershipExists { + expectedMembershipsAfter = append(expectedMembershipsAfter, targetOrg.ID) + } + require.ElementsMatch(t, expectedMembershipsAfter, extractOrgIDs(postReconcileMemberships)) + + // Verify prebuilds group behavior based on expected outcomes + prebuildsGroup, err = db.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{ + OrganizationID: targetOrg.ID, + Name: prebuilds.PrebuiltWorkspacesGroupName, + }) + if tc.expectGroupExists != nil && *tc.expectGroupExists { + require.NoError(t, err) + require.Equal(t, prebuilds.PrebuiltWorkspacesGroupName, prebuildsGroup.Name) + require.Equal(t, prebuilds.PrebuiltWorkspacesGroupDisplayName, prebuildsGroup.DisplayName) + require.Equal(t, int32(0), prebuildsGroup.QuotaAllowance) // Default quota should be 0 + + if tc.expectUserInGroup != nil && *tc.expectUserInGroup { + // Check that the system user is a member of the prebuilds group + groupMembers, err := db.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{ + GroupID: prebuildsGroup.ID, + IncludeSystem: true, + }) + require.NoError(t, err) + require.Len(t, groupMembers, 1) + require.Equal(t, database.PrebuildsSystemUserID, groupMembers[0].UserID) + } + + // If no preset exists, then we do not enforce group membership: + if tc.expectUserInGroup != nil && !*tc.expectUserInGroup { + // Check that the system user is NOT a member of the prebuilds group + groupMembers, err := db.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{ + GroupID: prebuildsGroup.ID, + IncludeSystem: true, + }) + require.NoError(t, err) + require.Len(t, groupMembers, 0) + } + } + + if !preExistingGroup && tc.expectGroupExists != nil && !*tc.expectGroupExists { + // Verify that no prebuilds group exists + require.Error(t, err) + require.True(t, errors.Is(err, sql.ErrNoRows)) + } + }) + } + } } - - presets := []database.GetTemplatePresetsWithPrebuildsRow{newPresetRow(unrelatedOrg.ID)} - if tc.includePreset { - presets = append(presets, newPresetRow(targetOrg.ID)) - } - - // Verify memberships before reconciliation. - preReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ - UserID: database.PrebuildsSystemUserID, - }) - require.NoError(t, err) - expectedMembershipsBefore := []uuid.UUID{defaultOrg.ID, unrelatedOrg.ID} - if tc.preExistingMembership { - expectedMembershipsBefore = append(expectedMembershipsBefore, targetOrg.ID) - } - require.ElementsMatch(t, expectedMembershipsBefore, extractOrgIDs(preReconcileMemberships)) - - // Reconcile - reconciler := prebuilds.NewStoreMembershipReconciler(db, clock) - require.NoError(t, reconciler.ReconcileAll(ctx, database.PrebuildsSystemUserID, presets)) - - // Verify memberships after reconciliation. - postReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ - UserID: database.PrebuildsSystemUserID, - }) - require.NoError(t, err) - expectedMembershipsAfter := expectedMembershipsBefore - if !tc.preExistingMembership && tc.includePreset { - expectedMembershipsAfter = append(expectedMembershipsAfter, targetOrg.ID) - } - require.ElementsMatch(t, expectedMembershipsAfter, extractOrgIDs(postReconcileMemberships)) - }) + } } } 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/prebuilds/reconcile_test.go b/enterprise/coderd/prebuilds/reconcile_test.go index 8d2a81e1ade83..413d61ddbbc6a 100644 --- a/enterprise/coderd/prebuilds/reconcile_test.go +++ b/enterprise/coderd/prebuilds/reconcile_test.go @@ -3,7 +3,6 @@ package prebuilds_test import ( "context" "database/sql" - "fmt" "sort" "sync" "sync/atomic" @@ -46,10 +45,6 @@ func TestNoReconciliationActionsIfNoPresets(t *testing.T) { // Scenario: No reconciliation actions are taken if there are no presets t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("dbmem times out on nesting transactions, postgres ignores the inner ones") - } - clock := quartz.NewMock(t) ctx := testutil.Context(t, testutil.WaitLong) db, ps := dbtestutil.NewDB(t) @@ -92,10 +87,6 @@ func TestNoReconciliationActionsIfNoPrebuilds(t *testing.T) { // Scenario: No reconciliation actions are taken if there are no prebuilds t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("dbmem times out on nesting transactions, postgres ignores the inner ones") - } - clock := quartz.NewMock(t) ctx := testutil.Context(t, testutil.WaitLong) db, ps := dbtestutil.NewDB(t) @@ -149,21 +140,7 @@ func TestNoReconciliationActionsIfNoPrebuilds(t *testing.T) { func TestPrebuildReconciliation(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - - type testCase struct { - name string - prebuildLatestTransitions []database.WorkspaceTransition - prebuildJobStatuses []database.ProvisionerJobStatus - templateVersionActive []bool - templateDeleted []bool - shouldCreateNewPrebuild *bool - shouldDeleteOldPrebuild *bool - } - - testCases := []testCase{ + testScenarios := []testScenario{ { name: "never create prebuilds for inactive template versions", prebuildLatestTransitions: allTransitions, @@ -181,8 +158,8 @@ func TestPrebuildReconciliation(t *testing.T) { database.ProvisionerJobStatusSucceeded, }, templateVersionActive: []bool{true}, - shouldCreateNewPrebuild: ptr.To(false), templateDeleted: []bool{false}, + shouldCreateNewPrebuild: ptr.To(false), }, { name: "don't create a new prebuild if one is queued to build or already building", @@ -313,119 +290,173 @@ func TestPrebuildReconciliation(t *testing.T) { templateDeleted: []bool{true}, }, } - for _, tc := range testCases { - for _, templateVersionActive := range tc.templateVersionActive { - for _, prebuildLatestTransition := range tc.prebuildLatestTransitions { - for _, prebuildJobStatus := range tc.prebuildJobStatuses { - for _, templateDeleted := range tc.templateDeleted { - for _, useBrokenPubsub := range []bool{true, false} { - t.Run(fmt.Sprintf("%s - %s - %s - pubsub_broken=%v", tc.name, prebuildLatestTransition, prebuildJobStatus, useBrokenPubsub), func(t *testing.T) { - t.Parallel() - t.Cleanup(func() { - if t.Failed() { - t.Logf("failed to run test: %s", tc.name) - t.Logf("templateVersionActive: %t", templateVersionActive) - t.Logf("prebuildLatestTransition: %s", prebuildLatestTransition) - t.Logf("prebuildJobStatus: %s", prebuildJobStatus) - } - }) - clock := quartz.NewMock(t) - ctx := testutil.Context(t, testutil.WaitShort) - cfg := codersdk.PrebuildsConfig{} - logger := slogtest.Make( - t, &slogtest.Options{IgnoreErrors: true}, - ).Leveled(slog.LevelDebug) - db, pubSub := dbtestutil.NewDB(t) - - ownerID := uuid.New() - dbgen.User(t, db, database.User{ - ID: ownerID, - }) - org, template := setupTestDBTemplate(t, db, ownerID, templateDeleted) - templateVersionID := setupTestDBTemplateVersion( - ctx, - t, - clock, - db, - pubSub, - org.ID, - ownerID, - template.ID, - ) - preset := setupTestDBPreset( - t, - db, - templateVersionID, - 1, - uuid.New().String(), - ) - prebuild, _ := setupTestDBPrebuild( - t, - clock, - db, - pubSub, - prebuildLatestTransition, - prebuildJobStatus, - org.ID, - preset, - template.ID, - templateVersionID, - ) - - setupTestDBPrebuildAntagonists(t, db, pubSub, org) - - if !templateVersionActive { - // Create a new template version and mark it as active - // This marks the template version that we care about as inactive - setupTestDBTemplateVersion(ctx, t, clock, db, pubSub, org.ID, ownerID, template.ID) - } - - if useBrokenPubsub { - pubSub = &brokenPublisher{Pubsub: pubSub} - } - cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) - controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer(), newNoopUsageCheckerPtr()) - - // Run the reconciliation multiple times to ensure idempotency - // 8 was arbitrary, but large enough to reasonably trust the result - for i := 1; i <= 8; i++ { - require.NoErrorf(t, controller.ReconcileAll(ctx), "failed on iteration %d", i) - - if tc.shouldCreateNewPrebuild != nil { - newPrebuildCount := 0 - workspaces, err := db.GetWorkspacesByTemplateID(ctx, template.ID) - require.NoError(t, err) - for _, workspace := range workspaces { - if workspace.ID != prebuild.ID { - newPrebuildCount++ - } - } - // This test configures a preset that desires one prebuild. - // In cases where new prebuilds should be created, there should be exactly one. - require.Equal(t, *tc.shouldCreateNewPrebuild, newPrebuildCount == 1) - } - - if tc.shouldDeleteOldPrebuild != nil { - builds, err := db.GetWorkspaceBuildsByWorkspaceID(ctx, database.GetWorkspaceBuildsByWorkspaceIDParams{ - WorkspaceID: prebuild.ID, - }) - require.NoError(t, err) - if *tc.shouldDeleteOldPrebuild { - require.Equal(t, 2, len(builds)) - require.Equal(t, database.WorkspaceTransitionDelete, builds[0].Transition) - } else { - require.Equal(t, 1, len(builds)) - require.Equal(t, prebuildLatestTransition, builds[0].Transition) - } - } - } - }) + for _, tc := range testScenarios { + testCases := tc.testCases() + for _, tc := range testCases { + tc.run(t) + } + } +} + +// testScenario is a collection of test cases that illustrate the same business rule. +// A testScenario describes a set of test properties for which the same test expecations +// hold. A testScenario may be decomposed into multiple testCase structs, which can then be run. +type testScenario struct { + name string + prebuildLatestTransitions []database.WorkspaceTransition + prebuildJobStatuses []database.ProvisionerJobStatus + templateVersionActive []bool + templateDeleted []bool + shouldCreateNewPrebuild *bool + shouldDeleteOldPrebuild *bool + expectOrgMembership *bool + expectGroupMembership *bool +} + +func (ts testScenario) testCases() []testCase { + testCases := []testCase{} + for _, templateVersionActive := range ts.templateVersionActive { + for _, prebuildLatestTransition := range ts.prebuildLatestTransitions { + for _, prebuildJobStatus := range ts.prebuildJobStatuses { + for _, templateDeleted := range ts.templateDeleted { + for _, useBrokenPubsub := range []bool{true, false} { + testCase := testCase{ + name: ts.name, + templateVersionActive: templateVersionActive, + prebuildLatestTransition: prebuildLatestTransition, + prebuildJobStatus: prebuildJobStatus, + templateDeleted: templateDeleted, + useBrokenPubsub: useBrokenPubsub, + shouldCreateNewPrebuild: ts.shouldCreateNewPrebuild, + shouldDeleteOldPrebuild: ts.shouldDeleteOldPrebuild, + expectOrgMembership: ts.expectOrgMembership, + expectGroupMembership: ts.expectGroupMembership, } + testCases = append(testCases, testCase) } } } } } + + return testCases +} + +type testCase struct { + name string + prebuildLatestTransition database.WorkspaceTransition + prebuildJobStatus database.ProvisionerJobStatus + templateVersionActive bool + templateDeleted bool + useBrokenPubsub bool + shouldCreateNewPrebuild *bool + shouldDeleteOldPrebuild *bool + expectOrgMembership *bool + expectGroupMembership *bool +} + +func (tc testCase) run(t *testing.T) { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + t.Cleanup(func() { + if t.Failed() { + t.Logf("failed to run test: %s", tc.name) + t.Logf("templateVersionActive: %t", tc.templateVersionActive) + t.Logf("prebuildLatestTransition: %s", tc.prebuildLatestTransition) + t.Logf("prebuildJobStatus: %s", tc.prebuildJobStatus) + } + }) + clock := quartz.NewMock(t) + ctx := testutil.Context(t, testutil.WaitShort) + cfg := codersdk.PrebuildsConfig{} + logger := slogtest.Make( + t, &slogtest.Options{IgnoreErrors: true}, + ).Leveled(slog.LevelDebug) + db, pubSub := dbtestutil.NewDB(t) + + ownerID := uuid.New() + dbgen.User(t, db, database.User{ + ID: ownerID, + }) + org, template := setupTestDBTemplate(t, db, ownerID, tc.templateDeleted) + templateVersionID := setupTestDBTemplateVersion( + ctx, + t, + clock, + db, + pubSub, + org.ID, + ownerID, + template.ID, + ) + preset := setupTestDBPreset( + t, + db, + templateVersionID, + 1, + uuid.New().String(), + ) + prebuild, _ := setupTestDBPrebuild( + t, + clock, + db, + pubSub, + tc.prebuildLatestTransition, + tc.prebuildJobStatus, + org.ID, + preset, + template.ID, + templateVersionID, + ) + + setupTestDBPrebuildAntagonists(t, db, pubSub, org) + + if !tc.templateVersionActive { + // Create a new template version and mark it as active + // This marks the template version that we care about as inactive + setupTestDBTemplateVersion(ctx, t, clock, db, pubSub, org.ID, ownerID, template.ID) + } + + if tc.useBrokenPubsub { + pubSub = &brokenPublisher{Pubsub: pubSub} + } + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer(), newNoopUsageCheckerPtr()) + + // Run the reconciliation multiple times to ensure idempotency + // 8 was arbitrary, but large enough to reasonably trust the result + for i := 1; i <= 8; i++ { + require.NoErrorf(t, controller.ReconcileAll(ctx), "failed on iteration %d", i) + + if tc.shouldCreateNewPrebuild != nil { + newPrebuildCount := 0 + workspaces, err := db.GetWorkspacesByTemplateID(ctx, template.ID) + require.NoError(t, err) + for _, workspace := range workspaces { + if workspace.ID != prebuild.ID { + newPrebuildCount++ + } + } + // This test configures a preset that desires one prebuild. + // In cases where new prebuilds should be created, there should be exactly one. + require.Equal(t, *tc.shouldCreateNewPrebuild, newPrebuildCount == 1) + } + + if tc.shouldDeleteOldPrebuild != nil { + builds, err := db.GetWorkspaceBuildsByWorkspaceID(ctx, database.GetWorkspaceBuildsByWorkspaceIDParams{ + WorkspaceID: prebuild.ID, + }) + require.NoError(t, err) + if *tc.shouldDeleteOldPrebuild { + require.Equal(t, 2, len(builds)) + require.Equal(t, database.WorkspaceTransitionDelete, builds[0].Transition) + } else { + require.Equal(t, 1, len(builds)) + require.Equal(t, tc.prebuildLatestTransition, builds[0].Transition) + } + } + } + }) } // brokenPublisher is used to validate that Publish() calls which always fail do not affect the reconciler's behavior, @@ -446,10 +477,6 @@ func (*brokenPublisher) Publish(event string, _ []byte) error { func TestMultiplePresetsPerTemplateVersion(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - prebuildLatestTransition := database.WorkspaceTransitionStart prebuildJobStatus := database.ProvisionerJobStatusRunning templateDeleted := false @@ -533,10 +560,6 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) { func TestPrebuildScheduling(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - templateDeleted := false // The test includes 2 presets, each with 2 schedules. @@ -679,10 +702,6 @@ func TestPrebuildScheduling(t *testing.T) { func TestInvalidPreset(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - templateDeleted := false clock := quartz.NewMock(t) @@ -744,10 +763,6 @@ func TestInvalidPreset(t *testing.T) { func TestDeletionOfPrebuiltWorkspaceWithInvalidPreset(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - templateDeleted := false clock := quartz.NewMock(t) @@ -814,10 +829,6 @@ func TestDeletionOfPrebuiltWorkspaceWithInvalidPreset(t *testing.T) { func TestSkippingHardLimitedPresets(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - // Test cases verify the behavior of prebuild creation depending on configured failure limits. testCases := []struct { name string @@ -955,10 +966,6 @@ func TestSkippingHardLimitedPresets(t *testing.T) { func TestHardLimitedPresetShouldNotBlockDeletion(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - // Test cases verify the behavior of prebuild creation depending on configured failure limits. testCases := []struct { name string @@ -1171,10 +1178,6 @@ func TestHardLimitedPresetShouldNotBlockDeletion(t *testing.T) { func TestRunLoop(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - prebuildLatestTransition := database.WorkspaceTransitionStart prebuildJobStatus := database.ProvisionerJobStatusRunning templateDeleted := false @@ -1305,9 +1308,6 @@ func TestRunLoop(t *testing.T) { func TestFailedBuildBackoff(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } ctx := testutil.Context(t, testutil.WaitSuperLong) // Setup. @@ -1426,10 +1426,6 @@ func TestFailedBuildBackoff(t *testing.T) { func TestReconciliationLock(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - ctx := testutil.Context(t, testutil.WaitSuperLong) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) db, ps := dbtestutil.NewDB(t) @@ -1470,10 +1466,6 @@ func TestReconciliationLock(t *testing.T) { func TestTrackResourceReplacement(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - ctx := testutil.Context(t, testutil.WaitSuperLong) // Setup. @@ -1559,10 +1551,6 @@ func TestTrackResourceReplacement(t *testing.T) { func TestExpiredPrebuildsMultipleActions(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - testCases := []struct { name string running int @@ -2268,10 +2256,6 @@ func mustParseTime(t *testing.T, layout, value string) time.Time { func TestReconciliationRespectsPauseSetting(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - ctx := testutil.Context(t, testutil.WaitLong) clock := quartz.NewMock(t) db, ps := dbtestutil.NewDB(t) diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index c8304952781d1..be03af29293f9 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, @@ -360,6 +361,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) }, api.NotificationsEnqueuer, &api.AGPL.PrebuildsReconciler, + api.ProvisionerdServerMetrics, ) if err != nil { if !xerrors.Is(err, context.Canceled) { 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 855dea4989c73..ed21b8160e2c3 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -205,7 +205,6 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S if opts.DefaultTTL != 0 { ttl = sql.NullInt64{Valid: true, Int64: int64(opts.DefaultTTL)} } - if err = tx.UpdateWorkspacesTTLByTemplateID(ctx, database.UpdateWorkspacesTTLByTemplateIDParams{ TemplateID: template.ID, Ttl: ttl, @@ -243,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) @@ -255,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, @@ -335,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) @@ -350,14 +358,23 @@ func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuild(ctx context.Conte return nil } + // Calculate the new autostop max_deadline from the workspace. Since + // autostop is always calculated from the build completion time, we don't + // want to use the returned autostop.Deadline property as it will likely be + // in the distant past. + // + // The only exception is if the newly calculated workspace TTL is now zero, + // which means the workspace can now stay on indefinitely. + // + // This also matches the behavior of updating a workspace's TTL, where we + // don't apply the changes until the workspace is rebuilt. autostop, err := agpl.CalculateAutostop(ctx, agpl.CalculateAutostopParams{ Database: db, TemplateScheduleStore: s, UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(), - // Use the job completion time as the time we calculate autostop from. - Now: job.CompletedAt.Time, - Workspace: workspace.WorkspaceTable(), - WorkspaceAutostart: workspace.AutostartSchedule.String, + WorkspaceBuildCompletedAt: job.CompletedAt.Time, + Workspace: workspace.WorkspaceTable(), + WorkspaceAutostart: workspace.AutostartSchedule.String, }) if err != nil { return xerrors.Errorf("calculate new autostop for workspace %q: %w", workspace.ID, err) @@ -389,9 +406,24 @@ func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuild(ctx context.Conte autostop.MaxDeadline = now.Add(time.Hour * 2) } + // If the new deadline is zero, the workspace can now stay on indefinitely. + // Otherwise, we want to discard the new value as per the comment above the + // CalculateAutostop call. + // + // We could potentially calculate a new deadline based on the TTL setting + // (on either the workspace or the template based on the template's policy) + // against the current time, but doing nothing here matches the current + // behavior of the workspace TTL update endpoint. + // + // Per the documentation of CalculateAutostop, the deadline is not intended + // as a policy measure, so it's fine that we don't update it when the + // template schedule changes. + if !autostop.Deadline.IsZero() { + autostop.Deadline = build.Deadline + } + // If the current deadline on the build is after the new max_deadline, then // set it to the max_deadline. - autostop.Deadline = build.Deadline if !autostop.MaxDeadline.IsZero() && autostop.Deadline.After(autostop.MaxDeadline) { autostop.Deadline = autostop.MaxDeadline } diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go index 4af06042b031f..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,15 +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/util/ptr" + "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" ) @@ -73,17 +77,23 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { buildTime := time.Date(nowY, nowM, nowD, 12, 0, 0, 0, time.UTC) // noon today UTC nextQuietHours := time.Date(nowY, nowM, nowD+1, 0, 0, 0, 0, time.UTC) // midnight tomorrow UTC - // Workspace old max_deadline too soon + defaultTTL := 8 * time.Hour + cases := []struct { - name string - now time.Time + name string + now time.Time + // Before: deadline time.Time maxDeadline time.Time - // Set to nil for no change. - newDeadline *time.Time + // After: + newDeadline time.Time newMaxDeadline time.Time - noQuietHours bool - autostopReq *agplschedule.TemplateAutostopRequirement + // Config: + noQuietHours bool + // Note that ttl will not influence the new build at all unless it's 0 + // AND the build does not have a max deadline post recalculation. + ttl time.Duration + autostopReq *agplschedule.TemplateAutostopRequirement }{ { name: "SkippedWorkspaceMaxDeadlineTooSoon", @@ -91,8 +101,9 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { deadline: buildTime, maxDeadline: buildTime.Add(1 * time.Hour), // Unchanged since the max deadline is too soon. - newDeadline: nil, + newDeadline: buildTime, newMaxDeadline: buildTime.Add(1 * time.Hour), + ttl: defaultTTL, // no effect }, { name: "NewWorkspaceMaxDeadlineBeforeNow", @@ -101,10 +112,11 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { deadline: buildTime, // Far into the future... maxDeadline: nextQuietHours.Add(24 * time.Hour), - newDeadline: nil, + newDeadline: buildTime, // We will use now() + 2 hours if the newly calculated max deadline // from the workspace build time is before now. newMaxDeadline: nextQuietHours.Add(8 * time.Hour), + ttl: defaultTTL, // no effect }, { name: "NewWorkspaceMaxDeadlineSoon", @@ -113,10 +125,11 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { deadline: buildTime, // Far into the future... maxDeadline: nextQuietHours.Add(24 * time.Hour), - newDeadline: nil, + newDeadline: buildTime, // We will use now() + 2 hours if the newly calculated max deadline // from the workspace build time is within the next 2 hours. newMaxDeadline: nextQuietHours.Add(1 * time.Hour), + ttl: defaultTTL, // no effect }, { name: "NewWorkspaceMaxDeadlineFuture", @@ -125,8 +138,9 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { deadline: buildTime, // Far into the future... maxDeadline: nextQuietHours.Add(24 * time.Hour), - newDeadline: nil, + newDeadline: buildTime, newMaxDeadline: nextQuietHours, + ttl: defaultTTL, // no effect }, { name: "DeadlineAfterNewWorkspaceMaxDeadline", @@ -136,8 +150,9 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { deadline: nextQuietHours.Add(24 * time.Hour), maxDeadline: nextQuietHours.Add(24 * time.Hour), // The deadline should match since it is after the new max deadline. - newDeadline: ptr.Ref(nextQuietHours), + newDeadline: nextQuietHours, newMaxDeadline: nextQuietHours, + ttl: defaultTTL, // no effect }, { // There was a bug if a user has no quiet hours set, and autostop @@ -151,13 +166,14 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { deadline: buildTime.Add(time.Hour * 8), maxDeadline: time.Time{}, // No max set // Should be unchanged - newDeadline: ptr.Ref(buildTime.Add(time.Hour * 8)), + newDeadline: buildTime.Add(time.Hour * 8), newMaxDeadline: time.Time{}, noQuietHours: true, autostopReq: &agplschedule.TemplateAutostopRequirement{ DaysOfWeek: 0, Weeks: 0, }, + ttl: defaultTTL, // no effect }, { // A bug existed where MaxDeadline could be set, but deadline was @@ -168,15 +184,15 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { deadline: time.Time{}, maxDeadline: time.Time{}, // No max set // Should be unchanged - newDeadline: ptr.Ref(time.Time{}), + newDeadline: time.Time{}, newMaxDeadline: time.Time{}, noQuietHours: true, autostopReq: &agplschedule.TemplateAutostopRequirement{ DaysOfWeek: 0, Weeks: 0, }, + ttl: defaultTTL, // no effect }, - { // Similar to 'NoDeadline' test. This has a MaxDeadline set, so // the deadline of the workspace should now be set. @@ -185,14 +201,33 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { // Start with unset times deadline: time.Time{}, maxDeadline: time.Time{}, - newDeadline: ptr.Ref(nextQuietHours), + newDeadline: nextQuietHours, newMaxDeadline: nextQuietHours, + ttl: defaultTTL, // no effect + }, + { + // If the build doesn't have a max_deadline anymore, and there is no + // TTL anymore, then both the deadline and max_deadline should be + // zero. + name: "NoTTLNoDeadlineNoMaxDeadline", + now: buildTime, + deadline: buildTime.Add(time.Hour * 8), + maxDeadline: buildTime.Add(time.Hour * 8), + newDeadline: time.Time{}, + newMaxDeadline: time.Time{}, + noQuietHours: true, + autostopReq: &agplschedule.TemplateAutostopRequirement{ + DaysOfWeek: 0, + Weeks: 0, + }, + ttl: 0, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) user := quietUser if c.noQuietHours { @@ -206,6 +241,7 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { t.Log("maxDeadline", c.maxDeadline) t.Log("newDeadline", c.newDeadline) t.Log("newMaxDeadline", c.newMaxDeadline) + t.Log("ttl", c.ttl) var ( template = dbgen.Template(t, db, database.Template{ @@ -300,7 +336,7 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { _, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{ UserAutostartEnabled: false, UserAutostopEnabled: false, - DefaultTTL: 0, + DefaultTTL: c.ttl, AutostopRequirement: autostopReq, FailureTTL: 0, TimeTilDormant: 0, @@ -312,11 +348,8 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { newBuild, err := db.GetWorkspaceBuildByID(ctx, wsBuild.ID) require.NoError(t, err) - if c.newDeadline == nil { - c.newDeadline = &wsBuild.Deadline - } - require.WithinDuration(t, *c.newDeadline, newBuild.Deadline, time.Second) - require.WithinDuration(t, c.newMaxDeadline, newBuild.MaxDeadline, time.Second) + require.WithinDuration(t, c.newDeadline, newBuild.Deadline, time.Second, "deadline") + require.WithinDuration(t, c.newMaxDeadline, newBuild.MaxDeadline, time.Second, "max_deadline") // Check that the new build has the same state as before. require.Equal(t, wsBuild.ProvisionerState, newBuild.ProvisionerState, "provisioner state mismatch") @@ -686,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, @@ -951,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/templates.go b/enterprise/coderd/templates.go index 438a7cfd5c65f..16f2e7fc4fac9 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -1,7 +1,6 @@ package coderd import ( - "context" "database/sql" "fmt" "net/http" @@ -15,6 +14,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac/acl" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" @@ -208,17 +208,10 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { return } - validErrs := validateTemplateACLPerms(ctx, api.Database, req.UserPerms, "user_perms") - validErrs = append(validErrs, validateTemplateACLPerms( - ctx, - api.Database, - req.GroupPerms, - "group_perms", - )...) - + validErrs := acl.Validate(ctx, api.Database, TemplateACLUpdateValidator(req)) if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid request to update template metadata!", + Message: "Invalid request to update template ACL", Validations: validErrs, }) return @@ -273,43 +266,31 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { }) } -func validateTemplateACLPerms(ctx context.Context, db database.Store, perms map[string]codersdk.TemplateRole, field string) []codersdk.ValidationError { - // nolint:gocritic // Validate requires full read access to users and groups - ctx = dbauthz.AsSystemRestricted(ctx) - var validErrs []codersdk.ValidationError - for idStr, role := range perms { - if err := validateTemplateRole(role); err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: err.Error()}) - continue - } +type TemplateACLUpdateValidator codersdk.UpdateTemplateACL - id, err := uuid.Parse(idStr) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: idStr + "is not a valid UUID."}) - continue - } +var ( + templateACLUpdateUsersFieldName = "user_perms" + templateACLUpdateGroupsFieldName = "group_perms" +) - switch field { - case "user_perms": - // This could get slow if we get a ton of user perm updates. - _, err = db.GetUserByID(ctx, id) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) - continue - } - case "group_perms": - // This could get slow if we get a ton of group perm updates. - _, err = db.GetGroupByID(ctx, id) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", idStr, err.Error())}) - continue - } - default: - validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: "invalid field"}) - } +// TemplateACLUpdateValidator implements acl.UpdateValidator[codersdk.TemplateRole] +var _ acl.UpdateValidator[codersdk.TemplateRole] = TemplateACLUpdateValidator{} + +func (w TemplateACLUpdateValidator) Users() (map[string]codersdk.TemplateRole, string) { + return w.UserPerms, templateACLUpdateUsersFieldName +} + +func (w TemplateACLUpdateValidator) Groups() (map[string]codersdk.TemplateRole, string) { + return w.GroupPerms, templateACLUpdateGroupsFieldName +} + +func (TemplateACLUpdateValidator) ValidateRole(role codersdk.TemplateRole) error { + actions := db2sdk.TemplateRoleActions(role) + if len(actions) == 0 && role != codersdk.TemplateRoleDeleted { + return xerrors.Errorf("role %q is not a valid template role", role) } - return validErrs + return nil } func convertTemplateUsers(tus []database.TemplateUser, orgIDsByUserIDs map[uuid.UUID][]uuid.UUID) []codersdk.TemplateUser { @@ -325,24 +306,15 @@ func convertTemplateUsers(tus []database.TemplateUser, orgIDsByUserIDs map[uuid. return users } -func validateTemplateRole(role codersdk.TemplateRole) error { - actions := db2sdk.TemplateRoleActions(role) - if len(actions) == 0 && role != codersdk.TemplateRoleDeleted { - return xerrors.Errorf("role %q is not a valid Template role", role) - } - - return nil -} - func convertToTemplateRole(actions []policy.Action) codersdk.TemplateRole { switch { - case len(actions) == 2 && slice.SameElements(actions, []policy.Action{policy.ActionUse, policy.ActionRead}): - return codersdk.TemplateRoleUse - case len(actions) == 1 && actions[0] == policy.WildcardSymbol: + case slice.SameElements(actions, db2sdk.TemplateRoleActions(codersdk.TemplateRoleAdmin)): return codersdk.TemplateRoleAdmin + case slice.SameElements(actions, db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse)): + return codersdk.TemplateRoleUse } - return "" + return codersdk.TemplateRoleDeleted } // TODO move to api.RequireFeatureMW when we are OK with changing the behavior. diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 6c7a20f85a642..e5eafa82f8d1c 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -70,8 +70,7 @@ func TestTemplates(t *testing.T) { _ = coderdtest.CreateWorkspace(t, otherClient, secondTemplate.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ DeprecationMessage: ptr.Ref("Stop using this template"), @@ -185,8 +184,7 @@ func TestTemplates(t *testing.T) { ws, err := client.Workspace(context.Background(), ws.ID) require.NoError(t, err) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) // OK var level codersdk.WorkspaceAgentPortShareLevel = codersdk.WorkspaceAgentPortShareLevelPublic @@ -261,9 +259,9 @@ func TestTemplates(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) updated, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, AutostartRequirement: &codersdk.TemplateAutostartRequirement{ DaysOfWeek: []string{"monday", "saturday"}, }, @@ -278,9 +276,9 @@ func TestTemplates(t *testing.T) { // Ensure a missing field is a noop updated, err = anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon + "something", + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: ptr.Ref(template.Icon + "something"), }) require.NoError(t, err) require.Equal(t, []string{"monday", "saturday"}, updated.AutostartRequirement.DaysOfWeek) @@ -315,9 +313,9 @@ func TestTemplates(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) _, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, AutostartRequirement: &codersdk.TemplateAutostartRequirement{ DaysOfWeek: []string{"foobar", "saturday"}, }, @@ -351,9 +349,9 @@ func TestTemplates(t *testing.T) { ctx := context.Background() updated, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, DefaultTTLMillis: time.Hour.Milliseconds(), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ @@ -405,9 +403,9 @@ func TestTemplates(t *testing.T) { updated, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, TimeTilDormantMillis: inactivityTTL.Milliseconds(), FailureTTLMillis: failureTTL.Milliseconds(), @@ -474,9 +472,9 @@ func TestTemplates(t *testing.T) { t.Run(c.Name, func(t *testing.T) { _, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, TimeTilDormantMillis: c.TimeTilDormantMS, FailureTTLMillis: c.FailureTTLMS, @@ -704,8 +702,7 @@ func TestTemplates(t *testing.T) { coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) require.True(t, template.RequireActiveVersion) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) // Update the field and assert it persists. updatedTemplate, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ @@ -761,9 +758,6 @@ func TestTemplates(t *testing.T) { }) require.NoError(t, err) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - _, err = owner.Template(ctx, template.ID) require.NoError(t, err) }) @@ -932,8 +926,7 @@ func TestTemplateACL(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) acl, err := anotherClient.TemplateACL(ctx, template.ID) require.NoError(t, err) @@ -955,8 +948,7 @@ func TestTemplateACL(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // non-template-admin cannot update template acl acl, err := client.TemplateACL(ctx, template.ID) @@ -1004,8 +996,7 @@ func TestTemplateACL(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // non-template-admin cannot get template acl acl, err := client.TemplateACL(ctx, template.ID) @@ -1013,9 +1004,9 @@ func TestTemplateACL(t *testing.T) { require.Equal(t, 1, len(acl.Groups)) _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, DisableEveryoneGroupAccess: true, }) @@ -1267,8 +1258,7 @@ func TestUpdateTemplateACL(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) err := anotherClient.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ UserPerms: map[string]codersdk.TemplateRole{ @@ -1359,8 +1349,7 @@ func TestUpdateTemplateACL(t *testing.T) { }, } - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) err := anotherClient.UpdateTemplateACL(ctx, template.ID, req) require.NoError(t, err) @@ -1413,13 +1402,40 @@ func TestUpdateTemplateACL(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) req := codersdk.UpdateTemplateACL{ UserPerms: map[string]codersdk.TemplateRole{ - "hi": "admin", + "hi": codersdk.TemplateRoleAdmin, + }, + } + + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Testing ACL validation + err := client.UpdateTemplateACL(ctx, template.ID, req) + require.Error(t, err) + cerr, _ := codersdk.AsError(err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) + + // We should report invalid UUIDs as errors + t.Run("DeleteRoleForInvalidUUID", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + "hi": codersdk.TemplateRoleDeleted, }, } ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // we're testing invalid UUID so testing RBAC is not relevant here. + //nolint:gocritic // Testing ACL validation err := client.UpdateTemplateACL(ctx, template.ID, req) require.Error(t, err) cerr, _ := codersdk.AsError(err) @@ -1445,13 +1461,75 @@ func TestUpdateTemplateACL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // we're testing invalid user so testing RBAC is not relevant here. + //nolint:gocritic // Testing ACL validation err := client.UpdateTemplateACL(ctx, template.ID, req) require.Error(t, err) cerr, _ := codersdk.AsError(err) require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) }) + // We should allow the special "Delete" role for valid UUIDs that don't + // correspond to a valid user, because the user might have been deleted. + t.Run("DeleteRoleForDeletedUser", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + _, deletedUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + //nolint:gocritic // Can't delete yourself + err := client.DeleteUser(ctx, deletedUser.ID) + require.NoError(t, err) + + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + deletedUser.ID.String(): codersdk.TemplateRoleDeleted, + }, + } + //nolint:gocritic // Testing ACL validation + err = client.UpdateTemplateACL(ctx, template.ID, req) + require.NoError(t, err) + }) + + t.Run("DeletedUser", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + _, deletedUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + //nolint:gocritic // Can't delete yourself + err := client.DeleteUser(ctx, deletedUser.ID) + require.NoError(t, err) + + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + deletedUser.ID.String(): codersdk.TemplateRoleAdmin, + }, + } + //nolint:gocritic // Testing ACL validation + err = client.UpdateTemplateACL(ctx, template.ID, req) + require.Error(t, err) + cerr, _ := codersdk.AsError(err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) + t.Run("InvalidRole", func(t *testing.T) { t.Parallel() @@ -1472,7 +1550,7 @@ func TestUpdateTemplateACL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // we're testing invalid role so testing RBAC is not relevant here. + //nolint:gocritic // Testing ACL validation err := client.UpdateTemplateACL(ctx, template.ID, req) require.Error(t, err) cerr, _ := codersdk.AsError(err) @@ -1590,8 +1668,7 @@ func TestUpdateTemplateACL(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) acl, err := anotherClient.TemplateACL(ctx, template.ID) require.NoError(t, err) @@ -1680,8 +1757,7 @@ func TestUpdateTemplateACL(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) acl, err := anotherClient.TemplateACL(ctx, template.ID) require.NoError(t, err) diff --git a/enterprise/coderd/usage/inserter.go b/enterprise/coderd/usage/inserter.go new file mode 100644 index 0000000000000..f3566595a181f --- /dev/null +++ b/enterprise/coderd/usage/inserter.go @@ -0,0 +1,68 @@ +package usage + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "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" +) + +// dbInserter collects usage events and stores them in the database for +// publishing. +type dbInserter struct { + clock quartz.Clock +} + +var _ agplusage.Inserter = &dbInserter{} + +// NewDBInserter creates a new database-backed usage event inserter. +func NewDBInserter(opts ...InserterOption) agplusage.Inserter { + c := &dbInserter{ + clock: quartz.NewReal(), + } + for _, opt := range opts { + opt(c) + } + return c +} + +type InserterOption func(*dbInserter) + +// InserterWithClock sets the quartz clock to use for the inserter. +func InserterWithClock(clock quartz.Clock) InserterOption { + return func(c *dbInserter) { + c.clock = clock + } +} + +// InsertDiscreteUsageEvent implements agplusage.Inserter. +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()) + } + if err := event.Valid(); err != nil { + return xerrors.Errorf("invalid %q event: %w", event.EventType(), err) + } + + jsonData, err := json.Marshal(event.Fields()) + if err != nil { + return xerrors.Errorf("marshal event as JSON: %w", err) + } + + // Duplicate events are ignored by the query, so we don't need to check the + // error. + return tx.InsertUsageEvent(ctx, database.InsertUsageEventParams{ + // Always generate a new UUID for discrete events. + ID: uuid.New().String(), + EventType: string(event.EventType()), + EventData: jsonData, + CreatedAt: dbtime.Time(i.clock.Now()), + }) +} diff --git a/enterprise/coderd/usage/inserter_test.go b/enterprise/coderd/usage/inserter_test.go new file mode 100644 index 0000000000000..7ac915be7a5a8 --- /dev/null +++ b/enterprise/coderd/usage/inserter_test.go @@ -0,0 +1,85 @@ +package usage_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "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/usage/usagetypes" + "github.com/coder/coder/v2/enterprise/coderd/usage" + "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" +) + +func TestInserter(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + clock := quartz.NewMock(t) + inserter := usage.NewDBInserter(usage.InserterWithClock(clock)) + + now := dbtime.Now() + events := []struct { + time time.Time + event usagetypes.DiscreteEvent + }{ + { + time: now, + event: usagetypes.DCManagedAgentsV1{ + Count: 1, + }, + }, + { + time: now.Add(1 * time.Minute), + event: usagetypes.DCManagedAgentsV1{ + Count: 2, + }, + }, + } + + 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, e.event.EventType(), usagetypes.UsageEventType(params.EventType)) + assert.JSONEq(t, eventJSON, string(params.EventData)) + assert.Equal(t, e.time, params.CreatedAt) + return nil + }, + ).Times(1) + + clock.Set(e.time) + err := inserter.InsertDiscreteUsageEvent(ctx, db, e.event) + require.NoError(t, err) + } + }) + + t.Run("InvalidEvent", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + // We should get an error if the event is invalid. + 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 new file mode 100644 index 0000000000000..ce38f9a24a925 --- /dev/null +++ b/enterprise/coderd/usage/publisher.go @@ -0,0 +1,433 @@ +package usage + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/google/uuid" + "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" + "github.com/coder/coder/v2/coderd/usage/usagetypes" + "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/quartz" +) + +const ( + tallymanURL = "https://tallyman-prod.coder.com" + tallymanIngestURLV1 = tallymanURL + "/api/v1/events/ingest" + + tallymanPublishInitialMinimumDelay = 5 * time.Minute + // Chosen to be a prime number and not a multiple of 5 like many other + // recurring tasks. + tallymanPublishInterval = 17 * time.Minute + tallymanPublishTimeout = 30 * time.Second + tallymanPublishBatchSize = 100 +) + +var errUsagePublishingDisabled = xerrors.New("usage publishing is not enabled by any license") + +// Publisher publishes usage events ***somewhere***. +type Publisher interface { + // Close closes the publisher and waits for it to finish. + io.Closer + // Start starts the publisher. It must only be called once. + Start() error +} + +type tallymanPublisher 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 + initialDelay time.Duration +} + +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, 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, + licenseKeys: keys, + done: make(chan struct{}), + + ingestURL: tallymanIngestURLV1, + httpClient: http.DefaultClient, + clock: quartz.NewReal(), + } + for _, opt := range opts { + opt(publisher) + } + return publisher +} + +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 + } +} + +// PublisherWithClock sets the clock to use for publishing usage events. +func PublisherWithClock(clock quartz.Clock) TallymanPublisherOption { + return func(p *tallymanPublisher) { + p.clock = clock + } +} + +// PublisherWithIngestURL sets the ingest URL to use for publishing usage +// events. +func PublisherWithIngestURL(ingestURL string) TallymanPublisherOption { + return func(p *tallymanPublisher) { + p.ingestURL = ingestURL + } +} + +// PublisherWithInitialDelay sets the initial delay for the publisher. +func PublisherWithInitialDelay(initialDelay time.Duration) TallymanPublisherOption { + return func(p *tallymanPublisher) { + p.initialDelay = initialDelay + } +} + +// Start implements Publisher. +func (p *tallymanPublisher) Start() error { + ctx := p.ctx + deploymentID, err := p.db.GetDeploymentID(ctx) + if err != nil { + return xerrors.Errorf("get deployment ID: %w", err) + } + deploymentUUID, err := uuid.Parse(deploymentID) + if err != nil { + return xerrors.Errorf("parse deployment ID %q: %w", deploymentID, err) + } + + if p.initialDelay <= 0 { + // Pick a random time between tallymanPublishInitialMinimumDelay and + // tallymanPublishInterval. + 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) + }) + return nil +} + +func (p *tallymanPublisher) publishLoop(ctx context.Context, deploymentID uuid.UUID) { + defer close(p.done) + + // Start the ticker with the initial delay. We will reset it to the interval + // after the first tick. + ticker := p.clock.NewTicker(p.initialDelay) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + + err := p.publish(ctx, deploymentID) + if err != nil { + p.log.Warn(ctx, "publish usage events to tallyman", slog.Error(err)) + } + ticker.Reset(tallymanPublishInterval) + } +} + +// publish publishes usage events to Tallyman in a loop until there is an error +// (or any rejection) or there are no more events to publish. +func (p *tallymanPublisher) publish(ctx context.Context, deploymentID uuid.UUID) error { + for { + publishCtx, publishCtxCancel := context.WithTimeout(ctx, tallymanPublishTimeout) + accepted, err := p.publishOnce(publishCtx, deploymentID) + publishCtxCancel() + if err != nil { + return xerrors.Errorf("publish usage events to tallyman: %w", err) + } + if accepted < tallymanPublishBatchSize { + // We published less than the batch size, so we're done. + return nil + } + } +} + +// publishOnce publishes up to tallymanPublishBatchSize usage events to +// tallyman. It returns the number of successfully published events. +func (p *tallymanPublisher) publishOnce(ctx context.Context, deploymentID uuid.UUID) (int, error) { + licenseJwt, err := p.getBestLicenseJWT(ctx) + if xerrors.Is(err, errUsagePublishingDisabled) { + return 0, nil + } else if err != nil { + return 0, xerrors.Errorf("find usage publishing license: %w", err) + } + + events, err := p.db.SelectUsageEventsForPublishing(ctx, dbtime.Time(p.clock.Now())) + if err != nil { + return 0, xerrors.Errorf("select usage events for publishing: %w", err) + } + if len(events) == 0 { + // No events to publish. + return 0, nil + } + + var ( + eventIDs = make(map[string]struct{}) + tallymanReq = usagetypes.TallymanV1IngestRequest{ + Events: make([]usagetypes.TallymanV1IngestEvent, 0, len(events)), + } + ) + for _, event := range events { + eventIDs[event.ID] = struct{}{} + 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, usagetypes.TallymanV1IngestEvent{ + ID: event.ID, + EventType: eventType, + EventData: event.EventData, + CreatedAt: event.CreatedAt, + }) + } + if len(eventIDs) != len(events) { + // This should never happen due to the unique constraint in the + // database. + return 0, xerrors.Errorf("duplicate event IDs found in events for publishing") + } + + 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 = usagetypes.TallymanV1IngestResponse{ + AcceptedEvents: []usagetypes.TallymanV1IngestAcceptedEvent{}, + RejectedEvents: make([]usagetypes.TallymanV1IngestRejectedEvent, len(events)), + } + for i, event := range events { + resp.RejectedEvents[i] = usagetypes.TallymanV1IngestRejectedEvent{ + ID: event.ID, + Message: fmt.Sprintf("failed to publish to tallyman: %v", err), + Permanent: false, + } + } + } else { + p.log.Debug(ctx, "published usage events to tallyman", slog.F("accepted", len(resp.AcceptedEvents)), slog.F("rejected", len(resp.RejectedEvents))) + } + + if len(resp.AcceptedEvents)+len(resp.RejectedEvents) != len(events) { + 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]*usagetypes.TallymanV1IngestAcceptedEvent) + rejectedEvents := make(map[string]*usagetypes.TallymanV1IngestRejectedEvent) + for _, event := range resp.AcceptedEvents { + acceptedEvents[event.ID] = &event + } + for _, event := range resp.RejectedEvents { + rejectedEvents[event.ID] = &event + } + + dbUpdate := database.UpdateUsageEventsPostPublishParams{ + Now: dbtime.Time(p.clock.Now()), + IDs: make([]string, len(events)), + FailureMessages: make([]string, len(events)), + SetPublishedAts: make([]bool, len(events)), + } + for i, event := range events { + dbUpdate.IDs[i] = event.ID + if _, ok := acceptedEvents[event.ID]; ok { + dbUpdate.FailureMessages[i] = "" + dbUpdate.SetPublishedAts[i] = true + continue + } + if rejectedEvent, ok := rejectedEvents[event.ID]; ok { + dbUpdate.FailureMessages[i] = rejectedEvent.Message + dbUpdate.SetPublishedAts[i] = rejectedEvent.Permanent + continue + } + // It's not good if this path gets hit, but we'll handle it as if it + // was a temporary rejection. + dbUpdate.FailureMessages[i] = "tallyman did not include the event in the response" + dbUpdate.SetPublishedAts[i] = false + } + + // Collate rejected events into a single map of ID to failure message for + // logging. We only want to log once. + // If all events failed, we'll log the overall error above. + if !allFailed { + rejectionReasonsForLog := make(map[string]string) + for i, id := range dbUpdate.IDs { + failureMessage := dbUpdate.FailureMessages[i] + if failureMessage == "" { + continue + } + setPublishedAt := dbUpdate.SetPublishedAts[i] + if setPublishedAt { + failureMessage = "permanently rejected: " + failureMessage + } + rejectionReasonsForLog[id] = failureMessage + } + if len(rejectionReasonsForLog) > 0 { + p.log.Warn(ctx, "tallyman rejected usage events", slog.F("count", len(rejectionReasonsForLog)), slog.F("event_failure_reasons", rejectionReasonsForLog)) + } + } + + err = p.db.UpdateUsageEventsPostPublish(ctx, dbUpdate) + if err != nil { + return 0, xerrors.Errorf("update usage events post publish: %w", err) + } + + var returnErr error + if len(resp.RejectedEvents) > 0 { + returnErr = xerrors.New("some events were rejected by tallyman") + } + return len(resp.AcceptedEvents), returnErr +} + +// getBestLicenseJWT returns the best license JWT to use for the request. The +// criteria is as follows: +// - The license must be valid and active (after nbf, before exp) +// - The license must have usage publishing enabled +// The most recently issued (iat) license is chosen. +// +// If no licenses are found or none have usage publishing enabled, +// errUsagePublishingDisabled is returned. +func (p *tallymanPublisher) getBestLicenseJWT(ctx context.Context) (string, error) { + licenses, err := p.db.GetUnexpiredLicenses(ctx) + if err != nil { + return "", xerrors.Errorf("get unexpired licenses: %w", err) + } + if len(licenses) == 0 { + return "", errUsagePublishingDisabled + } + + type licenseJWTWithClaims struct { + Claims *license.Claims + Raw string + } + + var bestLicense licenseJWTWithClaims + for _, dbLicense := range licenses { + claims, err := license.ParseClaims(dbLicense.JWT, p.licenseKeys) + if err != nil { + p.log.Warn(ctx, "failed to parse license claims", slog.F("license_id", dbLicense.ID), slog.Error(err)) + continue + } + if claims.AccountType != license.AccountTypeSalesforce { + // Non-Salesforce accounts cannot be tracked as they do not have a + // trusted Salesforce opportunity ID encoded in the license. + continue + } + if !claims.PublishUsageData { + // Publishing is disabled. + continue + } + + // Otherwise, if it's issued more recently, it's the best license. + // IssuedAt is verified to be non-nil in license.ParseClaims. + if bestLicense.Claims == nil || claims.IssuedAt.Time.After(bestLicense.Claims.IssuedAt.Time) { + bestLicense = licenseJWTWithClaims{ + Claims: claims, + Raw: dbLicense.JWT, + } + } + } + + if bestLicense.Raw == "" { + return "", errUsagePublishingDisabled + } + + return bestLicense.Raw, nil +} + +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 usagetypes.TallymanV1IngestResponse{}, err + } + + r, err := http.NewRequestWithContext(ctx, http.MethodPost, p.ingestURL, bytes.NewReader(body)) + if err != nil { + return usagetypes.TallymanV1IngestResponse{}, err + } + 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 usagetypes.TallymanV1IngestResponse{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var errBody usagetypes.TallymanV1Response + if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil { + errBody = usagetypes.TallymanV1Response{ + Message: fmt.Sprintf("could not decode error response body: %v", err), + } + } + return usagetypes.TallymanV1IngestResponse{}, xerrors.Errorf("unexpected status code %v, error: %s", resp.StatusCode, errBody.Message) + } + + var respBody usagetypes.TallymanV1IngestResponse + if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil { + return usagetypes.TallymanV1IngestResponse{}, xerrors.Errorf("decode response body: %w", err) + } + + return respBody, nil +} + +// Close implements Publisher. +func (p *tallymanPublisher) Close() error { + p.ctxCancel() + <-p.done + return nil +} diff --git a/enterprise/coderd/usage/publisher_test.go b/enterprise/coderd/usage/publisher_test.go new file mode 100644 index 0000000000000..c104c9712e499 --- /dev/null +++ b/enterprise/coderd/usage/publisher_test.go @@ -0,0 +1,746 @@ +package usage_test + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "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" + "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" + "github.com/coder/quartz" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m, testutil.GoleakOptions...) +} + +// TestIntegration tests the inserter and publisher by running them with a real +// database. +func TestIntegration(t *testing.T) { + t.Parallel() + const eventCount = 3 + + 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 usagetypes.TallymanV1IngestRequest) any + ) + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any { + calls++ + t.Logf("tallyman backend received call %d", calls) + + if handler == nil { + t.Errorf("handler is nil") + return usagetypes.TallymanV1IngestResponse{} + } + return handler(req) + })) + + 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, usagetypes.DCManagedAgentsV1{ + Count: 31, + }) + require.NoError(t, err) + + // Insert the events we expect to be published. + clock.Set(now.Add(1 * time.Second)) + for i := 0; i < eventCount; i++ { + clock.Advance(time.Second) + 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) + } + + // 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), + ) + defer publisher.Close() + + // Start the publisher with a trap. + tickerTrap := clock.Trap().NewTicker() + defer tickerTrap.Close() + startErr := make(chan error) + go func() { + err := publisher.Start() + testutil.AssertSend(ctx, t, startErr, err) + }() + tickerCall := tickerTrap.MustWait(ctx) + tickerCall.MustRelease(ctx) + // The initial duration will always be some time between 5m and 17m. + require.GreaterOrEqual(t, tickerCall.Duration, 5*time.Minute) + require.LessOrEqual(t, tickerCall.Duration, 17*time.Minute) + require.NoError(t, testutil.RequireReceive(ctx, t, startErr)) + + // Set up a trap for the ticker.Reset call. + tickerResetTrap := clock.Trap().TickerReset() + defer tickerResetTrap.Close() + + // Configure the handler for the first publish. This handler will accept the + // first event, temporarily reject the second, and permanently reject the + // third. + var temporarilyRejectedEventID string + 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([]usagetypes.TallymanV1IngestAcceptedEvent, 1) + rejectedEvents := make([]usagetypes.TallymanV1IngestRejectedEvent, 2) + if assert.Len(t, req.Events, eventCount) { + 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, 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, 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 usagetypes.TallymanV1IngestResponse{ + AcceptedEvents: acceptedEvents, + RejectedEvents: rejectedEvents, + } + } + + // Advance the clock to the initial tick, which should trigger the first + // publish, then wait for the reset call. The duration will always be 17m + // for resets (only the initial tick is variable). + clock.Advance(tickerCall.Duration) + tickerResetCall := tickerResetTrap.MustWait(ctx) + require.Equal(t, 17*time.Minute, tickerResetCall.Duration) + tickerResetCall.MustRelease(ctx) + + // The publisher should have published the events once. + require.Equal(t, 1, calls) + + // 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 usagetypes.TallymanV1IngestRequest) any { + assert.Len(t, req.Events, 1) + 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 usagetypes.TallymanV1IngestResponse{ + AcceptedEvents: acceptedEvents, + RejectedEvents: []usagetypes.TallymanV1IngestRejectedEvent{}, + } + } + + // Advance the clock to the next tick and wait for the reset call. + clock.Advance(tickerResetCall.Duration) + tickerResetCall = tickerResetTrap.MustWait(ctx) + tickerResetCall.MustRelease(ctx) + + // The publisher should have published the events again. + require.Equal(t, 2, calls) + + // There should be no more publish calls after this, so set the handler to + // nil. + handler = nil + + // Advance the clock to the next tick. + clock.Advance(tickerResetCall.Duration) + tickerResetTrap.MustWait(ctx).MustRelease(ctx) + + // No publish should have taken place since there are no more events to + // publish. + require.Equal(t, 2, calls) + + require.NoError(t, publisher.Close()) +} + +func TestPublisherNoEligibleLicenses(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + log := slogtest.Make(t, nil) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + clock := quartz.NewMock(t) + + // Configure the deployment manually. + deploymentID := uuid.New() + db.EXPECT().GetDeploymentID(gomock.Any()).Return(deploymentID.String(), nil).Times(1) + + var calls int + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), "", func(req usagetypes.TallymanV1IngestRequest) any { + calls++ + return usagetypes.TallymanV1IngestResponse{ + AcceptedEvents: []usagetypes.TallymanV1IngestAcceptedEvent{}, + RejectedEvents: []usagetypes.TallymanV1IngestRejectedEvent{}, + } + })) + + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, + usage.PublisherWithClock(clock), + usage.PublisherWithIngestURL(ingestURL), + ) + defer publisher.Close() + + // Start the publisher with a trap. + tickerTrap := clock.Trap().NewTicker() + defer tickerTrap.Close() + startErr := make(chan error) + go func() { + err := publisher.Start() + testutil.RequireSend(ctx, t, startErr, err) + }() + tickerCall := tickerTrap.MustWait(ctx) + tickerCall.MustRelease(ctx) + require.NoError(t, testutil.RequireReceive(ctx, t, startErr)) + + // Mock zero licenses. + db.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{}, nil).Times(1) + + // Tick and wait for the reset call. + tickerResetTrap := clock.Trap().TickerReset() + defer tickerResetTrap.Close() + clock.Advance(tickerCall.Duration) + tickerResetCall := tickerResetTrap.MustWait(ctx) + tickerResetCall.MustRelease(ctx) + + // The publisher should not have published the events. + require.Equal(t, 0, calls) + + // Mock a single license with usage publishing disabled. + licenseJWT := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + PublishUsageData: false, + }) + db.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{ + { + ID: 1, + JWT: licenseJWT, + UploadedAt: dbtime.Now(), + Exp: dbtime.Now().Add(48 * time.Hour), // fake + UUID: uuid.New(), + }, + }, nil).Times(1) + + // Tick and wait for the reset call. + clock.Advance(tickerResetCall.Duration) + tickerResetTrap.MustWait(ctx).MustRelease(ctx) + + // The publisher should still not have published the events. + require.Equal(t, 0, calls) +} + +// TestPublisherClaimExpiry tests the claim query to ensure that events are not +// claimed if they've recently been claimed by another publisher. +func TestPublisherClaimExpiry(t *testing.T) { + t.Parallel() + 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 + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any { + calls++ + return tallymanAcceptAllHandler(req) + })) + + inserter := usage.NewDBInserter( + usage.InserterWithClock(clock), + ) + + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, + usage.PublisherWithClock(clock), + usage.PublisherWithIngestURL(ingestURL), + usage.PublisherWithInitialDelay(17*time.Minute), + ) + defer publisher.Close() + + // 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, usagetypes.DCManagedAgentsV1{ + Count: 1, + }) + require.NoError(t, err) + // Claim the event in the past. Claiming it this way via the database + // directly means it won't be marked as published or unclaimed. + events, err := db.SelectUsageEventsForPublishing(ctx, now.Add(-42*time.Minute)) + require.NoError(t, err) + require.Len(t, events, 1) + + // Start the publisher with a trap. + tickerTrap := clock.Trap().NewTicker() + defer tickerTrap.Close() + startErr := make(chan error) + go func() { + err := publisher.Start() + testutil.RequireSend(ctx, t, startErr, err) + }() + tickerCall := tickerTrap.MustWait(ctx) + require.Equal(t, 17*time.Minute, tickerCall.Duration) + tickerCall.MustRelease(ctx) + require.NoError(t, testutil.RequireReceive(ctx, t, startErr)) + + // Set up a trap for the ticker.Reset call. + tickerResetTrap := clock.Trap().TickerReset() + defer tickerResetTrap.Close() + + // Advance the clock to the initial tick, which should trigger the first + // publish, then wait for the reset call. The duration will always be 17m + // for resets (only the initial tick is variable). + clock.Advance(tickerCall.Duration) + tickerResetCall := tickerResetTrap.MustWait(ctx) + require.Equal(t, 17*time.Minute, tickerResetCall.Duration) + tickerResetCall.MustRelease(ctx) + + // No events should have been published since none are eligible. + require.Equal(t, 0, calls) + + // Advance the clock to the next tick and wait for the reset call. + clock.Advance(tickerResetCall.Duration) + tickerResetCall = tickerResetTrap.MustWait(ctx) + tickerResetCall.MustRelease(ctx) + + // The publisher should have published the event, as it's now eligible. + require.Equal(t, 1, calls) +} + +// TestPublisherMissingEvents tests that the publisher notices events that are +// not returned by the Tallyman server and marks them as temporarily rejected. +func TestPublisherMissingEvents(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + log := slogtest.Make(t, nil) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + deploymentID, licenseJWT := configureMockDeployment(t, db) + clock := quartz.NewMock(t) + now := time.Now() + clock.Set(now) + + var calls int + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any { + calls++ + return usagetypes.TallymanV1IngestResponse{ + AcceptedEvents: []usagetypes.TallymanV1IngestAcceptedEvent{}, + RejectedEvents: []usagetypes.TallymanV1IngestRejectedEvent{}, + } + })) + + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, + usage.PublisherWithClock(clock), + usage.PublisherWithIngestURL(ingestURL), + ) + + // Expect the publisher to call SelectUsageEventsForPublishing, followed by + // UpdateUsageEventsPostPublish. + events := []database.UsageEvent{ + { + ID: uuid.New().String(), + EventType: string(usagetypes.UsageEventTypeDCManagedAgentsV1), + EventData: []byte(jsoninate(t, usagetypes.DCManagedAgentsV1{ + Count: 1, + })), + CreatedAt: now, + PublishedAt: sql.NullTime{}, + PublishStartedAt: sql.NullTime{}, + FailureMessage: sql.NullString{}, + }, + } + db.EXPECT().SelectUsageEventsForPublishing(gomock.Any(), gomock.Any()).Return(events, nil).Times(1) + db.EXPECT().UpdateUsageEventsPostPublish(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, params database.UpdateUsageEventsPostPublishParams) error { + assert.Equal(t, []string{events[0].ID}, params.IDs) + assert.Equal(t, []string{"tallyman did not include the event in the response"}, params.FailureMessages) + assert.Equal(t, []bool{false}, params.SetPublishedAts) + return nil + }, + ).Times(1) + + // Start the publisher with a trap. + tickerTrap := clock.Trap().NewTicker() + defer tickerTrap.Close() + startErr := make(chan error) + go func() { + err := publisher.Start() + testutil.RequireSend(ctx, t, startErr, err) + }() + tickerCall := tickerTrap.MustWait(ctx) + tickerCall.MustRelease(ctx) + require.NoError(t, testutil.RequireReceive(ctx, t, startErr)) + + // Tick and wait for the reset call. + tickerResetTrap := clock.Trap().TickerReset() + defer tickerResetTrap.Close() + clock.Advance(tickerCall.Duration) + tickerResetTrap.MustWait(ctx).MustRelease(ctx) + + // The publisher should have published the events once. + require.Equal(t, 1, calls) + + require.NoError(t, publisher.Close()) +} + +func TestPublisherLicenseSelection(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + log := slogtest.Make(t, nil) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + clock := quartz.NewMock(t) + now := time.Now() + + // Configure the deployment manually. + deploymentID := uuid.New() + db.EXPECT().GetDeploymentID(gomock.Any()).Return(deploymentID.String(), nil).Times(1) + + // Insert multiple licenses: + // 1. PublishUsageData false, type=salesforce, iat 30m ago (ineligible, publish not enabled) + // 2. PublishUsageData true, type=trial, iat 1h ago (ineligible, not salesforce) + // 3. PublishUsageData true, type=salesforce, iat 30m ago, exp 10m ago (ineligible, expired) + // 4. PublishUsageData true, type=salesforce, iat 1h ago (eligible) + // 5. PublishUsageData true, type=salesforce, iat 30m ago (eligible, and newer!) + badLicense1 := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + PublishUsageData: false, + IssuedAt: now.Add(-30 * time.Minute), + }) + badLicense2 := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + PublishUsageData: true, + IssuedAt: now.Add(-1 * time.Hour), + AccountType: "trial", + }) + badLicense3 := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + PublishUsageData: true, + IssuedAt: now.Add(-30 * time.Minute), + ExpiresAt: now.Add(-10 * time.Minute), + }) + badLicense4 := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + PublishUsageData: true, + IssuedAt: now.Add(-1 * time.Hour), + }) + expectedLicense := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + PublishUsageData: true, + IssuedAt: now.Add(-30 * time.Minute), + }) + // GetUnexpiredLicenses is not supposed to return expired licenses, but for + // the purposes of this test we're going to do it anyway. + db.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{ + { + ID: 1, + JWT: badLicense1, + Exp: now.Add(48 * time.Hour), // fake times, the JWT should be checked + UUID: uuid.New(), + UploadedAt: now, + }, + { + ID: 2, + JWT: badLicense2, + Exp: now.Add(48 * time.Hour), + UUID: uuid.New(), + UploadedAt: now, + }, + { + ID: 3, + JWT: badLicense3, + Exp: now.Add(48 * time.Hour), + UUID: uuid.New(), + UploadedAt: now, + }, + { + ID: 4, + JWT: badLicense4, + Exp: now.Add(48 * time.Hour), + UUID: uuid.New(), + UploadedAt: now, + }, + { + ID: 5, + JWT: expectedLicense, + Exp: now.Add(48 * time.Hour), + UUID: uuid.New(), + UploadedAt: now, + }, + }, nil) + + called := false + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), expectedLicense, func(req usagetypes.TallymanV1IngestRequest) any { + called = true + return tallymanAcceptAllHandler(req) + })) + + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, + usage.PublisherWithClock(clock), + usage.PublisherWithIngestURL(ingestURL), + ) + defer publisher.Close() + + // Start the publisher with a trap. + tickerTrap := clock.Trap().NewTicker() + defer tickerTrap.Close() + startErr := make(chan error) + go func() { + err := publisher.Start() + testutil.RequireSend(ctx, t, startErr, err) + }() + tickerCall := tickerTrap.MustWait(ctx) + tickerCall.MustRelease(ctx) + require.NoError(t, testutil.RequireReceive(ctx, t, startErr)) + + // Mock events to be published. + events := []database.UsageEvent{ + { + ID: uuid.New().String(), + EventType: string(usagetypes.UsageEventTypeDCManagedAgentsV1), + EventData: []byte(jsoninate(t, usagetypes.DCManagedAgentsV1{ + Count: 1, + })), + }, + } + db.EXPECT().SelectUsageEventsForPublishing(gomock.Any(), gomock.Any()).Return(events, nil).Times(1) + db.EXPECT().UpdateUsageEventsPostPublish(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, params database.UpdateUsageEventsPostPublishParams) error { + assert.Equal(t, []string{events[0].ID}, params.IDs) + assert.Equal(t, []string{""}, params.FailureMessages) + assert.Equal(t, []bool{true}, params.SetPublishedAts) + return nil + }, + ).Times(1) + + // Tick and wait for the reset call. + tickerResetTrap := clock.Trap().TickerReset() + defer tickerResetTrap.Close() + clock.Advance(tickerCall.Duration) + tickerResetTrap.MustWait(ctx).MustRelease(ctx) + + // The publisher should have published the events once. + require.True(t, called) +} + +func TestPublisherTallymanError(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + log := slogtest.Make(t, nil) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + clock := quartz.NewMock(t) + now := time.Now() + clock.Set(now) + + deploymentID, licenseJWT := configureMockDeployment(t, db) + const errorMessage = "tallyman error" + var calls int + ingestURL := fakeServer(t, tallymanHandler(t, deploymentID.String(), licenseJWT, func(req usagetypes.TallymanV1IngestRequest) any { + calls++ + return usagetypes.TallymanV1Response{ + Message: errorMessage, + } + })) + + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, + usage.PublisherWithClock(clock), + usage.PublisherWithIngestURL(ingestURL), + ) + defer publisher.Close() + + // Start the publisher with a trap. + tickerTrap := clock.Trap().NewTicker() + defer tickerTrap.Close() + startErr := make(chan error) + go func() { + err := publisher.Start() + testutil.RequireSend(ctx, t, startErr, err) + }() + tickerCall := tickerTrap.MustWait(ctx) + tickerCall.MustRelease(ctx) + require.NoError(t, testutil.RequireReceive(ctx, t, startErr)) + + // Mock events to be published. + events := []database.UsageEvent{ + { + ID: uuid.New().String(), + EventType: string(usagetypes.UsageEventTypeDCManagedAgentsV1), + EventData: []byte(jsoninate(t, usagetypes.DCManagedAgentsV1{ + Count: 1, + })), + }, + } + db.EXPECT().SelectUsageEventsForPublishing(gomock.Any(), gomock.Any()).Return(events, nil).Times(1) + db.EXPECT().UpdateUsageEventsPostPublish(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, params database.UpdateUsageEventsPostPublishParams) error { + assert.Equal(t, []string{events[0].ID}, params.IDs) + assert.Contains(t, params.FailureMessages[0], errorMessage) + assert.Equal(t, []bool{false}, params.SetPublishedAts) + return nil + }, + ).Times(1) + + // Tick and wait for the reset call. + tickerResetTrap := clock.Trap().TickerReset() + defer tickerResetTrap.Close() + clock.Advance(tickerCall.Duration) + tickerResetTrap.MustWait(ctx).MustRelease(ctx) + + // The publisher should have published the events once. + require.Equal(t, 1, calls) +} + +func jsoninate(t *testing.T, v any) string { + t.Helper() + if e, ok := v.(usagetypes.Event); ok { + v = e.Fields() + } + buf, err := json.Marshal(v) + require.NoError(t, err) + return string(buf) +} + +func configureDeployment(ctx context.Context, t *testing.T, db database.Store) (uuid.UUID, string) { + t.Helper() + deploymentID := uuid.New() + err := db.InsertDeploymentID(ctx, deploymentID.String()) + require.NoError(t, err) + + licenseRaw := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + PublishUsageData: true, + }) + _, err = db.InsertLicense(ctx, database.InsertLicenseParams{ + UploadedAt: dbtime.Now(), + JWT: licenseRaw, + Exp: dbtime.Now().Add(48 * time.Hour), + UUID: uuid.New(), + }) + require.NoError(t, err) + + return deploymentID, licenseRaw +} + +func configureMockDeployment(t *testing.T, db *dbmock.MockStore) (uuid.UUID, string) { + t.Helper() + deploymentID := uuid.New() + db.EXPECT().GetDeploymentID(gomock.Any()).Return(deploymentID.String(), nil).Times(1) + + licenseRaw := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + PublishUsageData: true, + }) + db.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{ + { + ID: 1, + UploadedAt: dbtime.Now(), + JWT: licenseRaw, + Exp: dbtime.Now().Add(48 * time.Hour), + UUID: uuid.New(), + }, + }, nil) + + return deploymentID, licenseRaw +} + +func fakeServer(t *testing.T, handler http.Handler) string { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return server.URL +} + +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(usagetypes.TallymanCoderLicenseKeyHeader) + if expectLicenseJWT != "" && !assert.Equal(t, expectLicenseJWT, licenseJWT, "license JWT in request did not match") { + rw.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(rw).Encode(usagetypes.TallymanV1Response{ + Message: "license JWT in request did not match", + }) + return + } + + 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) + 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 usagetypes.TallymanV1Response: + rw.WriteHeader(http.StatusInternalServerError) + default: + rw.WriteHeader(http.StatusOK) + } + err = json.NewEncoder(rw).Encode(resp) + if !assert.NoError(t, err, "could not encode response body") { + rw.WriteHeader(http.StatusInternalServerError) + return + } + }) +} + +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 usagetypes.TallymanV1IngestResponse{ + AcceptedEvents: acceptedEvents, + 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/workspaceproxy_test.go b/enterprise/coderd/workspaceproxy_test.go index 23775f370f95f..28d46c0137b0d 100644 --- a/enterprise/coderd/workspaceproxy_test.go +++ b/enterprise/coderd/workspaceproxy_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/coderdtest" @@ -311,8 +312,7 @@ func TestProxyRegisterDeregister(t *testing.T) { }) require.NoError(t, err) - proxyClient := wsproxysdk.New(client.URL) - proxyClient.SetSessionToken(createRes.ProxyToken) + proxyClient := wsproxysdk.New(client.URL, createRes.ProxyToken) // Register req := wsproxysdk.RegisterWorkspaceProxyRequest{ @@ -426,8 +426,7 @@ func TestProxyRegisterDeregister(t *testing.T) { }) require.NoError(t, err) - proxyClient := wsproxysdk.New(client.URL) - proxyClient.SetSessionToken(createRes.ProxyToken) + proxyClient := wsproxysdk.New(client.URL, createRes.ProxyToken) req := wsproxysdk.RegisterWorkspaceProxyRequest{ AccessURL: "https://proxy.coder.test", @@ -471,8 +470,7 @@ func TestProxyRegisterDeregister(t *testing.T) { }) require.NoError(t, err) - proxyClient := wsproxysdk.New(client.URL) - proxyClient.SetSessionToken(createRes.ProxyToken) + proxyClient := wsproxysdk.New(client.URL, createRes.ProxyToken) err = proxyClient.DeregisterWorkspaceProxy(ctx, wsproxysdk.DeregisterWorkspaceProxyRequest{ ReplicaID: uuid.New(), @@ -500,8 +498,7 @@ func TestProxyRegisterDeregister(t *testing.T) { // Register a replica on proxy 2. This shouldn't be returned by replicas // for proxy 1. - proxyClient2 := wsproxysdk.New(client.URL) - proxyClient2.SetSessionToken(createRes2.ProxyToken) + proxyClient2 := wsproxysdk.New(client.URL, createRes2.ProxyToken) _, err = proxyClient2.RegisterWorkspaceProxy(ctx, wsproxysdk.RegisterWorkspaceProxyRequest{ AccessURL: "https://other.proxy.coder.test", WildcardHostname: "*.other.proxy.coder.test", @@ -515,8 +512,7 @@ func TestProxyRegisterDeregister(t *testing.T) { require.NoError(t, err) // Register replica 1. - proxyClient1 := wsproxysdk.New(client.URL) - proxyClient1.SetSessionToken(createRes1.ProxyToken) + proxyClient1 := wsproxysdk.New(client.URL, createRes1.ProxyToken) req1 := wsproxysdk.RegisterWorkspaceProxyRequest{ AccessURL: "https://one.proxy.coder.test", WildcardHostname: "*.one.proxy.coder.test", @@ -573,8 +569,7 @@ func TestProxyRegisterDeregister(t *testing.T) { }) require.NoError(t, err) - proxyClient := wsproxysdk.New(client.URL) - proxyClient.SetSessionToken(createRes.ProxyToken) + proxyClient := wsproxysdk.New(client.URL, createRes.ProxyToken) for i := 0; i < 100; i++ { ok := false @@ -651,8 +646,7 @@ func TestIssueSignedAppToken(t *testing.T) { t.Run("BadAppRequest", func(t *testing.T) { t.Parallel() - proxyClient := wsproxysdk.New(client.URL) - proxyClient.SetSessionToken(proxyRes.ProxyToken) + proxyClient := wsproxysdk.New(client.URL, proxyRes.ProxyToken) ctx := testutil.Context(t, testutil.WaitLong) _, err := proxyClient.IssueSignedAppToken(ctx, workspaceapps.IssueTokenRequest{ @@ -673,8 +667,7 @@ func TestIssueSignedAppToken(t *testing.T) { } t.Run("OK", func(t *testing.T) { t.Parallel() - proxyClient := wsproxysdk.New(client.URL) - proxyClient.SetSessionToken(proxyRes.ProxyToken) + proxyClient := wsproxysdk.New(client.URL, proxyRes.ProxyToken) ctx := testutil.Context(t, testutil.WaitLong) _, err := proxyClient.IssueSignedAppToken(ctx, goodRequest) @@ -683,8 +676,7 @@ func TestIssueSignedAppToken(t *testing.T) { t.Run("OKHTML", func(t *testing.T) { t.Parallel() - proxyClient := wsproxysdk.New(client.URL) - proxyClient.SetSessionToken(proxyRes.ProxyToken) + proxyClient := wsproxysdk.New(client.URL, proxyRes.ProxyToken) rw := httptest.NewRecorder() ctx := testutil.Context(t, testutil.WaitLong) @@ -1009,11 +1001,16 @@ func TestGetCryptoKeys(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) db, pubsub := dbtestutil.NewDB(t) + // IgnoreErrors is set here to avoid a test failure due to "used of closed network connection". + logger := slogtest.Make(t, &slogtest.Options{ + IgnoreErrors: true, + }) cclient, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ Options: &coderdtest.Options{ Database: db, Pubsub: pubsub, IncludeProvisionerDaemon: true, + Logger: &logger, }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ @@ -1026,8 +1023,7 @@ func TestGetCryptoKeys(t *testing.T) { Name: testutil.GetRandomName(t), }) - client := wsproxysdk.New(cclient.URL) - client.SetSessionToken(cclient.SessionToken()) + client := wsproxysdk.New(cclient.URL, cclient.SessionToken()) _, err := client.CryptoKeys(ctx, codersdk.CryptoKeyFeatureWorkspaceAppsAPIKey) require.Error(t, err) diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go index f49e135ad55b3..186af3a787d94 100644 --- a/enterprise/coderd/workspacequota_test.go +++ b/enterprise/coderd/workspacequota_test.go @@ -395,6 +395,265 @@ func TestWorkspaceQuota(t *testing.T) { verifyQuotaUser(ctx, t, client, second.Org.ID.String(), user.ID.String(), consumed, 35) }) + + // ZeroQuota tests that a user with a zero quota allowance can't create a workspace. + // Although relevant for all users, this test ensures that the prebuilds system user + // cannot create workspaces in an organization for which it has exhausted its quota. + t.Run("ZeroQuota", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Create a client with no quota allowance + client, _, api, user := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + UserWorkspaceQuota: 0, // Set user workspace quota to 0 + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + coderdtest.NewProvisionerDaemon(t, api.AGPL) + + // Verify initial quota is 0 + verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 0) + + // Create a template with a workspace that costs 1 credit + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + DailyCost: 1, + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "example", + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + // Attempt to create a workspace with zero quota - should fail + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Verify the build failed due to quota + require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status) + require.Contains(t, build.Job.Error, "quota") + + // Verify quota consumption remains at 0 + verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 0) + + // Test with a template that has zero cost - should pass + versionZeroCost := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + DailyCost: 0, // Zero cost workspace + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "example", + Auth: &proto.Agent_Token{ + Token: uuid.NewString(), + }, + }}, + }}, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, versionZeroCost.ID) + templateZeroCost := coderdtest.CreateTemplate(t, client, user.OrganizationID, versionZeroCost.ID) + + // Workspace with zero cost should pass + workspaceZeroCost := coderdtest.CreateWorkspace(t, client, templateZeroCost.ID) + buildZeroCost := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceZeroCost.LatestBuild.ID) + + require.Equal(t, codersdk.WorkspaceStatusRunning, buildZeroCost.Status) + require.Empty(t, buildZeroCost.Job.Error) + + // Verify quota consumption remains at 0 + verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 0) + }) + + // MultiOrg tests that a user can create workspaces in multiple organizations + // as long as they have enough quota in each organization. Specifically, + // in exhausted quota in one organization does not affect the ability to + // create workspaces in other organizations. This test is relevant to all users + // but is particularly relevant for the prebuilds system user. + t.Run("MultiOrg", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a setup with multiple organizations + owner, _, api, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureMultipleOrganizations: 1, + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + coderdtest.NewProvisionerDaemon(t, api.AGPL) + + // Create a second organization + second := coderdenttest.CreateOrganization(t, owner, coderdenttest.CreateOrganizationOptions{ + IncludeProvisionerDaemon: true, + }) + + // Create a user that will be a member of both organizations + user, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgMember(second.ID)) + + // Set up quota allowances for both organizations + // First org: 2 credits total + _, err := owner.PatchGroup(ctx, first.OrganizationID, codersdk.PatchGroupRequest{ + QuotaAllowance: ptr.Ref(2), + }) + require.NoError(t, err) + + // Second org: 3 credits total + _, err = owner.PatchGroup(ctx, second.ID, codersdk.PatchGroupRequest{ + QuotaAllowance: ptr.Ref(3), + }) + require.NoError(t, err) + + // Verify initial quotas + verifyQuota(ctx, t, user, first.OrganizationID.String(), 0, 2) + verifyQuota(ctx, t, user, second.ID.String(), 0, 3) + + // Create templates for both organizations + authToken := uuid.NewString() + version1 := coderdtest.CreateTemplateVersion(t, owner, first.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + DailyCost: 1, + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "example", + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, owner, version1.ID) + template1 := coderdtest.CreateTemplate(t, owner, first.OrganizationID, version1.ID) + + version2 := coderdtest.CreateTemplateVersion(t, owner, second.ID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + DailyCost: 1, + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "example", + Auth: &proto.Agent_Token{ + Token: uuid.NewString(), + }, + }}, + }}, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, owner, version2.ID) + template2 := coderdtest.CreateTemplate(t, owner, second.ID, version2.ID) + + // Exhaust quota in the first organization by creating 2 workspaces + var workspaces1 []codersdk.Workspace + for i := 0; i < 2; i++ { + workspace := coderdtest.CreateWorkspace(t, user, template1.ID) + build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, workspace.LatestBuild.ID) + require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) + workspaces1 = append(workspaces1, workspace) + } + + // Verify first org quota is exhausted + verifyQuota(ctx, t, user, first.OrganizationID.String(), 2, 2) + + // Try to create another workspace in the first org - should fail + workspace := coderdtest.CreateWorkspace(t, user, template1.ID) + build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, workspace.LatestBuild.ID) + require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status) + require.Contains(t, build.Job.Error, "quota") + + // Verify first org quota consumption didn't increase + verifyQuota(ctx, t, user, first.OrganizationID.String(), 2, 2) + + // Verify second org quota is still available + verifyQuota(ctx, t, user, second.ID.String(), 0, 3) + + // Create workspaces in the second organization - should succeed + for i := 0; i < 3; i++ { + workspace := coderdtest.CreateWorkspace(t, user, template2.ID) + build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, workspace.LatestBuild.ID) + require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) + } + + // Verify second org quota is now exhausted + verifyQuota(ctx, t, user, second.ID.String(), 3, 3) + + // Try to create another workspace in the second org - should fail + workspace = coderdtest.CreateWorkspace(t, user, template2.ID) + build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, workspace.LatestBuild.ID) + require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status) + require.Contains(t, build.Job.Error, "quota") + + // Verify second org quota consumption didn't increase + verifyQuota(ctx, t, user, second.ID.String(), 3, 3) + + // Verify first org quota is still exhausted + verifyQuota(ctx, t, user, first.OrganizationID.String(), 2, 2) + + // Delete one workspace from the first org to free up quota + build = coderdtest.CreateWorkspaceBuild(t, user, workspaces1[0], database.WorkspaceTransitionDelete) + build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, build.ID) + require.Equal(t, codersdk.WorkspaceStatusDeleted, build.Status) + + // Verify first org quota is now available again + verifyQuota(ctx, t, user, first.OrganizationID.String(), 1, 2) + + // Create a workspace in the first org - should succeed + workspace = coderdtest.CreateWorkspace(t, user, template1.ID) + build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, workspace.LatestBuild.ID) + require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) + + // Verify first org quota is exhausted again + verifyQuota(ctx, t, user, first.OrganizationID.String(), 2, 2) + + // Verify second org quota remains exhausted + verifyQuota(ctx, t, user, second.ID.String(), 3, 3) + }) } // nolint:paralleltest,tparallel // Tests must run serially @@ -462,7 +721,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 +778,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 +846,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 +898,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 +941,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 +995,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 +1052,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 +1112,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 2278fb2a71939..0943fd9077868 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -15,41 +15,41 @@ import ( "testing" "time" - "github.com/prometheus/client_golang/prometheus" - - "github.com/coder/coder/v2/coderd/files" - agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds" - "github.com/coder/coder/v2/enterprise/coderd/prebuilds" - "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog" - "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest/promhelp" "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/files" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" + agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" 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" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/enterprise/coderd/prebuilds" "github.com/coder/coder/v2/enterprise/coderd/schedule" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk" @@ -571,7 +571,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) @@ -619,7 +618,7 @@ func TestWorkspaceAutobuild(t *testing.T) { failureTTL = time.Minute ) - client, user := coderdenttest.New(t, &coderdenttest.Options{ + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ Logger: &logger, AutobuildTicker: ticker, @@ -644,7 +643,12 @@ func TestWorkspaceAutobuild(t *testing.T) { ws := coderdtest.CreateWorkspace(t, client, template.ID) build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status) - ticker <- build.Job.CompletedAt.Add(failureTTL * 2) + tickTime := build.Job.CompletedAt.Add(failureTTL * 2) + + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime stats := <-statCh // Expect workspace to transition to stopped state for breaching // failure TTL. @@ -666,7 +670,7 @@ func TestWorkspaceAutobuild(t *testing.T) { failureTTL = time.Minute ) - client, user := coderdenttest.New(t, &coderdenttest.Options{ + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ Logger: &logger, AutobuildTicker: ticker, @@ -691,7 +695,12 @@ func TestWorkspaceAutobuild(t *testing.T) { build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status) // Make it impossible to trigger the failure TTL. - ticker <- build.Job.CompletedAt.Add(-failureTTL * 2) + tickTime := build.Job.CompletedAt.Add(-failureTTL * 2) + + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime stats := <-statCh // Expect no transitions since not enough time has elapsed. require.Len(t, stats.Transitions, 0) @@ -759,10 +768,11 @@ func TestWorkspaceAutobuild(t *testing.T) { client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ - AutobuildTicker: ticker, - AutobuildStats: statCh, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil), - Auditor: auditRecorder, + AutobuildTicker: ticker, + AutobuildStats: statCh, + IncludeProvisionerDaemon: true, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil), + Auditor: auditRecorder, }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -790,7 +800,12 @@ func TestWorkspaceAutobuild(t *testing.T) { auditRecorder.ResetLogs() // Simulate being inactive. - ticker <- workspace.LastUsedAt.Add(inactiveTTL * 2) + tickTime := workspace.LastUsedAt.Add(inactiveTTL * 2) + + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime stats := <-statCh // Expect workspace to transition to stopped state for breaching @@ -813,7 +828,7 @@ func TestWorkspaceAutobuild(t *testing.T) { dormantLastUsedAt := ws.LastUsedAt // nolint:gocritic // this test is not testing RBAC. - err := client.UpdateWorkspaceDormancy(ctx, ws.ID, codersdk.UpdateWorkspaceDormancy{Dormant: false}) + err = client.UpdateWorkspaceDormancy(ctx, ws.ID, codersdk.UpdateWorkspaceDormancy{Dormant: false}) require.NoError(t, err) // Assert that we updated our last_used_at so that we don't immediately @@ -888,7 +903,12 @@ func TestWorkspaceAutobuild(t *testing.T) { } // Simulate being inactive. - ticker <- time.Now().Add(time.Hour) + // Fix provisioner stale issue by updating LastSeenAt to the tick time + tickTime := time.Now().Add(time.Hour) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspaces[0].OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime stats := <-statCh // Expect workspace to transition to stopped state for breaching @@ -997,7 +1017,7 @@ func TestWorkspaceAutobuild(t *testing.T) { ) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - client, user := coderdenttest.New(t, &coderdenttest.Options{ + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, IncludeProvisionerDaemon: true, @@ -1029,7 +1049,11 @@ func TestWorkspaceAutobuild(t *testing.T) { ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Simulate not having accessed the workspace in a while. - ticker <- ws.LastUsedAt.Add(2 * inactiveTTL) + tickTime := ws.LastUsedAt.Add(2 * inactiveTTL) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime stats := <-statCh // Expect no transitions since workspace is stopped. require.Len(t, stats.Transitions, 0) @@ -1051,7 +1075,7 @@ func TestWorkspaceAutobuild(t *testing.T) { ) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - client, user := coderdenttest.New(t, &coderdenttest.Options{ + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, IncludeProvisionerDaemon: true, @@ -1079,7 +1103,11 @@ func TestWorkspaceAutobuild(t *testing.T) { require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) // Simulate not having accessed the workspace in a while. - ticker <- ws.LastUsedAt.Add(2 * transitionTTL) + tickTime := ws.LastUsedAt.Add(2 * transitionTTL) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime stats := <-statCh // Expect workspace to transition to stopped state for breaching // inactive TTL. @@ -1094,7 +1122,9 @@ func TestWorkspaceAutobuild(t *testing.T) { _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) // Simulate the workspace being dormant beyond the threshold. - ticker <- ws.DormantAt.Add(2 * transitionTTL) + tickTime2 := ws.DormantAt.Add(2 * transitionTTL) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime2) + ticker <- tickTime2 stats = <-statCh require.Len(t, stats.Transitions, 1) // The workspace should be scheduled for deletion. @@ -1106,7 +1136,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // Assert that the workspace is actually deleted. //nolint:gocritic // ensuring workspace is deleted and not just invisible to us due to RBAC - _, err := client.Workspace(testutil.Context(t, testutil.WaitShort), ws.ID) + _, err = client.Workspace(testutil.Context(t, testutil.WaitShort), ws.ID) require.Error(t, err) cerr, ok := codersdk.AsError(err) require.True(t, ok) @@ -1123,7 +1153,7 @@ func TestWorkspaceAutobuild(t *testing.T) { ) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - client, user := coderdenttest.New(t, &coderdenttest.Options{ + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, IncludeProvisionerDaemon: true, @@ -1158,7 +1188,11 @@ func TestWorkspaceAutobuild(t *testing.T) { require.NotNil(t, ws.DormantAt) // Ensure we haven't breached our threshold. - ticker <- ws.DormantAt.Add(-dormantTTL * 2) + tickTime := ws.DormantAt.Add(-dormantTTL * 2) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime stats := <-statCh // Expect no transitions since not enough time has elapsed. require.Len(t, stats.Transitions, 0) @@ -1169,7 +1203,9 @@ func TestWorkspaceAutobuild(t *testing.T) { require.NoError(t, err) // Simlute the workspace breaching the threshold. - ticker <- ws.DormantAt.Add(dormantTTL * 2) + tickTime2 := ws.DormantAt.Add(dormantTTL * 2) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime2) + ticker <- tickTime2 stats = <-statCh require.Len(t, stats.Transitions, 1) require.Equal(t, database.WorkspaceTransitionDelete, stats.Transitions[ws.ID]) @@ -1186,7 +1222,7 @@ func TestWorkspaceAutobuild(t *testing.T) { ) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - client, user := coderdenttest.New(t, &coderdenttest.Options{ + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, @@ -1217,7 +1253,11 @@ func TestWorkspaceAutobuild(t *testing.T) { ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Assert that autostart works when the workspace isn't dormant.. - tickCh <- sched.Next(ws.LatestBuild.CreatedAt) + tickTime := sched.Next(ws.LatestBuild.CreatedAt) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime stats := <-statsCh require.Len(t, stats.Errors, 0) require.Len(t, stats.Transitions, 1) @@ -1237,7 +1277,9 @@ func TestWorkspaceAutobuild(t *testing.T) { require.NoError(t, err) // We should see the workspace get stopped now. - tickCh <- ws.LastUsedAt.Add(inactiveTTL * 2) + tickTime2 := ws.LastUsedAt.Add(inactiveTTL * 2) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime2) + tickCh <- tickTime2 stats = <-statsCh require.Len(t, stats.Errors, 0) require.Len(t, stats.Transitions, 1) @@ -1267,7 +1309,7 @@ func TestWorkspaceAutobuild(t *testing.T) { ) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - client, user := coderdenttest.New(t, &coderdenttest.Options{ + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: ticker, IncludeProvisionerDaemon: true, @@ -1335,13 +1377,19 @@ func TestWorkspaceAutobuild(t *testing.T) { // Simulate ticking an hour after the workspace is expected to be deleted. // Under normal circumstances this should result in a transition but // since our last build resulted in failure it should be skipped. - ticker <- build.Job.CompletedAt.Add(time.Hour) + tickTime := build.Job.CompletedAt.Add(time.Hour) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime stats := <-statCh require.Len(t, stats.Transitions, 0) // Simulate ticking a day after the workspace was last attempted to // be deleted. This should result in an attempt. - ticker <- build.Job.CompletedAt.Add(time.Hour * 25) + tickTime2 := build.Job.CompletedAt.Add(time.Hour * 25) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime2) + ticker <- tickTime2 stats = <-statCh require.Len(t, stats.Transitions, 1) require.Equal(t, database.WorkspaceTransitionDelete, stats.Transitions[ws.ID]) @@ -1356,7 +1404,7 @@ func TestWorkspaceAutobuild(t *testing.T) { ) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - client, user := coderdenttest.New(t, &coderdenttest.Options{ + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, @@ -1401,7 +1449,11 @@ func TestWorkspaceAutobuild(t *testing.T) { require.NoError(t, err) // Kick of an autostart build. - tickCh <- sched.Next(ws.LatestBuild.CreatedAt) + tickTime := sched.Next(ws.LatestBuild.CreatedAt) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + tickCh <- tickTime stats := <-statsCh require.Len(t, stats.Errors, 0) require.Len(t, stats.Transitions, 1) @@ -1429,7 +1481,9 @@ func TestWorkspaceAutobuild(t *testing.T) { }) // Force an autostart transition again. - tickCh <- sched.Next(firstBuild.CreatedAt) + tickTime2 := sched.Next(firstBuild.CreatedAt) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime2) + tickCh <- tickTime2 stats = <-statsCh require.Len(t, stats.Errors, 0) require.Len(t, stats.Transitions, 1) @@ -1453,7 +1507,7 @@ func TestWorkspaceAutobuild(t *testing.T) { clock.Set(dbtime.Now()) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - client, user := coderdenttest.New(t, &coderdenttest.Options{ + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, @@ -1494,6 +1548,9 @@ func TestWorkspaceAutobuild(t *testing.T) { next = sched.Next(next) clock.Set(next) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, next) tickCh <- next stats := <-statsCh ws = coderdtest.MustWorkspace(t, client, ws.ID) @@ -1651,7 +1708,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()}, @@ -1722,7 +1778,7 @@ func TestTemplateDoesNotAllowUserAutostop(t *testing.T) { }) } -func TestExecutorPrebuilds(t *testing.T) { +func TestPrebuildsAutobuild(t *testing.T) { t.Parallel() if !dbtestutil.WillUsePostgres() { @@ -1800,14 +1856,21 @@ func TestExecutorPrebuilds(t *testing.T) { username string, version codersdk.TemplateVersion, presetID uuid.UUID, + autostartSchedule ...string, ) codersdk.Workspace { t.Helper() + var startSchedule string + if len(autostartSchedule) > 0 { + startSchedule = autostartSchedule[0] + } + workspaceName := strings.ReplaceAll(testutil.GetRandomName(t), "_", "-") userWorkspace, err := userClient.CreateUserWorkspace(ctx, username, codersdk.CreateWorkspaceRequest{ TemplateVersionID: version.ID, Name: workspaceName, TemplateVersionPresetID: presetID, + AutostartSchedule: ptr.Ref(startSchedule), }) require.NoError(t, err) build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, userWorkspace.LatestBuild.ID) @@ -1820,7 +1883,7 @@ func TestExecutorPrebuilds(t *testing.T) { // Prebuilt workspaces should not be autostopped based on the default TTL. // This test ensures that DefaultTTLMillis is ignored while the workspace is in a prebuild state. - // Once the workspace is claimed, the default autostop timer should take effect. + // Once the workspace is claimed, the default TTL should take effect. t.Run("DefaultTTLOnlyTriggersAfterClaim", func(t *testing.T) { t.Parallel() @@ -1875,9 +1938,9 @@ func TestExecutorPrebuilds(t *testing.T) { userClient, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(prebuildInstances)) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + // Set a template level TTL to trigger the autostop + // Template level TTL can only be set if autostop is disabled for users coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - // Set a template level TTL to trigger the autostop - // Template level TTL can only be set if autostop is disabled for users ctr.AllowUserAutostop = ptr.Ref[bool](false) ctr.DefaultTTLMillis = ptr.Ref[int64](ttlTime.Milliseconds()) }) @@ -1890,43 +1953,48 @@ func TestExecutorPrebuilds(t *testing.T) { runningPrebuilds := getRunningPrebuilds(t, ctx, db, int(prebuildInstances)) require.Len(t, runningPrebuilds, int(prebuildInstances)) - // Given: a running prebuilt workspace with a deadline, ready to be claimed + // Given: a running prebuilt workspace, ready to be claimed prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) - require.NotZero(t, prebuild.LatestBuild.Deadline) - - // When: the autobuild executor ticks *after* the deadline - next := prebuild.LatestBuild.Deadline.Time.Add(time.Minute) - clock.Set(next) + // Prebuilt workspaces should have an empty Deadline and MaxDeadline + // which is equivalent to 0001-01-01 00:00:00 +0000 + require.Zero(t, prebuild.LatestBuild.Deadline) + require.Zero(t, prebuild.LatestBuild.MaxDeadline) + + // When: the autobuild executor ticks *after* the TTL time (10:00 AM UTC) + next := clock.Now().Add(ttlTime).Add(time.Minute) + clock.Set(next) // 10:01 AM UTC go func() { tickCh <- next }() // Then: the prebuilt workspace should remain in a start transition - prebuildStats := <-statsCh + prebuildStats := testutil.RequireReceive(ctx, t, statsCh) require.Len(t, prebuildStats.Errors, 0) require.Len(t, prebuildStats.Transitions, 0) require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + require.Zero(t, prebuild.LatestBuild.Deadline) + require.Zero(t, prebuild.LatestBuild.MaxDeadline) // Given: a user claims the prebuilt workspace sometime later - clock.Set(clock.Now().Add(ttlTime)) + clock.Set(clock.Now().Add(1 * time.Hour)) // 11:01 AM UTC workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID) require.Equal(t, prebuild.ID, workspace.ID) - // Workspace deadline must be ttlTime from the time it is claimed + // Workspace deadline must be ttlTime from the time it is claimed (1:01 PM UTC) require.True(t, workspace.LatestBuild.Deadline.Time.Equal(clock.Now().Add(ttlTime))) - // When: the autobuild executor ticks *after* the deadline + // When: the autobuild executor ticks *after* the TTL time (1:01 PM UTC) next = workspace.LatestBuild.Deadline.Time.Add(time.Minute) - clock.Set(next) + clock.Set(next) // 1:02 PM UTC go func() { tickCh <- next close(tickCh) }() // Then: the workspace should be stopped - workspaceStats := <-statsCh + workspaceStats := testutil.RequireReceive(ctx, t, statsCh) require.Len(t, workspaceStats.Errors, 0) require.Len(t, workspaceStats.Transitions, 1) require.Contains(t, workspaceStats.Transitions, workspace.ID) @@ -1941,158 +2009,125 @@ func TestExecutorPrebuilds(t *testing.T) { t.Run("AutostopScheduleOnlyTriggersAfterClaim", func(t *testing.T) { t.Parallel() - cases := []struct { - name string - isClaimedBeforeDeadline bool - }{ - // If the prebuild is claimed before the scheduled deadline, - // the claimed workspace should inherit and respect that same deadline. - { - name: "ClaimedBeforeDeadline_UsesSameDeadline", - isClaimedBeforeDeadline: true, + // Set the clock to Monday, January 1st, 2024 at 8:00 AM UTC to keep the test deterministic + clock := quartz.NewMock(t) + clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC)) + + // Setup + ctx := testutil.Context(t, testutil.WaitSuperLong) + db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + logger := testutil.Logger(t) + tickCh := make(chan time.Time) + statsCh := make(chan autobuild.Stats) + notificationsNoop := notifications.NewNoopEnqueuer() + client, _, api, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pb, + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + Clock: clock, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore( + agplUserQuietHoursScheduleStore(), + notificationsNoop, + logger, + clock, + ), }, - // If the prebuild is claimed after the scheduled deadline, - // the workspace should not stop immediately, but instead respect the next - // valid scheduled deadline (the next day). - { - name: "ClaimedAfterDeadline_SchedulesForNextDay", - isClaimedBeforeDeadline: false, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, }, - } + }) - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Set the clock to Monday, January 1st, 2024 at 8:00 AM UTC to keep the test deterministic - clock := quartz.NewMock(t) - clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC)) - - // Setup - ctx := testutil.Context(t, testutil.WaitSuperLong) - db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) - logger := testutil.Logger(t) - tickCh := make(chan time.Time) - statsCh := make(chan autobuild.Stats) - notificationsNoop := notifications.NewNoopEnqueuer() - client, _, api, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - Database: db, - Pubsub: pb, - AutobuildTicker: tickCh, - IncludeProvisionerDaemon: true, - AutobuildStats: statsCh, - Clock: clock, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore( - agplUserQuietHoursScheduleStore(), - notificationsNoop, - logger, - clock, - ), - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, - }, - }) + // Setup Prebuild reconciler + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler( + db, pb, cache, + codersdk.PrebuildsConfig{}, + logger, + clock, + prometheus.NewRegistry(), + notificationsNoop, + api.AGPL.BuildUsageChecker, + ) + var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) + api.AGPL.PrebuildsClaimer.Store(&claimer) - // Setup Prebuild reconciler - cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) - reconciler := prebuilds.NewStoreReconciler( - db, pb, cache, - codersdk.PrebuildsConfig{}, - logger, - clock, - prometheus.NewRegistry(), - notificationsNoop, - api.AGPL.BuildUsageChecker, - ) - var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) - api.AGPL.PrebuildsClaimer.Store(&claimer) - - // Setup user, template and template version with a preset with 1 prebuild instance - prebuildInstances := int32(1) - userClient, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(prebuildInstances)) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - // Set a template level Autostop schedule to trigger the autostop daily - ctr.AutostopRequirement = ptr.Ref[codersdk.TemplateAutostopRequirement]( - codersdk.TemplateAutostopRequirement{ - DaysOfWeek: []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}, - Weeks: 1, - }) + // Setup user, template and template version with a preset with 1 prebuild instance + prebuildInstances := int32(1) + userClient, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(prebuildInstances)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + // Set a template level Autostop schedule to trigger the autostop daily + coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.AutostopRequirement = ptr.Ref[codersdk.TemplateAutostopRequirement]( + codersdk.TemplateAutostopRequirement{ + DaysOfWeek: []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}, + Weeks: 1, }) - presets, err := client.TemplateVersionPresets(ctx, version.ID) - require.NoError(t, err) - require.Len(t, presets, 1) - - // Given: Reconciliation loop runs and starts prebuilt workspace - runReconciliationLoop(t, ctx, db, reconciler, presets) - runningPrebuilds := getRunningPrebuilds(t, ctx, db, int(prebuildInstances)) - require.Len(t, runningPrebuilds, int(prebuildInstances)) - - // Given: a running prebuilt workspace with a deadline, ready to be claimed - prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) - require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) - require.NotZero(t, prebuild.LatestBuild.Deadline) - - next := clock.Now() - if tc.isClaimedBeforeDeadline { - // When: the autobuild executor ticks *before* the deadline: - next = next.Add(time.Minute) - } else { - // When: the autobuild executor ticks *after* the deadline: - next = next.Add(24 * time.Hour) - } + }) + presets, err := client.TemplateVersionPresets(ctx, version.ID) + require.NoError(t, err) + require.Len(t, presets, 1) - clock.Set(next) - go func() { - tickCh <- next - }() - - // Then: the prebuilt workspace should remain in a start transition - prebuildStats := <-statsCh - require.Len(t, prebuildStats.Errors, 0) - require.Len(t, prebuildStats.Transitions, 0) - require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) - prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) - require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) - - // Given: a user claims the prebuilt workspace - workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID) - require.Equal(t, prebuild.ID, workspace.ID) - - if tc.isClaimedBeforeDeadline { - // Then: the claimed workspace should inherit and respect that same deadline. - require.True(t, workspace.LatestBuild.Deadline.Time.Equal(prebuild.LatestBuild.Deadline.Time)) - } else { - // Then: the claimed workspace should respect the next valid scheduled deadline (next day). - require.True(t, workspace.LatestBuild.Deadline.Time.Equal(clock.Now().Truncate(24*time.Hour).Add(24*time.Hour))) - } + // Given: Reconciliation loop runs and starts prebuilt workspace + runReconciliationLoop(t, ctx, db, reconciler, presets) + runningPrebuilds := getRunningPrebuilds(t, ctx, db, int(prebuildInstances)) + require.Len(t, runningPrebuilds, int(prebuildInstances)) - // When: the autobuild executor ticks *after* the deadline: - next = workspace.LatestBuild.Deadline.Time.Add(time.Minute) - clock.Set(next) - go func() { - tickCh <- next - close(tickCh) - }() - - // Then: the workspace should be stopped - workspaceStats := <-statsCh - require.Len(t, workspaceStats.Errors, 0) - require.Len(t, workspaceStats.Transitions, 1) - require.Contains(t, workspaceStats.Transitions, workspace.ID) - require.Equal(t, database.WorkspaceTransitionStop, workspaceStats.Transitions[workspace.ID]) - workspace = coderdtest.MustWorkspace(t, client, workspace.ID) - require.Equal(t, codersdk.BuildReasonAutostop, workspace.LatestBuild.Reason) - }) - } + // Given: a running prebuilt workspace, ready to be claimed + prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + // Prebuilt workspaces should have an empty Deadline and MaxDeadline + // which is equivalent to 0001-01-01 00:00:00 +0000 + require.Zero(t, prebuild.LatestBuild.Deadline) + require.Zero(t, prebuild.LatestBuild.MaxDeadline) + + // When: the autobuild executor ticks *after* the deadline (2024-01-02 0:00 UTC) + next := clock.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(time.Minute) + clock.Set(next) // 2024-01-02 0:01 UTC + go func() { + tickCh <- next + }() + + // Then: the prebuilt workspace should remain in a start transition + prebuildStats := testutil.RequireReceive(ctx, t, statsCh) + require.Len(t, prebuildStats.Errors, 0) + require.Len(t, prebuildStats.Transitions, 0) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) + require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + require.Zero(t, prebuild.LatestBuild.Deadline) + require.Zero(t, prebuild.LatestBuild.MaxDeadline) + + // Given: a user claims the prebuilt workspace + workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID) + require.Equal(t, prebuild.ID, workspace.ID) + // Then: the claimed workspace should respect the next valid scheduled deadline (2024-01-03 0:00 UTC) + require.True(t, workspace.LatestBuild.Deadline.Time.Equal(clock.Now().Truncate(24*time.Hour).Add(24*time.Hour))) + + // When: the autobuild executor ticks *after* the deadline (2024-01-03 0:00 UTC) + next = workspace.LatestBuild.Deadline.Time.Add(time.Minute) + clock.Set(next) // 2024-01-03 0:01 UTC + go func() { + tickCh <- next + close(tickCh) + }() + + // Then: the workspace should be stopped + workspaceStats := testutil.RequireReceive(ctx, t, statsCh) + require.Len(t, workspaceStats.Errors, 0) + require.Len(t, workspaceStats.Transitions, 1) + require.Contains(t, workspaceStats.Transitions, workspace.ID) + require.Equal(t, database.WorkspaceTransitionStop, workspaceStats.Transitions[workspace.ID]) + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.Equal(t, codersdk.BuildReasonAutostop, workspace.LatestBuild.Reason) }) // Prebuild workspaces should not follow the autostart schedule. // This test verifies that AutostartRequirement (autostart schedule) is ignored while the workspace is a prebuild. + // After being claimed, the workspace should be started according to the autostart schedule. t.Run("AutostartScheduleOnlyTriggersAfterClaim", func(t *testing.T) { t.Parallel() @@ -2146,8 +2181,11 @@ func TestExecutorPrebuilds(t *testing.T) { userClient, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(prebuildInstances)) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + // Template-level autostart config only defines allowed days for workspaces to autostart + // The actual autostart schedule is set at the workspace level + sched, err := cron.Weekly("CRON_TZ=UTC 0 0 * * *") + require.NoError(t, err) coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - // Set a template level Autostart schedule to trigger the autostart daily ctr.AllowUserAutostart = ptr.Ref[bool](true) ctr.AutostartRequirement = &codersdk.TemplateAutostartRequirement{DaysOfWeek: codersdk.AllDaysOfWeek} }) @@ -2160,14 +2198,11 @@ func TestExecutorPrebuilds(t *testing.T) { runningPrebuilds := getRunningPrebuilds(t, ctx, db, int(prebuildInstances)) require.Len(t, runningPrebuilds, int(prebuildInstances)) - // Given: prebuilt workspace has autostart schedule daily at midnight + // Given: a running prebuilt workspace prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) - sched, err := cron.Weekly("CRON_TZ=UTC 0 0 * * *") - require.NoError(t, err) - err = client.UpdateWorkspaceAutostart(ctx, prebuild.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: ptr.Ref(sched.String()), - }) - require.NoError(t, err) + // Prebuilt workspaces should have an empty Autostart Schedule + require.Nil(t, prebuild.AutostartSchedule) + require.Nil(t, prebuild.NextStartAt) // Given: prebuilt workspace is stopped prebuild = coderdtest.MustTransitionWorkspace(t, client, prebuild.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) @@ -2181,51 +2216,62 @@ func TestExecutorPrebuilds(t *testing.T) { }() // Then: the prebuilt workspace should remain in a stop transition - prebuildStats := <-statsCh + prebuildStats := testutil.RequireReceive(ctx, t, statsCh) require.Len(t, prebuildStats.Errors, 0) require.Len(t, prebuildStats.Transitions, 0) require.Equal(t, codersdk.WorkspaceTransitionStop, prebuild.LatestBuild.Transition) prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + require.Nil(t, prebuild.AutostartSchedule) + require.Nil(t, prebuild.NextStartAt) // Given: a prebuilt workspace that is running and ready to be claimed prebuild = coderdtest.MustTransitionWorkspace(t, client, prebuild.ID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, prebuild.LatestBuild.ID) - // Make sure the workspace's agent is again ready getRunningPrebuilds(t, ctx, db, int(prebuildInstances)) - // Given: a user claims the prebuilt workspace - workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID) + // Given: a user claims the prebuilt workspace with an Autostart schedule request + workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID, sched.String()) require.Equal(t, prebuild.ID, workspace.ID) + // Then: newly claimed workspace's AutostartSchedule and NextStartAt should be set + require.NotNil(t, workspace.AutostartSchedule) require.NotNil(t, workspace.NextStartAt) // Given: workspace is stopped workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - // Then: the claimed workspace should inherit and respect that same NextStartAt - require.True(t, workspace.NextStartAt.Equal(*prebuild.NextStartAt)) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, sched.Next(prebuild.LatestBuild.CreatedAt)) + + // Wait for provisioner to be available for this specific workspace + coderdtest.MustWaitForProvisionersAvailable(t, db, prebuild, sched.Next(prebuild.LatestBuild.CreatedAt)) + + tickTime := sched.Next(prebuild.LatestBuild.CreatedAt).Add(time.Minute) + require.NoError(t, err) // Tick at the next scheduled time after the prebuild’s LatestBuild.CreatedAt, // since the next allowed autostart is calculated starting from that point. // When: the autobuild executor ticks after the scheduled time go func() { - tickCh <- sched.Next(prebuild.LatestBuild.CreatedAt).Add(time.Minute) + tickCh <- tickTime }() // Then: the workspace should have a NextStartAt equal to the next autostart schedule - workspaceStats := <-statsCh + workspaceStats := testutil.RequireReceive(ctx, t, statsCh) require.Len(t, workspaceStats.Errors, 0) require.Len(t, workspaceStats.Transitions, 1) workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.NotNil(t, workspace.AutostartSchedule) require.NotNil(t, workspace.NextStartAt) require.Equal(t, sched.Next(clock.Now()), workspace.NextStartAt.UTC()) }) - // Prebuild workspaces should not transition to dormant when the inactive TTL is reached. - // This test verifies that TimeTilDormantMillis is ignored while the workspace is a prebuild. - // After being claimed, the workspace should become dormant according to the configured inactivity period. + // Prebuild workspaces should not transition to dormant or be deleted due to inactivity. + // This test verifies that both TimeTilDormantMillis and TimeTilDormantAutoDeleteMillis + // are ignored while the workspace is a prebuild. After the workspace is claimed, + // it should respect these inactivity thresholds accordingly. t.Run("DormantOnlyAfterClaimed", func(t *testing.T) { t.Parallel() @@ -2276,13 +2322,15 @@ func TestExecutorPrebuilds(t *testing.T) { // Setup user, template and template version with a preset with 1 prebuild instance prebuildInstances := int32(1) - inactiveTTL := 2 * time.Hour + dormantTTL := 2 * time.Hour + deletionTTL := 2 * time.Hour userClient, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(prebuildInstances)) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + // Set a template level dormant TTL to trigger dormancy coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - // Set a template level inactive TTL to trigger dormancy - ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) + ctr.TimeTilDormantMillis = ptr.Ref[int64](dormantTTL.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](deletionTTL.Milliseconds()) }) presets, err := client.TemplateVersionPresets(ctx, version.ID) require.NoError(t, err) @@ -2296,41 +2344,73 @@ func TestExecutorPrebuilds(t *testing.T) { // Given: a running prebuilt workspace, ready to be claimed prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + require.Nil(t, prebuild.DormantAt) + require.Nil(t, prebuild.DeletingAt) - // When: the autobuild executor ticks *after* the inactive TTL + // When: the autobuild executor ticks *after* the dormant TTL (10:00 AM UTC) + next := clock.Now().Add(dormantTTL).Add(time.Minute) + clock.Set(next) // 10:01 AM UTC go func() { - tickCh <- prebuild.LastUsedAt.Add(inactiveTTL).Add(time.Minute) + tickCh <- next }() // Then: the prebuilt workspace should remain in a start transition - prebuildStats := <-statsCh + prebuildStats := testutil.RequireReceive(ctx, t, statsCh) require.Len(t, prebuildStats.Errors, 0) require.Len(t, prebuildStats.Transitions, 0) require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + require.Nil(t, prebuild.DormantAt) + require.Nil(t, prebuild.DeletingAt) // Given: a user claims the prebuilt workspace sometime later - clock.Set(clock.Now().Add(inactiveTTL)) + clock.Set(clock.Now().Add(1 * time.Hour)) // 11:01 AM UTC workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID) require.Equal(t, prebuild.ID, workspace.ID) - require.Nil(t, prebuild.DormantAt) + // Then: the claimed workspace should have DormantAt and DeletingAt unset (nil), + // and LastUsedAt updated + require.Nil(t, workspace.DormantAt) + require.Nil(t, workspace.DeletingAt) + require.True(t, workspace.LastUsedAt.After(prebuild.LastUsedAt)) - // When: the autobuild executor ticks *after* the inactive TTL + // When: the autobuild executor ticks *after* the dormant TTL (1:01 PM UTC) + next = clock.Now().Add(dormantTTL).Add(time.Minute) + clock.Set(next) // 1:02 PM UTC go func() { - tickCh <- prebuild.LastUsedAt.Add(inactiveTTL).Add(time.Minute) - close(tickCh) + tickCh <- next }() - // Then: the workspace should transition to stopped state for breaching failure TTL - workspaceStats := <-statsCh + // Then: the workspace should transition to stopped state for breaching dormant TTL + workspaceStats := testutil.RequireReceive(ctx, t, statsCh) require.Len(t, workspaceStats.Errors, 0) require.Len(t, workspaceStats.Transitions, 1) require.Contains(t, workspaceStats.Transitions, workspace.ID) require.Equal(t, database.WorkspaceTransitionStop, workspaceStats.Transitions[workspace.ID]) workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) require.Equal(t, codersdk.BuildReasonDormancy, workspace.LatestBuild.Reason) + require.Equal(t, codersdk.WorkspaceStatusStopped, workspace.LatestBuild.Status) require.NotNil(t, workspace.DormantAt) + require.NotNil(t, workspace.DeletingAt) + + tickTime := workspace.DeletingAt.Add(time.Minute) + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + + // When: the autobuild executor ticks *after* the deletion TTL + go func() { + tickCh <- tickTime + }() + + // Then: the workspace should be deleted + dormantWorkspaceStats := testutil.RequireReceive(ctx, t, statsCh) + require.Len(t, dormantWorkspaceStats.Errors, 0) + require.Len(t, dormantWorkspaceStats.Transitions, 1) + require.Contains(t, dormantWorkspaceStats.Transitions, workspace.ID) + require.Equal(t, database.WorkspaceTransitionDelete, dormantWorkspaceStats.Transitions[workspace.ID]) }) // Prebuild workspaces should not be deleted when the failure TTL is reached. @@ -2390,8 +2470,8 @@ func TestExecutorPrebuilds(t *testing.T) { failureTTL := 2 * time.Hour version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithFailedResponseAndPresetsWithPrebuilds(prebuildInstances)) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + // Set a template level Failure TTL to trigger workspace deletion template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - // Set a template level Failure TTL to trigger workspace deletion ctr.FailureTTLMillis = ptr.Ref[int64](failureTTL.Milliseconds()) }) presets, err := client.TemplateVersionPresets(ctx, version.ID) @@ -2400,7 +2480,6 @@ func TestExecutorPrebuilds(t *testing.T) { // Given: reconciliation loop runs and starts prebuilt workspace in failed state runReconciliationLoop(t, ctx, db, reconciler, presets) - var failedWorkspaceBuilds []database.GetFailedWorkspaceBuildsByTemplateIDRow require.Eventually(t, func() bool { rows, err := db.GetFailedWorkspaceBuildsByTemplateID(ctx, database.GetFailedWorkspaceBuildsByTemplateIDParams{ @@ -2427,7 +2506,7 @@ func TestExecutorPrebuilds(t *testing.T) { }() // Then: the prebuilt workspace should remain in a start transition - prebuildStats := <-statsCh + prebuildStats := testutil.RequireReceive(ctx, t, statsCh) require.Len(t, prebuildStats.Errors, 0) require.Len(t, prebuildStats.Transitions, 0) require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) @@ -2437,50 +2516,46 @@ func TestExecutorPrebuilds(t *testing.T) { } func templateWithAgentAndPresetsWithPrebuilds(desiredInstances int32) *echo.Responses { - return &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Response{ - { - Type: &proto.Response_Plan{ - Plan: &proto.PlanComplete{ - Presets: []*proto.Preset{ - { - Name: "preset-test", - Parameters: []*proto.PresetParameter{ - { - Name: "k1", - Value: "v1", - }, - }, - Prebuild: &proto.Prebuild{ - Instances: desiredInstances, - }, - }, - }, - }, + agent := &proto.Agent{ + Name: "smith", + OperatingSystem: "linux", + Architecture: "i386", + } + + resource := func(withAgent bool) *proto.Resource { + r := &proto.Resource{Type: "compute", Name: "main"} + if withAgent { + r.Agents = []*proto.Agent{agent} + } + return r + } + + applyResponse := func(withAgent bool) *proto.Response { + return &proto.Response{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{resource(withAgent)}, }, }, - }, - ProvisionApply: []*proto.Response{ - { - Type: &proto.Response_Apply{ - Apply: &proto.ApplyComplete{ - Resources: []*proto.Resource{ - { - Type: "compute", - Name: "main", - Agents: []*proto.Agent{ - { - Name: "smith", - OperatingSystem: "linux", - Architecture: "i386", - }, - }, - }, - }, - }, + } + } + + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Presets: []*proto.Preset{{ + Name: "preset-test", + Parameters: []*proto.PresetParameter{{Name: "k1", Value: "v1"}}, + Prebuild: &proto.Prebuild{Instances: desiredInstances}, + }}, }, }, + }}, + ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Response{ + proto.WorkspaceTransition_START: {applyResponse(true)}, + proto.WorkspaceTransition_STOP: {applyResponse(false)}, }, } } @@ -2514,6 +2589,428 @@ func templateWithFailedResponseAndPresetsWithPrebuilds(desiredInstances int32) * } } +func TestPrebuildUpdateLifecycleParams(t *testing.T) { + t.Parallel() + + // Autostart schedule configuration set to weekly at 9:30 AM UTC + autostartSchedule, err := cron.Weekly("CRON_TZ=UTC 30 9 * * 1-5") + require.NoError(t, err) + + // TTL configuration set to 8 hours + ttlMillis := ptr.Ref((8 * time.Hour).Milliseconds()) + + // Deadline configuration set to January 1st, 2024 at 10:00 AM UTC + deadline := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + + cases := []struct { + name string + endpoint func(*testing.T, context.Context, *codersdk.Client, uuid.UUID) error + apiErrorMsg string + assertUpdate func(*testing.T, *quartz.Mock, *codersdk.Client, uuid.UUID) + }{ + { + name: "AutostartUpdatePrebuildAfterClaim", + endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error { + err = client.UpdateWorkspaceAutostart(ctx, workspaceID, codersdk.UpdateWorkspaceAutostartRequest{ + Schedule: ptr.Ref(autostartSchedule.String()), + }) + return err + }, + apiErrorMsg: "Autostart is not supported for prebuilt workspaces", + assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) { + // The workspace's autostart schedule should be updated to the given schedule, + // and its next start time should be set to 2024-01-01 09:30 AM UTC + updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID) + require.Equal(t, autostartSchedule.String(), *updatedWorkspace.AutostartSchedule) + require.Equal(t, autostartSchedule.Next(clock.Now()), updatedWorkspace.NextStartAt.UTC()) + expectedNext := time.Date(2024, 1, 1, 9, 30, 0, 0, time.UTC) + require.Equal(t, expectedNext, updatedWorkspace.NextStartAt.UTC()) + }, + }, + { + name: "TTLUpdatePrebuildAfterClaim", + endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error { + err := client.UpdateWorkspaceTTL(ctx, workspaceID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: ttlMillis, + }) + return err + }, + apiErrorMsg: "TTL updates are not supported for prebuilt workspaces", + assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) { + // The workspace's TTL should be updated accordingly + updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID) + require.Equal(t, ttlMillis, updatedWorkspace.TTLMillis) + }, + }, + { + name: "DormantUpdatePrebuildAfterClaim", + endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error { + err := client.UpdateWorkspaceDormancy(ctx, workspaceID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, + }) + return err + }, + apiErrorMsg: "Dormancy updates are not supported for prebuilt workspaces", + assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) { + // The workspace's dormantAt should be updated accordingly + updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID) + require.Equal(t, clock.Now(), updatedWorkspace.DormantAt.UTC()) + }, + }, + { + name: "DeadlineUpdatePrebuildAfterClaim", + endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error { + err := client.PutExtendWorkspace(ctx, workspaceID, codersdk.PutExtendWorkspaceRequest{ + Deadline: deadline, + }) + return err + }, + apiErrorMsg: "Deadline extension is not supported for prebuilt workspaces", + assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) { + // The workspace build's deadline should be updated accordingly + updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID) + require.Equal(t, deadline, updatedWorkspace.LatestBuild.Deadline.Time.UTC()) + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Set the clock to Monday, January 1st, 2024 at 8:00 AM UTC to keep the test deterministic + clock := quartz.NewMock(t) + clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC)) + + // Setup + 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) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + dbgen.Preset(t, db, database.InsertPresetParams{ + ID: presetID, + TemplateVersionID: version.ID, + DesiredInstances: sql.NullInt32{Int32: 1, Valid: true}, + }) + workspaceBuild := 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, + }, + }).WithAgent(func(agent []*proto.Agent) []*proto.Agent { + return agent + }).Do() + + // Mark the prebuilt workspace's agent as ready so the prebuild can be claimed + ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong)) + agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, uuid.MustParse(workspaceBuild.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 + prebuild := coderdtest.MustWorkspace(t, client, workspaceBuild.Workspace.ID) + + // When: the lifecycle-update endpoint is called for the prebuilt workspace + err = tc.endpoint(t, ctx, client, prebuild.ID) + + // Then: a 409 Conflict should be returned, with an error message specific to the lifecycle parameter + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) + require.Equal(t, tc.apiErrorMsg, apiErr.Response.Message) + + // 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), + // The 'extend' endpoint requires the workspace to have an existing deadline. + // To ensure this, we set the workspace's TTL to 1 hour. + TTLMillis: ptr.Ref[int64](time.Hour.Milliseconds()), + }) + 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) + + // When: the same lifecycle-update endpoint is called for the claimed workspace + err = tc.endpoint(t, ctx, client, workspace.ID) + require.NoError(t, err) + + // Then: the workspace's lifecycle parameter should be updated accordingly + tc.assertUpdate(t, clock, client, claimedWorkspace.ID) + }) + } +} + +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) +} + +func TestWorkspaceProvisionerdServerMetrics(t *testing.T) { + t.Parallel() + + // Setup + clock := quartz.NewMock(t) + ctx := testutil.Context(t, testutil.WaitSuperLong) + db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + logger := testutil.Logger(t) + reg := prometheus.NewRegistry() + provisionerdserverMetrics := provisionerdserver.NewMetrics(logger) + err := provisionerdserverMetrics.Register(reg) + require.NoError(t, err) + client, _, api, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pb, + IncludeProvisionerDaemon: true, + Clock: clock, + ProvisionerdServerMetrics: provisionerdserverMetrics, + }, + }) + + // Setup Prebuild reconciler + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler( + db, pb, cache, + codersdk.PrebuildsConfig{}, + logger, + clock, + prometheus.NewRegistry(), + notifications.NewNoopEnqueuer(), + api.AGPL.BuildUsageChecker, + ) + var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) + api.AGPL.PrebuildsClaimer.Store(&claimer) + + organizationName, err := client.Organization(ctx, owner.OrganizationID) + require.NoError(t, err) + userClient, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) + + // Setup template and template version with a preset with 1 prebuild instance + versionPrebuild := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(1)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, versionPrebuild.ID) + templatePrebuild := coderdtest.CreateTemplate(t, client, owner.OrganizationID, versionPrebuild.ID) + presetsPrebuild, err := client.TemplateVersionPresets(ctx, versionPrebuild.ID) + require.NoError(t, err) + require.Len(t, presetsPrebuild, 1) + + // Setup template and template version with a preset without prebuild instances + versionNoPrebuild := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(0)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, versionNoPrebuild.ID) + templateNoPrebuild := coderdtest.CreateTemplate(t, client, owner.OrganizationID, versionNoPrebuild.ID) + presetsNoPrebuild, err := client.TemplateVersionPresets(ctx, versionNoPrebuild.ID) + require.NoError(t, err) + require.Len(t, presetsNoPrebuild, 1) + + // Given: no histogram value for prebuilt workspaces creation + prebuildCreationMetric := promhelp.MetricValue(t, reg, "coderd_workspace_creation_duration_seconds", prometheus.Labels{ + "organization_name": organizationName.Name, + "template_name": templatePrebuild.Name, + "preset_name": presetsPrebuild[0].Name, + "type": "prebuild", + }) + require.Nil(t, prebuildCreationMetric) + + // Given: reconciliation loop runs and starts prebuilt workspace + coderdenttest.MustRunReconciliationLoopForPreset(ctx, t, db, reconciler, presetsPrebuild[0]) + runningPrebuilds := coderdenttest.GetRunningPrebuilds(ctx, t, db, 1) + require.Len(t, runningPrebuilds, 1) + + // Then: the histogram value for prebuilt workspace creation should be updated + prebuildCreationHistogram := promhelp.HistogramValue(t, reg, "coderd_workspace_creation_duration_seconds", prometheus.Labels{ + "organization_name": organizationName.Name, + "template_name": templatePrebuild.Name, + "preset_name": presetsPrebuild[0].Name, + "type": "prebuild", + }) + require.NotNil(t, prebuildCreationHistogram) + require.Equal(t, uint64(1), prebuildCreationHistogram.GetSampleCount()) + + // Given: a running prebuilt workspace, ready to be claimed + prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + require.Nil(t, prebuild.DormantAt) + require.Nil(t, prebuild.DeletingAt) + + // Given: no histogram value for prebuilt workspaces claim + prebuildClaimMetric := promhelp.MetricValue(t, reg, "coderd_prebuilt_workspace_claim_duration_seconds", prometheus.Labels{ + "organization_name": organizationName.Name, + "template_name": templatePrebuild.Name, + "preset_name": presetsPrebuild[0].Name, + }) + require.Nil(t, prebuildClaimMetric) + + // Given: the prebuilt workspace is claimed by a user + workspace := coderdenttest.MustClaimPrebuild(ctx, t, client, userClient, user.Username, versionPrebuild, presetsPrebuild[0].ID) + require.Equal(t, prebuild.ID, workspace.ID) + + // Then: the histogram value for prebuilt workspace claim should be updated + prebuildClaimHistogram := promhelp.HistogramValue(t, reg, "coderd_prebuilt_workspace_claim_duration_seconds", prometheus.Labels{ + "organization_name": organizationName.Name, + "template_name": templatePrebuild.Name, + "preset_name": presetsPrebuild[0].Name, + }) + require.NotNil(t, prebuildClaimHistogram) + require.Equal(t, uint64(1), prebuildClaimHistogram.GetSampleCount()) + + // Given: no histogram value for regular workspaces creation + regularWorkspaceHistogramMetric := promhelp.MetricValue(t, reg, "coderd_workspace_creation_duration_seconds", prometheus.Labels{ + "organization_name": organizationName.Name, + "template_name": templateNoPrebuild.Name, + "preset_name": presetsNoPrebuild[0].Name, + "type": "regular", + }) + require.Nil(t, regularWorkspaceHistogramMetric) + + // Given: a user creates a regular workspace (without prebuild pool) + regularWorkspace, err := client.CreateUserWorkspace(ctx, user.ID.String(), codersdk.CreateWorkspaceRequest{ + TemplateVersionID: versionNoPrebuild.ID, + TemplateVersionPresetID: presetsNoPrebuild[0].ID, + Name: coderdtest.RandomUsername(t), + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, regularWorkspace.LatestBuild.ID) + + // Then: the histogram value for regular workspace creation should be updated + regularWorkspaceHistogram := promhelp.HistogramValue(t, reg, "coderd_workspace_creation_duration_seconds", prometheus.Labels{ + "organization_name": organizationName.Name, + "template_name": templateNoPrebuild.Name, + "preset_name": presetsNoPrebuild[0].Name, + "type": "regular", + }) + require.NotNil(t, regularWorkspaceHistogram) + require.Equal(t, uint64(1), regularWorkspaceHistogram.GetSampleCount()) +} + // 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. @@ -3360,7 +3857,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)) @@ -3396,7 +3892,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) @@ -3406,8 +3901,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, @@ -3425,7 +3918,6 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { }, }) require.NoError(t, err) - // nolint:gocritic // unit testing err = db.DeleteOldProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) require.NoError(t, err) @@ -3436,7 +3928,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) @@ -3473,7 +3964,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) @@ -3482,7 +3972,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, @@ -3523,3 +4012,93 @@ func must[T any](value T, err error) T { } return value } + +func TestUpdateWorkspaceACL(t *testing.T) { + t.Parallel() + + t.Run("OKWithGroup", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + orgID := adminUser.OrganizationID + client, _ := coderdtest.CreateAnotherUser(t, adminClient, orgID) + _, friend := coderdtest.CreateAnotherUser(t, adminClient, orgID) + group := coderdtest.CreateGroup(t, adminClient, orgID, "bloob") + + tv := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, tv.ID) + template := coderdtest.CreateTemplate(t, adminClient, orgID, tv.ID) + + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := client.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + friend.ID.String(): codersdk.WorkspaceRoleUse, + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + group.ID.String(): codersdk.WorkspaceRoleAdmin, + }, + }) + require.NoError(t, err) + + workspaceACL, err := client.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + require.Len(t, workspaceACL.Users, 1) + require.Equal(t, workspaceACL.Users[0].ID, friend.ID) + require.Equal(t, workspaceACL.Users[0].Role, codersdk.WorkspaceRoleUse) + require.Len(t, workspaceACL.Groups, 1) + require.Equal(t, workspaceACL.Groups[0].ID, group.ID) + require.Equal(t, workspaceACL.Groups[0].Role, codersdk.WorkspaceRoleAdmin) + }) + + t.Run("UnknownIDs", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + adminClient := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dv, + }) + adminUser := coderdtest.CreateFirstUser(t, adminClient) + orgID := adminUser.OrganizationID + client, _ := coderdtest.CreateAnotherUser(t, adminClient, orgID) + + tv := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, tv.ID) + template := coderdtest.CreateTemplate(t, adminClient, orgID, tv.ID) + + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := client.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + uuid.NewString(): codersdk.WorkspaceRoleAdmin, + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + uuid.NewString(): codersdk.WorkspaceRoleAdmin, + }, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Len(t, cerr.Validations, 2) + require.Equal(t, cerr.Validations[0].Field, "group_roles") + require.Equal(t, cerr.Validations[1].Field, "user_roles") + }) +} diff --git a/enterprise/replicasync/replicasync.go b/enterprise/replicasync/replicasync.go index 528540a262464..129e652c97de5 100644 --- a/enterprise/replicasync/replicasync.go +++ b/enterprise/replicasync/replicasync.go @@ -23,6 +23,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/pproflabel" ) var PubsubEvent = "replica" @@ -104,7 +105,7 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, ps pubsub.P return nil, xerrors.Errorf("subscribe: %w", err) } manager.closeWait.Add(1) - go manager.loop(ctx) + pproflabel.Go(ctx, pproflabel.Service(pproflabel.ServiceReplicaSync), manager.loop) return manager, nil } diff --git a/enterprise/wsproxy/wsproxy.go b/enterprise/wsproxy/wsproxy.go index 69241d8aa1c17..6e1da2f25853d 100644 --- a/enterprise/wsproxy/wsproxy.go +++ b/enterprise/wsproxy/wsproxy.go @@ -163,11 +163,7 @@ func New(ctx context.Context, opts *Options) (*Server, error) { return nil, err } - client := wsproxysdk.New(opts.DashboardURL) - err := client.SetSessionToken(opts.ProxySessionToken) - if err != nil { - return nil, xerrors.Errorf("set client token: %w", err) - } + client := wsproxysdk.New(opts.DashboardURL, opts.ProxySessionToken) // Use the configured client if provided. if opts.HTTPClient != nil { @@ -333,6 +329,7 @@ func New(ctx context.Context, opts *Options) (*Server, error) { r.Use( // TODO: @emyrk Should we standardize these in some other package? httpmw.Recover(s.Logger), + httpmw.WithProfilingLabels, tracing.StatusWriterMiddleware, tracing.Middleware(s.TracerProvider), httpmw.AttachRequestID, diff --git a/enterprise/wsproxy/wsproxy_test.go b/enterprise/wsproxy/wsproxy_test.go index b49dfd6c1ceaa..0e8e61af88995 100644 --- a/enterprise/wsproxy/wsproxy_test.go +++ b/enterprise/wsproxy/wsproxy_test.go @@ -94,14 +94,8 @@ func TestDERPOnly(t *testing.T) { func TestDERP(t *testing.T) { t.Parallel() - deploymentValues := coderdtest.DeploymentValues(t) - deploymentValues.Experiments = []string{ - "*", - } - client, closer, api, user := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ Options: &coderdtest.Options{ - DeploymentValues: deploymentValues, AppHostname: "*.primary.test.coder.com", IncludeProvisionerDaemon: true, RealIPConfig: &httpmw.RealIPConfig{ @@ -146,7 +140,7 @@ func TestDERP(t *testing.T) { }) require.NoError(t, err) - // Wait for both running proxies to become healthy. + // Wait for all three running proxies to become healthy. require.Eventually(t, func() bool { err := api.ProxyHealth.ForceUpdate(ctx) if !assert.NoError(t, err) { @@ -207,7 +201,7 @@ resourceLoop: require.NoError(t, err) // There should be three DERP regions in the map: the primary, and each - // of the two running proxies. Also the STUN-only regions. + // of the two DERP-enabled running proxies. Also the STUN-only regions. require.NotNil(t, connInfo.DERPMap) require.Len(t, connInfo.DERPMap.Regions, 3+len(api.DeploymentValues.DERP.Server.STUNAddresses.Value())) @@ -290,6 +284,7 @@ resourceLoop: t.Run(r.RegionName, func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) derpMap := &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ @@ -582,8 +577,7 @@ func TestWorkspaceProxyDERPMeshProbe(t *testing.T) { t.Cleanup(srv.Close) // Register a proxy. - wsproxyClient := wsproxysdk.New(primaryAccessURL) - wsproxyClient.SetSessionToken(token) + wsproxyClient := wsproxysdk.New(primaryAccessURL, token) hostname, err := cryptorand.String(6) require.NoError(t, err) replicaID := uuid.New() @@ -884,8 +878,7 @@ func TestWorkspaceProxyDERPMeshProbe(t *testing.T) { require.Contains(t, respJSON.Warnings[0], "High availability networking") // Deregister the other replica. - wsproxyClient := wsproxysdk.New(api.AccessURL) - wsproxyClient.SetSessionToken(proxy.Options.ProxySessionToken) + wsproxyClient := wsproxysdk.New(api.AccessURL, proxy.Options.ProxySessionToken) err = wsproxyClient.DeregisterWorkspaceProxy(ctx, wsproxysdk.DeregisterWorkspaceProxyRequest{ ReplicaID: otherReplicaID, }) diff --git a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go index b0051551a0f3d..15400a2d33c16 100644 --- a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go +++ b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go @@ -33,15 +33,20 @@ type Client struct { // New creates a external proxy client for the provided primary coder server // URL. -func New(serverURL *url.URL) *Client { +func New(serverURL *url.URL, sessionToken string) *Client { sdkClient := codersdk.New(serverURL) - sdkClient.SessionTokenHeader = httpmw.WorkspaceProxyAuthTokenHeader - + sdkClient.SessionTokenProvider = codersdk.FixedSessionTokenProvider{ + SessionToken: sessionToken, + SessionTokenHeader: httpmw.WorkspaceProxyAuthTokenHeader, + } sdkClientIgnoreRedirects := codersdk.New(serverURL) sdkClientIgnoreRedirects.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } - sdkClientIgnoreRedirects.SessionTokenHeader = httpmw.WorkspaceProxyAuthTokenHeader + sdkClientIgnoreRedirects.SessionTokenProvider = codersdk.FixedSessionTokenProvider{ + SessionToken: sessionToken, + SessionTokenHeader: httpmw.WorkspaceProxyAuthTokenHeader, + } return &Client{ SDKClient: sdkClient, @@ -49,14 +54,6 @@ func New(serverURL *url.URL) *Client { } } -// SetSessionToken sets the session token for the client. An error is returned -// if the session token is not in the correct format for external proxies. -func (c *Client) SetSessionToken(token string) error { - c.SDKClient.SetSessionToken(token) - c.sdkClientIgnoreRedirects.SetSessionToken(token) - return nil -} - // SessionToken returns the currently set token for the client. func (c *Client) SessionToken() string { return c.SDKClient.SessionToken() @@ -75,7 +72,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) } @@ -506,17 +503,12 @@ func (c *Client) TailnetDialer() (*workspacesdk.WebsocketDialer, error) { if err != nil { return nil, xerrors.Errorf("parse url: %w", err) } - coordinateHeaders := make(http.Header) - tokenHeader := codersdk.SessionTokenHeader - if c.SDKClient.SessionTokenHeader != "" { - tokenHeader = c.SDKClient.SessionTokenHeader + wsOptions := &websocket.DialOptions{ + HTTPClient: c.SDKClient.HTTPClient, } - coordinateHeaders.Set(tokenHeader, c.SessionToken()) + c.SDKClient.SessionTokenProvider.SetDialOption(wsOptions) - return workspacesdk.NewWebsocketDialer(logger, coordinateURL, &websocket.DialOptions{ - HTTPClient: c.SDKClient.HTTPClient, - HTTPHeader: coordinateHeaders, - }), nil + return workspacesdk.NewWebsocketDialer(logger, coordinateURL, wsOptions), nil } type CryptoKeysResponse struct { diff --git a/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go b/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go index aada23da9dc12..6b4da6831c9bf 100644 --- a/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go +++ b/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go @@ -60,8 +60,7 @@ func Test_IssueSignedAppTokenHTML(t *testing.T) { u, err := url.Parse(srv.URL) require.NoError(t, err) - client := wsproxysdk.New(u) - client.SetSessionToken(expectedProxyToken) + client := wsproxysdk.New(u, expectedProxyToken) ctx := testutil.Context(t, testutil.WaitLong) @@ -111,8 +110,7 @@ func Test_IssueSignedAppTokenHTML(t *testing.T) { u, err := url.Parse(srv.URL) require.NoError(t, err) - client := wsproxysdk.New(u) - _ = client.SetSessionToken(expectedProxyToken) + client := wsproxysdk.New(u, expectedProxyToken) ctx := testutil.Context(t, testutil.WaitLong) diff --git a/examples/templates/aws-linux/main.tf b/examples/templates/aws-linux/main.tf index bf59dadc67846..ba22558432293 100644 --- a/examples/templates/aws-linux/main.tf +++ b/examples/templates/aws-linux/main.tf @@ -205,24 +205,14 @@ module "code-server" { order = 1 } -# See https://registry.coder.com/modules/jetbrains-gateway -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/modules/jetbrains-gateway/coder" - - # JetBrains IDEs to make available for the user to select - jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] - default = "IU" - - # Default folder to open when starting a JetBrains IDE - folder = "/home/coder" - - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. - version = "~> 1.0" - +# See https://registry.coder.com/modules/coder/jetbrains +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" agent_id = coder_agent.dev[0].id agent_name = "dev" - order = 2 + folder = "/home/coder" } locals { @@ -293,4 +283,4 @@ resource "coder_metadata" "workspace_info" { resource "aws_ec2_instance_state" "dev" { instance_id = aws_instance.dev.id state = data.coder_workspace.me.transition == "start" ? "running" : "stopped" -} +} \ No newline at end of file diff --git a/examples/templates/azure-linux/main.tf b/examples/templates/azure-linux/main.tf index 687c8cae2a007..f19f468af3827 100644 --- a/examples/templates/azure-linux/main.tf +++ b/examples/templates/azure-linux/main.tf @@ -148,24 +148,14 @@ module "code-server" { order = 1 } -# See https://registry.coder.com/modules/coder/jetbrains-gateway -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/jetbrains-gateway/coder" - - # JetBrains IDEs to make available for the user to select - jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] - default = "IU" - - # Default folder to open when starting a JetBrains IDE - folder = "/home/coder" - - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. - version = "~> 1.0" - +# See https://registry.coder.com/modules/coder/jetbrains +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" agent_id = coder_agent.main.id agent_name = "main" - order = 2 + folder = "/home/coder" } locals { @@ -322,4 +312,4 @@ resource "coder_metadata" "home_info" { key = "size" value = "${data.coder_parameter.home_size.value} GiB" } -} +} \ No newline at end of file diff --git a/examples/templates/digitalocean-linux/main.tf b/examples/templates/digitalocean-linux/main.tf index 4daf4b8b8a626..e179952659b6c 100644 --- a/examples/templates/digitalocean-linux/main.tf +++ b/examples/templates/digitalocean-linux/main.tf @@ -276,24 +276,14 @@ module "code-server" { order = 1 } -# See https://registry.coder.com/modules/coder/jetbrains-gateway -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/jetbrains-gateway/coder" - - # JetBrains IDEs to make available for the user to select - jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] - default = "IU" - - # Default folder to open when starting a JetBrains IDE - folder = "/home/coder" - - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. - version = "~> 1.0" - +# See https://registry.coder.com/modules/coder/jetbrains +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" agent_id = coder_agent.main.id agent_name = "main" - order = 2 + folder = "/home/coder" } resource "digitalocean_volume" "home_volume" { @@ -358,4 +348,4 @@ resource "coder_metadata" "volume-info" { key = "size" value = "${digitalocean_volume.home_volume.size} GiB" } -} +} \ No newline at end of file diff --git a/examples/templates/docker-envbuilder/main.tf b/examples/templates/docker-envbuilder/main.tf index 2765874f80181..47e486c81b558 100644 --- a/examples/templates/docker-envbuilder/main.tf +++ b/examples/templates/docker-envbuilder/main.tf @@ -334,24 +334,14 @@ module "code-server" { order = 1 } -# See https://registry.coder.com/modules/coder/jetbrains-gateway -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/jetbrains-gateway/coder" - - # JetBrains IDEs to make available for the user to select - jetbrains_ides = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"] - default = "IU" - - # Default folder to open when starting a JetBrains IDE - folder = "/workspaces" - - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. - version = "~> 1.0" - +# See https://registry.coder.com/modules/coder/jetbrains +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" agent_id = coder_agent.main.id agent_name = "main" - order = 2 + folder = "/workspaces" } resource "coder_metadata" "container_info" { diff --git a/examples/templates/docker/main.tf b/examples/templates/docker/main.tf index 234c4338234d2..d7f87b1923674 100644 --- a/examples/templates/docker/main.tf +++ b/examples/templates/docker/main.tf @@ -133,24 +133,14 @@ module "code-server" { order = 1 } -# See https://registry.coder.com/modules/coder/jetbrains-gateway -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/jetbrains-gateway/coder" - - # JetBrains IDEs to make available for the user to select - jetbrains_ides = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"] - default = "IU" - - # Default folder to open when starting a JetBrains IDE - folder = "/home/coder" - - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. - version = "~> 1.0" - +# See https://registry.coder.com/modules/coder/jetbrains +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" agent_id = coder_agent.main.id agent_name = "main" - order = 2 + folder = "/home/coder" } resource "docker_volume" "home_volume" { @@ -217,4 +207,4 @@ resource "docker_container" "workspace" { label = "coder.workspace_name" value = data.coder_workspace.me.name } -} +} \ No newline at end of file diff --git a/examples/templates/gcp-devcontainer/main.tf b/examples/templates/gcp-devcontainer/main.tf index 317a22fccd36c..015fa935c45cc 100644 --- a/examples/templates/gcp-devcontainer/main.tf +++ b/examples/templates/gcp-devcontainer/main.tf @@ -295,24 +295,14 @@ module "code-server" { order = 1 } -# See https://registry.coder.com/modules/coder/jetbrains-gateway -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/jetbrains-gateway/coder" - - # JetBrains IDEs to make available for the user to select - jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] - default = "IU" - - # Default folder to open when starting a JetBrains IDE - folder = "/workspaces" - - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. - version = "~> 1.0" - +# See https://registry.coder.com/modules/coder/jetbrains +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" agent_id = coder_agent.main.id agent_name = "main" - order = 2 + folder = "/workspaces" } # Create metadata for the workspace and home disk. @@ -338,4 +328,4 @@ resource "coder_metadata" "home_info" { key = "size" value = "${google_compute_disk.root.size} GiB" } -} +} \ No newline at end of file diff --git a/examples/templates/gcp-linux/main.tf b/examples/templates/gcp-linux/main.tf index 286db4e41d2cb..da4ef2bae62a6 100644 --- a/examples/templates/gcp-linux/main.tf +++ b/examples/templates/gcp-linux/main.tf @@ -103,24 +103,14 @@ module "code-server" { order = 1 } -# See https://registry.coder.com/modules/coder/jetbrains-gateway -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/jetbrains-gateway/coder" - - # JetBrains IDEs to make available for the user to select - jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] - default = "IU" - - # Default folder to open when starting a JetBrains IDE - folder = "/home/coder" - - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. - version = "~> 1.0" - +# See https://registry.coder.com/modules/coder/jetbrains +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" agent_id = coder_agent.main.id agent_name = "main" - order = 2 + folder = "/home/coder" } resource "google_compute_instance" "dev" { @@ -181,4 +171,4 @@ resource "coder_metadata" "home_info" { key = "size" value = "${google_compute_disk.root.size} GiB" } -} +} \ No newline at end of file diff --git a/examples/templates/gcp-vm-container/main.tf b/examples/templates/gcp-vm-container/main.tf index 20ced766808a0..86023e3b7e865 100644 --- a/examples/templates/gcp-vm-container/main.tf +++ b/examples/templates/gcp-vm-container/main.tf @@ -56,24 +56,14 @@ module "code-server" { order = 1 } -# See https://registry.coder.com/modules/coder/jetbrains-gateway -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/jetbrains-gateway/coder" - - # JetBrains IDEs to make available for the user to select - jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] - default = "IU" - - # Default folder to open when starting a JetBrains IDE - folder = "/home/coder" - - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. - version = "~> 1.0" - +# See https://registry.coder.com/modules/coder/jetbrains +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" agent_id = coder_agent.main.id agent_name = "main" - order = 2 + folder = "/home/coder" } # See https://registry.terraform.io/modules/terraform-google-modules/container-vm @@ -133,4 +123,4 @@ resource "coder_metadata" "workspace_info" { key = "image" value = module.gce-container.container.image } -} +} \ No newline at end of file diff --git a/examples/templates/kubernetes-devcontainer/main.tf b/examples/templates/kubernetes-devcontainer/main.tf index 8fc79fa25c57e..6d9dcfda0a550 100644 --- a/examples/templates/kubernetes-devcontainer/main.tf +++ b/examples/templates/kubernetes-devcontainer/main.tf @@ -426,24 +426,14 @@ module "code-server" { order = 1 } -# See https://registry.coder.com/modules/coder/jetbrains-gateway -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/jetbrains-gateway/coder" - - # JetBrains IDEs to make available for the user to select - jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] - default = "IU" - - # Default folder to open when starting a JetBrains IDE - folder = "/home/coder" - - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. - version = "~> 1.0" - +# See https://registry.coder.com/modules/coder/jetbrains +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" agent_id = coder_agent.main.id agent_name = "main" - order = 2 + folder = "/home/coder" } resource "coder_metadata" "container_info" { @@ -461,4 +451,4 @@ resource "coder_metadata" "container_info" { key = "cache repo" value = var.cache_repo == "" ? "not enabled" : var.cache_repo } -} +} \ No newline at end of file diff --git a/examples/templates/kubernetes-envbox/main.tf b/examples/templates/kubernetes-envbox/main.tf index 00ae9a2f1fc71..09692bc8400cf 100644 --- a/examples/templates/kubernetes-envbox/main.tf +++ b/examples/templates/kubernetes-envbox/main.tf @@ -110,24 +110,14 @@ module "code-server" { order = 1 } -# See https://registry.coder.com/modules/coder/jetbrains-gateway -module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/jetbrains-gateway/coder" - - # JetBrains IDEs to make available for the user to select - jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] - default = "IU" - - # Default folder to open when starting a JetBrains IDE - folder = "/home/coder" - - # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. - version = "~> 1.0" - +# See https://registry.coder.com/modules/coder/jetbrains +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" agent_id = coder_agent.main.id agent_name = "main" - order = 2 + folder = "/home/coder" } resource "kubernetes_persistent_volume_claim" "home" { @@ -319,4 +309,4 @@ resource "kubernetes_pod" "main" { } } } -} +} \ No newline at end of file diff --git a/flake.nix b/flake.nix index 6fd251111884a..f934d1641bdc7 100644 --- a/flake.nix +++ b/flake.nix @@ -57,7 +57,7 @@ formatter = pkgs.nixfmt-rfc-style; nodejs = pkgs.nodejs_20; - pnpm = pkgs.pnpm_9.override { + pnpm = pkgs.pnpm_10.override { inherit nodejs; # Ensure it points to the above nodejs version }; diff --git a/go.mod b/go.mod index 802b7aa1fda6a..dd8109b35bcf0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/coder/coder/v2 -go 1.24.4 +go 1.24.6 // Required until a v3 of chroma is created to lazily initialize all XML files. // None of our dependencies seem to use the registries anyways, so this @@ -36,7 +36,7 @@ replace github.com/tcnksm/go-httpstat => github.com/coder/go-httpstat v0.0.0-202 // There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here: // https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main -replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20250729141742-067f1e5d9716 +replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20250829055706-6eafe0f9199e // This is replaced to include // 1. a fix for a data race: c.f. https://github.com/tailscale/wireguard-go/pull/25 @@ -58,7 +58,7 @@ replace github.com/imulab/go-scim/pkg/v2 => github.com/coder/go-scim/pkg/v2 v2.0 // Adds support for a new Listener from a driver.Connector // This lets us use rotating authentication tokens for passwords in connection strings // which we use in the awsiamrds package. -replace github.com/lib/pq => github.com/coder/pq v1.10.5-0.20250630052411-a259f96b6102 +replace github.com/lib/pq => github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151 // Removes an init() function that causes terminal sequences to be printed to the web terminal when // used in conjunction with agent-exec. See https://github.com/coder/coder/pull/15817 @@ -74,7 +74,7 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713 require ( cdr.dev/slog v1.6.2-0.20250703074222-9df5e0a6c145 - cloud.google.com/go/compute/metadata v0.7.0 + cloud.google.com/go/compute/metadata v0.8.0 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/adrg/xdg v0.5.0 github.com/ammario/tlru v0.4.0 @@ -82,7 +82,7 @@ require ( github.com/aquasecurity/trivy-iac v0.8.0 github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/awalterschulze/gographviz v2.0.3+incompatible - github.com/aws/smithy-go v1.22.3 + github.com/aws/smithy-go v1.22.5 github.com/bramvdbogaerde/go-scp v1.5.0 github.com/briandowns/spinner v1.23.0 github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 @@ -92,8 +92,8 @@ require ( github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8 - github.com/chromedp/chromedp v0.13.3 + github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 + github.com/chromedp/chromedp v0.14.1 github.com/cli/safeexec v1.0.1 github.com/coder/flog v1.1.0 github.com/coder/guts v1.5.0 @@ -101,10 +101,10 @@ require ( github.com/coder/quartz v0.2.1 github.com/coder/retry v1.5.1 github.com/coder/serpent v0.10.0 - github.com/coder/terraform-provider-coder/v2 v2.9.0 + github.com/coder/terraform-provider-coder/v2 v2.10.0 github.com/coder/websocket v1.8.13 github.com/coder/wgtunnel v0.1.13-0.20240522110300-ade90dfb2da0 - github.com/coreos/go-oidc/v3 v3.14.1 + github.com/coreos/go-oidc/v3 v3.15.0 github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf github.com/creack/pty v1.1.21 github.com/dave/dst v0.27.2 @@ -116,14 +116,14 @@ require ( github.com/fatih/color v1.18.0 github.com/fatih/structs v1.1.0 github.com/fatih/structtag v1.2.0 - github.com/fergusstrange/embedded-postgres v1.31.0 + github.com/fergusstrange/embedded-postgres v1.32.0 github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa github.com/gen2brain/beeep v0.11.1 github.com/gliderlabs/ssh v0.3.8 github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.1 github.com/go-chi/httprate v0.15.0 - github.com/go-jose/go-jose/v4 v4.1.0 + github.com/go-jose/go-jose/v4 v4.1.1 github.com/go-logr/logr v1.4.3 github.com/go-playground/validator/v10 v10.27.0 github.com/gofrs/flock v0.12.0 @@ -165,9 +165,9 @@ require ( github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e github.com/pkg/sftp v1.13.7 github.com/prometheus-community/pro-bing v0.7.0 - github.com/prometheus/client_golang v1.22.0 - github.com/prometheus/client_model v0.6.1 - github.com/prometheus/common v0.63.0 + github.com/prometheus/client_golang v1.23.0 + github.com/prometheus/client_model v0.6.2 + github.com/prometheus/common v0.65.0 github.com/quasilyte/go-ruleguard/dsl v0.3.22 github.com/robfig/cron/v3 v3.0.1 github.com/shirou/gopsutil/v4 v4.25.4 @@ -181,7 +181,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/u-root/u-root v0.14.0 github.com/unrolled/secure v1.17.0 - github.com/valyala/fasthttp v1.64.0 + github.com/valyala/fasthttp v1.65.0 github.com/wagslane/go-password-validator v0.3.0 github.com/zclconf/go-cty-yaml v1.1.0 go.mozilla.org/pkcs7 v0.9.0 @@ -193,21 +193,21 @@ require ( go.opentelemetry.io/otel/trace v1.37.0 go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.3.1-0.20240429205332-517bace7cc29 - go.uber.org/mock v0.5.0 + go.uber.org/mock v0.6.0 go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 - golang.org/x/crypto v0.40.0 + golang.org/x/crypto v0.41.0 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 - golang.org/x/mod v0.26.0 - golang.org/x/net v0.42.0 + golang.org/x/mod v0.27.0 + golang.org/x/net v0.43.0 golang.org/x/oauth2 v0.30.0 golang.org/x/sync v0.16.0 - golang.org/x/sys v0.34.0 - golang.org/x/term v0.33.0 - golang.org/x/text v0.27.0 - golang.org/x/tools v0.35.0 + golang.org/x/sys v0.35.0 + golang.org/x/term v0.34.0 + golang.org/x/text v0.28.0 + golang.org/x/tools v0.36.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da - google.golang.org/api v0.242.0 - google.golang.org/grpc v1.73.0 + google.golang.org/api v0.246.0 + google.golang.org/grpc v1.75.0 google.golang.org/protobuf v1.36.6 gopkg.in/DataDog/dd-trace-go.v1 v1.74.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -219,7 +219,7 @@ require ( ) require ( - cloud.google.com/go/auth v0.16.2 // indirect + cloud.google.com/go/auth v0.16.3 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/logging v1.13.0 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect @@ -255,20 +255,20 @@ require ( github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go-v2 v1.36.4 - github.com/aws/aws-sdk-go-v2/config v1.29.14 - github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect - github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.5.1 - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2 v1.38.1 + github.com/aws/aws-sdk-go-v2/config v1.31.3 + github.com/aws/aws-sdk-go-v2/credentials v1.18.7 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2 + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.52.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.28.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -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 @@ -326,7 +326,7 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.14.2 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect @@ -394,7 +394,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/procfs v0.15.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/riandyrn/otelchi v0.5.1 // indirect github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 // indirect @@ -454,9 +454,9 @@ require ( golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect howett.net/plist v1.0.0 // indirect @@ -471,22 +471,24 @@ require github.com/SherClockHolmes/webpush-go v1.4.0 require ( github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect ) require ( + github.com/anthropics/anthropic-sdk-go v1.4.0 + github.com/brianvoe/gofakeit/v7 v7.4.0 github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 github.com/coder/aisdk-go v0.0.9 - github.com/coder/preview v1.0.3-0.20250714153828-a737d4750448 + github.com/coder/preview v1.0.3 github.com/fsnotify/fsnotify v1.9.0 github.com/go-git/go-git/v5 v5.16.2 - github.com/mark3labs/mcp-go v0.34.0 + github.com/mark3labs/mcp-go v0.32.0 ) require ( - cel.dev/expr v0.23.0 // indirect + cel.dev/expr v0.24.0 // indirect cloud.google.com/go v0.120.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect @@ -495,17 +497,16 @@ require ( github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.2 // indirect github.com/DataDog/datadog-agent/pkg/version v0.64.2 // indirect github.com/DataDog/dd-trace-go/v2 v2.0.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect 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 github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect + github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect @@ -513,7 +514,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/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect @@ -529,10 +530,10 @@ require ( github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/tmaxmax/go-sse v0.10.0 // indirect - github.com/ulikunitz/xz v0.5.12 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect google.golang.org/genai v1.12.0 // indirect diff --git a/go.sum b/go.sum index 812dc4fb08bc4..b0ec2563d5dbf 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ cdr.dev/slog v1.6.2-0.20250703074222-9df5e0a6c145 h1:Mk4axSLxKw3hjkf3PffBLQYta7nPVIWObuKCPDWgQLc= cdr.dev/slog v1.6.2-0.20250703074222-9df5e0a6c145/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ= -cel.dev/expr v0.23.0 h1:wUb94w6OYQS4uXraxo9U+wUAs9jT47Xvl4iPgAwM2ss= -cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -101,8 +101,8 @@ cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVo cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo= cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0= cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E= -cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= -cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= +cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= @@ -184,8 +184,8 @@ cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZ cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= +cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= cloud.google.com/go/contactcenterinsights v1.3.0/go.mod h1:Eu2oemoePuEFc/xKFPjbTuPSj0fYJcPls9TFlPNnHHY= cloud.google.com/go/contactcenterinsights v1.4.0/go.mod h1:L2YzkGbPsv+vMQMCADxJoT9YiTTnSEd6fEvCeHTYVck= cloud.google.com/go/contactcenterinsights v1.6.0/go.mod h1:IIDlT6CLcDoyv79kDv8iWxMSTZhLxSCofVV5W6YFM/w= @@ -668,8 +668,8 @@ github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0 h1:GlvoS github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0/go.mod h1:mYQmU7mbHH6DrCaS8N6GZcxwPoeNfyuopUoLQltwSzs= github.com/DataDog/sketches-go v1.4.7 h1:eHs5/0i2Sdf20Zkj0udVFWuCrXGRFig2Dcfm5rtcTxc= github.com/DataDog/sketches-go v1.4.7/go.mod h1:eAmQ/EBmtSO+nQp7IZMZVRPT4BQTmIc5RZQ+deGlTPM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 h1:5IT7xOdq17MtcdtL/vtl6mGfzhaq4m4vpollPRmlsBQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0/go.mod h1:ZV4VOm0/eHR06JLrXWe09068dHpr3TRpY9Uo7T+anuA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.50.0 h1:nNMpRpnkWDAaqcpxMJvxa/Ud98gjbYwayJY4/9bdjiU= @@ -754,36 +754,36 @@ github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.36.4 h1:GySzjhVvx0ERP6eyfAbAuAXLtAda5TEy19E5q5W8I9E= -github.com/aws/aws-sdk-go-v2 v1.36.4/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= -github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= -github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= -github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.5.1 h1:yg6nrV33ljY6CppoRnnsKLqIZ5ExNdQOGRBGNfc56Yw= -github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.5.1/go.mod h1:hGdIV5nndhIclFFvI1apVfQWn9ZKqedykZ1CtLZd03E= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2 v1.38.1 h1:j7sc33amE74Rz0M/PoCpsZQ6OunLqys/m5antM0J+Z8= +github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= +github.com/aws/aws-sdk-go-v2/config v1.31.3 h1:RIb3yr/+PZ18YYNe6MDiG/3jVoJrPmdoCARwNkMGvco= +github.com/aws/aws-sdk-go-v2/config v1.31.3/go.mod h1:jjgx1n7x0FAKl6TnakqrpkHWWKcX3xfWtdnIJs5K9CE= +github.com/aws/aws-sdk-go-v2/credentials v1.18.7 h1:zqg4OMrKj+t5HlswDApgvAHjxKtlduKS7KicXB+7RLg= +github.com/aws/aws-sdk-go-v2/credentials v1.18.7/go.mod h1:/4M5OidTskkgkv+nCIfC9/tbiQ/c8qTox9QcUDV0cgc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4 h1:lpdMwTzmuDLkgW7086jE94HweHCqG+uOJwHf3LZs7T0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4/go.mod h1:9xzb8/SV62W6gHQGC/8rrvgNXU6ZoYM3sAIJCIrXJxY= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2 h1:QbFjOdplTkOgviHNKyTW/TZpvIYhD6lqEc3tkIvqMoQ= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.2/go.mod h1:d0pTYUeTv5/tPSlbPZZQSqssM158jZBs02jx2LDslM8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 h1:IdCLsiiIj5YJ3AFevsewURCPV+YWUlOW8JiPhoAy8vg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 h1:j7vjtr1YIssWQOMeOWRbh3z8g2oY/xPjnZH2gLY4sGw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4 h1:ueB2Te0NacDMnaC+68za9jLwkjzxGWm0KB5HTUHjLTI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4/go.mod h1:nLEfLnVMmLvyIG58/6gsSA03F1voKGaCfHV7+lR8S7s= github.com/aws/aws-sdk-go-v2/service/ssm v1.52.4 h1:hgSBvRT7JEWx2+vEGI9/Ld5rZtl7M5lu8PqdvOmbRHw= github.com/aws/aws-sdk-go-v2/service/ssm v1.52.4/go.mod h1:v7NIzEFIHBiicOMaMTuEmbnzGnqW0d+6ulNALul6fYE= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= -github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/aws-sdk-go-v2/service/sso v1.28.2 h1:ve9dYBB8CfJGTFqcQ3ZLAAb/KXWgYlgu/2R2TZL2Ko0= +github.com/aws/aws-sdk-go-v2/service/sso v1.28.2/go.mod h1:n9bTZFZcBa9hGGqVz3i/a6+NG0zmZgtkB9qVVFDqPA8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.0 h1:Bnr+fXrlrPEoR1MAFrHVsge3M/WoK4n23VNhRM7TPHI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.0/go.mod h1:eknndR9rU8UpE/OmFpqU78V1EcXPKFTTm5l/buZYgvM= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.0 h1:iV1Ko4Em/lkJIsoKyGfc0nQySi+v0Udxr6Igq+y9JZc= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.0/go.mod h1:bEPcjW7IbolPfK67G1nilqWyoxYMSPrDiIQ3RdIdKgo= +github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= +github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -828,6 +828,8 @@ github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bramvdbogaerde/go-scp v1.5.0 h1:a9BinAjTfQh273eh7vd3qUgmBC+bx+3TRDtkZWmIpzM= github.com/bramvdbogaerde/go-scp v1.5.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ= +github.com/brianvoe/gofakeit/v7 v7.4.0 h1:Q7R44v1E9vkath1SxBqxXzhLnyOcGm/Ex3CQwjudJuI= +github.com/brianvoe/gofakeit/v7 v7.4.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA= github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q= github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 h1:BjkPE3785EwPhhyuFkbINB+2a1xATwk8SNDWnJiD41g= @@ -861,10 +863,10 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= -github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8 h1:AqW2bDQf67Zbq6Tpop/+yJSIknxhiQecO2B8jNYTAPs= -github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= -github.com/chromedp/chromedp v0.13.3 h1:c6nTn97XQBykzcXiGYL5LLebw3h3CEyrCihm4HquYh0= -github.com/chromedp/chromedp v0.13.3/go.mod h1:khsDP9OP20GrowpJfZ7N05iGCwcAYxk7qf9AZBzR3Qw= +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= +github.com/chromedp/chromedp v0.14.1 h1:0uAbnxewy/Q+Bg7oafVePE/6EXEho9hnaC38f+TTENg= +github.com/chromedp/chromedp v0.14.1/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -893,8 +895,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4= github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo= @@ -912,12 +914,12 @@ github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136 h1:0RgB61LcNs github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136/go.mod h1:VkD1P761nykiq75dz+4iFqIQIZka189tx1BQLOp0Skc= github.com/coder/guts v1.5.0 h1:a94apf7xMf5jDdg1bIHzncbRiTn3+BvBZgrFSDbUnyI= github.com/coder/guts v1.5.0/go.mod h1:0Sbv5Kp83u1Nl7MIQiV2zmacJ3o02I341bkWkjWXSUQ= -github.com/coder/pq v1.10.5-0.20250630052411-a259f96b6102 h1:ahTJlTRmTogsubgRVGOUj40dg62WvqPQkzTQP7pyepI= -github.com/coder/pq v1.10.5-0.20250630052411-a259f96b6102/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151 h1:YAxwg3lraGNRwoQ18H7R7n+wsCqNve7Brdvj0F1rDnU= +github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= -github.com/coder/preview v1.0.3-0.20250714153828-a737d4750448 h1:S86sFp4Dr4dUn++fXOMOTu6ClnEZ/NrGCYv7bxZjYYc= -github.com/coder/preview v1.0.3-0.20250714153828-a737d4750448/go.mod h1:hQtBEqOFMJ3SHl9Q9pVvDA9CpeCEXBwbONNK29+3MLk= +github.com/coder/preview v1.0.3 h1:et0/frnLB68PPwsGaa1KAZQdBKBxNSqzMplYKsBpcNA= +github.com/coder/preview v1.0.3/go.mod h1:hQtBEqOFMJ3SHl9Q9pVvDA9CpeCEXBwbONNK29+3MLk= github.com/coder/quartz v0.2.1 h1:QgQ2Vc1+mvzewg2uD/nj8MJ9p9gE+QhGJm+Z+NGnrSE= github.com/coder/quartz v0.2.1/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA= github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc= @@ -926,12 +928,12 @@ github.com/coder/serpent v0.10.0 h1:ofVk9FJXSek+SmL3yVE3GoArP83M+1tX+H7S4t8BSuM= github.com/coder/serpent v0.10.0/go.mod h1:cZFW6/fP+kE9nd/oRkEHJpG6sXCtQ+AX7WMMEHv0Y3Q= github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuOD6a/zVP3rcxezNsoDseTUw= github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ= -github.com/coder/tailscale v1.1.1-0.20250729141742-067f1e5d9716 h1:hi7o0sA+RPBq8Rvvz+hNrC/OTL2897OKREMIRIuQeTs= -github.com/coder/tailscale v1.1.1-0.20250729141742-067f1e5d9716/go.mod h1:l7ml5uu7lFh5hY28lGYM4b/oFSmuPHYX6uk4RAu23Lc= +github.com/coder/tailscale v1.1.1-0.20250829055706-6eafe0f9199e h1:9RKGKzGLHtTvVBQublzDGtCtal3cXP13diCHoAIGPeI= +github.com/coder/tailscale v1.1.1-0.20250829055706-6eafe0f9199e/go.mod h1:jU9T1vEs+DOs8NtGp1F2PT0/TOGVwtg/JCCKYRgvMOs= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e h1:JNLPDi2P73laR1oAclY6jWzAbucf70ASAvf5mh2cME0= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI= -github.com/coder/terraform-provider-coder/v2 v2.9.0 h1:nd9d1/qHTdx5foBLZoy0SWCc0W13GQUbPTzeGsuLlU0= -github.com/coder/terraform-provider-coder/v2 v2.9.0/go.mod h1:f8xPh0riDTRwqoPWkjas5VgIBaiRiWH+STb0TZw2fgY= +github.com/coder/terraform-provider-coder/v2 v2.10.0 h1:cGPMfARGHKb80kZsbDX/t/YKwMOwI5zkIyVCQziHR2M= +github.com/coder/terraform-provider-coder/v2 v2.10.0/go.mod h1:f8xPh0riDTRwqoPWkjas5VgIBaiRiWH+STb0TZw2fgY= github.com/coder/trivy v0.0.0-20250527170238-9416a59d7019 h1:MHkv/W7l9eRAN9gOG0qZ1TLRGWIIfNi92273vPAQ8Fs= github.com/coder/trivy v0.0.0-20250527170238-9416a59d7019/go.mod h1:eqk+w9RLBmbd/cB5XfPZFuVn77cf/A6fB7qmEVeSmXk= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= @@ -948,8 +950,8 @@ github.com/containerd/platforms v1.0.0-rc.1 h1:83KIq4yy1erSRgOVHNk1HYdPvzdJ5CnsW github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= github.com/coreos/go-iptables v0.6.0 h1:is9qnZMPYjLd8LYqmm/qlE+wwEgJIkTYdhV3rfZo4jk= github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= -github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= -github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= +github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= @@ -1054,8 +1056,8 @@ github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4 github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fergusstrange/embedded-postgres v1.31.0 h1:JmRxw2BcPRcU141nOEuGXbIU6jsh437cBB40rmftZSk= -github.com/fergusstrange/embedded-postgres v1.31.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= +github.com/fergusstrange/embedded-postgres v1.32.0 h1:kh2ozEvAx2A0LoIJZEGNwHmoFTEQD243KrHjifcYGMo= +github.com/fergusstrange/embedded-postgres v1.32.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= @@ -1107,10 +1109,10 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= -github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= -github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY= -github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -1148,8 +1150,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= @@ -1322,8 +1324,8 @@ github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqE github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY= github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= -github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= -github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= @@ -1347,8 +1349,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= @@ -1503,8 +1505,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0= github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA= -github.com/mark3labs/mcp-go v0.34.0 h1:eWy7WBGvhk6EyAAyVzivTCprE52iXJwNtvHV6Cv3bR0= -github.com/mark3labs/mcp-go v0.34.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8= +github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -1675,17 +1677,17 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= @@ -1826,14 +1828,14 @@ github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1 github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a h1:BH1SOPEvehD2kVrndDnGJiUF0TrBpNs+iyYocu6h0og= github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= -github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og= -github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA= +github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= +github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= @@ -1940,8 +1942,8 @@ go.opentelemetry.io/collector/semconv v0.123.0/go.mod h1:te6VQ4zZJO5Lp8dM2XIhDxD go.opentelemetry.io/contrib v1.0.0/go.mod h1:EH4yDYeNoaTqn/8yCWQmfNB78VHfGX2Jt2bvnvzBlGM= go.opentelemetry.io/contrib v1.19.0 h1:rnYI7OEPMWFeM4QCqWQ3InMJ0arWMR1i0Cx9A5hcjYM= go.opentelemetry.io/contrib v1.19.0/go.mod h1:gIzjwWFoGazJmtCaDgViqOSJPde2mCWzv60o0bWPcZs= -go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= -go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -1979,8 +1981,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.1-0.20240429205332-517bace7cc29 h1:w0QrHuh0hhUZ++UTQaBM2DMdrWQghZ/UsUb+Wb1+8YE= go.uber.org/goleak v1.3.1-0.20240429205332-517bace7cc29/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= @@ -2004,8 +2006,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -2070,8 +2072,8 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -2134,8 +2136,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -2286,8 +2288,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -2306,8 +2308,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -2330,8 +2332,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2404,8 +2406,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -2426,6 +2428,8 @@ gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJ gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0= gonum.org/v1/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= gonum.org/v1/plot v0.9.0/go.mod h1:3Pcqqmp6RHvJI72kgb8fThyUnav364FOsdDo2aGW5lY= @@ -2487,8 +2491,8 @@ google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/ google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI= google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0= google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg= -google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg= -google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/api v0.246.0 h1:H0ODDs5PnMZVZAEtdLMn2Ul2eQi7QNjqM2DIFp8TlTM= +google.golang.org/api v0.246.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -2629,12 +2633,12 @@ google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOl google.golang.org/genproto v0.0.0-20230330154414-c0448cd141ea/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= -google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= -google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= -google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0= -google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -2676,8 +2680,8 @@ google.golang.org/grpc v1.52.3/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5v google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= diff --git a/helm/coder/templates/_coder.tpl b/helm/coder/templates/_coder.tpl index 3964fd1e3f66d..2efa530c34a47 100644 --- a/helm/coder/templates/_coder.tpl +++ b/helm/coder/templates/_coder.tpl @@ -41,6 +41,8 @@ env: value: "0.0.0.0:8080" - name: CODER_PROMETHEUS_ADDRESS value: "0.0.0.0:2112" +- name: CODER_PPROF_ADDRESS + value: "0.0.0.0:6060" {{- if .Values.provisionerDaemon.pskSecretName }} - name: CODER_PROVISIONER_DAEMON_PSK valueFrom: diff --git a/helm/coder/tests/testdata/auto_access_url_1.golden b/helm/coder/tests/testdata/auto_access_url_1.golden index a8455dd53357f..82b78f878e0a9 100644 --- a/helm/coder/tests/testdata/auto_access_url_1.golden +++ b/helm/coder/tests/testdata/auto_access_url_1.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: KUBE_POD_IP valueFrom: fieldRef: diff --git a/helm/coder/tests/testdata/auto_access_url_1_coder.golden b/helm/coder/tests/testdata/auto_access_url_1_coder.golden index 5862de46fa900..849553b8ab023 100644 --- a/helm/coder/tests/testdata/auto_access_url_1_coder.golden +++ b/helm/coder/tests/testdata/auto_access_url_1_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: KUBE_POD_IP valueFrom: fieldRef: diff --git a/helm/coder/tests/testdata/auto_access_url_2.golden b/helm/coder/tests/testdata/auto_access_url_2.golden index d5c7ce4dd17ac..666341a133394 100644 --- a/helm/coder/tests/testdata/auto_access_url_2.golden +++ b/helm/coder/tests/testdata/auto_access_url_2.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/auto_access_url_2_coder.golden b/helm/coder/tests/testdata/auto_access_url_2_coder.golden index 94341b196c2b3..4a2c6074b058e 100644 --- a/helm/coder/tests/testdata/auto_access_url_2_coder.golden +++ b/helm/coder/tests/testdata/auto_access_url_2_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/auto_access_url_3.golden b/helm/coder/tests/testdata/auto_access_url_3.golden index 5ce3303dcdca3..a0b24ff212346 100644 --- a/helm/coder/tests/testdata/auto_access_url_3.golden +++ b/helm/coder/tests/testdata/auto_access_url_3.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: KUBE_POD_IP valueFrom: fieldRef: diff --git a/helm/coder/tests/testdata/auto_access_url_3_coder.golden b/helm/coder/tests/testdata/auto_access_url_3_coder.golden index 9298e7411bc74..2e62cb18b60ab 100644 --- a/helm/coder/tests/testdata/auto_access_url_3_coder.golden +++ b/helm/coder/tests/testdata/auto_access_url_3_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: KUBE_POD_IP valueFrom: fieldRef: diff --git a/helm/coder/tests/testdata/command.golden b/helm/coder/tests/testdata/command.golden index 9ef66d7ad3a07..a11cb7564e392 100644 --- a/helm/coder/tests/testdata/command.golden +++ b/helm/coder/tests/testdata/command.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/command_args.golden b/helm/coder/tests/testdata/command_args.golden index d5633ce361966..d296c1a8b58d9 100644 --- a/helm/coder/tests/testdata/command_args.golden +++ b/helm/coder/tests/testdata/command_args.golden @@ -154,6 +154,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/command_args_coder.golden b/helm/coder/tests/testdata/command_args_coder.golden index 8fafa90a7f080..c606627a02e67 100644 --- a/helm/coder/tests/testdata/command_args_coder.golden +++ b/helm/coder/tests/testdata/command_args_coder.golden @@ -154,6 +154,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/command_coder.golden b/helm/coder/tests/testdata/command_coder.golden index 055cec2380d59..a7027d4eed4da 100644 --- a/helm/coder/tests/testdata/command_coder.golden +++ b/helm/coder/tests/testdata/command_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/custom_resources.golden b/helm/coder/tests/testdata/custom_resources.golden index ca5391e3ac5d9..e9889d36dee51 100644 --- a/helm/coder/tests/testdata/custom_resources.golden +++ b/helm/coder/tests/testdata/custom_resources.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/custom_resources_coder.golden b/helm/coder/tests/testdata/custom_resources_coder.golden index f783a4f7e53e5..3e45a160f1c58 100644 --- a/helm/coder/tests/testdata/custom_resources_coder.golden +++ b/helm/coder/tests/testdata/custom_resources_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/default_values.golden b/helm/coder/tests/testdata/default_values.golden index c48dffefd12f1..bbaa590568e46 100644 --- a/helm/coder/tests/testdata/default_values.golden +++ b/helm/coder/tests/testdata/default_values.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/default_values_coder.golden b/helm/coder/tests/testdata/default_values_coder.golden index bb8157ea46153..d63411508ed66 100644 --- a/helm/coder/tests/testdata/default_values_coder.golden +++ b/helm/coder/tests/testdata/default_values_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/env_from.golden b/helm/coder/tests/testdata/env_from.golden index eb43115a79187..aca0cb45b3825 100644 --- a/helm/coder/tests/testdata/env_from.golden +++ b/helm/coder/tests/testdata/env_from.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/env_from_coder.golden b/helm/coder/tests/testdata/env_from_coder.golden index a539842ce9187..b4c074225011b 100644 --- a/helm/coder/tests/testdata/env_from_coder.golden +++ b/helm/coder/tests/testdata/env_from_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/extra_templates.golden b/helm/coder/tests/testdata/extra_templates.golden index 2b0d5117c855f..77f06833e3c27 100644 --- a/helm/coder/tests/testdata/extra_templates.golden +++ b/helm/coder/tests/testdata/extra_templates.golden @@ -162,6 +162,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/extra_templates_coder.golden b/helm/coder/tests/testdata/extra_templates_coder.golden index bca6beee0c1ea..ec5d34eec870d 100644 --- a/helm/coder/tests/testdata/extra_templates_coder.golden +++ b/helm/coder/tests/testdata/extra_templates_coder.golden @@ -162,6 +162,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/labels_annotations.golden b/helm/coder/tests/testdata/labels_annotations.golden index 6a83ee5ec1684..0acc2521ba045 100644 --- a/helm/coder/tests/testdata/labels_annotations.golden +++ b/helm/coder/tests/testdata/labels_annotations.golden @@ -161,6 +161,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/labels_annotations_coder.golden b/helm/coder/tests/testdata/labels_annotations_coder.golden index f4454b575ba93..bef5c25d68525 100644 --- a/helm/coder/tests/testdata/labels_annotations_coder.golden +++ b/helm/coder/tests/testdata/labels_annotations_coder.golden @@ -161,6 +161,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/partial_resources.golden b/helm/coder/tests/testdata/partial_resources.golden index 9eade81274a44..2f5fd5f3c7cad 100644 --- a/helm/coder/tests/testdata/partial_resources.golden +++ b/helm/coder/tests/testdata/partial_resources.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/partial_resources_coder.golden b/helm/coder/tests/testdata/partial_resources_coder.golden index 3edfa2a2fcbb3..14c47eab84c8e 100644 --- a/helm/coder/tests/testdata/partial_resources_coder.golden +++ b/helm/coder/tests/testdata/partial_resources_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/pod_securitycontext.golden b/helm/coder/tests/testdata/pod_securitycontext.golden index 17f6272f3637b..e0b02c62ed91c 100644 --- a/helm/coder/tests/testdata/pod_securitycontext.golden +++ b/helm/coder/tests/testdata/pod_securitycontext.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/pod_securitycontext_coder.golden b/helm/coder/tests/testdata/pod_securitycontext_coder.golden index c8d1ced840fc3..9133b085074f6 100644 --- a/helm/coder/tests/testdata/pod_securitycontext_coder.golden +++ b/helm/coder/tests/testdata/pod_securitycontext_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/prometheus.golden b/helm/coder/tests/testdata/prometheus.golden index 0caa782975e8f..2e6b185a6c326 100644 --- a/helm/coder/tests/testdata/prometheus.golden +++ b/helm/coder/tests/testdata/prometheus.golden @@ -152,6 +152,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/prometheus_coder.golden b/helm/coder/tests/testdata/prometheus_coder.golden index 6985f714612c1..e335d22523709 100644 --- a/helm/coder/tests/testdata/prometheus_coder.golden +++ b/helm/coder/tests/testdata/prometheus_coder.golden @@ -152,6 +152,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/provisionerd_psk.golden b/helm/coder/tests/testdata/provisionerd_psk.golden index 8efac9058c2fc..72cfdd976b5e9 100644 --- a/helm/coder/tests/testdata/provisionerd_psk.golden +++ b/helm/coder/tests/testdata/provisionerd_psk.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_PROVISIONER_DAEMON_PSK valueFrom: secretKeyRef: diff --git a/helm/coder/tests/testdata/provisionerd_psk_coder.golden b/helm/coder/tests/testdata/provisionerd_psk_coder.golden index cb9908874c686..a34e294f992dc 100644 --- a/helm/coder/tests/testdata/provisionerd_psk_coder.golden +++ b/helm/coder/tests/testdata/provisionerd_psk_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_PROVISIONER_DAEMON_PSK valueFrom: secretKeyRef: diff --git a/helm/coder/tests/testdata/sa.golden b/helm/coder/tests/testdata/sa.golden index f57293b211df6..ff423c318baa5 100644 --- a/helm/coder/tests/testdata/sa.golden +++ b/helm/coder/tests/testdata/sa.golden @@ -154,6 +154,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/sa_coder.golden b/helm/coder/tests/testdata/sa_coder.golden index ae3ce59e35a24..8725a6724e6a8 100644 --- a/helm/coder/tests/testdata/sa_coder.golden +++ b/helm/coder/tests/testdata/sa_coder.golden @@ -154,6 +154,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/sa_disabled.golden b/helm/coder/tests/testdata/sa_disabled.golden index 387a05f79536f..122c297571a44 100644 --- a/helm/coder/tests/testdata/sa_disabled.golden +++ b/helm/coder/tests/testdata/sa_disabled.golden @@ -139,6 +139,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/sa_disabled_coder.golden b/helm/coder/tests/testdata/sa_disabled_coder.golden index 77f9b0fc58ae9..da091e00279a2 100644 --- a/helm/coder/tests/testdata/sa_disabled_coder.golden +++ b/helm/coder/tests/testdata/sa_disabled_coder.golden @@ -139,6 +139,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/sa_extra_rules.golden b/helm/coder/tests/testdata/sa_extra_rules.golden index 8d74df5001d34..0a01a6411e33a 100644 --- a/helm/coder/tests/testdata/sa_extra_rules.golden +++ b/helm/coder/tests/testdata/sa_extra_rules.golden @@ -167,6 +167,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/sa_extra_rules_coder.golden b/helm/coder/tests/testdata/sa_extra_rules_coder.golden index 50849b76e89f2..91133dd9803bf 100644 --- a/helm/coder/tests/testdata/sa_extra_rules_coder.golden +++ b/helm/coder/tests/testdata/sa_extra_rules_coder.golden @@ -167,6 +167,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/securitycontext.golden b/helm/coder/tests/testdata/securitycontext.golden index ee1a3e3a795fd..486447d93a4aa 100644 --- a/helm/coder/tests/testdata/securitycontext.golden +++ b/helm/coder/tests/testdata/securitycontext.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/securitycontext_coder.golden b/helm/coder/tests/testdata/securitycontext_coder.golden index fd3d70482df5b..7d5b409b8eed3 100644 --- a/helm/coder/tests/testdata/securitycontext_coder.golden +++ b/helm/coder/tests/testdata/securitycontext_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/svc_loadbalancer.golden b/helm/coder/tests/testdata/svc_loadbalancer.golden index dd55f8d530087..71310077bb6c0 100644 --- a/helm/coder/tests/testdata/svc_loadbalancer.golden +++ b/helm/coder/tests/testdata/svc_loadbalancer.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/svc_loadbalancer_class.golden b/helm/coder/tests/testdata/svc_loadbalancer_class.golden index 92969226da94b..548c360f1c089 100644 --- a/helm/coder/tests/testdata/svc_loadbalancer_class.golden +++ b/helm/coder/tests/testdata/svc_loadbalancer_class.golden @@ -154,6 +154,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/svc_loadbalancer_class_coder.golden b/helm/coder/tests/testdata/svc_loadbalancer_class_coder.golden index aa8a19a234b3d..aad0731549777 100644 --- a/helm/coder/tests/testdata/svc_loadbalancer_class_coder.golden +++ b/helm/coder/tests/testdata/svc_loadbalancer_class_coder.golden @@ -154,6 +154,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/svc_loadbalancer_coder.golden b/helm/coder/tests/testdata/svc_loadbalancer_coder.golden index a7d389fb048df..667f4f84cd7f8 100644 --- a/helm/coder/tests/testdata/svc_loadbalancer_coder.golden +++ b/helm/coder/tests/testdata/svc_loadbalancer_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/svc_nodeport.golden b/helm/coder/tests/testdata/svc_nodeport.golden index 9a271628728f7..d2f1c5c9767ef 100644 --- a/helm/coder/tests/testdata/svc_nodeport.golden +++ b/helm/coder/tests/testdata/svc_nodeport.golden @@ -152,6 +152,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/svc_nodeport_coder.golden b/helm/coder/tests/testdata/svc_nodeport_coder.golden index 0a8805f84ba8b..5d258cfb10d8c 100644 --- a/helm/coder/tests/testdata/svc_nodeport_coder.golden +++ b/helm/coder/tests/testdata/svc_nodeport_coder.golden @@ -152,6 +152,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/tls.golden b/helm/coder/tests/testdata/tls.golden index 1cd0fb75bc6c6..66e1dd69915df 100644 --- a/helm/coder/tests/testdata/tls.golden +++ b/helm/coder/tests/testdata/tls.golden @@ -158,6 +158,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: https://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/tls_coder.golden b/helm/coder/tests/testdata/tls_coder.golden index 95bec4a8c510e..ddad245300a6f 100644 --- a/helm/coder/tests/testdata/tls_coder.golden +++ b/helm/coder/tests/testdata/tls_coder.golden @@ -158,6 +158,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: https://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/topology.golden b/helm/coder/tests/testdata/topology.golden index 4d8af24ce3c7f..2a061efaf2b8d 100644 --- a/helm/coder/tests/testdata/topology.golden +++ b/helm/coder/tests/testdata/topology.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/topology_coder.golden b/helm/coder/tests/testdata/topology_coder.golden index 3b81214417262..0256522c4dcc7 100644 --- a/helm/coder/tests/testdata/topology_coder.golden +++ b/helm/coder/tests/testdata/topology_coder.golden @@ -153,6 +153,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/workspace_proxy.golden b/helm/coder/tests/testdata/workspace_proxy.golden index d096bfe94feea..3a7386af35d25 100644 --- a/helm/coder/tests/testdata/workspace_proxy.golden +++ b/helm/coder/tests/testdata/workspace_proxy.golden @@ -154,6 +154,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.default.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/tests/testdata/workspace_proxy_coder.golden b/helm/coder/tests/testdata/workspace_proxy_coder.golden index 2ed59d5591261..3cafe9855474e 100644 --- a/helm/coder/tests/testdata/workspace_proxy_coder.golden +++ b/helm/coder/tests/testdata/workspace_proxy_coder.golden @@ -154,6 +154,8 @@ spec: value: 0.0.0.0:8080 - name: CODER_PROMETHEUS_ADDRESS value: 0.0.0.0:2112 + - name: CODER_PPROF_ADDRESS + value: 0.0.0.0:6060 - name: CODER_ACCESS_URL value: http://coder.coder.svc.cluster.local - name: KUBE_POD_IP diff --git a/helm/coder/values.yaml b/helm/coder/values.yaml index fcc8f7746b0f1..72708c88495ad 100644 --- a/helm/coder/values.yaml +++ b/helm/coder/values.yaml @@ -12,6 +12,8 @@ coder: # - CODER_TLS_KEY_FILE: set if tls.secretName is not empty. # - CODER_PROMETHEUS_ADDRESS: set to 0.0.0.0:2112 and cannot be changed. # Prometheus must still be enabled by setting CODER_PROMETHEUS_ENABLE. + # - CODER_PPROF_ADDRESS: set to 0.0.0.0:6060 and cannot be changed. + # Profiling must still be enabled by setting CODER_PPROF_ENABLE. # - KUBE_POD_IP # - CODER_DERP_SERVER_RELAY_URL # diff --git a/install.sh b/install.sh index 6fc73fce11f21..1dbf813b96690 100755 --- a/install.sh +++ b/install.sh @@ -273,7 +273,7 @@ EOF main() { MAINLINE=1 STABLE=0 - TERRAFORM_VERSION="1.12.2" + TERRAFORM_VERSION="1.13.0" if [ "${TRACE-}" ]; then set -x @@ -657,7 +657,6 @@ install_standalone() { darwin) STANDALONE_ARCHIVE_FORMAT=zip ;; *) STANDALONE_ARCHIVE_FORMAT=tar.gz ;; esac - fetch "https://github.com/coder/coder/releases/download/v$VERSION/coder_${VERSION}_${OS}_${ARCH}.$STANDALONE_ARCHIVE_FORMAT" \ "$CACHE_DIR/coder_${VERSION}_${OS}_${ARCH}.$STANDALONE_ARCHIVE_FORMAT" diff --git a/offlinedocs/package.json b/offlinedocs/package.json index 77af85ccf4874..d06b54a64ca4f 100644 --- a/offlinedocs/package.json +++ b/offlinedocs/package.json @@ -46,7 +46,8 @@ }, "pnpm": { "overrides": { - "@babel/runtime": "7.26.10" + "@babel/runtime": "7.26.10", + "brace-expansion": "1.1.12" } } } diff --git a/offlinedocs/pnpm-lock.yaml b/offlinedocs/pnpm-lock.yaml index 5fff8a2098456..dca4871c014cf 100644 --- a/offlinedocs/pnpm-lock.yaml +++ b/offlinedocs/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: '@babel/runtime': 7.26.10 + brace-expansion: 1.1.12 importers: @@ -730,11 +731,8 @@ packages: bare-events@2.4.2: resolution: {integrity: sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -3222,15 +3220,11 @@ snapshots: bare-events@2.4.2: optional: true - brace-expansion@1.1.11: + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.1: - dependencies: - balanced-match: 1.0.2 - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -4807,15 +4801,15 @@ snapshots: minimatch@3.1.2: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.12 minimatch@5.1.6: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 1.1.12 minimatch@9.0.5: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 1.1.12 minimist@1.2.8: {} diff --git a/package.json b/package.json index ee5cba7ecf538..b220803ad729b 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,21 @@ "_comment": "This version doesn't matter, it's just to allow importing from other repos.", "name": "coder", "version": "0.0.0", - "packageManager": "pnpm@9.14.4", + "packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748", "scripts": { "format-docs": "markdown-table-formatter $(find docs -name '*.md') *.md", "lint-docs": "markdownlint-cli2 --fix $(find docs -name '*.md') *.md", "storybook": "pnpm run -C site/ storybook" }, "devDependencies": { + "@biomejs/biome": "2.2.0", "markdown-table-formatter": "^1.6.1", "markdownlint-cli2": "^0.16.0", "quicktype": "^23.0.0" + }, + "pnpm": { + "overrides": { + "brace-expansion": "1.1.12" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c136ad0acdcbf..1e2921375adb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,10 +4,16 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + brace-expansion: 1.1.12 + importers: .: devDependencies: + '@biomejs/biome': + specifier: 2.2.0 + version: 2.2.0 markdown-table-formatter: specifier: ^1.6.1 version: 1.6.1 @@ -20,6 +26,59 @@ importers: packages: + '@biomejs/biome@2.2.0': + resolution: {integrity: sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.2.0': + resolution: {integrity: sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.2.0': + resolution: {integrity: sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.2.0': + resolution: {integrity: sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.2.0': + resolution: {integrity: sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.2.0': + resolution: {integrity: sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.2.0': + resolution: {integrity: sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.2.0': + resolution: {integrity: sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.2.0': + resolution: {integrity: sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -135,11 +194,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -722,6 +778,41 @@ packages: snapshots: + '@biomejs/biome@2.2.0': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.2.0 + '@biomejs/cli-darwin-x64': 2.2.0 + '@biomejs/cli-linux-arm64': 2.2.0 + '@biomejs/cli-linux-arm64-musl': 2.2.0 + '@biomejs/cli-linux-x64': 2.2.0 + '@biomejs/cli-linux-x64-musl': 2.2.0 + '@biomejs/cli-win32-arm64': 2.2.0 + '@biomejs/cli-win32-x64': 2.2.0 + + '@biomejs/cli-darwin-arm64@2.2.0': + optional: true + + '@biomejs/cli-darwin-x64@2.2.0': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.2.0': + optional: true + + '@biomejs/cli-linux-arm64@2.2.0': + optional: true + + '@biomejs/cli-linux-x64-musl@2.2.0': + optional: true + + '@biomejs/cli-linux-x64@2.2.0': + optional: true + + '@biomejs/cli-win32-arm64@2.2.0': + optional: true + + '@biomejs/cli-win32-x64@2.2.0': + optional: true + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -823,15 +914,11 @@ snapshots: base64-js@1.5.1: {} - brace-expansion@1.1.11: + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.1: - dependencies: - balanced-match: 1.0.2 - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1113,11 +1200,11 @@ snapshots: minimatch@3.1.2: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.12 minimatch@9.0.5: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 1.1.12 minipass@7.1.2: {} 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/install.go b/provisioner/terraform/install.go index dbb7d3f88917b..63d6b0278231d 100644 --- a/provisioner/terraform/install.go +++ b/provisioner/terraform/install.go @@ -22,10 +22,10 @@ var ( // when Terraform is not available on the system. // NOTE: Keep this in sync with the version in scripts/Dockerfile.base. // NOTE: Keep this in sync with the version in install.sh. - TerraformVersion = version.Must(version.NewVersion("1.12.2")) + TerraformVersion = version.Must(version.NewVersion("1.13.0")) minTerraformVersion = version.Must(version.NewVersion("1.1.0")) - maxTerraformVersion = version.Must(version.NewVersion("1.12.9")) // use .9 to automatically allow patch releases + maxTerraformVersion = version.Must(version.NewVersion("1.13.9")) // use .9 to automatically allow patch releases errTerraformMinorVersionMismatch = xerrors.New("Terraform binary minor version mismatch.") ) 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..feaae22bac7e9 100644 --- a/provisioner/terraform/testdata/resources/version.txt +++ b/provisioner/terraform/testdata/resources/version.txt @@ -1 +1 @@ -1.11.4 +1.13.0 diff --git a/provisioner/terraform/testdata/version.txt b/provisioner/terraform/testdata/version.txt index 6b89d58f861a7..feaae22bac7e9 100644 --- a/provisioner/terraform/testdata/version.txt +++ b/provisioner/terraform/testdata/version.txt @@ -1 +1 @@ -1.12.2 +1.13.0 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/README.md b/scaletest/README.md deleted file mode 100644 index 9fa475ae29ab5..0000000000000 --- a/scaletest/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# Scale Testing - -This folder contains CLI commands, Terraform code, and scripts to aid in performing load tests of Coder. -At a high level, it performs the following steps: - -- Using the Terraform code in `./terraform`, stands up a preconfigured Google Cloud environment - consisting of a VPC, GKE Cluster, and CloudSQL instance. - > **Note: You must have an existing Google Cloud project available.** -- Creates a dedicated namespace for Coder and installs Coder using the Helm chart in this namespace. -- Configures the Coder deployment with random credentials and a predefined Kubernetes template. - > **Note:** These credentials are stored in `${PROJECT_ROOT}/scaletest/.coderv2/coder.env`. -- Creates a number of workspaces and waits for them to all start successfully. These workspaces - are ephemeral and do not contain any persistent resources. -- Waits for 10 minutes to allow things to settle and establish a baseline. -- Generates web terminal traffic to all workspaces for 30 minutes. -- Directly after traffic generation, captures goroutine and heap snapshots of the Coder deployment. -- Tears down all resources (unless `--skip-cleanup` is specified). - -## Usage - -The main entrypoint is the `scaletest.sh` script. - -```console -$ scaletest.sh --help -Usage: scaletest.sh --name --project --num-workspaces --scenario [--dry-run] [--skip-cleanup] -``` - -### Required arguments - -- `--name`: Name for the loadtest. This is added as a prefix to resources created by Terraform (e.g. `joe-big-loadtest`). -- `--project`: Google Cloud project in which to create the resources (example: `my-loadtest-project`). -- `--num-workspaces`: Number of workspaces to create (example: `10`). -- `--scenario`: Deployment scenario to use (example: `small`). See `terraform/scenario-*.tfvars`. - -> **Note:** In order to capture Prometheus metrics, you must define the environment variables -> `SCALETEST_PROMETHEUS_REMOTE_WRITE_USER` and `SCALETEST_PROMETHEUS_REMOTE_WRITE_PASSWORD`. - -### Optional arguments - -- `--dry-run`: Do not perform any action and instead print what would be executed. -- `--skip-cleanup`: Do not perform any cleanup. You will be responsible for deleting any resources this creates. - -### Environment Variables - -All of the above arguments may be specified as environment variables. Consult the script for details. - -### Prometheus Metrics - -To capture Prometheus metrics from the loadtest, two environment variables are required: - -- `SCALETEST_PROMETHEUS_REMOTE_WRITE_USER` -- `SCALETEST_PROMETHEUS_REMOTE_WRITE_PASSWORD` - -### Enterprise License - -To add an Enterprise license, set the `SCALETEST_CODER_LICENSE` environment variable to the JWT string - -## Scenarios - -A scenario defines a number of variables that override the default Terraform variables. -A number of existing scenarios are provided in `scaletest/terraform/scenario-*.tfvars`. - -For example, `scenario-small.tfvars` includes the following variable definitions: - -```hcl -nodepool_machine_type_coder = "t2d-standard-2" -nodepool_machine_type_workspaces = "t2d-standard-2" -coder_cpu = "1000m" # Leaving 1 CPU for system workloads -coder_mem = "4Gi" # Leaving 4GB for system workloads -``` - -To create your own scenario, simply add a new file `terraform/scenario-$SCENARIO_NAME.tfvars`. -In this file, override variables as required, consulting `vars.tf` as needed. -You can then use this scenario by specifying `--scenario $SCENARIO_NAME`. -For example, if your scenario file were named `scenario-big-whopper2x.tfvars`, you would specify -`--scenario=big-whopper2x`. - -## Utility scripts - -A number of utility scripts are provided in `lib`, and are used by `scaletest.sh`: - -- `coder_shim.sh`: a convenience script to run the `coder` binary with a predefined config root. - This is intended to allow running Coder CLI commands against the loadtest cluster without - modifying a user's existing Coder CLI configuration. -- `coder_init.sh`: Performs first-time user setup of an existing Coder instance, generating - a random password for the admin user. The admin user is named `admin@coder.com` by default. - Credentials are written to `scaletest/.coderv2/coder.env`. -- `coder_workspacetraffic.sh`: Runs traffic generation against the loadtest cluster and creates - a monitoring manifest for the traffic generation pod. This pod will restart automatically - after the traffic generation has completed. - -## Grafana Dashboard - -A sample Grafana dashboard is provided in `scaletest_dashboard.json`. This dashboard is intended -to be imported into an existing Grafana instance. It provides a number of useful metrics: - -- **Control Plane Resources**: CPU, memory, and network usage for the Coder deployment, as well as the number of pod restarts. -- **Database**: Rows inserted/updated/deleted/returned, active connections, and transactions per second. Fine-grained `sqlQuerier` metrics are provided for Coder's database as well, broken down my query method. -- **HTTP requests**: Number of HTTP requests per second, broken down by status code and path. -- **Workspace Resources**: CPU, memory, and network usage for all workspaces. -- **Workspace Agents**: Workspace agent network usage, connection latency, and number of active connections. -- **Workspace Traffic**: Statistics related to workspace traffic generation. -- **Internals**: Provisioner job timings, concurrency, workspace builds, and AuthZ duration. - -A subset of these metrics may be useful for a production deployment, but some are only useful -for load testing. - -> **Note:** in particular, `sqlQuerier` metrics produce a large number of time series and may cause -> increased charges in your metrics provider. 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/scaletest.sh b/scaletest/scaletest.sh deleted file mode 100755 index dd0a6cb4f450c..0000000000000 --- a/scaletest/scaletest.sh +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env bash - -[[ -n ${VERBOSE:-} ]] && set -x -set -euo pipefail - -PROJECT_ROOT="$(git rev-parse --show-toplevel)" -# shellcheck source=scripts/lib.sh -source "${PROJECT_ROOT}/scripts/lib.sh" - -DRY_RUN="${DRY_RUN:-0}" -SCALETEST_NAME="${SCALETEST_NAME:-}" -SCALETEST_NUM_WORKSPACES="${SCALETEST_NUM_WORKSPACES:-}" -SCALETEST_SCENARIO="${SCALETEST_SCENARIO:-}" -SCALETEST_PROJECT="${SCALETEST_PROJECT:-}" -SCALETEST_PROMETHEUS_REMOTE_WRITE_USER="${SCALETEST_PROMETHEUS_REMOTE_WRITE_USER:-}" -SCALETEST_PROMETHEUS_REMOTE_WRITE_PASSWORD="${SCALETEST_PROMETHEUS_REMOTE_WRITE_PASSWORD:-}" -SCALETEST_CODER_LICENSE="${SCALETEST_CODER_LICENSE:-}" -SCALETEST_SKIP_CLEANUP="${SCALETEST_SKIP_CLEANUP:-0}" -SCALETEST_CREATE_CONCURRENCY="${SCALETEST_CREATE_CONCURRENCY:-10}" -SCALETEST_TRAFFIC_BYTES_PER_TICK="${SCALETEST_TRAFFIC_BYTES_PER_TICK:-1024}" -SCALETEST_TRAFFIC_TICK_INTERVAL="${SCALETEST_TRAFFIC_TICK_INTERVAL:-10s}" -SCALETEST_DESTROY="${SCALETEST_DESTROY:-0}" - -script_name=$(basename "$0") -args="$(getopt -o "" -l create-concurrency:,destroy,dry-run,help,name:,num-workspaces:,project:,scenario:,skip-cleanup,traffic-bytes-per-tick:,traffic-tick-interval:, -- "$@")" -eval set -- "$args" -while true; do - case "$1" in - --create-concurrency) - SCALETEST_CREATE_CONCURRENCY="$2" - shift 2 - ;; - --destroy) - SCALETEST_DESTROY=1 - shift - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --help) - echo "Usage: $script_name --name --project --num-workspaces --scenario [--create-concurrency ] [--destroy] [--dry-run] [--skip-cleanup] [--traffic-bytes-per-tick ] [--traffic-tick-interval ]" - exit 1 - ;; - --name) - SCALETEST_NAME="$2" - shift 2 - ;; - --num-workspaces) - SCALETEST_NUM_WORKSPACES="$2" - shift 2 - ;; - --project) - SCALETEST_PROJECT="$2" - shift 2 - ;; - --scenario) - SCALETEST_SCENARIO="$2" - shift 2 - ;; - --skip-cleanup) - SCALETEST_SKIP_CLEANUP=1 - shift - ;; - --traffic-bytes-per-tick) - SCALETEST_TRAFFIC_BYTES_PER_TICK="$2" - shift 2 - ;; - --traffic-tick-interval) - SCALETEST_TRAFFIC_TICK_INTERVAL="$2" - shift 2 - ;; - --) - shift - break - ;; - *) - error "Unrecognized option: $1" - ;; - esac -done - -dependencies gcloud kubectl terraform - -if [[ -z "${SCALETEST_NAME}" ]]; then - echo "Must specify --name" - exit 1 -fi - -if [[ -z "${SCALETEST_PROJECT}" ]]; then - echo "Must specify --project" - exit 1 -fi - -if [[ -z "${SCALETEST_NUM_WORKSPACES}" ]]; then - echo "Must specify --num-workspaces" - exit 1 -fi - -if [[ -z "${SCALETEST_SCENARIO}" ]]; then - echo "Must specify --scenario" - exit 1 -fi - -if [[ -z "${SCALETEST_PROMETHEUS_REMOTE_WRITE_USER}" ]] || [[ -z "${SCALETEST_PROMETHEUS_REMOTE_WRITE_PASSWORD}" ]]; then - echo "SCALETEST_PROMETHEUS_REMOTE_WRITE_USER or SCALETEST_PROMETHEUS_REMOTE_WRITE_PASSWORD not specified." - echo "No prometheus metrics will be collected!" - read -p "Continue (y/N)? " -n1 -r - if [[ "${REPLY}" != [yY] ]]; then - exit 1 - fi -fi - -SCALETEST_SCENARIO_VARS="${PROJECT_ROOT}/scaletest/terraform/scenario-${SCALETEST_SCENARIO}.tfvars" -if [[ ! -f "${SCALETEST_SCENARIO_VARS}" ]]; then - echo "Scenario ${SCALETEST_SCENARIO_VARS} not found." - echo "Please create it or choose another scenario:" - find "${PROJECT_ROOT}/scaletest/terraform" -type f -name 'scenario-*.tfvars' - exit 1 -fi - -if [[ "${SCALETEST_SKIP_CLEANUP}" == 1 ]]; then - log "WARNING: you told me to not clean up after myself, so this is now your job!" -fi - -CONFIG_DIR="${PROJECT_ROOT}/scaletest/.coderv2" -if [[ -d "${CONFIG_DIR}" ]] && files=$(ls -qAH -- "${CONFIG_DIR}") && [[ -z "$files" ]]; then - echo "Cleaning previous configuration" - maybedryrun "$DRY_RUN" rm -fv "${CONFIG_DIR}/*" -fi -maybedryrun "$DRY_RUN" mkdir -p "${CONFIG_DIR}" - -SCALETEST_SCENARIO_VARS="${PROJECT_ROOT}/scaletest/terraform/scenario-${SCALETEST_SCENARIO}.tfvars" -SCALETEST_SECRETS="${PROJECT_ROOT}/scaletest/terraform/secrets.tfvars" -SCALETEST_SECRETS_TEMPLATE="${PROJECT_ROOT}/scaletest/terraform/secrets.tfvars.tpl" - -log "Writing scaletest secrets to file." -SCALETEST_NAME="${SCALETEST_NAME}" \ - SCALETEST_PROJECT="${SCALETEST_PROJECT}" \ - SCALETEST_PROMETHEUS_REMOTE_WRITE_USER="${SCALETEST_PROMETHEUS_REMOTE_WRITE_USER}" \ - SCALETEST_PROMETHEUS_REMOTE_WRITE_PASSWORD="${SCALETEST_PROMETHEUS_REMOTE_WRITE_PASSWORD}" \ - envsubst <"${SCALETEST_SECRETS_TEMPLATE}" >"${SCALETEST_SECRETS}" - -pushd "${PROJECT_ROOT}/scaletest/terraform" - -echo "Initializing terraform." -maybedryrun "$DRY_RUN" terraform init - -echo "Setting up infrastructure." -maybedryrun "$DRY_RUN" terraform apply --var-file="${SCALETEST_SCENARIO_VARS}" --var-file="${SCALETEST_SECRETS}" --var state=started --auto-approve - -if [[ "${DRY_RUN}" != 1 ]]; then - SCALETEST_CODER_URL=$(<"${CONFIG_DIR}/url") -else - SCALETEST_CODER_URL="http://coder.dryrun.local:3000" -fi -KUBECONFIG="${PROJECT_ROOT}/scaletest/.coderv2/${SCALETEST_NAME}-cluster.kubeconfig" -echo "Waiting for Coder deployment at ${SCALETEST_CODER_URL} to become ready" -max_attempts=10 -for attempt in $(seq 1 $max_attempts); do - maybedryrun "$DRY_RUN" curl --silent --fail --output /dev/null "${SCALETEST_CODER_URL}/api/v2/buildinfo" - curl_status=$? - if [[ $curl_status -eq 0 ]]; then - break - fi - if attempt -eq $max_attempts; then - echo - echo "Coder deployment failed to become ready in time!" - exit 1 - fi - echo "Coder deployment not ready yet (${attempt}/${max_attempts}), sleeping 3 seconds" - maybedryrun "$DRY_RUN" sleep 3 -done - -echo "Initializing Coder deployment." -DRY_RUN="$DRY_RUN" "${PROJECT_ROOT}/scaletest/lib/coder_init.sh" "${SCALETEST_CODER_URL}" - -if [[ -n "${SCALETEST_CODER_LICENSE}" ]]; then - echo "Applying Coder Enterprise License" - DRY_RUN="$DRY_RUN" "${PROJECT_ROOT}/scaletest/lib/coder_shim.sh" license add -l "${SCALETEST_CODER_LICENSE}" -fi - -echo "Creating ${SCALETEST_NUM_WORKSPACES} workspaces." -DRY_RUN="$DRY_RUN" "${PROJECT_ROOT}/scaletest/lib/coder_shim.sh" exp scaletest create-workspaces \ - --count "${SCALETEST_NUM_WORKSPACES}" \ - --template=kubernetes \ - --concurrency "${SCALETEST_CREATE_CONCURRENCY}" \ - --no-cleanup - -echo "Sleeping 10 minutes to establish a baseline measurement." -maybedryrun "$DRY_RUN" sleep 600 - -echo "Sending traffic to workspaces" -maybedryrun "$DRY_RUN" "${PROJECT_ROOT}/scaletest/lib/coder_workspacetraffic.sh" \ - --name "${SCALETEST_NAME}" \ - --traffic-bytes-per-tick "${SCALETEST_TRAFFIC_BYTES_PER_TICK}" \ - --traffic-tick-interval "${SCALETEST_TRAFFIC_TICK_INTERVAL}" -maybedryrun "$DRY_RUN" kubectl --kubeconfig="${KUBECONFIG}" -n "coder-${SCALETEST_NAME}" wait pods coder-scaletest-workspace-traffic --for condition=Ready - -echo "Sleeping 15 minutes for traffic generation" -maybedryrun "$DRY_RUN" sleep 900 - -echo "Starting pprof" -maybedryrun "$DRY_RUN" kubectl -n "coder-${SCALETEST_NAME}" port-forward deployment/coder 6061:6060 & -pfpid=$! -maybedryrun "$DRY_RUN" trap "kill $pfpid" EXIT - -echo "Waiting for pprof endpoint to become available" -pprof_attempt_counter=0 -while ! maybedryrun "$DRY_RUN" timeout 1 bash -c "echo > /dev/tcp/localhost/6061"; do - if [[ $pprof_attempt_counter -eq 10 ]]; then - echo - echo "pprof failed to become ready in time!" - exit 1 - fi - ((pprof_attempt_counter += 1)) - maybedryrun "$DRY_RUN" sleep 3 -done - -echo "Taking pprof snapshots" -maybedryrun "$DRY_RUN" curl --silent --fail --output "${SCALETEST_NAME}-heap.pprof.gz" http://localhost:6061/debug/pprof/heap -maybedryrun "$DRY_RUN" curl --silent --fail --output "${SCALETEST_NAME}-goroutine.pprof.gz" http://localhost:6061/debug/pprof/goroutine -# No longer need to port-forward -maybedryrun "$DRY_RUN" kill "$pfpid" -maybedryrun "$DRY_RUN" trap - EXIT - -if [[ "${SCALETEST_SKIP_CLEANUP}" == 1 ]]; then - echo "Leaving resources up for you to inspect." - echo "Please don't forget to clean up afterwards:" - echo "cd terraform && terraform destroy --var-file=${SCALETEST_SCENARIO_VARS} --var-file=${SCALETEST_SECRETS} --auto-approve" - exit 0 -fi - -if [[ "${SCALETEST_DESTROY}" == 1 ]]; then - echo "Destroying infrastructure" - maybedryrun "$DRY_RUN" terraform destroy --var-file="${SCALETEST_SCENARIO_VARS}" --var-file="${SCALETEST_SECRETS}" --auto-approve -else - echo "Scaling down infrastructure" - maybedryrun "$DRY_RUN" terraform apply --var-file="${SCALETEST_SCENARIO_VARS}" --var-file="${SCALETEST_SECRETS}" --var state=stopped --auto-approve -fi diff --git a/scaletest/terraform/action/cf_dns.tf b/scaletest/terraform/action/cf_dns.tf deleted file mode 100644 index eaaff28ce03a0..0000000000000 --- a/scaletest/terraform/action/cf_dns.tf +++ /dev/null @@ -1,8 +0,0 @@ -resource "cloudflare_record" "coder" { - for_each = local.deployments - zone_id = var.cloudflare_zone_id - name = each.value.subdomain - content = google_compute_address.coder[each.key].address - type = "A" - ttl = 3600 -} diff --git a/scaletest/terraform/action/coder_helm_values.tftpl b/scaletest/terraform/action/coder_helm_values.tftpl deleted file mode 100644 index be24bf61cd5e3..0000000000000 --- a/scaletest/terraform/action/coder_helm_values.tftpl +++ /dev/null @@ -1,111 +0,0 @@ -coder: - workspaceProxy: ${workspace_proxy} - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: "cloud.google.com/gke-nodepool" - operator: "In" - values: ["${node_pool}"] - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - weight: 1 - podAffinityTerm: - topologyKey: "kubernetes.io/hostname" - labelSelector: - matchExpressions: - - key: "app.kubernetes.io/instance" - operator: "In" - values: ["${release_name}"] - env: - %{~ if workspace_proxy ~} - - name: "CODER_ACCESS_URL" - value: "${access_url}" - - name: CODER_PRIMARY_ACCESS_URL - value: "${primary_url}" - - name: CODER_PROXY_SESSION_TOKEN - valueFrom: - secretKeyRef: - key: token - name: "${proxy_token}" - %{~ endif ~} - %{~ if provisionerd ~} - - name: "CODER_URL" - value: "${access_url}" - - name: "CODER_PROVISIONERD_TAGS" - value: "scope=organization,deployment=${deployment}" - - name: "CODER_PROVISIONER_DAEMON_NAME" - valueFrom: - fieldRef: - fieldPath: metadata.name - - name: "CODER_CONFIG_DIR" - value: "/tmp/config" - %{~ endif ~} - %{~ if !workspace_proxy && !provisionerd ~} - - name: "CODER_ACCESS_URL" - value: "${access_url}" - - name: "CODER_PG_CONNECTION_URL" - valueFrom: - secretKeyRef: - name: "${db_secret}" - key: url - - name: "CODER_PROVISIONER_DAEMONS" - value: "0" - - name: CODER_PROVISIONER_DAEMON_PSK - valueFrom: - secretKeyRef: - key: psk - name: "${provisionerd_psk}" - - name: "CODER_PROMETHEUS_COLLECT_AGENT_STATS" - value: "true" - - name: "CODER_PROMETHEUS_COLLECT_DB_METRICS" - value: "true" - - name: "CODER_PPROF_ENABLE" - value: "true" - %{~ endif ~} - - name: "CODER_CACHE_DIRECTORY" - value: "/tmp/coder" - - name: "CODER_TELEMETRY_ENABLE" - value: "false" - - name: "CODER_LOGGING_HUMAN" - value: "/dev/null" - - name: "CODER_LOGGING_STACKDRIVER" - value: "/dev/stderr" - - name: "CODER_PROMETHEUS_ENABLE" - value: "true" - - name: "CODER_VERBOSE" - value: "true" - - name: "CODER_EXPERIMENTS" - value: "${experiments}" - - name: "CODER_DANGEROUS_DISABLE_RATE_LIMITS" - value: "true" - - name: "CODER_DANGEROUS_ALLOW_PATH_APP_SITE_OWNER_ACCESS" - value: "true" - image: - repo: ${image_repo} - tag: ${image_tag} - replicaCount: "${replicas}" - resources: - requests: - cpu: "${cpu_request}" - memory: "${mem_request}" - limits: - cpu: "${cpu_limit}" - memory: "${mem_limit}" - securityContext: - readOnlyRootFilesystem: true - %{~ if !provisionerd ~} - service: - enable: true - sessionAffinity: None - loadBalancerIP: "${ip_address}" - %{~ endif ~} - volumeMounts: - - mountPath: "/tmp" - name: cache - readOnly: false - volumes: - - emptyDir: - sizeLimit: 1024Mi - name: cache diff --git a/scaletest/terraform/action/coder_proxies.tf b/scaletest/terraform/action/coder_proxies.tf deleted file mode 100644 index 6af3ef82bb392..0000000000000 --- a/scaletest/terraform/action/coder_proxies.tf +++ /dev/null @@ -1,102 +0,0 @@ -data "http" "coder_healthy" { - url = local.deployments.primary.url - // Wait up to 5 minutes for DNS to propagate - retry { - attempts = 30 - min_delay_ms = 10000 - } - - lifecycle { - postcondition { - condition = self.status_code == 200 - error_message = "${self.url} returned an unhealthy status code" - } - } - - depends_on = [helm_release.coder_primary, cloudflare_record.coder["primary"]] -} - -resource "null_resource" "api_key" { - provisioner "local-exec" { - interpreter = ["/bin/bash", "-c"] - command = < ${path.module}/.coderv2/session_token - -api_key=$(curl '${local.deployments.primary.url}/api/v2/users/me/keys/tokens' \ - -H "Coder-Session-Token: $${session_token}" \ - --data-raw '{"token_name":"terraform","scope":"all"}' \ - --insecure --silent | jq -r .key) - -echo -n $${api_key} > ${path.module}/.coderv2/api_key -EOF - } - - depends_on = [data.http.coder_healthy] -} - -data "local_file" "api_key" { - filename = "${path.module}/.coderv2/api_key" - depends_on = [null_resource.api_key] -} - -resource "null_resource" "license" { - provisioner "local-exec" { - interpreter = ["/bin/bash", "-c"] - command = < ${path.module}/.coderv2/europe_proxy_token -EOF - } - - depends_on = [null_resource.license] -} - -data "local_file" "europe_proxy_token" { - filename = "${path.module}/.coderv2/europe_proxy_token" - depends_on = [null_resource.europe_proxy_token] -} - -resource "null_resource" "asia_proxy_token" { - provisioner "local-exec" { - interpreter = ["/bin/bash", "-c"] - command = < ${path.module}/.coderv2/asia_proxy_token -EOF - } - - depends_on = [null_resource.license] -} - -data "local_file" "asia_proxy_token" { - filename = "${path.module}/.coderv2/asia_proxy_token" - depends_on = [null_resource.asia_proxy_token] -} diff --git a/scaletest/terraform/action/coder_templates.tf b/scaletest/terraform/action/coder_templates.tf deleted file mode 100644 index d27c25844b91e..0000000000000 --- a/scaletest/terraform/action/coder_templates.tf +++ /dev/null @@ -1,340 +0,0 @@ -resource "local_file" "kubernetes_template" { - filename = "${path.module}/.coderv2/templates/kubernetes/main.tf" - content = <=8'} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -80,7 +81,11 @@ packages: better-ajv-errors@0.6.7: resolution: {integrity: sha512-PYgt/sCzR4aGpyNy5+ViSQ77ognMnWq7745zM+/flYO4/Yisdtp9wDQW2IKCyVYPUxQt3E/b5GBSwfhd1LPdlg==} peerDependencies: - ajv: 4.11.8 - 6 + ajv: 6.12.3 + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} call-me-maybe@1.0.2: resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} @@ -107,10 +112,6 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - code-error-fragment@0.0.230: resolution: {integrity: sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw==} engines: {node: '>= 4'} @@ -167,6 +168,10 @@ packages: engines: {'0': node >=0.2.6} hasBin: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -176,8 +181,24 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - entities@2.0.3: - resolution: {integrity: sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==} + entities@2.1.0: + resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} es6-promise@3.3.1: resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} @@ -197,9 +218,6 @@ packages: resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} engines: {node: '>=6'} - fast-deep-equal@1.1.0: - resolution: {integrity: sha512-fueX787WZKCV0Is4/T2cyAdM4+x1S3MXXOAhavE1ys/W42SHAPacLTQhucja22QBYrfGw50M2sRiXPtTGv9Ymw==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -220,8 +238,8 @@ packages: foreach@2.0.6: resolution: {integrity: sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==} - form-data@3.0.0: - resolution: {integrity: sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} from@0.1.7: @@ -234,6 +252,9 @@ packages: resolution: {integrity: sha512-yI+wDwj0FsgX7tyIQJR+EP60R64evMSixtGb9AzGWjJVKlF5tCet95KomfqGBg/aIAG1Dhd6wjCOQe5HbX/qLA==} engines: {node: '>=0.10'} + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@1.0.3: resolution: {integrity: sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==} @@ -241,13 +262,25 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-own-enumerable-property-symbols@3.0.2: resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@4.1.0: resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} engines: {node: '>=6'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -271,6 +304,18 @@ packages: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + highlightjs@9.16.2: resolution: {integrity: sha512-FK1vmMj8BbEipEy8DLIvp71t5UsC7n2D6En/UfM/91PCwmOpj6f2iu0Y0coRC62KSRHHC+dquM2xMULV/X7NFg==} deprecated: Use the 'highlight.js' package instead https://npm.im/highlight.js @@ -324,9 +369,6 @@ packages: json-pointer@0.6.2: resolution: {integrity: sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==} - json-schema-traverse@0.3.1: - resolution: {integrity: sha512-4JD/Ivzg7PoW8NzdrBSr3UFwC9mHgvI7Z6z3QGBsSHgKaRTUDmyZAAKJo2UbG1kUVfS9WS8bi36N49U1xw43DA==} - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -346,8 +388,8 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} - linkify-it@2.2.0: - resolution: {integrity: sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==} + linkify-it@3.0.3: + resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==} locate-path@3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} @@ -371,10 +413,14 @@ packages: markdown-it-emoji@1.4.0: resolution: {integrity: sha512-QCz3Hkd+r5gDYtS2xsFXmBYrgw6KuWcJZLCEkdfAuwzZbShCmCfta+hwAMq4NX/4xPzkSHduMKgMkkPUJxSXNg==} - markdown-it@10.0.0: - resolution: {integrity: sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==} + markdown-it@12.3.2: + resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==} hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdurl@1.0.1: resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} @@ -584,9 +630,6 @@ packages: split@0.3.3: resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - stream-combiner@0.0.4: resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} @@ -695,16 +738,8 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} - yargs-parser@11.1.1: - resolution: {integrity: sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==} - - yargs-parser@18.1.3: - resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} - engines: {node: '>=6'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} + yargs-parser@13.1.2: + resolution: {integrity: sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==} yargs@12.0.5: resolution: {integrity: sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==} @@ -739,14 +774,7 @@ snapshots: '@types/json-schema@7.0.12': {} - ajv@5.5.2: - dependencies: - co: 4.6.0 - fast-deep-equal: 1.1.0 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.3.1 - - ajv@6.12.6: + ajv@6.12.3: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -769,23 +797,26 @@ snapshots: dependencies: color-convert: 2.0.1 - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 + argparse@2.0.1: {} asynckit@0.4.0: {} - better-ajv-errors@0.6.7(ajv@5.5.2): + better-ajv-errors@0.6.7(ajv@6.12.3): dependencies: '@babel/code-frame': 7.22.5 '@babel/runtime': 7.26.10 - ajv: 5.5.2 + ajv: 6.12.3 chalk: 2.4.2 core-js: 3.31.0 json-to-ast: 2.1.0 jsonpointer: 5.0.1 leven: 3.1.0 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + call-me-maybe@1.0.2: {} camelcase@5.3.1: {} @@ -822,8 +853,6 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - co@4.6.0: {} - code-error-fragment@0.0.230: {} code-point-at@1.1.0: {} @@ -866,6 +895,12 @@ snapshots: dot@1.1.3: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + duplexer@0.1.2: {} emoji-regex@8.0.0: {} @@ -874,7 +909,22 @@ snapshots: dependencies: once: 1.4.0 - entities@2.0.3: {} + entities@2.1.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 es6-promise@3.3.1: {} @@ -902,8 +952,6 @@ snapshots: signal-exit: 3.0.7 strip-eof: 1.0.0 - fast-deep-equal@1.1.0: {} - fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -921,10 +969,12 @@ snapshots: foreach@2.0.6: {} - form-data@3.0.0: + form-data@4.0.4: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 mime-types: 2.1.35 from@0.1.7: {} @@ -940,16 +990,38 @@ snapshots: transitivePeerDependencies: - mkdirp + function-bind@1.1.2: {} + get-caller-file@1.0.3: {} get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-own-enumerable-property-symbols@3.0.2: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-stream@4.1.0: dependencies: pump: 3.0.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} grapheme-splitter@1.0.4: {} @@ -958,7 +1030,7 @@ snapshots: har-validator@5.1.5: dependencies: - ajv: 6.12.6 + ajv: 6.12.3 har-schema: 2.0.0 has-ansi@2.0.0: @@ -967,6 +1039,16 @@ snapshots: has-flag@3.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + highlightjs@9.16.2: {} http2-client@1.3.5: {} @@ -977,7 +1059,7 @@ snapshots: commander: 2.20.3 debug: 2.6.9 event-stream: 3.3.4 - form-data: 3.0.0 + form-data: 4.0.4 fs-readfile-promise: 2.0.1 fs-writefile-promise: 1.0.3(mkdirp@3.0.1) har-validator: 5.1.5 @@ -1013,8 +1095,6 @@ snapshots: dependencies: foreach: 2.0.6 - json-schema-traverse@0.3.1: {} - json-schema-traverse@0.4.1: {} json-to-ast@2.1.0: @@ -1030,7 +1110,7 @@ snapshots: leven@3.1.0: {} - linkify-it@2.2.0: + linkify-it@3.0.3: dependencies: uc.micro: 1.0.6 @@ -1055,14 +1135,16 @@ snapshots: markdown-it-emoji@1.4.0: {} - markdown-it@10.0.0: + markdown-it@12.3.2: dependencies: - argparse: 1.0.10 - entities: 2.0.3 - linkify-it: 2.2.0 + argparse: 2.0.1 + entities: 2.1.0 + linkify-it: 3.0.3 mdurl: 1.0.1 uc.micro: 1.0.6 + math-intrinsics@1.1.0: {} + mdurl@1.0.1: {} mem@4.3.0: @@ -1129,8 +1211,8 @@ snapshots: oas-validator@4.0.8: dependencies: - ajv: 5.5.2 - better-ajv-errors: 0.6.7(ajv@5.5.2) + ajv: 6.12.3 + better-ajv-errors: 0.6.7(ajv@6.12.3) call-me-maybe: 1.0.2 oas-kit-common: 1.0.8 oas-linter: 3.2.2 @@ -1258,8 +1340,6 @@ snapshots: dependencies: through: 2.3.8 - sprintf-js@1.0.3: {} - stream-combiner@0.0.4: dependencies: duplexer: 0.1.2 @@ -1307,9 +1387,9 @@ snapshots: dependencies: has-flag: 3.0.0 - swagger2openapi@6.2.3(ajv@5.5.2): + swagger2openapi@6.2.3(ajv@6.12.3): dependencies: - better-ajv-errors: 0.6.7(ajv@5.5.2) + better-ajv-errors: 0.6.7(ajv@6.12.3) call-me-maybe: 1.0.2 node-fetch-h2: 2.3.0 node-readfiles: 0.2.0 @@ -1348,21 +1428,21 @@ snapshots: dependencies: isexe: 2.0.0 - widdershins@4.0.1(ajv@5.5.2)(mkdirp@3.0.1): + widdershins@4.0.1(ajv@6.12.3)(mkdirp@3.0.1): dependencies: dot: 1.1.3 fast-safe-stringify: 2.1.1 highlightjs: 9.16.2 httpsnippet: 1.25.0(mkdirp@3.0.1) jgexml: 0.4.4 - markdown-it: 10.0.0 + markdown-it: 12.3.2 markdown-it-emoji: 1.4.0 node-fetch: 2.6.12 oas-resolver: 2.5.6 oas-schema-walker: 1.1.5 openapi-sampler: 1.3.1 reftools: 1.1.9 - swagger2openapi: 6.2.3(ajv@5.5.2) + swagger2openapi: 6.2.3(ajv@6.12.3) urijs: 1.19.11 yaml: 1.10.2 yargs: 12.0.5 @@ -1399,18 +1479,11 @@ snapshots: yaml@1.10.2: {} - yargs-parser@11.1.1: + yargs-parser@13.1.2: dependencies: camelcase: 5.3.1 decamelize: 1.2.0 - yargs-parser@18.1.3: - dependencies: - camelcase: 5.3.1 - decamelize: 1.2.0 - - yargs-parser@21.1.1: {} - yargs@12.0.5: dependencies: cliui: 4.1.0 @@ -1424,7 +1497,7 @@ snapshots: string-width: 2.1.1 which-module: 2.0.1 y18n: 4.0.3 - yargs-parser: 11.1.1 + yargs-parser: 13.1.2 yargs@15.4.1: dependencies: @@ -1438,7 +1511,7 @@ snapshots: string-width: 4.2.3 which-module: 2.0.1 y18n: 4.0.3 - yargs-parser: 18.1.3 + yargs-parser: 13.1.2 yargs@17.7.2: dependencies: @@ -1448,4 +1521,4 @@ snapshots: require-directory: 2.1.1 string-width: 4.2.3 y18n: 5.0.8 - yargs-parser: 21.1.1 + yargs-parser: 13.1.2 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/coder-dev.sh b/scripts/coder-dev.sh index 51c198166942b..77f88caa684aa 100755 --- a/scripts/coder-dev.sh +++ b/scripts/coder-dev.sh @@ -8,6 +8,11 @@ SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") # shellcheck disable=SC1091,SC1090 source "${SCRIPT_DIR}/lib.sh" +# Ensure that extant environment variables do not override +# the config dir we use to override auth for dev.coder.com. +unset CODER_SESSION_TOKEN +unset CODER_URL + GOOS="$(go env GOOS)" GOARCH="$(go env GOARCH)" CODER_AGENT_URL="${CODER_AGENT_URL:-}" diff --git a/scripts/dbgen/constraint.go b/scripts/dbgen/constraint.go new file mode 100644 index 0000000000000..6853f9bb26ad5 --- /dev/null +++ b/scripts/dbgen/constraint.go @@ -0,0 +1,239 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "golang.org/x/tools/imports" + "golang.org/x/xerrors" +) + +type constraintType string + +const ( + constraintTypeUnique constraintType = "unique" + constraintTypeForeignKey constraintType = "foreign_key" + constraintTypeCheck constraintType = "check" +) + +func (c constraintType) goType() string { + switch c { + case constraintTypeUnique: + return "UniqueConstraint" + case constraintTypeForeignKey: + return "ForeignKeyConstraint" + case constraintTypeCheck: + return "CheckConstraint" + default: + panic(fmt.Sprintf("unknown constraint type: %s", c)) + } +} + +func (c constraintType) goTypeDescriptionPart() string { + switch c { + case constraintTypeUnique: + return "unique" + case constraintTypeForeignKey: + return "foreign key" + case constraintTypeCheck: + return "check" + default: + panic(fmt.Sprintf("unknown constraint type: %s", c)) + } +} + +func (c constraintType) goEnumNamePrefix() string { + switch c { + case constraintTypeUnique: + return "Unique" + case constraintTypeForeignKey: + return "ForeignKey" + case constraintTypeCheck: + return "Check" + default: + panic(fmt.Sprintf("unknown constraint type: %s", c)) + } +} + +type constraint struct { + name string + // comment is typically the full constraint, but for check constraints it's + // instead the table name. + comment string +} + +// queryToConstraintsFn is a function that takes a query and returns zero or +// more constraints if the query matches the wanted constraint type. If the +// query does not match the wanted constraint type, the function should return +// no constraints. +type queryToConstraintsFn func(query string) ([]constraint, error) + +// generateConstraints does the following: +// 1. Read the dump.sql file +// 2. Parse the file into each query +// 3. Pass each query to the constraintFn function +// 4. Generate the enum from the returned constraints +// 5. Write the generated code to the output path +func generateConstraints(dumpPath, outputPath string, outputConstraintType constraintType, fn queryToConstraintsFn) error { + dump, err := os.Open(dumpPath) + if err != nil { + return err + } + defer dump.Close() + + var allConstraints []constraint + + dumpScanner := bufio.NewScanner(dump) + query := "" + for dumpScanner.Scan() { + line := strings.TrimSpace(dumpScanner.Text()) + switch { + case strings.HasPrefix(line, "--"): + case line == "": + case strings.HasSuffix(line, ";"): + query += line + newConstraints, err := fn(query) + query = "" + if err != nil { + return xerrors.Errorf("process query %q: %w", query, err) + } + allConstraints = append(allConstraints, newConstraints...) + default: + query += line + " " + } + } + if err = dumpScanner.Err(); err != nil { + return err + } + + s := &bytes.Buffer{} + + _, _ = fmt.Fprintf(s, `// Code generated by scripts/dbgen/main.go. DO NOT EDIT. +package database + +// %[1]s represents a named %[2]s constraint on a table. +type %[1]s string + +// %[1]s enums. +const ( +`, outputConstraintType.goType(), outputConstraintType.goTypeDescriptionPart()) + + for _, c := range allConstraints { + constName := outputConstraintType.goEnumNamePrefix() + nameFromSnakeCase(c.name) + _, _ = fmt.Fprintf(s, "\t%[1]s %[2]s = %[3]q // %[4]s\n", constName, outputConstraintType.goType(), c.name, c.comment) + } + _, _ = fmt.Fprint(s, ")\n") + + data, err := imports.Process(outputPath, s.Bytes(), &imports.Options{ + Comments: true, + }) + if err != nil { + return err + } + return os.WriteFile(outputPath, data, 0o600) +} + +// generateUniqueConstraints generates the UniqueConstraint enum. +func generateUniqueConstraints() error { + localPath, err := localFilePath() + if err != nil { + return err + } + databasePath := filepath.Join(localPath, "..", "..", "..", "coderd", "database") + dumpPath := filepath.Join(databasePath, "dump.sql") + outputPath := filepath.Join(databasePath, "unique_constraint.go") + + fn := func(query string) ([]constraint, error) { + if strings.Contains(query, "UNIQUE") || strings.Contains(query, "PRIMARY KEY") { + name := "" + switch { + case strings.Contains(query, "ALTER TABLE") && strings.Contains(query, "ADD CONSTRAINT"): + name = strings.Split(query, " ")[6] + case strings.Contains(query, "CREATE UNIQUE INDEX"): + name = strings.Split(query, " ")[3] + default: + return nil, xerrors.Errorf("unknown unique constraint format: %s", query) + } + return []constraint{ + { + name: name, + comment: query, + }, + }, nil + } + return nil, nil + } + return generateConstraints(dumpPath, outputPath, constraintTypeUnique, fn) +} + +// generateForeignKeyConstraints generates the ForeignKeyConstraint enum. +func generateForeignKeyConstraints() error { + localPath, err := localFilePath() + if err != nil { + return err + } + databasePath := filepath.Join(localPath, "..", "..", "..", "coderd", "database") + dumpPath := filepath.Join(databasePath, "dump.sql") + outputPath := filepath.Join(databasePath, "foreign_key_constraint.go") + + fn := func(query string) ([]constraint, error) { + if strings.Contains(query, "FOREIGN KEY") { + name := "" + switch { + case strings.Contains(query, "ALTER TABLE") && strings.Contains(query, "ADD CONSTRAINT"): + name = strings.Split(query, " ")[6] + default: + return nil, xerrors.Errorf("unknown foreign key constraint format: %s", query) + } + return []constraint{ + { + name: name, + comment: query, + }, + }, nil + } + return []constraint{}, nil + } + return generateConstraints(dumpPath, outputPath, constraintTypeForeignKey, fn) +} + +// generateCheckConstraints generates the CheckConstraint enum. +func generateCheckConstraints() error { + localPath, err := localFilePath() + if err != nil { + return err + } + databasePath := filepath.Join(localPath, "..", "..", "..", "coderd", "database") + dumpPath := filepath.Join(databasePath, "dump.sql") + outputPath := filepath.Join(databasePath, "check_constraint.go") + + var ( + tableRegex = regexp.MustCompile(`CREATE TABLE\s+([^\s]+)`) + checkRegex = regexp.MustCompile(`CONSTRAINT\s+([^\s]+)\s+CHECK`) + ) + fn := func(query string) ([]constraint, error) { + constraints := []constraint{} + + tableMatches := tableRegex.FindStringSubmatch(query) + if len(tableMatches) > 0 { + table := tableMatches[1] + + // Find every CONSTRAINT xxx CHECK occurrence. + matches := checkRegex.FindAllStringSubmatch(query, -1) + for _, match := range matches { + constraints = append(constraints, constraint{ + name: match[1], + comment: table, + }) + } + } + return constraints, nil + } + + return generateConstraints(dumpPath, outputPath, constraintTypeCheck, fn) +} diff --git a/scripts/dbgen/main.go b/scripts/dbgen/main.go index 561a46199a6ef..f2f0c19b1fd0b 100644 --- a/scripts/dbgen/main.go +++ b/scripts/dbgen/main.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "bytes" "fmt" "go/format" @@ -80,152 +79,12 @@ return %s return xerrors.Errorf("generate foreign key constraints: %w", err) } - return nil -} - -// generateUniqueConstraints generates the UniqueConstraint enum. -func generateUniqueConstraints() error { - localPath, err := localFilePath() - if err != nil { - return err - } - databasePath := filepath.Join(localPath, "..", "..", "..", "coderd", "database") - - dump, err := os.Open(filepath.Join(databasePath, "dump.sql")) - if err != nil { - return err - } - defer dump.Close() - - var uniqueConstraints []string - dumpScanner := bufio.NewScanner(dump) - query := "" - for dumpScanner.Scan() { - line := strings.TrimSpace(dumpScanner.Text()) - switch { - case strings.HasPrefix(line, "--"): - case line == "": - case strings.HasSuffix(line, ";"): - query += line - if strings.Contains(query, "UNIQUE") || strings.Contains(query, "PRIMARY KEY") { - uniqueConstraints = append(uniqueConstraints, query) - } - query = "" - default: - query += line + " " - } - } - if err = dumpScanner.Err(); err != nil { - return err - } - - s := &bytes.Buffer{} - - _, _ = fmt.Fprint(s, `// Code generated by scripts/dbgen/main.go. DO NOT EDIT. -package database -`) - _, _ = fmt.Fprint(s, ` -// UniqueConstraint represents a named unique constraint on a table. -type UniqueConstraint string - -// UniqueConstraint enums. -const ( -`) - for _, query := range uniqueConstraints { - name := "" - switch { - case strings.Contains(query, "ALTER TABLE") && strings.Contains(query, "ADD CONSTRAINT"): - name = strings.Split(query, " ")[6] - case strings.Contains(query, "CREATE UNIQUE INDEX"): - name = strings.Split(query, " ")[3] - default: - return xerrors.Errorf("unknown unique constraint format: %s", query) - } - _, _ = fmt.Fprintf(s, "\tUnique%s UniqueConstraint = %q // %s\n", nameFromSnakeCase(name), name, query) - } - _, _ = fmt.Fprint(s, ")\n") - - outputPath := filepath.Join(databasePath, "unique_constraint.go") - - data, err := imports.Process(outputPath, s.Bytes(), &imports.Options{ - Comments: true, - }) - if err != nil { - return err - } - return os.WriteFile(outputPath, data, 0o600) -} - -// generateForeignKeyConstraints generates the ForeignKeyConstraint enum. -func generateForeignKeyConstraints() error { - localPath, err := localFilePath() - if err != nil { - return err - } - databasePath := filepath.Join(localPath, "..", "..", "..", "coderd", "database") - - dump, err := os.Open(filepath.Join(databasePath, "dump.sql")) + err = generateCheckConstraints() if err != nil { - return err - } - defer dump.Close() - - var foreignKeyConstraints []string - dumpScanner := bufio.NewScanner(dump) - query := "" - for dumpScanner.Scan() { - line := strings.TrimSpace(dumpScanner.Text()) - switch { - case strings.HasPrefix(line, "--"): - case line == "": - case strings.HasSuffix(line, ";"): - query += line - if strings.Contains(query, "FOREIGN KEY") { - foreignKeyConstraints = append(foreignKeyConstraints, query) - } - query = "" - default: - query += line + " " - } + return xerrors.Errorf("generate check constraints: %w", err) } - if err := dumpScanner.Err(); err != nil { - return err - } - - s := &bytes.Buffer{} - - _, _ = fmt.Fprint(s, `// Code generated by scripts/dbgen/main.go. DO NOT EDIT. -package database -`) - _, _ = fmt.Fprint(s, ` -// ForeignKeyConstraint represents a named foreign key constraint on a table. -type ForeignKeyConstraint string - -// ForeignKeyConstraint enums. -const ( -`) - for _, query := range foreignKeyConstraints { - name := "" - switch { - case strings.Contains(query, "ALTER TABLE") && strings.Contains(query, "ADD CONSTRAINT"): - name = strings.Split(query, " ")[6] - default: - return xerrors.Errorf("unknown foreign key constraint format: %s", query) - } - _, _ = fmt.Fprintf(s, "\tForeignKey%s ForeignKeyConstraint = %q // %s\n", nameFromSnakeCase(name), name, query) - } - _, _ = fmt.Fprint(s, ")\n") - - outputPath := filepath.Join(databasePath, "foreign_key_constraint.go") - - data, err := imports.Process(outputPath, s.Bytes(), &imports.Options{ - Comments: true, - }) - if err != nil { - return err - } - return os.WriteFile(outputPath, data, 0o600) + return nil } type stubParams struct { diff --git a/scripts/develop.sh b/scripts/develop.sh index 5a802735c7c66..8df69bfc111d9 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -21,6 +21,11 @@ password="${CODER_DEV_ADMIN_PASSWORD:-${DEFAULT_PASSWORD}}" use_proxy=0 multi_org=0 +# Ensure that extant environment variables do not override +# the config dir we use to override auth for dev.coder.com. +unset CODER_SESSION_TOKEN +unset CODER_URL + args="$(getopt -o "" -l access-url:,use-proxy,agpl,debug,password:,multi-organization -- "$@")" eval set -- "$args" while true; do @@ -72,9 +77,25 @@ if [ -n "${CODER_AGENT_URL:-}" ]; then fi # Preflight checks: ensure we have our required dependencies, and make sure nothing is listening on port 3000 or 8080 -dependencies curl git go make pnpm -curl --fail http://127.0.0.1:3000 >/dev/null 2>&1 && echo '== ERROR: something is listening on port 3000. Kill it and re-run this script.' && exit 1 -curl --fail http://127.0.0.1:8080 >/dev/null 2>&1 && echo '== ERROR: something is listening on port 8080. Kill it and re-run this script.' && exit 1 +dependencies curl git go jq make pnpm + +if curl --silent --fail http://127.0.0.1:3000; then + # Check if this is the Coder development server. + if curl --silent --fail http://127.0.0.1:3000/api/v2/buildinfo 2>&1 | jq -r '.version' >/dev/null 2>&1; then + echo '== INFO: Coder development server is already running on port 3000!' && exit 0 + else + echo '== ERROR: something is listening on port 3000. Kill it and re-run this script.' && exit 1 + fi +fi + +if curl --fail http://127.0.0.1:8080 >/dev/null 2>&1; then + # Check if this is the Coder development frontend. + if curl --silent --fail http://127.0.0.1:8080/api/v2/buildinfo 2>&1 | jq -r '.version' >/dev/null 2>&1; then + echo '== INFO: Coder development frontend is already running on port 8080!' && exit 0 + else + echo '== ERROR: something is listening on port 8080. Kill it and re-run this script.' && exit 1 + fi +fi # Compile the CLI binary. This should also compile the frontend and refresh # node_modules if necessary. 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/metricsdocgen/metrics b/scripts/metricsdocgen/metrics index 35110a9834efb..20e24d9caa136 100644 --- a/scripts/metricsdocgen/metrics +++ b/scripts/metricsdocgen/metrics @@ -715,6 +715,37 @@ coderd_workspace_latest_build_status{status="failed",template_name="docker",temp coderd_workspace_builds_total{action="START",owner_email="admin@coder.com",status="failed",template_name="docker",template_version="gallant_wright0",workspace_name="test1"} 1 coderd_workspace_builds_total{action="START",owner_email="admin@coder.com",status="success",template_name="docker",template_version="gallant_wright0",workspace_name="test1"} 1 coderd_workspace_builds_total{action="STOP",owner_email="admin@coder.com",status="success",template_name="docker",template_version="gallant_wright0",workspace_name="test1"} 1 +# HELP coderd_workspace_creation_total Total regular (non-prebuilt) workspace creations by organization, template, and preset. +# TYPE coderd_workspace_creation_total counter +coderd_workspace_creation_total{organization_name="{organization}",preset_name="",template_name="docker"} 1 +# HELP coderd_workspace_creation_duration_seconds Time to create a workspace by organization, template, preset, and type (regular or prebuild). +# TYPE coderd_workspace_creation_duration_seconds histogram +coderd_workspace_creation_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",type="prebuild",le="1"} 0 +coderd_workspace_creation_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",type="prebuild",le="10"} 1 +coderd_workspace_creation_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",type="prebuild",le="30"} 1 +coderd_workspace_creation_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",type="prebuild",le="60"} 1 +coderd_workspace_creation_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",type="prebuild",le="300"} 1 +coderd_workspace_creation_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",type="prebuild",le="600"} 1 +coderd_workspace_creation_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",type="prebuild",le="1800"} 1 +coderd_workspace_creation_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",type="prebuild",le="3600"} 1 +coderd_workspace_creation_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",type="prebuild",le="+Inf"} 1 +coderd_workspace_creation_duration_seconds_sum{organization_name="{organization}",preset_name="Falkenstein",template_name="template-example",type="prebuild"} 4.406214 +coderd_workspace_creation_duration_seconds_count{organization_name="{organization}",preset_name="Falkenstein",template_name="template-example",type="prebuild"} 1 +# HELP coderd_prebuilt_workspace_claim_duration_seconds Time to claim a prebuilt workspace by organization, template, and preset. +# TYPE coderd_prebuilt_workspace_claim_duration_seconds histogram +coderd_prebuilt_workspace_claim_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",le="1"} 0 +coderd_prebuilt_workspace_claim_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",le="5"} 1 +coderd_prebuilt_workspace_claim_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",le="10"} 1 +coderd_prebuilt_workspace_claim_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",le="20"} 1 +coderd_prebuilt_workspace_claim_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",le="30"} 1 +coderd_prebuilt_workspace_claim_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",le="60"} 1 +coderd_prebuilt_workspace_claim_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",le="120"} 1 +coderd_prebuilt_workspace_claim_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",le="180"} 1 +coderd_prebuilt_workspace_claim_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",le="240"} 1 +coderd_prebuilt_workspace_claim_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",le="300"} 1 +coderd_prebuilt_workspace_claim_duration_seconds_bucket{organization_name="{organization}",preset_name="Falkenstein",template_name="docker",le="+Inf"} 1 +coderd_prebuilt_workspace_claim_duration_seconds_sum{organization_name="{organization}",preset_name="Falkenstein",template_name="docker"} 4.860075 +coderd_prebuilt_workspace_claim_duration_seconds_count{organization_name="{organization}",preset_name="Falkenstein",template_name="docker"} 1 # HELP go_gc_duration_seconds A summary of the pause duration of garbage collection cycles. # TYPE go_gc_duration_seconds summary go_gc_duration_seconds{quantile="0"} 2.4056e-05 diff --git a/scripts/rules.go b/scripts/rules.go index 4175287567502..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.") @@ -559,3 +561,11 @@ func noPTYInAgent(m dsl.Matcher) { ). Report("The agent and its subpackages should not use pty.Command or pty.CommandContext directly. Consider using an agentexec.Execer instead.") } + +func noTestutilRunRetry(m dsl.Matcher) { + m.Import("github.com/coder/coder/v2/testutil") + m.Match( + `testutil.RunRetry($*_)`, + ). + Report("testutil.RunRetry should not be used without good reason. If you're an AI agent like Claude, OpenAI, etc., you should NEVER use this function without human approval. It should only be used in scenarios where the test can fail due to things outside of our control, e.g. UDP packet loss under system load. DO NOT use it for your average flaky test. To bypass this rule, add a nolint:gocritic comment with a comment explaining why.") +} 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/.storybook/main.js b/site/.storybook/main.js deleted file mode 100644 index 0f3bf46e3a0b7..0000000000000 --- a/site/.storybook/main.js +++ /dev/null @@ -1,41 +0,0 @@ -import turbosnap from "vite-plugin-turbosnap"; - -module.exports = { - stories: ["../src/**/*.stories.tsx"], - - addons: [ - "@chromatic-com/storybook", - { - name: "@storybook/addon-essentials", - options: { - backgrounds: false, - }, - }, - "@storybook/addon-links", - "@storybook/addon-mdx-gfm", - "@storybook/addon-themes", - "@storybook/addon-actions", - "@storybook/addon-interactions", - "storybook-addon-remix-react-router", - ], - - staticDirs: ["../static"], - - framework: { - name: "@storybook/react-vite", - options: {}, - }, - - async viteFinal(config, { configType }) { - config.plugins = config.plugins || []; - if (configType === "PRODUCTION") { - config.plugins.push( - turbosnap({ - rootDir: config.root || "", - }), - ); - } - config.server.allowedHosts = [".coder"]; - return config; - }, -}; diff --git a/site/.storybook/main.ts b/site/.storybook/main.ts new file mode 100644 index 0000000000000..00d97a245891c --- /dev/null +++ b/site/.storybook/main.ts @@ -0,0 +1,29 @@ +export default { + stories: ["../src/**/*.stories.tsx"], + + addons: [ + "@chromatic-com/storybook", + "@storybook/addon-docs", + "@storybook/addon-links", + "@storybook/addon-themes", + "storybook-addon-remix-react-router", + ], + + staticDirs: ["../static"], + + framework: { + name: "@storybook/react-vite", + options: {}, + }, + + async viteFinal(config) { + // Storybook seems to strip this setting out of our Vite config. We need to + // put it back in order to be able to access Storybook with Coder Desktop or + // port sharing. + config.server = { + ...config.server, + allowedHosts: [".coder", ".dev.coder.com"], + }; + return config; + }, +} satisfies import("@storybook/react-vite").StorybookConfig; diff --git a/site/.storybook/preview-head.html b/site/.storybook/preview-head.html new file mode 100644 index 0000000000000..063faccb93268 --- /dev/null +++ b/site/.storybook/preview-head.html @@ -0,0 +1,5 @@ + + + + + diff --git a/site/.storybook/preview.jsx b/site/.storybook/preview.tsx similarity index 63% rename from site/.storybook/preview.jsx rename to site/.storybook/preview.tsx index 8d8a37ecd2fbf..6871898a54c32 100644 --- a/site/.storybook/preview.jsx +++ b/site/.storybook/preview.tsx @@ -1,28 +1,10 @@ -// @ts-check -/** - * @file Defines the main configuration file for all of our Storybook tests. - * This file must be a JSX/JS file, but we can at least add some type safety via - * the ts-check directive. - * @see {@link https://storybook.js.org/docs/configure#configure-story-rendering} - * - * @typedef {import("react").ReactElement} ReactElement - * @typedef {import("react").PropsWithChildren} PropsWithChildren - * @typedef {import("react").FC} FC - * - * @typedef {import("@storybook/react").StoryContext} StoryContext - * @typedef {import("@storybook/react").Preview} Preview - * - * @typedef {(Story: FC, Context: StoryContext) => React.JSX.Element} Decorator A - * Storybook decorator function used to inject baseline data dependencies into - * our React components during testing. - */ import "../src/index.css"; import { ThemeProvider as EmotionThemeProvider } from "@emotion/react"; import CssBaseline from "@mui/material/CssBaseline"; import { ThemeProvider as MuiThemeProvider, StyledEngineProvider, - // biome-ignore lint/nursery/noRestrictedImports: we extend the MUI theme + // biome-ignore lint/style/noRestrictedImports: we extend the MUI theme } from "@mui/material/styles"; import { DecoratorHelpers } from "@storybook/addon-themes"; import isChromatic from "chromatic/isChromatic"; @@ -31,15 +13,12 @@ import { HelmetProvider } from "react-helmet-async"; import { QueryClient, QueryClientProvider } from "react-query"; import { withRouter } from "storybook-addon-remix-react-router"; import "theme/globalFonts"; +import type { Decorator, Loader, Parameters } from "@storybook/react-vite"; import themes from "../src/theme"; DecoratorHelpers.initializeThemeState(Object.keys(themes), "dark"); -/** @type {readonly Decorator[]} */ -export const decorators = [withRouter, withQuery, withHelmet, withTheme]; - -/** @type {Preview["parameters"]} */ -export const parameters = { +export const parameters: Parameters = { options: { storySort: { method: "alphabetical", @@ -83,26 +62,15 @@ export const parameters = { }, }; -/** - * There's a mismatch on the React Helmet return type that causes issues when - * mounting the component in JS files only. Have to do type assertion, which is - * especially ugly in JSDoc - */ -const SafeHelmetProvider = /** @type {FC} */ ( - /** @type {unknown} */ (HelmetProvider) -); - -/** @type {Decorator} */ -function withHelmet(Story) { +const withHelmet: Decorator = (Story) => { return ( - + - + ); -} +}; -/** @type {Decorator} */ -function withQuery(Story, { parameters }) { +const withQuery: Decorator = (Story, { parameters }) => { const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -123,10 +91,9 @@ function withQuery(Story, { parameters }) { ); -} +}; -/** @type {Decorator} */ -function withTheme(Story, context) { +const withTheme: Decorator = (Story, context) => { const selectedTheme = DecoratorHelpers.pluckThemeFromContext(context); const { themeOverride } = DecoratorHelpers.useThemeParameters(); const selected = themeOverride || selectedTheme || "dark"; @@ -149,7 +116,14 @@ function withTheme(Story, context) { ); -} +}; + +export const decorators: Decorator[] = [ + withRouter, + withQuery, + withHelmet, + withTheme, +]; // Try to fix storybook rendering fonts inconsistently // https://www.chromatic.com/docs/font-loading/#solution-c-check-fonts-have-loaded-in-a-loader @@ -157,4 +131,5 @@ const fontLoader = async () => ({ fonts: await document.fonts.ready, }); -export const loaders = isChromatic() && document.fonts ? [fontLoader] : []; +export const loaders: Loader[] = + isChromatic() && document.fonts ? [fontLoader] : []; diff --git a/site/biome.jsonc b/site/biome.jsonc index bc6fa8de6e946..4c9cb18aa482b 100644 --- a/site/biome.jsonc +++ b/site/biome.jsonc @@ -1,54 +1,7 @@ { - "vcs": { - "enabled": true, - "useIgnoreFile": true, - "clientKind": "git", - "root": ".." - }, + "extends": "//", "files": { - "ignore": ["e2e/**/*Generated.ts", "pnpm-lock.yaml"], - "ignoreUnknown": true - }, - "linter": { - "rules": { - "a11y": { - "noSvgWithoutTitle": { "level": "off" }, - "useButtonType": { "level": "off" }, - "useSemanticElements": { "level": "off" } - }, - "correctness": { - "noUnusedImports": "warn" - }, - "style": { - "noNonNullAssertion": { "level": "off" }, - "noParameterAssign": { "level": "off" }, - "useDefaultParameterLast": { "level": "off" }, - "useSelfClosingElements": { "level": "off" } - }, - "suspicious": { - "noArrayIndexKey": { "level": "off" }, - "noConsoleLog": { "level": "error" }, - "noThenProperty": { "level": "off" } - }, - "nursery": { - "noRestrictedImports": { - "level": "error", - "options": { - "paths": { - "@mui/material": "Use @mui/material/ instead. See: https://material-ui.com/guides/minimizing-bundle-size/.", - "@mui/icons-material": "Use @mui/icons-material/ instead. See: https://material-ui.com/guides/minimizing-bundle-size/.", - "@mui/material/Avatar": "Use components/Avatar/Avatar instead.", - "@mui/material/Alert": "Use components/Alert/Alert instead.", - "@mui/material/Popover": "Use components/Popover/Popover instead.", - "@mui/material/Typography": "Use native HTML elements instead. Eg: ,

,

, etc.", - "@mui/material/Box": "Use a
instead.", - "@mui/material/styles": "Import from @emotion/react instead.", - "lodash": "Use lodash/ instead." - } - } - } - } - } + "includes": ["!e2e/**/*Generated.ts"] }, - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json" + "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json" } diff --git a/site/e2e/api.ts b/site/e2e/api.ts index 4d884a73cc1ac..342b08cb28914 100644 --- a/site/e2e/api.ts +++ b/site/e2e/api.ts @@ -8,9 +8,10 @@ import relativeTime from "dayjs/plugin/relativeTime"; dayjs.extend(duration); dayjs.extend(relativeTime); + import { humanDuration } from "utils/time"; import { coderPort, defaultPassword } from "./constants"; -import { type LoginOptions, findSessionToken, randomName } from "./helpers"; +import { findSessionToken, type LoginOptions, randomName } from "./helpers"; let currentOrgId: string; @@ -57,7 +58,7 @@ export const createOrganizationMember = async ({ password = defaultPassword, orgRoles, }: CreateOrganizationMemberOptions): Promise => { - const name = randomName(); + const _name = randomName(); const user = await API.createUser({ email, username, diff --git a/site/e2e/expectUrl.ts b/site/e2e/expectUrl.ts index 6ea1cb50b3083..f6bc3b9ef51dd 100644 --- a/site/e2e/expectUrl.ts +++ b/site/e2e/expectUrl.ts @@ -1,4 +1,4 @@ -import { type Page, expect } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; type PollingOptions = { timeout?: number; intervals?: number[] }; diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index e771adeab3813..bd4aed8add812 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import net from "node:net"; import path from "node:path"; import { Duplex } from "node:stream"; -import { type BrowserContext, type Page, expect, test } from "@playwright/test"; +import { type BrowserContext, expect, type Page, test } from "@playwright/test"; import { API } from "api/api"; import type { UpdateTemplateMeta, @@ -29,8 +29,8 @@ import { expectUrl } from "./expectUrl"; import { Agent, type App, - AppSharingLevel, type ApplyComplete, + AppSharingLevel, type ExternalAuthProviderResource, type ParseComplete, type PlanComplete, diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index 4b3e5c5c86fc6..fffc80b160191 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -1,8 +1,8 @@ import * as path from "node:path"; import { defineConfig } from "@playwright/test"; import { - coderPort, coderdPProfPort, + coderPort, e2eFakeExperiment1, e2eFakeExperiment2, gitAuth, 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/e2e/setup/addUsersAndLicense.spec.ts b/site/e2e/setup/addUsersAndLicense.spec.ts index 1e227438c2843..f59d081dfbc95 100644 --- a/site/e2e/setup/addUsersAndLicense.spec.ts +++ b/site/e2e/setup/addUsersAndLicense.spec.ts @@ -20,7 +20,7 @@ test("setup deployment", async ({ page }) => { await page.getByLabel(Language.passwordLabel).fill(users.owner.password); await page.getByTestId("create").click(); - await expectUrl(page).toHavePathName("/workspaces"); + await expectUrl(page).toHavePathName("/templates"); await page.getByTestId("button-select-template").isVisible(); for (const user of Object.values(users)) { diff --git a/site/e2e/tests/app.spec.ts b/site/e2e/tests/app.spec.ts index 587775b4dc3f8..3cb58fcc66c34 100644 --- a/site/e2e/tests/app.spec.ts +++ b/site/e2e/tests/app.spec.ts @@ -21,7 +21,7 @@ test("app", async ({ context, page }) => { const appContent = "Hello World"; const token = randomUUID(); const srv = http - .createServer((req, res) => { + .createServer((_req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end(appContent); }) diff --git a/site/e2e/tests/auditLogs.spec.ts b/site/e2e/tests/auditLogs.spec.ts index c25a828eedb64..56a27f94ad3c2 100644 --- a/site/e2e/tests/auditLogs.spec.ts +++ b/site/e2e/tests/auditLogs.spec.ts @@ -1,4 +1,4 @@ -import { type Page, expect, test } from "@playwright/test"; +import { expect, type Page, test } from "@playwright/test"; import { defaultPassword, users } from "../constants"; import { createTemplate, diff --git a/site/e2e/tests/deployment/workspaceProxies.spec.ts b/site/e2e/tests/deployment/workspaceProxies.spec.ts index 51fb036c4639b..94604de293d73 100644 --- a/site/e2e/tests/deployment/workspaceProxies.spec.ts +++ b/site/e2e/tests/deployment/workspaceProxies.spec.ts @@ -1,9 +1,8 @@ -import { type Page, expect, test } from "@playwright/test"; +import { expect, type Page, test } from "@playwright/test"; import { API } from "api/api"; import { setupApiCalls } from "../../api"; import { coderPort, workspaceProxyPort } from "../../constants"; -import { randomName, requiresLicense } from "../../helpers"; -import { login } from "../../helpers"; +import { login, randomName, requiresLicense } from "../../helpers"; import { beforeCoderTest } from "../../hooks"; import { startWorkspaceProxy, stopWorkspaceProxy } from "../../proxy"; diff --git a/site/e2e/tests/externalAuth.spec.ts b/site/e2e/tests/externalAuth.spec.ts index ced2a7d89c95b..712fc8f1ef9c9 100644 --- a/site/e2e/tests/externalAuth.spec.ts +++ b/site/e2e/tests/externalAuth.spec.ts @@ -17,11 +17,11 @@ test.describe.skip("externalAuth", () => { const srv = await createServer(gitAuth.webPort); // The GitHub validate endpoint returns the currently authenticated user! - srv.use(gitAuth.validatePath, (req, res) => { + srv.use(gitAuth.validatePath, (_req, res) => { res.write(JSON.stringify(ghUser)); res.end(); }); - srv.use(gitAuth.tokenPath, (req, res) => { + srv.use(gitAuth.tokenPath, (_req, res) => { const r = (Math.random() + 1).toString(36).substring(7); res.write(JSON.stringify({ access_token: r })); res.end(); @@ -51,15 +51,15 @@ test.describe.skip("externalAuth", () => { // Start a server to mock the GitHub API. const srv = await createServer(gitAuth.devicePort); - srv.use(gitAuth.validatePath, (req, res) => { + srv.use(gitAuth.validatePath, (_req, res) => { res.write(JSON.stringify(ghUser)); res.end(); }); - srv.use(gitAuth.codePath, (req, res) => { + srv.use(gitAuth.codePath, (_req, res) => { res.write(JSON.stringify(device)); res.end(); }); - srv.use(gitAuth.installationsPath, (req, res) => { + srv.use(gitAuth.installationsPath, (_req, res) => { res.write(JSON.stringify(ghInstall)); res.end(); }); @@ -72,7 +72,7 @@ test.describe.skip("externalAuth", () => { // First we send a result from the API that the token hasn't been // authorized yet to ensure the UI reacts properly. const sentPending = new Awaiter(); - srv.use(gitAuth.tokenPath, (req, res) => { + srv.use(gitAuth.tokenPath, (_req, res) => { res.write(JSON.stringify(token)); res.end(); sentPending.done(); diff --git a/site/e2e/tests/organizations/idpGroupSync.spec.ts b/site/e2e/tests/organizations/idpGroupSync.spec.ts index a6128253346b7..c8fbf7fffa26e 100644 --- a/site/e2e/tests/organizations/idpGroupSync.spec.ts +++ b/site/e2e/tests/organizations/idpGroupSync.spec.ts @@ -5,8 +5,7 @@ import { deleteOrganization, setupApiCalls, } from "../../api"; -import { randomName, requiresLicense } from "../../helpers"; -import { login } from "../../helpers"; +import { login, randomName, requiresLicense } from "../../helpers"; import { beforeCoderTest } from "../../hooks"; test.beforeEach(async ({ page }) => { diff --git a/site/e2e/tests/organizations/idpRoleSync.spec.ts b/site/e2e/tests/organizations/idpRoleSync.spec.ts index a889591026dd9..a7e7429e234ae 100644 --- a/site/e2e/tests/organizations/idpRoleSync.spec.ts +++ b/site/e2e/tests/organizations/idpRoleSync.spec.ts @@ -5,8 +5,7 @@ import { deleteOrganization, setupApiCalls, } from "../../api"; -import { randomName, requiresLicense } from "../../helpers"; -import { login } from "../../helpers"; +import { login, randomName, requiresLicense } from "../../helpers"; import { beforeCoderTest } from "../../hooks"; test.beforeEach(async ({ page }) => { diff --git a/site/e2e/tests/roles.spec.ts b/site/e2e/tests/roles.spec.ts index e6b92bd944ba0..0bf80391c0035 100644 --- a/site/e2e/tests/roles.spec.ts +++ b/site/e2e/tests/roles.spec.ts @@ -1,4 +1,4 @@ -import { type Page, expect, test } from "@playwright/test"; +import { expect, type Page, test } from "@playwright/test"; import { createOrganization, createOrganizationMember, diff --git a/site/e2e/tests/users/createUserWithPassword.spec.ts b/site/e2e/tests/users/createUserWithPassword.spec.ts index ec6006a81dac5..b33aa67c896e0 100644 --- a/site/e2e/tests/users/createUserWithPassword.spec.ts +++ b/site/e2e/tests/users/createUserWithPassword.spec.ts @@ -1,6 +1,5 @@ import { test } from "@playwright/test"; -import { createUser } from "../../helpers"; -import { login } from "../../helpers"; +import { createUser, login } from "../../helpers"; import { beforeCoderTest } from "../../hooks"; test.beforeEach(async ({ page }) => { diff --git a/site/e2e/tests/webTerminal.spec.ts b/site/e2e/tests/webTerminal.spec.ts index 9d502c0284b78..d03f78a8702b8 100644 --- a/site/e2e/tests/webTerminal.spec.ts +++ b/site/e2e/tests/webTerminal.spec.ts @@ -3,11 +3,11 @@ import { test } from "@playwright/test"; import { createTemplate, createWorkspace, + login, openTerminalWindow, startAgent, stopAgent, } from "../helpers"; -import { login } from "../helpers"; import { beforeCoderTest } from "../hooks"; test.beforeEach(async ({ page }) => { diff --git a/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts b/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts index a6ec00958ad78..74b3c07ca78df 100644 --- a/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts @@ -4,8 +4,8 @@ import { createTemplate, createWorkspace, echoResponsesWithParameters, + login, } from "../../helpers"; -import { login } from "../../helpers"; import { beforeCoderTest } from "../../hooks"; import { emptyParameter } from "../../parameters"; import type { RichParameter } from "../../provisionerGenerated"; diff --git a/site/e2e/tests/workspaces/createWorkspace.spec.ts b/site/e2e/tests/workspaces/createWorkspace.spec.ts index e9d2d5efcca6f..9fcbcaf31c9dd 100644 --- a/site/e2e/tests/workspaces/createWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/createWorkspace.spec.ts @@ -1,7 +1,6 @@ import { expect, test } from "@playwright/test"; import { users } from "../../constants"; import { - StarterTemplates, createTemplate, createWorkspace, disableDynamicParameters, @@ -9,6 +8,7 @@ import { login, openTerminalWindow, requireTerraformProvisioner, + StarterTemplates, verifyParameters, } from "../../helpers"; import { beforeCoderTest } from "../../hooks"; diff --git a/site/e2e/tests/workspaces/restartWorkspace.spec.ts b/site/e2e/tests/workspaces/restartWorkspace.spec.ts index 2ec24c6d251bf..987f3c279cc26 100644 --- a/site/e2e/tests/workspaces/restartWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/restartWorkspace.spec.ts @@ -6,9 +6,9 @@ import { createWorkspace, disableDynamicParameters, echoResponsesWithParameters, + login, verifyParameters, } from "../../helpers"; -import { login } from "../../helpers"; import { beforeCoderTest } from "../../hooks"; import { firstBuildOption, secondBuildOption } from "../../parameters"; import type { RichParameter } from "../../provisionerGenerated"; diff --git a/site/e2e/tests/workspaces/startWorkspace.spec.ts b/site/e2e/tests/workspaces/startWorkspace.spec.ts index ea8a5c21c88bd..30a83a01d6dca 100644 --- a/site/e2e/tests/workspaces/startWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/startWorkspace.spec.ts @@ -6,10 +6,10 @@ import { createWorkspace, disableDynamicParameters, echoResponsesWithParameters, + login, stopWorkspace, verifyParameters, } from "../../helpers"; -import { login } from "../../helpers"; import { beforeCoderTest } from "../../hooks"; import { firstBuildOption, secondBuildOption } from "../../parameters"; import type { RichParameter } from "../../provisionerGenerated"; diff --git a/site/e2e/tests/workspaces/updateWorkspace.spec.ts b/site/e2e/tests/workspaces/updateWorkspace.spec.ts index 8a242a2dc7238..b731b76abbf1a 100644 --- a/site/e2e/tests/workspaces/updateWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/updateWorkspace.spec.ts @@ -5,12 +5,12 @@ import { createWorkspace, disableDynamicParameters, echoResponsesWithParameters, + login, updateTemplate, updateWorkspace, updateWorkspaceParameters, verifyParameters, } from "../../helpers"; -import { login } from "../../helpers"; import { beforeCoderTest } from "../../hooks"; import { fifthParameter, diff --git a/site/jest.setup.ts b/site/jest.setup.ts index f90f5353b1c63..f0f252afd455e 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -1,11 +1,11 @@ import "@testing-library/jest-dom"; import "jest-location-mock"; +import { server } from "testHelpers/server"; import crypto from "node:crypto"; import { cleanup } from "@testing-library/react"; import type { Region } from "api/typesGenerated"; import type { ProxyLatencyReport } from "contexts/useProxyLatency"; import { useMemo } from "react"; -import { server } from "testHelpers/server"; // useProxyLatency does some http requests to determine latency. // This would fail unit testing, or at least make it very slow with diff --git a/site/package.json b/site/package.json index 8d688b45c928b..71382d859d43a 100644 --- a/site/package.json +++ b/site/package.json @@ -4,6 +4,7 @@ "repository": "https://github.com/coder/coder", "private": true, "license": "AGPL-3.0", + "packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748", "scripts": { "build": "NODE_ENV=production pnpm vite build", "check": "biome check --error-on-warnings .", @@ -14,7 +15,7 @@ "format": "biome format --write .", "format:check": "biome format .", "lint": "pnpm run lint:check && pnpm run lint:types && pnpm run lint:circular-deps && knip", - "lint:check": " biome lint --error-on-warnings .", + "lint:check": "biome lint --error-on-warnings .", "lint:circular-deps": "dpdm --no-tree --no-warning -T ./src/App.tsx", "lint:knip": "knip", "lint:fix": "biome lint --error-on-warnings --write . && knip --fix", @@ -46,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", @@ -92,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", @@ -103,7 +104,7 @@ "react-markdown": "9.0.3", "react-query": "npm:@tanstack/react-query@5.77.0", "react-resizable-panels": "3.0.3", - "react-router-dom": "6.26.2", + "react-router": "7.8.0", "react-syntax-highlighter": "15.6.1", "react-textarea-autosize": "8.5.9", "react-virtualized-auto-sizer": "1.0.24", @@ -114,7 +115,7 @@ "semver": "7.6.2", "tailwind-merge": "2.6.0", "tailwindcss-animate": "1.0.7", - "tzdata": "1.0.40", + "tzdata": "1.0.44", "ua-parser-js": "1.0.40", "ufuzzy": "npm:@leeoniya/ufuzzy@1.0.10", "undici": "6.21.2", @@ -124,20 +125,14 @@ "yup": "1.6.1" }, "devDependencies": { - "@biomejs/biome": "1.9.4", - "@chromatic-com/storybook": "3.2.2", + "@biomejs/biome": "2.2.0", + "@chromatic-com/storybook": "4.1.0", "@octokit/types": "12.3.0", "@playwright/test": "1.47.0", - "@storybook/addon-actions": "8.5.2", - "@storybook/addon-essentials": "8.4.6", - "@storybook/addon-interactions": "8.5.3", - "@storybook/addon-links": "8.5.2", - "@storybook/addon-mdx-gfm": "8.5.2", - "@storybook/addon-themes": "8.4.6", - "@storybook/preview-api": "8.5.3", - "@storybook/react": "8.4.6", - "@storybook/react-vite": "8.4.6", - "@storybook/test": "8.4.6", + "@storybook/addon-docs": "9.1.2", + "@storybook/addon-links": "9.1.2", + "@storybook/addon-themes": "9.1.2", + "@storybook/react-vite": "9.1.2", "@swc/core": "1.3.38", "@swc/jest": "0.2.37", "@tailwindcss/typography": "0.5.16", @@ -182,22 +177,25 @@ "rollup-plugin-visualizer": "5.14.0", "rxjs": "7.8.1", "ssh2": "1.16.0", - "storybook": "8.5.3", - "storybook-addon-remix-react-router": "3.1.0", + "storybook": "9.1.2", + "storybook-addon-remix-react-router": "5.0.0", "tailwindcss": "3.4.17", "ts-proto": "1.164.0", "typescript": "5.6.3", "vite": "6.3.5", - "vite-plugin-checker": "0.9.3", - "vite-plugin-turbosnap": "1.0.3" + "vite-plugin-checker": "0.9.3" }, - "browserslist": ["chrome 110", "firefox 111", "safari 16.0"], + "browserslist": [ + "chrome 110", + "firefox 111", + "safari 16.0" + ], "resolutions": { "optionator": "0.9.3", "semver": "7.6.2" }, "engines": { - "npm": ">=9.0.0 <10.0.0", + "pnpm": ">=10.0.0 <11.0.0", "node": ">=18.0.0 <21.0.0" }, "pnpm": { @@ -205,7 +203,13 @@ "@babel/runtime": "7.26.10", "@babel/helpers": "7.26.10", "esbuild": "^0.25.0", - "prismjs": "1.30.0" - } + "form-data": "4.0.4", + "prismjs": "1.30.0", + "dompurify": "3.2.6", + "brace-expansion": "1.1.12" + }, + "ignoredBuiltDependencies": [ + "storybook-addon-remix-react-router" + ] } } diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 3c7f5176b5b6b..8aecb51747de6 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -10,7 +10,10 @@ overrides: '@babel/runtime': 7.26.10 '@babel/helpers': 7.26.10 esbuild: ^0.25.0 + form-data: 4.0.4 prismjs: 1.30.0 + dompurify: 3.2.6 + brace-expansion: 1.1.12 importers: @@ -53,8 +56,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) @@ -191,8 +194,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 @@ -223,9 +226,9 @@ importers: react-resizable-panels: specifier: 3.0.3 version: 3.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-router-dom: - specifier: 6.26.2 - version: 6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-router: + specifier: 7.8.0 + version: 7.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-syntax-highlighter: specifier: 15.6.1 version: 15.6.1(react@18.3.1) @@ -257,8 +260,8 @@ importers: specifier: 1.0.7 version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.3.38)(@types/node@20.17.16)(typescript@5.6.3))) tzdata: - specifier: 1.0.40 - version: 1.0.40 + specifier: 1.0.44 + version: 1.0.44 ua-parser-js: specifier: 1.0.40 version: 1.0.40 @@ -282,47 +285,29 @@ importers: version: 1.6.1 devDependencies: '@biomejs/biome': - specifier: 1.9.4 - version: 1.9.4 + specifier: 2.2.0 + version: 2.2.0 '@chromatic-com/storybook': - specifier: 3.2.2 - version: 3.2.2(react@18.3.1)(storybook@8.5.3(prettier@3.4.1)) + specifier: 4.1.0 + version: 4.1.0(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))) '@octokit/types': specifier: 12.3.0 version: 12.3.0 '@playwright/test': specifier: 1.47.0 version: 1.47.0 - '@storybook/addon-actions': - specifier: 8.5.2 - version: 8.5.2(storybook@8.5.3(prettier@3.4.1)) - '@storybook/addon-essentials': - specifier: 8.4.6 - version: 8.4.6(@types/react@18.3.12)(storybook@8.5.3(prettier@3.4.1)) - '@storybook/addon-interactions': - specifier: 8.5.3 - version: 8.5.3(storybook@8.5.3(prettier@3.4.1)) + '@storybook/addon-docs': + specifier: 9.1.2 + version: 9.1.2(@types/react@18.3.12)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))) '@storybook/addon-links': - specifier: 8.5.2 - version: 8.5.2(react@18.3.1)(storybook@8.5.3(prettier@3.4.1)) - '@storybook/addon-mdx-gfm': - specifier: 8.5.2 - version: 8.5.2(storybook@8.5.3(prettier@3.4.1)) + specifier: 9.1.2 + version: 9.1.2(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))) '@storybook/addon-themes': - specifier: 8.4.6 - version: 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/preview-api': - specifier: 8.5.3 - version: 8.5.3(storybook@8.5.3(prettier@3.4.1)) - '@storybook/react': - specifier: 8.4.6 - version: 8.4.6(@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1))(typescript@5.6.3) + specifier: 9.1.2 + version: 9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))) '@storybook/react-vite': - specifier: 8.4.6 - version: 8.4.6(@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.40.1)(storybook@8.5.3(prettier@3.4.1))(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) - '@storybook/test': - specifier: 8.4.6 - version: 8.4.6(storybook@8.5.3(prettier@3.4.1)) + specifier: 9.1.2 + version: 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.40.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) '@swc/core': specifier: 1.3.38 version: 1.3.38 @@ -456,11 +441,11 @@ importers: specifier: 1.16.0 version: 1.16.0 storybook: - specifier: 8.5.3 - version: 8.5.3(prettier@3.4.1) + specifier: 9.1.2 + version: 9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) storybook-addon-remix-react-router: - specifier: 3.1.0 - version: 3.1.0(@storybook/blocks@8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1)))(@storybook/channels@8.1.11)(@storybook/components@8.4.6(storybook@8.5.3(prettier@3.4.1)))(@storybook/core-events@8.1.11)(@storybook/manager-api@8.4.6(storybook@8.5.3(prettier@3.4.1)))(@storybook/preview-api@8.5.3(storybook@8.5.3(prettier@3.4.1)))(@storybook/theming@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + specifier: 5.0.0 + version: 5.0.0(react-dom@18.3.1(react@18.3.1))(react-router@7.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))) tailwindcss: specifier: 3.4.17 version: 3.4.17(ts-node@10.9.2(@swc/core@1.3.38)(@types/node@20.17.16)(typescript@5.6.3)) @@ -475,10 +460,7 @@ importers: version: 6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0) vite-plugin-checker: specifier: 0.9.3 - version: 0.9.3(@biomejs/biome@1.9.4)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) - vite-plugin-turbosnap: - specifier: 1.0.3 - version: 1.0.3 + version: 0.9.3(@biomejs/biome@2.2.0)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) packages: @@ -735,10 +717,6 @@ packages: resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz} engines: {node: '>=6.9.0'} - '@babel/traverse@7.25.9': - resolution: {integrity: sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.26.4': resolution: {integrity: sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz} engines: {node: '>=6.9.0'} @@ -747,10 +725,6 @@ packages: resolution: {integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz} engines: {node: '>=6.9.0'} - '@babel/types@7.26.0': - resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz} - engines: {node: '>=6.9.0'} - '@babel/types@7.26.3': resolution: {integrity: sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz} engines: {node: '>=6.9.0'} @@ -766,55 +740,55 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==, tarball: https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz} - '@biomejs/biome@1.9.4': - resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==, tarball: https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz} + '@biomejs/biome@2.2.0': + resolution: {integrity: sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw==, tarball: https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.0.tgz} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@1.9.4': - resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==, tarball: https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz} + '@biomejs/cli-darwin-arm64@2.2.0': + resolution: {integrity: sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg==, tarball: https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.0.tgz} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@1.9.4': - resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==, tarball: https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz} + '@biomejs/cli-darwin-x64@2.2.0': + resolution: {integrity: sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw==, tarball: https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.0.tgz} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@1.9.4': - resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz} + '@biomejs/cli-linux-arm64-musl@2.2.0': + resolution: {integrity: sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.0.tgz} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@1.9.4': - resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz} + '@biomejs/cli-linux-arm64@2.2.0': + resolution: {integrity: sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.0.tgz} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@1.9.4': - resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz} + '@biomejs/cli-linux-x64-musl@2.2.0': + resolution: {integrity: sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.0.tgz} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@1.9.4': - resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz} + '@biomejs/cli-linux-x64@2.2.0': + resolution: {integrity: sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw==, tarball: https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.0.tgz} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@1.9.4': - resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==, tarball: https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz} + '@biomejs/cli-win32-arm64@2.2.0': + resolution: {integrity: sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA==, tarball: https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.0.tgz} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@1.9.4': - resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==, tarball: https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz} + '@biomejs/cli-win32-x64@2.2.0': + resolution: {integrity: sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww==, tarball: https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.0.tgz} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -828,11 +802,11 @@ packages: '@bundled-es-modules/tough-cookie@0.1.6': resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==, tarball: https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz} - '@chromatic-com/storybook@3.2.2': - resolution: {integrity: sha512-xmXt/GW0hAPbzNTrxYuVo43Adrtjue4DeVrsoIIEeJdGaPNNeNf+DHMlJKOBdlHmCnFUoe9R/0mLM9zUp5bKWw==, tarball: https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-3.2.2.tgz} - engines: {node: '>=16.0.0', yarn: '>=1.22.18'} + '@chromatic-com/storybook@4.1.0': + resolution: {integrity: sha512-B9XesFX5lQUdP81/QBTtkiYOFqEsJwQpzkZlcYPm2n/L1S/8ZabSPbz6NoY8hOJTXWZ2p7grygUlxyGy+gAvfQ==, tarball: https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-4.1.0.tgz} + engines: {node: '>=20.0.0', yarn: '>=1.22.18'} peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 + storybook: ^0.0.0-0 || ^9.0.0 || ^9.1.0-0 || ^9.2.0-0 '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==, tarball: https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz} @@ -1240,11 +1214,11 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==, tarball: https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2': - resolution: {integrity: sha512-feQ+ntr+8hbVudnsTUapiMN9q8T90XA1d5jn9QzY09sNoj4iD9wi0PY1vsBFTda4ZjEaxRK9S81oarR2nj7TFQ==, tarball: https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.4.2.tgz} + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1': + resolution: {integrity: sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw==, tarball: https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.1.tgz} peerDependencies: typescript: '>= 4.3.x' - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: typescript: optional: true @@ -1279,17 +1253,24 @@ packages: '@types/react': '>=16' react: '>=16' - '@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' + '@mjackson/form-data-parser@0.4.0': + resolution: {integrity: sha512-zDQ0sFfXqn2bJaZ/ypXfGUe0lUjCzXybBHYEoyWaO2w1dZ0nOM9nRER8tVVv3a8ZIgO/zF6p2I5ieWJAUOzt3w==, tarball: https://registry.npmjs.org/@mjackson/form-data-parser/-/form-data-parser-0.4.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} + '@mjackson/headers@0.5.1': + resolution: {integrity: sha512-sJpFgecPT/zJvwk3GRNVWNs8EkwaJoUNU2D0VMlp+gDJs6cuSTm1q/aCZi3ZtuV6CgDEQ4l2ZjUG3A9JrQlbNA==, tarball: https://registry.npmjs.org/@mjackson/headers/-/headers-0.5.1.tgz} + + '@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.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.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} @@ -1405,6 +1386,9 @@ packages: '@emotion/styled': optional: true + '@neoconfetti/react@1.0.0': + resolution: {integrity: sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==, tarball: https://registry.npmjs.org/@neoconfetti/react/-/react-1.0.0.tgz} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, tarball: https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz} engines: {node: '>= 8'} @@ -2051,10 +2035,6 @@ packages: '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==, tarball: https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz} - '@remix-run/router@1.19.2': - resolution: {integrity: sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==, tarball: https://registry.npmjs.org/@remix-run/router/-/router-1.19.2.tgz} - engines: {node: '>=14.0.0'} - '@rolldown/pluginutils@1.0.0-beta.9': resolution: {integrity: sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==, tarball: https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz} @@ -2176,220 +2156,74 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==, tarball: https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz} - '@storybook/addon-actions@8.4.6': - resolution: {integrity: sha512-vbplwjMj7UXbdzoFhQkqFHLQAPJX8OVGTM9Q+yjuWDHViaKKUlgRWp0jclT7aIDNJQU2a6wJbTimHgJeF16Vhg==, tarball: https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-actions@8.5.2': - resolution: {integrity: sha512-g0gLesVSFgstUq5QphsLeC1vEdwNHgqo2TE0m+STM47832xbxBwmK6uvBeqi416xZvnt1TTKaaBr4uCRRQ64Ww==, tarball: https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.5.2.tgz} - peerDependencies: - storybook: ^8.5.2 - - '@storybook/addon-backgrounds@8.4.6': - resolution: {integrity: sha512-RSjJ3iElxlQXebZrz1s5LeoLpAXr9LAGifX7w0abMzN5sg6QSwNeUHko2eT3V57M3k1Fa/5Eelso/QBQifFEog==, tarball: https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-controls@8.4.6': - resolution: {integrity: sha512-70pEGWh0C2g8s0DYsISElOzsMbQS6p/K9iU5EqfotDF+hvEqstjsV/bTbR5f3OK4vR/7Gxamk7j8RVd14Nql6A==, tarball: https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-docs@8.4.6': - resolution: {integrity: sha512-olxz61W7PW/EsXrKhLrYbI3rn9GMBhY3KIOF/6tumbRkh0Siu/qe4EAImaV9NNwiC1R7+De/1OIVMY6o0EIZVw==, tarball: https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-essentials@8.4.6': - resolution: {integrity: sha512-TbFqyvWFUKw8LBpVcZuGQydzVB/3kSuHxDHi+Wj3Qas3cxBl7+w4/HjwomT2D2Tni1dZ1uPDOsAtNLmwp1POsg==, tarball: https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.4.6.tgz} + '@storybook/addon-docs@9.1.2': + resolution: {integrity: sha512-U3eHJ8lQFfEZ/OcgdKkUBbW2Y2tpAsHfy8lQOBgs5Pgj9biHEJcUmq+drOS/sJhle673eoBcUFmspXulI4KP1w==, tarball: https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-9.1.2.tgz} peerDependencies: - storybook: ^8.4.6 + storybook: ^9.1.2 - '@storybook/addon-highlight@8.4.6': - resolution: {integrity: sha512-m8wedbqDMbwkP99dNHkHAiAUkx5E7FEEEyLPX1zfkhZWOGtTkavXHH235SGp50zD75LQ6eC/BvgegrzxSQa9Wg==, tarball: https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-interactions@8.5.3': - resolution: {integrity: sha512-nQuP65iFGgqfVp/O8NxNDUwLTWmQBW4bofUFaT4wzYn7Jk9zobOZYtgQvdqBZtNzBDYmLrfrCutEBj5jVPRyuQ==, tarball: https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.5.3.tgz} - peerDependencies: - storybook: ^8.5.3 - - '@storybook/addon-links@8.5.2': - resolution: {integrity: sha512-eDKOQoAKKUQo0JqeLNzMLu6fm1s3oxwZ6O+rAWS6n5bsrjZS2Ul8esKkRriFVwHtDtqx99wneqOscS8IzE/ENw==, tarball: https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.5.2.tgz} + '@storybook/addon-links@9.1.2': + resolution: {integrity: sha512-drAWdhn5cRo5WcaORoCYfJ6tgTAw1m+ZJb1ICyNtTU6i/0nErV8jJjt7AziUcUIyzaGVJAkAMNC3+R4uDPSFDA==, tarball: https://registry.npmjs.org/@storybook/addon-links/-/addon-links-9.1.2.tgz} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.5.2 + storybook: ^9.1.2 peerDependenciesMeta: react: optional: true - '@storybook/addon-mdx-gfm@8.5.2': - resolution: {integrity: sha512-UuJDa2Asch8Z6H+vzLg+/VQQNbHhqmDtn8OSfNHo6Lr6a0uk6LofYKvP/nB7i6wMUvnaM+Qh/b5hAI/VCXitBQ==, tarball: https://registry.npmjs.org/@storybook/addon-mdx-gfm/-/addon-mdx-gfm-8.5.2.tgz} - peerDependencies: - storybook: ^8.5.2 - - '@storybook/addon-measure@8.4.6': - resolution: {integrity: sha512-N2IRpr39g5KpexCAS1vIHJT+phc9Yilwm3PULds2rQ66VMTbkxobXJDdt0NS05g5n9/eDniroNQwdCeLg4tkpw==, tarball: https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-outline@8.4.6': - resolution: {integrity: sha512-EhcWx8OpK85HxQulLWzpWUHEwQpDYuAiKzsFj9ivAbfeljkIWNTG04mierfaH1xX016uL9RtLJL/zwBS5ChnFg==, tarball: https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-themes@8.4.6': - resolution: {integrity: sha512-0Eyh7jxxQ8hc7KIO2bJF8BKY1CRJ9zPo2DKoRiUKDoSGSP8qdlj4V/ks892GcUffdhTjoFAJCRzG7Ff+TnVKrA==, tarball: https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-toolbars@8.4.6': - resolution: {integrity: sha512-+Xao/uGa8FnYsyUiREUkYXWNysm3Aba8tL/Bwd+HufHtdiKJGa9lrXaC7VLCqBUaEjwqM3aaPwqEWIROsthmPQ==, tarball: https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/addon-viewport@8.4.6': - resolution: {integrity: sha512-BuQll5YzOCpMS7p5Rsw9wcmi8hTnEKyg6+qAbkZNfiZ2JhXCa1GFUqX725fF1whpYVQULtkQxU8r+vahoRn7Yg==, tarball: https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/blocks@8.4.6': - resolution: {integrity: sha512-Gzbx8hM7ZQIHlQELcFIMbY1v+r1Po4mlinq0QVPtKS4lBcW4eZIsesbxOaL+uFNrxb583TLFzXo0DbRPzS46sg==, tarball: https://registry.npmjs.org/@storybook/blocks/-/blocks-8.4.6.tgz} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - - '@storybook/builder-vite@8.4.6': - resolution: {integrity: sha512-PyJsaEPyuRFFEplpNUi+nbuJd7d1DC2dAZjpsaHTXyqg5iPIbkIgsbCJLUDeIXnUDqM/utjmMpN0sQKJuhIc6w==, tarball: https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.4.6.tgz} + '@storybook/addon-themes@9.1.2': + resolution: {integrity: sha512-dpWCx0IpKKFGEuOe2u8cUD2ShWMaE6Keh0zkM1gP8jx5gL8lLv9uhRHaZcQamwnG3BgnnKFgArODNxewsRSFfA==, tarball: https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-9.1.2.tgz} peerDependencies: - storybook: ^8.4.6 - vite: ^4.0.0 || ^5.0.0 || ^6.0.0 + storybook: ^9.1.2 - '@storybook/channels@8.1.11': - resolution: {integrity: sha512-fu5FTqo6duOqtJFa6gFzKbiSLJoia+8Tibn3xFfB6BeifWrH81hc+AZq0lTmHo5qax2G5t8ZN8JooHjMw6k2RA==, tarball: https://registry.npmjs.org/@storybook/channels/-/channels-8.1.11.tgz} - - '@storybook/client-logger@8.1.11': - resolution: {integrity: sha512-DVMh2usz3yYmlqCLCiCKy5fT8/UR9aTh+gSqwyNFkGZrIM4otC5A8eMXajXifzotQLT5SaOEnM3WzHwmpvMIEA==, tarball: https://registry.npmjs.org/@storybook/client-logger/-/client-logger-8.1.11.tgz} - - '@storybook/components@8.4.6': - resolution: {integrity: sha512-9tKSJJCyFT5RZMRGyozTBJkr9C9Yfk1nuOE9XbDEE1Z+3/IypKR9+iwc5mfNBStDNY+rxtYWNLKBb5GPR2yhzA==, tarball: https://registry.npmjs.org/@storybook/components/-/components-8.4.6.tgz} + '@storybook/builder-vite@9.1.2': + resolution: {integrity: sha512-5Y7e5wnSzFxCGP63UNRRZVoxHe1znU4dYXazJBobAlEcUPBk7A0sH2716tA6bS4oz92oG9tgvn1g996hRrw4ow==, tarball: https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.2.tgz} peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 + storybook: ^9.1.2 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 - '@storybook/core-events@8.1.11': - resolution: {integrity: sha512-vXaNe2KEW9BGlLrg0lzmf5cJ0xt+suPjWmEODH5JqBbrdZ67X6ApA2nb6WcxDQhykesWCuFN5gp1l+JuDOBi7A==, tarball: https://registry.npmjs.org/@storybook/core-events/-/core-events-8.1.11.tgz} - - '@storybook/core@8.5.3': - resolution: {integrity: sha512-ZLlr2pltbj/hmC54lggJTnh09FCAJR62lIdiXNwa+V+/eJz0CfD8tfGmZGKPSmaQeZBpMwAOeRM97k2oLPF+0w==, tarball: https://registry.npmjs.org/@storybook/core/-/core-8.5.3.tgz} + '@storybook/csf-plugin@9.1.2': + resolution: {integrity: sha512-bfMh6r+RieBLPWtqqYN70le2uTE4JzOYPMYSCagHykUti3uM/1vRFaZNkZtUsRy5GwEzE5jLdDXioG1lOEeT2Q==, tarball: https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-9.1.2.tgz} peerDependencies: - prettier: ^2 || ^3 - peerDependenciesMeta: - prettier: - optional: true - - '@storybook/csf-plugin@8.4.6': - resolution: {integrity: sha512-JDIT0czC4yMgKGNf39KTZr3zm5MusAZdn6LBrTfvWb7CrTCR4iVHa4lp2yb7EJk41vHsBec0QUYDDuiFH/vV0g==, tarball: https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/csf@0.1.11': - resolution: {integrity: sha512-dHYFQH3mA+EtnCkHXzicbLgsvzYjcDJ1JWsogbItZogkPHgSJM/Wr71uMkcvw8v9mmCyP4NpXJuu6bPoVsOnzg==, tarball: https://registry.npmjs.org/@storybook/csf/-/csf-0.1.11.tgz} - - '@storybook/csf@0.1.12': - resolution: {integrity: sha512-9/exVhabisyIVL0VxTCxo01Tdm8wefIXKXfltAPTSr8cbLn5JAxGQ6QV3mjdecLGEOucfoVhAKtJfVHxEK1iqw==, tarball: https://registry.npmjs.org/@storybook/csf/-/csf-0.1.12.tgz} - - '@storybook/csf@0.1.13': - resolution: {integrity: sha512-7xOOwCLGB3ebM87eemep89MYRFTko+D8qE7EdAAq74lgdqRR5cOUtYWJLjO2dLtP94nqoOdHJo6MdLLKzg412Q==, tarball: https://registry.npmjs.org/@storybook/csf/-/csf-0.1.13.tgz} + storybook: ^9.1.2 '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==, tarball: https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz} - '@storybook/icons@1.2.12': - resolution: {integrity: sha512-UxgyK5W3/UV4VrI3dl6ajGfHM4aOqMAkFLWe2KibeQudLf6NJpDrDMSHwZj+3iKC4jFU7dkKbbtH2h/al4sW3Q==, tarball: https://registry.npmjs.org/@storybook/icons/-/icons-1.2.12.tgz} + '@storybook/icons@1.4.0': + resolution: {integrity: sha512-Td73IeJxOyalzvjQL+JXx72jlIYHgs+REaHiREOqfpo3A2AYYG71AUbcv+lg7mEDIweKVCxsMQ0UKo634c8XeA==, tarball: https://registry.npmjs.org/@storybook/icons/-/icons-1.4.0.tgz} engines: {node: '>=14.0.0'} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - - '@storybook/instrumenter@8.4.6': - resolution: {integrity: sha512-snXjlgbp065A6KoK9zkjBYEIMCSlN5JefPKzt1FC0rbcbtahhD+iPpqISKhDSczwgOku/JVhVUDp/vU7AIf4mg==, tarball: https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/instrumenter@8.5.3': - resolution: {integrity: sha512-pxaTbGeju8MkwouIiaWX5DMWtpRruxqig8W3nZPOvzoSCCbQY+sLMQoyXxFlpGxLBjcvXivkL7AMVBKps5sFEQ==, tarball: https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.5.3.tgz} - peerDependencies: - storybook: ^8.5.3 - - '@storybook/manager-api@8.4.6': - resolution: {integrity: sha512-TsXlQ5m5rTl2KNT9icPFyy822AqXrx1QplZBt/L7cFn7SpqQKDeSta21FH7MG0piAvzOweXebVSqKngJ6cCWWQ==, tarball: https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.4.6.tgz} - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - - '@storybook/preview-api@8.4.6': - resolution: {integrity: sha512-LbD+lR1FGvWaJBXteVx5xdgs1x1D7tyidBg2CsW2ex+cP0iJ176JgjPfutZxlWOfQnhfRYNnJ3WKoCIfxFOTKA==, tarball: https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.4.6.tgz} - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - - '@storybook/preview-api@8.5.3': - resolution: {integrity: sha512-dUsuXW+KgDg4tWXOB6dk5j5gwwRUzbPvicHAY9mzbpSVScbWXuE5T/S/9hHlbtfkhFroWQgPx2eB8z3rai+7RQ==, tarball: https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.5.3.tgz} - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - '@storybook/react-dom-shim@8.4.6': - resolution: {integrity: sha512-f7RM8GO++fqMxbjNdEzeGS1P821jXuwRnAraejk5hyjB5SqetauFxMwoFYEYfJXPaLX2qIubnIJ78hdJ/IBaEA==, tarball: https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.4.6.tgz} + '@storybook/react-dom-shim@9.1.2': + resolution: {integrity: sha512-nw7BLAHCJswPZGsuL0Gs2AvFUWriusCTgPBmcHppSw/AqvT4XRFRDE+5q3j04/XKuZBrAA2sC4L+HuC0uzEChQ==, tarball: https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-9.1.2.tgz} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 + storybook: ^9.1.2 - '@storybook/react-vite@8.4.6': - resolution: {integrity: sha512-bVoYj3uJRz0SknK2qN3vBVSoEXsvyARQLuHjP9eX0lWBd9XSxZinmVbexPdD0OeJYcJIdmbli2/Gw7/hu5CjFA==, tarball: https://registry.npmjs.org/@storybook/react-vite/-/react-vite-8.4.6.tgz} - engines: {node: '>=18.0.0'} + '@storybook/react-vite@9.1.2': + resolution: {integrity: sha512-dv3CBjOzmMoSyIotMtdmsBRjB25i19OjFP0IZqauLeUoVm6QddILW7JRcZVLrzhATyBEn+sEAdWQ4j79Z11HAg==, tarball: https://registry.npmjs.org/@storybook/react-vite/-/react-vite-9.1.2.tgz} + engines: {node: '>=20.0.0'} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 - vite: ^4.0.0 || ^5.0.0 || ^6.0.0 + storybook: ^9.1.2 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 - '@storybook/react@8.4.6': - resolution: {integrity: sha512-QAT23beoYNLhFGAXPimtuMErvpcI7eZbZ4AlLqW1fhiTZrRYw06cjC1bs9H3tODMcHH9LS5p3Wz9b29jtV2XGw==, tarball: https://registry.npmjs.org/@storybook/react/-/react-8.4.6.tgz} - engines: {node: '>=18.0.0'} + '@storybook/react@9.1.2': + resolution: {integrity: sha512-VVXu1HrhDExj/yj+heFYc8cgIzBruXy1UYT3LW0WiJyadgzYz3J41l/Lf/j2FCppyxwlXb19Uv51plb1F1C77w==, tarball: https://registry.npmjs.org/@storybook/react/-/react-9.1.2.tgz} + engines: {node: '>=20.0.0'} peerDependencies: - '@storybook/test': 8.4.6 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 - typescript: '>= 4.2.x' + storybook: ^9.1.2 + typescript: '>= 4.9.x' peerDependenciesMeta: - '@storybook/test': - optional: true typescript: optional: true - '@storybook/test@8.4.6': - resolution: {integrity: sha512-MeU1g65YgU66M2NtmEIL9gVeHk+en0k9Hp0wfxEO7NT/WLfaOD5RXLRDJVhbAlrH/6tLeWKIPNh/D26y27vO/g==, tarball: https://registry.npmjs.org/@storybook/test/-/test-8.4.6.tgz} - peerDependencies: - storybook: ^8.4.6 - - '@storybook/test@8.5.3': - resolution: {integrity: sha512-2smoDbtU6Qh4yk0uD18qGfW6ll7lZBzKlF58Ha1CgWR4o+jpeeTQcfDLH9gG6sNrpojF7AVzMh/aN9BDHD+Chg==, tarball: https://registry.npmjs.org/@storybook/test/-/test-8.5.3.tgz} - peerDependencies: - storybook: ^8.5.3 - - '@storybook/theming@8.4.6': - resolution: {integrity: sha512-q7vDPN/mgj7cXIVQ9R1/V75hrzNgKkm2G0LjMo57//9/djQ+7LxvBsR1iScbFIRSEqppvMiBFzkts+2uXidySA==, tarball: https://registry.npmjs.org/@storybook/theming/-/theming-8.4.6.tgz} - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@swc/core-darwin-arm64@1.3.38': resolution: {integrity: sha512-4ZTJJ/cR0EsXW5UxFCifZoGfzQ07a8s4ayt1nLvLQ5QoB1GTAf9zsACpvWG8e7cmCR0L76R5xt8uJuyr+noIXA==, tarball: https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.38.tgz} engines: {node: '>=10'} @@ -2493,10 +2327,6 @@ packages: resolution: {integrity: sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==, tarball: https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz} engines: {node: '>=14'} - '@testing-library/jest-dom@6.5.0': - resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==, tarball: https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.5.0.tgz} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@testing-library/jest-dom@6.6.3': resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==, tarball: https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} @@ -2508,12 +2338,6 @@ packages: react: ^18.0.0 react-dom: ^18.0.0 - '@testing-library/user-event@14.5.2': - resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==, tarball: https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz} - engines: {node: '>=12', npm: '>=6'} - peerDependencies: - '@testing-library/dom': '>=7.21.4' - '@testing-library/user-event@14.6.1': resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==, tarball: https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz} engines: {node: '>=12', npm: '>=6'} @@ -2554,6 +2378,9 @@ packages: '@types/body-parser@1.19.2': resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==, tarball: https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz} + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==, tarball: https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz} + '@types/chroma-js@2.4.0': resolution: {integrity: sha512-JklMxityrwjBTjGY2anH8JaTx3yjRU3/sEHSblLH1ba5lqcSh1LnImXJZO5peJfXyqKYWjHTGy4s5Wz++hARrw==, tarball: https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.0.tgz} @@ -2599,6 +2426,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==, tarball: https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, tarball: https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz} + '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==, tarball: https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz} @@ -2812,23 +2642,28 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 - '@vitest/expect@2.0.5': - resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==, tarball: https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==, tarball: https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz} - '@vitest/pretty-format@2.0.5': - resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==, tarball: https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz} - - '@vitest/pretty-format@2.1.8': - resolution: {integrity: sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==, tarball: https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==, tarball: https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true - '@vitest/spy@2.0.5': - resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==, tarball: https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz} + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==, tarball: https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz} - '@vitest/utils@2.0.5': - resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==, tarball: https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==, tarball: https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz} - '@vitest/utils@2.1.8': - resolution: {integrity: sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==, tarball: https://registry.npmjs.org/@vitest/utils/-/utils-2.1.8.tgz} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==, tarball: https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz} '@xterm/addon-canvas@0.7.0': resolution: {integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==, tarball: https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz} @@ -3051,19 +2886,13 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==, tarball: https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==, tarball: https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz} - - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==, tarball: https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==, tarball: https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, tarball: https://registry.npmjs.org/braces/-/braces-3.0.3.tgz} engines: {node: '>=8'} - browser-assert@1.2.1: - resolution: {integrity: sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==, tarball: https://registry.npmjs.org/browser-assert/-/browser-assert-1.2.1.tgz} - browserslist@4.24.2: resolution: {integrity: sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==, tarball: https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3133,9 +2962,9 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==, tarball: https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz} - chai@5.1.2: - resolution: {integrity: sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==, tarball: https://registry.npmjs.org/chai/-/chai-5.1.2.tgz} - engines: {node: '>=12'} + chai@5.2.1: + resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==, tarball: https://registry.npmjs.org/chai/-/chai-5.2.1.tgz} + engines: {node: '>=18'} chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==, tarball: https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz} @@ -3201,6 +3030,18 @@ packages: '@chromatic-com/playwright': optional: true + chromatic@12.2.0: + resolution: {integrity: sha512-GswmBW9ZptAoTns1BMyjbm55Z7EsIJnUvYKdQqXIBZIKbGErmpA+p4c0BYA+nzw5B0M+rb3Iqp1IaH8TFwIQew==, tarball: https://registry.npmjs.org/chromatic/-/chromatic-12.2.0.tgz} + hasBin: true + peerDependencies: + '@chromatic-com/cypress': ^0.*.* || ^1.0.0 + '@chromatic-com/playwright': ^0.*.* || ^1.0.0 + peerDependenciesMeta: + '@chromatic-com/cypress': + optional: true + '@chromatic-com/playwright': + optional: true + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==, tarball: https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz} engines: {node: '>=8'} @@ -3309,6 +3150,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, tarball: https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==, tarball: https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==, tarball: https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz} @@ -3818,6 +3663,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, tarball: https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz} engines: {node: '>=10'} + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==, tarball: https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz} + engines: {node: '>=18'} + flat-cache@3.2.0: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==, tarball: https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz} engines: {node: ^10.12.0 || >=12.0.0} @@ -3842,8 +3691,8 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==, tarball: https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz} engines: {node: '>=14'} - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==, tarball: https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==, tarball: https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz} engines: {node: '>= 6'} format@0.2.2: @@ -3983,10 +3832,6 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==, tarball: https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz} engines: {node: '>= 0.4'} - hasown@2.0.0: - resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==, tarball: https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz} - engines: {node: '>= 0.4'} - hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, tarball: https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz} engines: {node: '>= 0.4'} @@ -4145,9 +3990,6 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==, tarball: https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz} engines: {node: '>= 0.4'} - is-core-module@2.13.1: - resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==, tarball: https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz} - is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==, tarball: https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz} engines: {node: '>= 0.4'} @@ -4179,10 +4021,6 @@ packages: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==, tarball: https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz} engines: {node: '>=6'} - is-generator-function@1.1.0: - resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==, tarball: https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz} - engines: {node: '>= 0.4'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, tarball: https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz} engines: {node: '>=0.10.0'} @@ -4226,10 +4064,6 @@ packages: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==, tarball: https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz} engines: {node: '>= 0.4'} - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==, tarball: https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz} - engines: {node: '>= 0.4'} - is-set@2.0.2: resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==, tarball: https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz} @@ -4501,10 +4335,6 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==, tarball: https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz} hasBin: true - jsdoc-type-pratt-parser@4.1.0: - resolution: {integrity: sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==, tarball: https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz} - engines: {node: '>=12.0.0'} - jsdom@20.0.3: resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==, tarball: https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz} engines: {node: '>=14'} @@ -4586,6 +4416,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, tarball: https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz} engines: {node: '>=10'} + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==, tarball: https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==, tarball: https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz} @@ -4615,11 +4449,8 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==, tarball: https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz} hasBin: true - loupe@3.1.2: - resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==, tarball: https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz} - - loupe@3.1.3: - resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==, tarball: https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz} + loupe@3.2.0: + resolution: {integrity: sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==, tarball: https://registry.npmjs.org/loupe/-/loupe-3.2.0.tgz} lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==, tarball: https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz} @@ -4643,13 +4474,8 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==, tarball: https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz} hasBin: true - magic-string@0.27.0: - resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==, tarball: https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz} - engines: {node: '>=12'} - - magic-string@0.30.5: - resolution: {integrity: sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==, tarball: https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz} - engines: {node: '>=12'} + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==, tarball: https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==, tarball: https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz} @@ -4661,9 +4487,6 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==, tarball: https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz} - map-or-similar@1.5.0: - resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==, tarball: https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz} - markdown-table@3.0.3: resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==, tarball: https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz} @@ -4735,9 +4558,6 @@ packages: memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==, tarball: https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz} - memoizerific@1.11.3: - resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==, tarball: https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz} - merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==, tarball: https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz} @@ -4936,8 +4756,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} @@ -5066,6 +4886,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, tarball: https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==, tarball: https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==, tarball: https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz} engines: {node: '>=8'} @@ -5074,6 +4898,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, tarball: https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz} engines: {node: '>=10'} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==, tarball: https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==, tarball: https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz} engines: {node: '>=6'} @@ -5113,6 +4941,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz} engines: {node: '>=8'} + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==, tarball: https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz} engines: {node: '>=0.10.0'} @@ -5179,10 +5011,6 @@ packages: engines: {node: '>=18'} hasBin: true - polished@4.3.1: - resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==, tarball: https://registry.npmjs.org/polished/-/polished-4.3.1.tgz} - engines: {node: '>=10'} - possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==, tarball: https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz} engines: {node: '>= 0.4'} @@ -5268,10 +5096,6 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==, tarball: https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz} - process@0.11.10: - resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==, tarball: https://registry.npmjs.org/process/-/process-0.11.10.tgz} - engines: {node: '>= 0.6.0'} - prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==, tarball: https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz} engines: {node: '>= 6'} @@ -5352,9 +5176,9 @@ packages: peerDependencies: typescript: '>= 4.3.x' - react-docgen@7.0.3: - resolution: {integrity: sha512-i8aF1nyKInZnANZ4uZrH49qn1paRgBZ7wZiCNBMnenlPzEv0mRl+ShpTVEI6wZNl8sSc79xZkivtgLKQArcanQ==, tarball: https://registry.npmjs.org/react-docgen/-/react-docgen-7.0.3.tgz} - engines: {node: '>=16.14.0'} + react-docgen@8.0.0: + resolution: {integrity: sha512-kmob/FOTwep7DUWf9KjuenKX0vyvChr3oTdvvPt09V60Iz75FJp+T/0ZeHMbAfJj2WaVWqAPP5Hmm3PYzSPPKg==, tarball: https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.0.tgz} + engines: {node: ^20.9.0 || >=22} react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==, tarball: https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz} @@ -5440,18 +5264,15 @@ packages: react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-router-dom@6.26.2: - resolution: {integrity: sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==, tarball: https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.2.tgz} - engines: {node: '>=14.0.0'} + react-router@7.8.0: + resolution: {integrity: sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg==, tarball: https://registry.npmjs.org/react-router/-/react-router-7.8.0.tgz} + engines: {node: '>=20.0.0'} peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - react-router@6.26.2: - resolution: {integrity: sha512-tvN1iuT03kHgOFnLPfLJ8V95eijteveqdOSk+srqfePtQvqCExB8eHOYnlilbOcyJyKnYkr1vJvf7YqotAJu1A==, tarball: https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true react-smooth@4.0.4: resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==, tarball: https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz} @@ -5597,10 +5418,6 @@ packages: engines: {node: '>= 0.4'} hasBin: true - resolve@1.22.8: - resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==, tarball: https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz} - hasBin: true - restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==, tarball: https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz} engines: {node: '>=8'} @@ -5644,10 +5461,6 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, tarball: https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz} - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==, tarball: https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz} - engines: {node: '>= 0.4'} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, tarball: https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz} @@ -5671,6 +5484,9 @@ packages: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==, tarball: https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz} engines: {node: '>= 0.8.0'} + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==, tarball: https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==, tarball: https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz} engines: {node: '>= 0.4'} @@ -5780,27 +5596,21 @@ packages: resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==, tarball: https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz} engines: {node: '>= 0.4'} - storybook-addon-remix-react-router@3.1.0: - resolution: {integrity: sha512-h6cOD+afyAddNrDz5ezoJGV6GBSeH7uh92VAPDz+HLuay74Cr9Ozz+aFmlzMEyVJ1hhNIMOIWDsmK56CueZjsw==, tarball: https://registry.npmjs.org/storybook-addon-remix-react-router/-/storybook-addon-remix-react-router-3.1.0.tgz} + storybook-addon-remix-react-router@5.0.0: + resolution: {integrity: sha512-XjNGLD8vhI7DhjPgkjkU9rjqjF6YSRvRjBignwo2kCGiz5HIR4TZTDRRABuwYo35/GoC2aMtxFs7zybJ4pVlsg==, tarball: https://registry.npmjs.org/storybook-addon-remix-react-router/-/storybook-addon-remix-react-router-5.0.0.tgz} peerDependencies: - '@storybook/blocks': ^8.0.0 - '@storybook/channels': ^8.0.0 - '@storybook/components': ^8.0.0 - '@storybook/core-events': ^8.0.0 - '@storybook/manager-api': ^8.0.0 - '@storybook/preview-api': ^8.0.0 - '@storybook/theming': ^8.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 - react-router-dom: ^6.4.0 || ^7.0.0 + react-router: ^7.0.2 + storybook: ^9.0.0 peerDependenciesMeta: react: optional: true react-dom: optional: true - storybook@8.5.3: - resolution: {integrity: sha512-2WtNBZ45u1AhviRU+U+ld588tH8gDa702dNSq5C8UBaE9PlOsazGsyp90dw1s9YRvi+ejrjKAupQAU0GwwUiVg==, tarball: https://registry.npmjs.org/storybook/-/storybook-8.5.3.tgz} + storybook@9.1.2: + resolution: {integrity: sha512-TYcq7WmgfVCAQge/KueGkVlM/+g33sQcmbATlC3X6y/g2FEeSSLGrb6E6d3iemht8oio+aY6ld3YOdAnMwx45Q==, tarball: https://registry.npmjs.org/storybook/-/storybook-9.1.2.tgz} hasBin: true peerDependencies: prettier: ^2 || ^3 @@ -5915,9 +5725,6 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==, tarball: https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz} engines: {node: '>=6'} - telejson@7.2.0: - resolution: {integrity: sha512-1QTEcJkJEhc8OnStBx/ILRu5J2p0GjvWsBx56bmZRqnrkdBMUe+nX92jxV+p3dB4CP6PZCdJMQJwCggkNBMzkQ==, tarball: https://registry.npmjs.org/telejson/-/telejson-7.2.0.tgz} - test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==, tarball: https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz} engines: {node: '>=8'} @@ -5948,12 +5755,12 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz} engines: {node: '>=12.0.0'} - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==, tarball: https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==, tarball: https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz} engines: {node: '>=14.0.0'} - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==, tarball: https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz} + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==, tarball: https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz} engines: {node: '>=14.0.0'} tmpl@1.0.5: @@ -6074,8 +5881,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - tzdata@1.0.40: - resolution: {integrity: sha512-IsWNGfC5GrVPG4ejYJtf3tOlBdJYs0uNzv1a+vkdANHDq2kPg4oAN2UlCfpqrCwErPZVhI6MLA2gkeuXAVnpLg==, tarball: https://registry.npmjs.org/tzdata/-/tzdata-1.0.40.tgz} + tzdata@1.0.44: + resolution: {integrity: sha512-xJ8xcdoFRwFpIQ90QV3WFXJNCO/feNn9vHVsZMJiKmtMYuo7nvF6CTpBc+SgegC1fb/3L+m32ytXT9XrBjrINg==, tarball: https://registry.npmjs.org/tzdata/-/tzdata-1.0.44.tgz} ua-parser-js@1.0.40: resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==, tarball: https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz} @@ -6094,6 +5901,10 @@ packages: resolution: {integrity: sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==, tarball: https://registry.npmjs.org/undici/-/undici-6.21.2.tgz} engines: {node: '>=18.17'} + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==, tarball: https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz} + engines: {node: '>=18'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==, tarball: https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz} engines: {node: '>=18'} @@ -6215,9 +6026,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, tarball: https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz} - util@0.12.5: - resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==, tarball: https://registry.npmjs.org/util/-/util-0.12.5.tgz} - utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==, tarball: https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz} engines: {node: '>= 0.4.0'} @@ -6280,9 +6088,6 @@ packages: vue-tsc: optional: true - vite-plugin-turbosnap@1.0.3: - resolution: {integrity: sha512-p4D8CFVhZS412SyQX125qxyzOgIFouwOcvjZWk6bQbNPR1wtaEzFT6jZxAjf1dejlGqa6fqHcuCvQea6EWUkUA==, tarball: https://registry.npmjs.org/vite-plugin-turbosnap/-/vite-plugin-turbosnap-1.0.3.tgz} - vite@6.3.5: resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==, tarball: https://registry.npmjs.org/vite/-/vite-6.3.5.tgz} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6463,6 +6268,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, tarball: https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz} engines: {node: '>=10'} + yocto-queue@1.2.1: + resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==, tarball: https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz} + engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.2: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==, tarball: https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz} engines: {node: '>=18'} @@ -6778,18 +6587,6 @@ snapshots: '@babel/parser': 7.27.2 '@babel/types': 7.27.1 - '@babel/traverse@7.25.9': - dependencies: - '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.3 - '@babel/parser': 7.26.3 - '@babel/template': 7.25.9 - '@babel/types': 7.26.0 - debug: 4.4.0 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.26.4': dependencies: '@babel/code-frame': 7.26.2 @@ -6814,11 +6611,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.26.0': - dependencies: - '@babel/helper-string-parser': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - '@babel/types@7.26.3': dependencies: '@babel/helper-string-parser': 7.25.9 @@ -6836,39 +6628,39 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@biomejs/biome@1.9.4': + '@biomejs/biome@2.2.0': optionalDependencies: - '@biomejs/cli-darwin-arm64': 1.9.4 - '@biomejs/cli-darwin-x64': 1.9.4 - '@biomejs/cli-linux-arm64': 1.9.4 - '@biomejs/cli-linux-arm64-musl': 1.9.4 - '@biomejs/cli-linux-x64': 1.9.4 - '@biomejs/cli-linux-x64-musl': 1.9.4 - '@biomejs/cli-win32-arm64': 1.9.4 - '@biomejs/cli-win32-x64': 1.9.4 - - '@biomejs/cli-darwin-arm64@1.9.4': + '@biomejs/cli-darwin-arm64': 2.2.0 + '@biomejs/cli-darwin-x64': 2.2.0 + '@biomejs/cli-linux-arm64': 2.2.0 + '@biomejs/cli-linux-arm64-musl': 2.2.0 + '@biomejs/cli-linux-x64': 2.2.0 + '@biomejs/cli-linux-x64-musl': 2.2.0 + '@biomejs/cli-win32-arm64': 2.2.0 + '@biomejs/cli-win32-x64': 2.2.0 + + '@biomejs/cli-darwin-arm64@2.2.0': optional: true - '@biomejs/cli-darwin-x64@1.9.4': + '@biomejs/cli-darwin-x64@2.2.0': optional: true - '@biomejs/cli-linux-arm64-musl@1.9.4': + '@biomejs/cli-linux-arm64-musl@2.2.0': optional: true - '@biomejs/cli-linux-arm64@1.9.4': + '@biomejs/cli-linux-arm64@2.2.0': optional: true - '@biomejs/cli-linux-x64-musl@1.9.4': + '@biomejs/cli-linux-x64-musl@2.2.0': optional: true - '@biomejs/cli-linux-x64@1.9.4': + '@biomejs/cli-linux-x64@2.2.0': optional: true - '@biomejs/cli-win32-arm64@1.9.4': + '@biomejs/cli-win32-arm64@2.2.0': optional: true - '@biomejs/cli-win32-x64@1.9.4': + '@biomejs/cli-win32-x64@2.2.0': optional: true '@bundled-es-modules/cookie@2.0.1': @@ -6884,18 +6676,17 @@ snapshots: '@types/tough-cookie': 4.0.5 tough-cookie: 4.1.4 - '@chromatic-com/storybook@3.2.2(react@18.3.1)(storybook@8.5.3(prettier@3.4.1))': + '@chromatic-com/storybook@4.1.0(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))': dependencies: - chromatic: 11.25.2 + '@neoconfetti/react': 1.0.0 + chromatic: 12.2.0 filesize: 10.1.2 jsonfile: 6.1.0 - react-confetti: 6.2.2(react@18.3.1) - storybook: 8.5.3(prettier@3.4.1) + storybook: 9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) strip-ansi: 7.1.0 transitivePeerDependencies: - '@chromatic-com/cypress' - '@chromatic-com/playwright' - - react '@cspotcode/source-map-support@0.8.1': dependencies: @@ -7396,9 +7187,10 @@ snapshots: '@types/yargs': 17.0.33 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))': dependencies: - magic-string: 0.27.0 + glob: 10.4.5 + magic-string: 0.30.17 react-docgen-typescript: 2.2.2(typescript@5.6.3) vite: 6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0) optionalDependencies: @@ -7435,15 +7227,24 @@ snapshots: '@types/react': 18.3.12 react: 18.3.1 - '@monaco-editor/loader@1.4.0(monaco-editor@0.52.0)': + '@mjackson/form-data-parser@0.4.0': + dependencies: + '@mjackson/multipart-parser': 0.6.3 + + '@mjackson/headers@0.5.1': {} + + '@mjackson/multipart-parser@0.6.3': + dependencies: + '@mjackson/headers': 0.5.1 + + '@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) @@ -7566,6 +7367,8 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@neoconfetti/react@1.0.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8203,13 +8006,11 @@ snapshots: '@radix-ui/rect@1.1.0': {} - '@remix-run/router@1.19.2': {} - '@rolldown/pluginutils@1.0.0-beta.9': {} '@rollup/pluginutils@5.0.5(rollup@4.40.1)': dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.7 estree-walker: 2.0.2 picomatch: 2.3.1 optionalDependencies: @@ -8285,301 +8086,86 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.0 - '@storybook/addon-actions@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/global': 5.0.0 - '@types/uuid': 9.0.2 - dequal: 2.0.3 - polished: 4.3.1 - storybook: 8.5.3(prettier@3.4.1) - uuid: 9.0.1 - - '@storybook/addon-actions@8.5.2(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/global': 5.0.0 - '@types/uuid': 9.0.2 - dequal: 2.0.3 - polished: 4.3.1 - storybook: 8.5.3(prettier@3.4.1) - uuid: 9.0.1 - - '@storybook/addon-backgrounds@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/global': 5.0.0 - memoizerific: 1.11.3 - storybook: 8.5.3(prettier@3.4.1) - ts-dedent: 2.2.0 - - '@storybook/addon-controls@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/global': 5.0.0 - dequal: 2.0.3 - storybook: 8.5.3(prettier@3.4.1) - ts-dedent: 2.2.0 - - '@storybook/addon-docs@8.4.6(@types/react@18.3.12)(storybook@8.5.3(prettier@3.4.1))': + '@storybook/addon-docs@9.1.2(@types/react@18.3.12)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))': dependencies: '@mdx-js/react': 3.0.1(@types/react@18.3.12)(react@18.3.1) - '@storybook/blocks': 8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1)) - '@storybook/csf-plugin': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/react-dom-shim': 8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1)) + '@storybook/csf-plugin': 9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))) + '@storybook/icons': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@storybook/react-dom-shim': 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.5.3(prettier@3.4.1) - ts-dedent: 2.2.0 - transitivePeerDependencies: - - '@types/react' - - '@storybook/addon-essentials@8.4.6(@types/react@18.3.12)(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/addon-actions': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/addon-backgrounds': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/addon-controls': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/addon-docs': 8.4.6(@types/react@18.3.12)(storybook@8.5.3(prettier@3.4.1)) - '@storybook/addon-highlight': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/addon-measure': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/addon-outline': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/addon-toolbars': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/addon-viewport': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - storybook: 8.5.3(prettier@3.4.1) + storybook: 9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-highlight@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/global': 5.0.0 - storybook: 8.5.3(prettier@3.4.1) - - '@storybook/addon-interactions@8.5.3(storybook@8.5.3(prettier@3.4.1))': + '@storybook/addon-links@9.1.2(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))': dependencies: '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.5.3(storybook@8.5.3(prettier@3.4.1)) - '@storybook/test': 8.5.3(storybook@8.5.3(prettier@3.4.1)) - polished: 4.3.1 - storybook: 8.5.3(prettier@3.4.1) - ts-dedent: 2.2.0 - - '@storybook/addon-links@8.5.2(react@18.3.1)(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/csf': 0.1.12 - '@storybook/global': 5.0.0 - storybook: 8.5.3(prettier@3.4.1) - ts-dedent: 2.2.0 + storybook: 9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) optionalDependencies: react: 18.3.1 - '@storybook/addon-mdx-gfm@8.5.2(storybook@8.5.3(prettier@3.4.1))': - dependencies: - remark-gfm: 4.0.0 - storybook: 8.5.3(prettier@3.4.1) - ts-dedent: 2.2.0 - transitivePeerDependencies: - - supports-color - - '@storybook/addon-measure@8.4.6(storybook@8.5.3(prettier@3.4.1))': + '@storybook/addon-themes@9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))': dependencies: - '@storybook/global': 5.0.0 - storybook: 8.5.3(prettier@3.4.1) - tiny-invariant: 1.3.3 - - '@storybook/addon-outline@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/global': 5.0.0 - storybook: 8.5.3(prettier@3.4.1) + storybook: 9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) ts-dedent: 2.2.0 - '@storybook/addon-themes@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - storybook: 8.5.3(prettier@3.4.1) - ts-dedent: 2.2.0 - - '@storybook/addon-toolbars@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - storybook: 8.5.3(prettier@3.4.1) - - '@storybook/addon-viewport@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - memoizerific: 1.11.3 - storybook: 8.5.3(prettier@3.4.1) - - '@storybook/blocks@8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1))': + '@storybook/builder-vite@9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))': dependencies: - '@storybook/csf': 0.1.13 - '@storybook/icons': 1.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - storybook: 8.5.3(prettier@3.4.1) - ts-dedent: 2.2.0 - optionalDependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@storybook/builder-vite@8.4.6(storybook@8.5.3(prettier@3.4.1))(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))': - dependencies: - '@storybook/csf-plugin': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - browser-assert: 1.2.1 - storybook: 8.5.3(prettier@3.4.1) + '@storybook/csf-plugin': 9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))) + storybook: 9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) ts-dedent: 2.2.0 vite: 6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0) - '@storybook/channels@8.1.11': - dependencies: - '@storybook/client-logger': 8.1.11 - '@storybook/core-events': 8.1.11 - '@storybook/global': 5.0.0 - telejson: 7.2.0 - tiny-invariant: 1.3.3 - - '@storybook/client-logger@8.1.11': - dependencies: - '@storybook/global': 5.0.0 - - '@storybook/components@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - storybook: 8.5.3(prettier@3.4.1) - - '@storybook/core-events@8.1.11': - dependencies: - '@storybook/csf': 0.1.13 - ts-dedent: 2.2.0 - - '@storybook/core@8.5.3(prettier@3.4.1)': - dependencies: - '@storybook/csf': 0.1.12 - better-opn: 3.0.2 - browser-assert: 1.2.1 - esbuild: 0.25.3 - esbuild-register: 3.6.0(esbuild@0.25.3) - jsdoc-type-pratt-parser: 4.1.0 - process: 0.11.10 - recast: 0.23.9 - semver: 7.6.2 - util: 0.12.5 - ws: 8.18.0 - optionalDependencies: - prettier: 3.4.1 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@storybook/csf-plugin@8.4.6(storybook@8.5.3(prettier@3.4.1))': + '@storybook/csf-plugin@9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))': dependencies: - storybook: 8.5.3(prettier@3.4.1) + storybook: 9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) unplugin: 1.5.0 - '@storybook/csf@0.1.11': - dependencies: - type-fest: 2.19.0 - - '@storybook/csf@0.1.12': - dependencies: - type-fest: 2.19.0 - - '@storybook/csf@0.1.13': - dependencies: - type-fest: 2.19.0 - '@storybook/global@5.0.0': {} - '@storybook/icons@1.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@storybook/icons@1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/instrumenter@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/global': 5.0.0 - '@vitest/utils': 2.1.8 - storybook: 8.5.3(prettier@3.4.1) - - '@storybook/instrumenter@8.5.3(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/global': 5.0.0 - '@vitest/utils': 2.1.8 - storybook: 8.5.3(prettier@3.4.1) - - '@storybook/manager-api@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - storybook: 8.5.3(prettier@3.4.1) - - '@storybook/preview-api@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - storybook: 8.5.3(prettier@3.4.1) - - '@storybook/preview-api@8.5.3(storybook@8.5.3(prettier@3.4.1))': - dependencies: - storybook: 8.5.3(prettier@3.4.1) - - '@storybook/react-dom-shim@8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1))': + '@storybook/react-dom-shim@9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.5.3(prettier@3.4.1) + storybook: 9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) - '@storybook/react-vite@8.4.6(@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.40.1)(storybook@8.5.3(prettier@3.4.1))(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))': + '@storybook/react-vite@9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.40.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.4.2(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) '@rollup/pluginutils': 5.0.5(rollup@4.40.1) - '@storybook/builder-vite': 8.4.6(storybook@8.5.3(prettier@3.4.1))(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) - '@storybook/react': 8.4.6(@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1))(typescript@5.6.3) - find-up: 5.0.0 - magic-string: 0.30.5 + '@storybook/builder-vite': 9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) + '@storybook/react': 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))(typescript@5.6.3) + find-up: 7.0.0 + magic-string: 0.30.17 react: 18.3.1 - react-docgen: 7.0.3 + react-docgen: 8.0.0 react-dom: 18.3.1(react@18.3.1) - resolve: 1.22.8 - storybook: 8.5.3(prettier@3.4.1) + resolve: 1.22.10 + storybook: 9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) tsconfig-paths: 4.2.0 vite: 6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0) transitivePeerDependencies: - - '@storybook/test' - rollup - supports-color - typescript - '@storybook/react@8.4.6(@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1))(typescript@5.6.3)': + '@storybook/react@9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)))(typescript@5.6.3)': dependencies: - '@storybook/components': 8.4.6(storybook@8.5.3(prettier@3.4.1)) '@storybook/global': 5.0.0 - '@storybook/manager-api': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/preview-api': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/react-dom-shim': 8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1)) - '@storybook/theming': 8.4.6(storybook@8.5.3(prettier@3.4.1)) + '@storybook/react-dom-shim': 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.5.3(prettier@3.4.1) + storybook: 9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) optionalDependencies: - '@storybook/test': 8.4.6(storybook@8.5.3(prettier@3.4.1)) typescript: 5.6.3 - '@storybook/test@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/csf': 0.1.11 - '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@testing-library/dom': 10.4.0 - '@testing-library/jest-dom': 6.5.0 - '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) - '@vitest/expect': 2.0.5 - '@vitest/spy': 2.0.5 - storybook: 8.5.3(prettier@3.4.1) - - '@storybook/test@8.5.3(storybook@8.5.3(prettier@3.4.1))': - dependencies: - '@storybook/csf': 0.1.12 - '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.5.3(storybook@8.5.3(prettier@3.4.1)) - '@testing-library/dom': 10.4.0 - '@testing-library/jest-dom': 6.5.0 - '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) - '@vitest/expect': 2.0.5 - '@vitest/spy': 2.0.5 - storybook: 8.5.3(prettier@3.4.1) - - '@storybook/theming@8.4.6(storybook@8.5.3(prettier@3.4.1))': - dependencies: - storybook: 8.5.3(prettier@3.4.1) - '@swc/core-darwin-arm64@1.3.38': optional: true @@ -8657,7 +8243,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.27.1 '@babel/runtime': 7.26.10 '@types/aria-query': 5.0.3 aria-query: 5.3.0 @@ -8677,16 +8263,6 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.5.0': - dependencies: - '@adobe/css-tools': 4.4.1 - aria-query: 5.3.2 - chalk: 3.0.0 - css.escape: 1.5.1 - dom-accessibility-api: 0.6.3 - lodash: 4.17.21 - redent: 3.0.0 - '@testing-library/jest-dom@6.6.3': dependencies: '@adobe/css-tools': 4.4.1 @@ -8705,10 +8281,6 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': - dependencies: - '@testing-library/dom': 10.4.0 - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': dependencies: '@testing-library/dom': 10.4.0 @@ -8755,6 +8327,10 @@ snapshots: '@types/connect': 3.4.35 '@types/node': 20.17.16 + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + '@types/chroma-js@2.4.0': {} '@types/color-convert@2.0.4': @@ -8797,6 +8373,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/doctrine@0.0.9': {} '@types/estree-jsx@1.0.5': @@ -9025,37 +8603,36 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/expect@2.0.5': + '@vitest/expect@3.2.4': dependencies: - '@vitest/spy': 2.0.5 - '@vitest/utils': 2.0.5 - chai: 5.1.2 - tinyrainbow: 1.2.0 + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.1 + tinyrainbow: 2.0.0 - '@vitest/pretty-format@2.0.5': + '@vitest/mocker@3.2.4(msw@2.4.8(typescript@5.6.3))(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))': dependencies: - tinyrainbow: 1.2.0 - - '@vitest/pretty-format@2.1.8': - dependencies: - tinyrainbow: 1.2.0 + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + msw: 2.4.8(typescript@5.6.3) + vite: 6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0) - '@vitest/spy@2.0.5': + '@vitest/pretty-format@3.2.4': dependencies: - tinyspy: 3.0.2 + tinyrainbow: 2.0.0 - '@vitest/utils@2.0.5': + '@vitest/spy@3.2.4': dependencies: - '@vitest/pretty-format': 2.0.5 - estree-walker: 3.0.3 - loupe: 3.1.2 - tinyrainbow: 1.2.0 + tinyspy: 4.0.3 - '@vitest/utils@2.1.8': + '@vitest/utils@3.2.4': dependencies: - '@vitest/pretty-format': 2.1.8 - loupe: 3.1.3 - tinyrainbow: 1.2.0 + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.0 + tinyrainbow: 2.0.0 '@xterm/addon-canvas@0.7.0(@xterm/xterm@5.5.0)': dependencies: @@ -9102,8 +8679,7 @@ snapshots: acorn@8.14.0: {} - acorn@8.14.1: - optional: true + acorn@8.14.1: {} agent-base@6.0.2: dependencies: @@ -9211,7 +8787,7 @@ snapshots: axios@1.8.2: dependencies: follow-redirects: 1.15.9 - form-data: 4.0.2 + form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -9316,21 +8892,15 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@1.1.11: + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.1: - dependencies: - balanced-match: 1.0.2 - braces@3.0.3: dependencies: fill-range: 7.1.1 - browser-assert@1.2.1: {} - browserslist@4.24.2: dependencies: caniuse-lite: 1.0.30001717 @@ -9400,12 +8970,12 @@ snapshots: ccount@2.0.1: {} - chai@5.1.2: + chai@5.2.1: dependencies: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.2 + loupe: 3.2.0 pathval: 2.0.0 chalk@2.4.2: @@ -9462,6 +9032,8 @@ snapshots: chromatic@11.25.2: {} + chromatic@12.2.0: {} + ci-info@3.9.0: {} cjs-module-lexer@1.3.1: {} @@ -9548,6 +9120,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.0.2: {} + core-util-is@1.0.3: {} cosmiconfig@7.1.0: @@ -9671,7 +9245,6 @@ snapshots: debug@4.4.1: dependencies: ms: 2.1.3 - optional: true decimal.js-light@2.5.1: {} @@ -9871,7 +9444,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.3): dependencies: - debug: 4.4.0 + debug: 4.4.1 esbuild: 0.25.3 transitivePeerDependencies: - supports-color @@ -10142,6 +9715,13 @@ snapshots: dependencies: locate-path: 6.0.0 path-exists: 4.0.0 + optional: true + + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 flat-cache@3.2.0: dependencies: @@ -10164,11 +9744,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.2: + form-data@4.0.4: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 + hasown: 2.0.2 mime-types: 2.1.35 format@0.2.2: {} @@ -10303,10 +9884,6 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.0: - dependencies: - function-bind: 1.1.2 - hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -10490,10 +10067,6 @@ snapshots: is-callable@1.2.7: {} - is-core-module@2.13.1: - dependencies: - hasown: 2.0.0 - is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -10514,13 +10087,6 @@ snapshots: is-generator-fn@2.1.0: {} - is-generator-function@1.1.0: - dependencies: - call-bound: 1.0.3 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -10553,13 +10119,6 @@ snapshots: call-bind: 1.0.7 has-tostringtag: 1.0.2 - is-regex@1.2.1: - dependencies: - call-bound: 1.0.3 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - is-set@2.0.2: {} is-shared-array-buffer@1.0.2: @@ -11045,8 +10604,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsdoc-type-pratt-parser@4.1.0: {} - jsdom@20.0.3: dependencies: abab: 2.0.6 @@ -11058,7 +10615,7 @@ snapshots: decimal.js: 10.4.3 domexception: 4.0.0 escodegen: 2.1.0 - form-data: 4.0.2 + form-data: 4.0.4 html-encoding-sniffer: 3.0.0 http-proxy-agent: 5.0.0 https-proxy-agent: 5.0.1 @@ -11159,6 +10716,11 @@ snapshots: locate-path@6.0.0: dependencies: p-locate: 5.0.0 + optional: true + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 lodash-es@4.17.21: {} @@ -11183,9 +10745,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@3.1.2: {} - - loupe@3.1.3: {} + loupe@3.2.0: {} lowlight@1.20.0: dependencies: @@ -11206,11 +10766,7 @@ snapshots: lz-string@1.5.0: {} - magic-string@0.27.0: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - - magic-string@0.30.5: + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -11225,8 +10781,6 @@ snapshots: dependencies: tmpl: 1.0.5 - map-or-similar@1.5.0: {} - markdown-table@3.0.3: {} material-colors@1.2.6: {} @@ -11423,10 +10977,6 @@ snapshots: memoize-one@5.2.1: {} - memoizerific@1.11.3: - dependencies: - map-or-similar: 1.5.0 - merge-descriptors@1.0.3: {} merge-stream@2.0.0: {} @@ -11770,11 +11320,11 @@ snapshots: minimatch@3.1.2: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.12 minimatch@9.0.5: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 1.1.12 minimist@1.2.8: {} @@ -11782,7 +11332,7 @@ snapshots: mock-socket@9.3.1: {} - monaco-editor@0.52.0: {} + monaco-editor@0.52.2: {} moo-color@1.0.3: dependencies: @@ -11922,6 +11472,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.1 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -11929,6 +11483,11 @@ snapshots: p-locate@5.0.0: dependencies: p-limit: 3.1.0 + optional: true + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 p-try@2.2.0: {} @@ -11976,6 +11535,8 @@ snapshots: path-exists@4.0.0: {} + path-exists@5.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -12019,10 +11580,6 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - polished@4.3.1: - dependencies: - '@babel/runtime': 7.26.10 - possible-typed-array-names@1.0.0: {} postcss-import@15.1.0(postcss@8.5.1): @@ -12102,8 +11659,6 @@ snapshots: process-nextick-args@2.0.1: {} - process@0.11.10: {} - prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -12201,17 +11756,17 @@ snapshots: dependencies: typescript: 5.6.3 - react-docgen@7.0.3: + react-docgen@8.0.0: dependencies: - '@babel/core': 7.26.0 - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/core': 7.27.1 + '@babel/traverse': 7.27.1 + '@babel/types': 7.27.1 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 '@types/doctrine': 0.0.9 '@types/resolve': 1.20.4 doctrine: 3.0.0 - resolve: 1.22.8 + resolve: 1.22.10 strip-indent: 4.0.0 transitivePeerDependencies: - supports-color @@ -12304,17 +11859,13 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router@7.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@remix-run/router': 1.19.2 + cookie: 1.0.2 react: 18.3.1 + set-cookie-parser: 2.7.1 + optionalDependencies: react-dom: 18.3.1(react@18.3.1) - react-router: 6.26.2(react@18.3.1) - - react-router@6.26.2(react@18.3.1): - dependencies: - '@remix-run/router': 1.19.2 - react: 18.3.1 react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -12507,12 +12058,6 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - resolve@1.22.8: - dependencies: - is-core-module: 2.13.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - restore-cursor@3.1.0: dependencies: onetime: 5.1.2 @@ -12572,12 +12117,6 @@ snapshots: safe-buffer@5.2.1: {} - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.3 - es-errors: 1.3.0 - is-regex: 1.2.1 - safer-buffer@2.1.2: {} saxes@6.0.0: @@ -12617,6 +12156,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -12723,31 +12264,40 @@ snapshots: dependencies: internal-slot: 1.0.6 - storybook-addon-remix-react-router@3.1.0(@storybook/blocks@8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1)))(@storybook/channels@8.1.11)(@storybook/components@8.4.6(storybook@8.5.3(prettier@3.4.1)))(@storybook/core-events@8.1.11)(@storybook/manager-api@8.4.6(storybook@8.5.3(prettier@3.4.1)))(@storybook/preview-api@8.5.3(storybook@8.5.3(prettier@3.4.1)))(@storybook/theming@8.4.6(storybook@8.5.3(prettier@3.4.1)))(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + storybook-addon-remix-react-router@5.0.0(react-dom@18.3.1(react@18.3.1))(react-router@7.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0))): dependencies: - '@storybook/blocks': 8.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.5.3(prettier@3.4.1)) - '@storybook/channels': 8.1.11 - '@storybook/components': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/core-events': 8.1.11 - '@storybook/manager-api': 8.4.6(storybook@8.5.3(prettier@3.4.1)) - '@storybook/preview-api': 8.5.3(storybook@8.5.3(prettier@3.4.1)) - '@storybook/theming': 8.4.6(storybook@8.5.3(prettier@3.4.1)) + '@mjackson/form-data-parser': 0.4.0 compare-versions: 6.1.0 react-inspector: 6.0.2(react@18.3.1) - react-router-dom: 6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-router: 7.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + storybook: 9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) optionalDependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook@8.5.3(prettier@3.4.1): + storybook@9.1.2(@testing-library/dom@10.4.0)(msw@2.4.8(typescript@5.6.3))(prettier@3.4.1)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)): dependencies: - '@storybook/core': 8.5.3(prettier@3.4.1) + '@storybook/global': 5.0.0 + '@testing-library/jest-dom': 6.6.3 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.4.8(typescript@5.6.3))(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)) + '@vitest/spy': 3.2.4 + better-opn: 3.0.2 + esbuild: 0.25.3 + esbuild-register: 3.6.0(esbuild@0.25.3) + recast: 0.23.9 + semver: 7.6.2 + ws: 8.18.0 optionalDependencies: prettier: 3.4.1 transitivePeerDependencies: + - '@testing-library/dom' - bufferutil + - msw - supports-color - utf-8-validate + - vite strict-event-emitter@0.5.1: {} @@ -12874,10 +12424,6 @@ snapshots: tapable@2.2.1: {} - telejson@7.2.0: - dependencies: - memoizerific: 1.11.3 - test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.3 @@ -12908,9 +12454,9 @@ snapshots: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 - tinyrainbow@1.2.0: {} + tinyrainbow@2.0.0: {} - tinyspy@3.0.2: {} + tinyspy@4.0.3: {} tmpl@1.0.5: {} @@ -13026,7 +12572,7 @@ snapshots: typescript@5.6.3: {} - tzdata@1.0.40: {} + tzdata@1.0.44: {} ua-parser-js@1.0.40: {} @@ -13038,6 +12584,8 @@ snapshots: undici@6.21.2: {} + unicorn-magic@0.1.0: {} + unicorn-magic@0.3.0: {} unified@11.0.4: @@ -13093,7 +12641,7 @@ snapshots: unplugin@1.5.0: dependencies: - acorn: 8.14.0 + acorn: 8.14.1 chokidar: 3.6.0 webpack-sources: 3.2.3 webpack-virtual-modules: 0.5.0 @@ -13168,14 +12716,6 @@ snapshots: util-deprecate@1.0.2: {} - util@0.12.5: - dependencies: - inherits: 2.0.4 - is-arguments: 1.2.0 - is-generator-function: 1.1.0 - is-typed-array: 1.1.15 - which-typed-array: 1.1.18 - utils-merge@1.0.1: {} uuid@9.0.1: {} @@ -13218,7 +12758,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-checker@0.9.3(@biomejs/biome@1.9.4)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)): + vite-plugin-checker@0.9.3(@biomejs/biome@2.2.0)(eslint@8.52.0)(optionator@0.9.3)(typescript@5.6.3)(vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0)): dependencies: '@babel/code-frame': 7.27.1 chokidar: 4.0.3 @@ -13231,13 +12771,11 @@ snapshots: vite: 6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0) vscode-uri: 3.1.0 optionalDependencies: - '@biomejs/biome': 1.9.4 + '@biomejs/biome': 2.2.0 eslint: 8.52.0 optionator: 0.9.3 typescript: 5.6.3 - vite-plugin-turbosnap@1.0.3: {} - vite@6.3.5(@types/node@20.17.16)(jiti@2.4.2)(yaml@2.7.0): dependencies: esbuild: 0.25.3 @@ -13373,6 +12911,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.1: {} + yoctocolors-cjs@2.1.2: {} yup@1.6.1: diff --git a/site/site.go b/site/site.go index 682d21c695a88..b91bde14cccf8 100644 --- a/site/site.go +++ b/site/site.go @@ -448,7 +448,6 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht var user database.User var themePreference string var terminalFont string - var tasksTabVisible bool orgIDs := []uuid.UUID{} eg.Go(func() error { var err error @@ -484,20 +483,6 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht orgIDs = memberIDs[0].OrganizationIDs return err }) - eg.Go(func() error { - // If HideAITasks is true, force hide the tasks tab - if h.opts.HideAITasks { - tasksTabVisible = false - return nil - } - - hasAITask, err := h.opts.Database.HasTemplateVersionsWithAITask(ctx) - if err != nil { - return err - } - tasksTabVisible = hasAITask - return nil - }) err := eg.Wait() if err == nil { var wg sync.WaitGroup @@ -571,7 +556,7 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht wg.Add(1) go func() { defer wg.Done() - tasksTabVisible, err := json.Marshal(tasksTabVisible) + tasksTabVisible, err := json.Marshal(!h.opts.HideAITasks) if err == nil { state.TasksTabVisible = html.EscapeString(string(tasksTabVisible)) } @@ -1045,6 +1030,16 @@ func (b *binMetadataCache) getMetadata(name string) (binMetadata, error) { b.sem <- struct{}{} defer func() { <-b.sem }() + // Reject any invalid or non-basename paths before touching the filesystem. + if name == "" || + name == "." || + strings.Contains(name, "/") || + strings.Contains(name, "\\") || + !fs.ValidPath(name) || + path.Base(name) != name { + return binMetadata{}, os.ErrNotExist + } + f, err := b.binFS.Open(name) if err != nil { return binMetadata{}, err diff --git a/site/src/@types/mui.d.ts b/site/src/@types/mui.d.ts index a1b4b61b07eb2..49804d33f8971 100644 --- a/site/src/@types/mui.d.ts +++ b/site/src/@types/mui.d.ts @@ -1,4 +1,4 @@ -// biome-ignore lint/nursery/noRestrictedImports: base theme types +// biome-ignore lint/style/noRestrictedImports: base theme types import type { PaletteColor, PaletteColorOptions } from "@mui/material/styles"; declare module "@mui/material/styles" { diff --git a/site/src/@types/storybook.d.ts b/site/src/@types/storybook.d.ts index 836728d170b9f..599324a291ae4 100644 --- a/site/src/@types/storybook.d.ts +++ b/site/src/@types/storybook.d.ts @@ -8,8 +8,9 @@ import type { } from "api/typesGenerated"; import type { Permissions } from "modules/permissions"; import type { QueryKey } from "react-query"; +import type { ReactRouterAddonStoryParameters } from "storybook-addon-remix-react-router"; -declare module "@storybook/react" { +declare module "@storybook/react-vite" { type WebSocketEvent = | { event: "message"; data: string } | { event: "error" | "close" }; @@ -24,5 +25,6 @@ declare module "@storybook/react" { permissions?: Partial; deploymentValues?: DeploymentValues; deploymentOptions?: SerpentOption[]; + reactRouter?: ReactRouterAddonStoryParameters; } } diff --git a/site/src/App.tsx b/site/src/App.tsx index e4e6d4a665996..2db41214a0423 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -9,10 +9,10 @@ import { } from "react"; import { HelmetProvider } from "react-helmet-async"; import { QueryClient, QueryClientProvider } from "react-query"; -import { RouterProvider } from "react-router-dom"; +import { RouterProvider } from "react-router"; import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar"; -import { ThemeProvider } from "./contexts/ThemeProvider"; import { AuthProvider } from "./contexts/auth/AuthProvider"; +import { ThemeProvider } from "./contexts/ThemeProvider"; import { router } from "./router"; const defaultQueryClient = new QueryClient({ diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 04536675f8943..8c4c8556d4423 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -8,7 +8,7 @@ import { MockWorkspaceBuild, MockWorkspaceBuildParameter1, } from "testHelpers/entities"; -import { API, MissingBuildParameters, getURLWithSearchParams } from "./api"; +import { API, getURLWithSearchParams, MissingBuildParameters } from "./api"; import type * as TypesGen from "./typesGenerated"; const axiosInstance = API.getAxiosInstance(); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2b21ddf1e8a08..caf0f5c0944bb 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -21,9 +21,10 @@ */ 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 { OneWayWebSocket } from "../utils/OneWayWebSocket"; import { delay } from "../utils/delay"; +import { OneWayWebSocket } from "../utils/OneWayWebSocket"; import { type FieldError, isApiError } from "./errors"; import type { DynamicParametersRequest, @@ -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; }; @@ -1222,7 +1229,7 @@ class ApiMethods { waitForBuild = (build: TypesGen.WorkspaceBuild) => { return new Promise((res, reject) => { void (async () => { - let latestJobInfo: TypesGen.ProvisionerJob | undefined = undefined; + let latestJobInfo: TypesGen.ProvisionerJob | undefined; while ( !["succeeded", "canceled"].some((status) => @@ -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, @@ -2665,6 +2682,42 @@ class ExperimentalApiMethods { return response.data; }; + + createTask = async ( + user: string, + req: TypesGen.CreateTaskRequest, + ): Promise => { + const response = await this.axios.post( + `/api/experimental/tasks/${user}`, + req, + ); + + return response.data; + }; + + getTasks = async (filter: TasksFilter): Promise => { + const queryExpressions = ["has-ai-task:true"]; + + if (filter.username) { + queryExpressions.push(`owner:${filter.username}`); + } + + const res = await API.getWorkspaces({ + q: queryExpressions.join(" "), + }); + // Exclude prebuild workspaces as they are not user-facing. + const workspaces = res.workspaces.filter( + (workspace) => !workspace.is_prebuild, + ); + const prompts = await API.experimental.getAITasksPrompts( + workspaces.map((workspace) => workspace.latest_build.id), + ); + + return 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/templates.ts b/site/src/api/queries/templates.ts index 5135f2304426e..8c3b294f7fad8 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -48,21 +48,6 @@ export const templates = ( }; }; -const getTemplatesByOrganizationQueryKey = ( - organization: string, - options?: GetTemplatesOptions, -) => [organization, "templates", options?.deprecated]; - -const templatesByOrganization = ( - organization: string, - options: GetTemplatesOptions = {}, -) => { - return { - queryKey: getTemplatesByOrganizationQueryKey(organization, options), - queryFn: () => API.getTemplatesByOrganization(organization, options), - }; -}; - export const templateACL = (templateId: string) => { return { queryKey: ["templateAcl", templateId], @@ -121,9 +106,11 @@ export const templateExamples = () => { }; }; +export const templateVersionRoot: string = "templateVersion"; + export const templateVersion = (versionId: string) => { return { - queryKey: ["templateVersion", versionId], + queryKey: [templateVersionRoot, versionId], queryFn: () => API.getTemplateVersion(versionId), }; }; @@ -134,7 +121,7 @@ export const templateVersionByName = ( versionName: string, ) => { return { - queryKey: ["templateVersion", organizationId, templateName, versionName], + queryKey: [templateVersionRoot, organizationId, templateName, versionName], queryFn: () => API.getTemplateVersionByName(organizationId, templateName, versionName), }; @@ -153,7 +140,7 @@ export const templateVersions = (templateId: string) => { }; export const templateVersionVariablesKey = (versionId: string) => [ - "templateVersion", + templateVersionRoot, versionId, "variables", ]; @@ -216,7 +203,7 @@ export const templaceACLAvailable = ( }; const templateVersionExternalAuthKey = (versionId: string) => [ - "templateVersion", + templateVersionRoot, versionId, "externalAuth", ]; @@ -257,21 +244,21 @@ const createTemplateFn = async (options: CreateTemplateOptions) => { export const templateVersionLogs = (versionId: string) => { return { - queryKey: ["templateVersion", versionId, "logs"], + queryKey: [templateVersionRoot, versionId, "logs"], queryFn: () => API.getTemplateVersionLogs(versionId), }; }; export const richParameters = (versionId: string) => { return { - queryKey: ["templateVersion", versionId, "richParameters"], + queryKey: [templateVersionRoot, versionId, "richParameters"], queryFn: () => API.getTemplateVersionRichParameters(versionId), }; }; export const resources = (versionId: string) => { return { - queryKey: ["templateVersion", versionId, "resources"], + queryKey: [templateVersionRoot, versionId, "resources"], queryFn: () => API.getTemplateVersionResources(versionId), }; }; @@ -293,7 +280,7 @@ export const previousTemplateVersion = ( ) => { return { queryKey: [ - "templateVersion", + templateVersionRoot, organizationId, templateName, versionName, @@ -313,7 +300,7 @@ export const previousTemplateVersion = ( export const templateVersionPresets = (versionId: string) => { return { - queryKey: ["templateVersion", versionId, "presets"], + queryKey: [templateVersionRoot, versionId, "presets"], queryFn: () => API.getTemplateVersionPresets(versionId), }; }; @@ -323,7 +310,7 @@ const waitBuildToBeFinished = async ( onRequest?: (data: TemplateVersion) => void, ) => { let data: TemplateVersion; - let jobStatus: ProvisionerJobStatus | undefined = undefined; + let jobStatus: ProvisionerJobStatus | undefined; do { // When pending we want to poll more frequently await delay(jobStatus === "pending" ? 250 : 1000); diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 4d87232ee698c..31a0302c94653 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -12,11 +12,12 @@ import type { UsersRequest, } from "api/typesGenerated"; import { - type MetadataState, defaultMetadataManager, + type MetadataState, } 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..65fdac7715821 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -3,7 +3,6 @@ import { DetailedError, isApiValidationError } from "api/errors"; import type { CreateWorkspaceRequest, ProvisionerLogLevel, - UpdateWorkspaceACL, UsageAppName, Workspace, WorkspaceAgentLog, @@ -139,15 +138,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; } @@ -423,10 +421,12 @@ export const workspacePermissions = (workspace?: Workspace) => { }; }; -export const updateWorkspaceACL = (workspaceId: string) => { +export const workspaceAgentCredentials = ( + workspaceId: string, + agentName: string, +) => { return { - mutationFn: async (patch: UpdateWorkspaceACL) => { - await API.updateWorkspaceACL(workspaceId, patch); - }, + queryKey: ["workspaces", workspaceId, "agents", agentName, "credentials"], + queryFn: () => API.getWorkspaceAgentCredentials(workspaceId, agentName), }; }; diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 5d632d57fad95..145b9ff9f8d7f 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -159,6 +159,11 @@ export const RBACResourceActions: Partial< use: "use the template to initially create a workspace, then workspace lifecycle permissions take over", view_insights: "view insights", }, + usage_event: { + create: "create a usage event", + read: "read usage events", + update: "update usage events", + }, user: { create: "create a new user", delete: "delete an existing user", @@ -167,6 +172,12 @@ export const RBACResourceActions: Partial< update: "update an existing user", update_personal: "update personal data", }, + user_secret: { + create: "create a user secret", + delete: "delete a user secret", + read: "read user secret metadata and value", + update: "update user secret metadata and value", + }, webpush_subscription: { create: "create webpush subscriptions", delete: "delete webpush subscriptions", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index db901630b71cf..54984cd11548f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -476,6 +476,13 @@ export interface CreateProvisionerKeyResponse { readonly key: string; } +// From codersdk/aitasks.go +export interface CreateTaskRequest { + readonly template_version_id: string; + readonly template_version_preset_id?: string; + readonly prompt: string; +} + // From codersdk/organizations.go export interface CreateTemplateRequest { readonly name: string; @@ -931,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; @@ -1044,6 +1057,7 @@ export type FeatureName = | "user_limit" | "user_role_management" | "workspace_batch_actions" + | "workspace_external_agent" | "workspace_prebuilds" | "workspace_proxy"; @@ -1067,6 +1081,7 @@ export const FeatureNames: FeatureName[] = [ "user_limit", "user_role_management", "workspace_batch_actions", + "workspace_external_agent", "workspace_prebuilds", "workspace_proxy", ]; @@ -1825,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; } @@ -2385,7 +2403,9 @@ export type RBACResource = | "system" | "tailnet_coordinator" | "template" + | "usage_event" | "user" + | "user_secret" | "webpush_subscription" | "*" | "workspace" @@ -2425,7 +2445,9 @@ export const RBACResources: RBACResource[] = [ "system", "tailnet_coordinator", "template", + "usage_event", "user", + "user_secret", "webpush_subscription", "*", "workspace", @@ -2785,6 +2807,51 @@ 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 owner_name: string; + readonly name: string; + readonly template_id: string; + readonly template_name: string; + readonly template_display_name: string; + readonly template_icon: string; + readonly workspace_id: string | null; + readonly workspace_agent_id: string | null; + readonly workspace_agent_lifecycle: WorkspaceAgentLifecycle | null; + readonly workspace_agent_health: WorkspaceAgentHealth | 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; @@ -2988,6 +3055,7 @@ export interface TemplateVersion { readonly archived: boolean; readonly warnings?: readonly TemplateVersionWarning[]; readonly matched_provisioners?: MatchedProvisioners; + readonly has_external_agent: boolean; } // From codersdk/templateversions.go @@ -3510,6 +3578,12 @@ export interface Workspace { readonly is_prebuild: boolean; } +// From codersdk/workspaces.go +export interface WorkspaceACL { + readonly users: readonly WorkspaceUser[]; + readonly group: readonly WorkspaceGroup[]; +} + // From codersdk/workspaceagents.go export interface WorkspaceAgent { readonly id: string; @@ -3864,6 +3938,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 @@ -3907,6 +3982,11 @@ export interface WorkspaceFilter { readonly q?: string; } +// From codersdk/workspaces.go +export interface WorkspaceGroup extends Group { + readonly role: WorkspaceRole; +} + // From codersdk/workspaces.go export interface WorkspaceHealth { readonly healthy: boolean; @@ -4016,6 +4096,11 @@ export const WorkspaceTransitions: WorkspaceTransition[] = [ "stop", ]; +// From codersdk/workspaces.go +export interface WorkspaceUser extends MinimalUser { + readonly role: WorkspaceRole; +} + // From codersdk/workspaces.go export interface WorkspacesRequest extends Pagination { readonly q?: string; diff --git a/site/src/components/Abbr/Abbr.stories.tsx b/site/src/components/Abbr/Abbr.stories.tsx index 6720b90fffda5..7d079e4ac7416 100644 --- a/site/src/components/Abbr/Abbr.stories.tsx +++ b/site/src/components/Abbr/Abbr.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { Abbr } from "./Abbr"; const meta: Meta = { @@ -6,10 +6,10 @@ const meta: Meta = { component: Abbr, decorators: [ (Story) => ( - <> +

Try the following text out in a screen reader!

- +
), ], }; @@ -25,10 +25,10 @@ export const InlinedShorthand: Story = { }, decorators: [ (Story) => ( -

+

The physical pain of getting bonked on the head with a cartoon mallet - lasts precisely 593{" "} - + lasts precisely 593 + . The emotional turmoil and complete embarrassment lasts forever. @@ -45,7 +45,7 @@ export const Acronym: Story = { }, decorators: [ (Story) => ( - + ), @@ -60,16 +60,9 @@ export const Initialism: Story = { }, decorators: [ (Story) => ( - + ), ], }; - -const styles = { - // Just here to make the abbreviated part more obvious in the component library - underlined: { - textDecoration: "underline dotted", - }, -}; diff --git a/site/src/components/Abbr/Abbr.test.tsx b/site/src/components/Abbr/Abbr.test.tsx index 3ae76f071bdfb..b67406299685a 100644 --- a/site/src/components/Abbr/Abbr.test.tsx +++ b/site/src/components/Abbr/Abbr.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@testing-library/react"; -import { Abbr, type Pronunciation } from "./Abbr"; +import { Abbr } from "./Abbr"; type AbbreviationData = { abbreviation: string; @@ -7,28 +7,8 @@ type AbbreviationData = { expectedLabel: string; }; -type AssertionInput = AbbreviationData & { - pronunciation: Pronunciation; -}; - -function assertAccessibleLabel({ - abbreviation, - title, - expectedLabel, - pronunciation, -}: AssertionInput) { - const { unmount } = render( - - {abbreviation} - , - ); - - screen.getByLabelText(expectedLabel, { selector: "abbr" }); - unmount(); -} - describe(Abbr.name, () => { - it("Has an aria-label that equals the title if the abbreviation is shorthand", () => { + it("Omits abbreviation from screen-reader output if it is shorthand", () => { const sampleShorthands: AbbreviationData[] = [ { abbreviation: "ms", @@ -43,11 +23,22 @@ describe(Abbr.name, () => { ]; for (const shorthand of sampleShorthands) { - assertAccessibleLabel({ ...shorthand, pronunciation: "shorthand" }); + const { unmount } = render( + + {shorthand.abbreviation} + , + ); + + // The element doesn't have any ARIA role semantics baked in, + // so we have to get a little bit more creative with making sure the + // expected content is on screen in an accessible way + const element = screen.getByTitle(shorthand.title); + expect(element).toHaveTextContent(shorthand.expectedLabel); + unmount(); } }); - it("Has an aria label with title and 'flattened' pronunciation if abbreviation is acronym", () => { + it("Adds title and 'flattened' pronunciation if abbreviation is acronym", () => { const sampleAcronyms: AbbreviationData[] = [ { abbreviation: "NASA", @@ -67,11 +58,19 @@ describe(Abbr.name, () => { ]; for (const acronym of sampleAcronyms) { - assertAccessibleLabel({ ...acronym, pronunciation: "acronym" }); + const { unmount } = render( + + {acronym.abbreviation} + , + ); + + const element = screen.getByTitle(acronym.title); + expect(element).toHaveTextContent(acronym.expectedLabel); + unmount(); } }); - it("Has an aria label with title and initialized pronunciation if abbreviation is initialism", () => { + it("Adds title and initialized pronunciation if abbreviation is initialism", () => { const sampleInitialisms: AbbreviationData[] = [ { abbreviation: "FBI", @@ -91,7 +90,15 @@ describe(Abbr.name, () => { ]; for (const initialism of sampleInitialisms) { - assertAccessibleLabel({ ...initialism, pronunciation: "initialism" }); + const { unmount } = render( + + {initialism.abbreviation} + , + ); + + const element = screen.getByTitle(initialism.title); + expect(element).toHaveTextContent(initialism.expectedLabel); + unmount(); } }); }); diff --git a/site/src/components/Abbr/Abbr.tsx b/site/src/components/Abbr/Abbr.tsx index c41f68e08117f..0c08c33e111ce 100644 --- a/site/src/components/Abbr/Abbr.tsx +++ b/site/src/components/Abbr/Abbr.tsx @@ -1,11 +1,13 @@ import type { FC, HTMLAttributes } from "react"; +import { cn } from "utils/cn"; -export type Pronunciation = "shorthand" | "acronym" | "initialism"; +type Pronunciation = "shorthand" | "acronym" | "initialism"; type AbbrProps = HTMLAttributes & { children: string; title: string; pronunciation?: Pronunciation; + className?: string; }; /** @@ -21,21 +23,26 @@ export const Abbr: FC = ({ children, title, pronunciation = "shorthand", + className, ...delegatedProps }) => { return ( {children} + + {getAccessibleLabel(children, title, pronunciation)} + ); }; diff --git a/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx b/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx index f4961f0cedba8..c63f867eb0de8 100644 --- a/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx +++ b/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { ActiveUserChart } from "./ActiveUserChart"; const meta: Meta = { @@ -6,13 +6,13 @@ const meta: Meta = { component: ActiveUserChart, args: { data: [ - { date: "2024-01-01", amount: 5 }, - { date: "2024-01-02", amount: 6 }, - { date: "2024-01-03", amount: 7 }, - { date: "2024-01-04", amount: 8 }, - { date: "2024-01-05", amount: 9 }, - { date: "2024-01-06", amount: 10 }, - { date: "2024-01-07", amount: 11 }, + { date: "2024-01-01", amount: 12 }, + { date: "2024-01-02", amount: 8 }, + { date: "2024-01-03", amount: 15 }, + { date: "2024-01-04", amount: 3 }, + { date: "2024-01-05", amount: 22 }, + { date: "2024-01-06", amount: 7 }, + { date: "2024-01-07", amount: 18 }, ], }, decorators: [ @@ -31,12 +31,31 @@ export const Example: Story = {}; export const ManyDataPoints: Story = { args: { - data: Array.from({ length: 30 }).map((_, i) => { - const date = new Date(2024, 0, i + 1); - return { - date: date.toISOString().split("T")[0], - amount: 5 + Math.floor(Math.random() * 15), - }; - }), + data: [ + { date: "2024-01-01", amount: 12 }, + { date: "2024-01-02", amount: 8 }, + { date: "2024-01-03", amount: 15 }, + { date: "2024-01-04", amount: 3 }, + { date: "2024-01-05", amount: 22 }, + { date: "2024-01-06", amount: 7 }, + { date: "2024-01-07", amount: 18 }, + { date: "2024-01-08", amount: 31 }, + { date: "2024-01-09", amount: 5 }, + { date: "2024-01-10", amount: 27 }, + { date: "2024-01-11", amount: 14 }, + { date: "2024-01-12", amount: 9 }, + { date: "2024-01-13", amount: 35 }, + { date: "2024-01-14", amount: 21 }, + { date: "2024-01-15", amount: 6 }, + { date: "2024-01-16", amount: 29 }, + { date: "2024-01-17", amount: 11 }, + { date: "2024-01-18", amount: 17 }, + { date: "2024-01-19", amount: 4 }, + { date: "2024-01-20", amount: 25 }, + { date: "2024-01-21", amount: 13 }, + { date: "2024-01-22", amount: 33 }, + { date: "2024-01-23", amount: 19 }, + { date: "2024-01-24", amount: 26 }, + ], }, }; diff --git a/site/src/components/ActiveUserChart/ActiveUserChart.tsx b/site/src/components/ActiveUserChart/ActiveUserChart.tsx index 084ed7b16559f..ef55e06d568a4 100644 --- a/site/src/components/ActiveUserChart/ActiveUserChart.tsx +++ b/site/src/components/ActiveUserChart/ActiveUserChart.tsx @@ -68,7 +68,7 @@ export const ActiveUserChart: FC = ({ data }) => { const item = p[0]; return `${item.value} active users`; }} - formatter={(v, n, item) => { + formatter={(_v, _n, item) => { const date = new Date(item.payload.date); return date.toLocaleString(undefined, { month: "long", @@ -113,7 +113,7 @@ type ActiveUsersTitleProps = { export const ActiveUsersTitle: FC = ({ interval }) => { return ( -

+
{interval === "day" ? "Daily" : "Weekly"} Active Users diff --git a/site/src/components/Alert/Alert.stories.tsx b/site/src/components/Alert/Alert.stories.tsx index a170f0b29d244..e122c0c07c5a6 100644 --- a/site/src/components/Alert/Alert.stories.tsx +++ b/site/src/components/Alert/Alert.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { Button } from "components/Button/Button"; import { Alert } from "./Alert"; diff --git a/site/src/components/Alert/Alert.tsx b/site/src/components/Alert/Alert.tsx index e97b690f82833..1cbf36b2df1d2 100644 --- a/site/src/components/Alert/Alert.tsx +++ b/site/src/components/Alert/Alert.tsx @@ -1,7 +1,7 @@ import MuiAlert, { type AlertColor as MuiAlertColor, type AlertProps as MuiAlertProps, - // biome-ignore lint/nursery/noRestrictedImports: Used as base component + // biome-ignore lint/style/noRestrictedImports: Used as base component } from "@mui/material/Alert"; import Collapse from "@mui/material/Collapse"; import { Button } from "components/Button/Button"; diff --git a/site/src/components/Alert/ErrorAlert.stories.tsx b/site/src/components/Alert/ErrorAlert.stories.tsx index e62314c622cc6..28120dd1054d1 100644 --- a/site/src/components/Alert/ErrorAlert.stories.tsx +++ b/site/src/components/Alert/ErrorAlert.stories.tsx @@ -1,6 +1,6 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { Button } from "components/Button/Button"; import { mockApiError } from "testHelpers/entities"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "components/Button/Button"; import { ErrorAlert } from "./ErrorAlert"; const mockError = mockApiError({ diff --git a/site/src/components/Avatar/Avatar.stories.tsx b/site/src/components/Avatar/Avatar.stories.tsx index 55deeb9073dbe..256da41bfd645 100644 --- a/site/src/components/Avatar/Avatar.stories.tsx +++ b/site/src/components/Avatar/Avatar.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { Avatar } from "./Avatar"; const meta: Meta = { diff --git a/site/src/components/Avatar/Avatar.tsx b/site/src/components/Avatar/Avatar.tsx index 8661dceda0f6a..3b9de3657d623 100644 --- a/site/src/components/Avatar/Avatar.tsx +++ b/site/src/components/Avatar/Avatar.tsx @@ -12,7 +12,7 @@ import { useTheme } from "@emotion/react"; import * as AvatarPrimitive from "@radix-ui/react-avatar"; -import { type VariantProps, cva } from "class-variance-authority"; +import { cva, type VariantProps } from "class-variance-authority"; import * as React from "react"; import { getExternalImageStylesFromUrl } from "theme/externalImages"; import { cn } from "utils/cn"; diff --git a/site/src/components/Avatar/AvatarCard.stories.tsx b/site/src/components/Avatar/AvatarCard.stories.tsx index cc8fb56e16c05..b067877e3c8dc 100644 --- a/site/src/components/Avatar/AvatarCard.stories.tsx +++ b/site/src/components/Avatar/AvatarCard.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { AvatarCard } from "./AvatarCard"; const meta: Meta = { diff --git a/site/src/components/Avatar/AvatarData.stories.tsx b/site/src/components/Avatar/AvatarData.stories.tsx index 53fc4d8f17555..22f8cb45d7699 100644 --- a/site/src/components/Avatar/AvatarData.stories.tsx +++ b/site/src/components/Avatar/AvatarData.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { AvatarData } from "./AvatarData"; const meta: Meta = { diff --git a/site/src/components/Avatar/AvatarDataSkeleton.stories.tsx b/site/src/components/Avatar/AvatarDataSkeleton.stories.tsx index 0df5ca083b98b..99b19a47657d6 100644 --- a/site/src/components/Avatar/AvatarDataSkeleton.stories.tsx +++ b/site/src/components/Avatar/AvatarDataSkeleton.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { AvatarDataSkeleton } from "./AvatarDataSkeleton"; const meta: Meta = { diff --git a/site/src/components/Badge/Badge.stories.tsx b/site/src/components/Badge/Badge.stories.tsx index 7d900b49ff6f6..524d0e3642588 100644 --- a/site/src/components/Badge/Badge.stories.tsx +++ b/site/src/components/Badge/Badge.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { Settings, TriangleAlert } from "lucide-react"; import { Badge } from "./Badge"; diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx index 0d11c96d30433..c3d0b27475bf2 100644 --- a/site/src/components/Badge/Badge.tsx +++ b/site/src/components/Badge/Badge.tsx @@ -3,7 +3,7 @@ * @see {@link https://ui.shadcn.com/docs/components/badge} */ import { Slot } from "@radix-ui/react-slot"; -import { type VariantProps, cva } from "class-variance-authority"; +import { cva, type VariantProps } from "class-variance-authority"; import { forwardRef } from "react"; import { cn } from "utils/cn"; @@ -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/Badges/Badges.stories.tsx b/site/src/components/Badges/Badges.stories.tsx index 2a0a60498f487..36c8fddb37ea9 100644 --- a/site/src/components/Badges/Badges.stories.tsx +++ b/site/src/components/Badges/Badges.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { AlphaBadge, Badges, diff --git a/site/src/components/Badges/Badges.tsx b/site/src/components/Badges/Badges.tsx index 278eb890cd2ee..f0db2fb0e9fbc 100644 --- a/site/src/components/Badges/Badges.tsx +++ b/site/src/components/Badges/Badges.tsx @@ -3,9 +3,9 @@ import Tooltip from "@mui/material/Tooltip"; import { Stack } from "components/Stack/Stack"; import { type FC, + forwardRef, type HTMLAttributes, type PropsWithChildren, - forwardRef, } from "react"; const styles = { diff --git a/site/src/components/Breadcrumb/Breadcrumb.stories.tsx b/site/src/components/Breadcrumb/Breadcrumb.stories.tsx index 0b02b2ebb9939..bc14950462d9a 100644 --- a/site/src/components/Breadcrumb/Breadcrumb.stories.tsx +++ b/site/src/components/Breadcrumb/Breadcrumb.stories.tsx @@ -1,4 +1,5 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import { MockOrganization } from "testHelpers/entities"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { Breadcrumb, BreadcrumbEllipsis, @@ -8,7 +9,6 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "components/Breadcrumb/Breadcrumb"; -import { MockOrganization } from "testHelpers/entities"; const meta: Meta = { title: "components/Breadcrumb", diff --git a/site/src/components/Breadcrumb/Breadcrumb.tsx b/site/src/components/Breadcrumb/Breadcrumb.tsx index 35f90d30a5d7b..61d06c3755542 100644 --- a/site/src/components/Breadcrumb/Breadcrumb.tsx +++ b/site/src/components/Breadcrumb/Breadcrumb.tsx @@ -8,8 +8,8 @@ import { type ComponentProps, type ComponentPropsWithoutRef, type FC, - type ReactNode, forwardRef, + type ReactNode, } from "react"; import { cn } from "utils/cn"; diff --git a/site/src/components/BuildIcon/BuildIcon.stories.tsx b/site/src/components/BuildIcon/BuildIcon.stories.tsx index b2f01ad5ae38b..22481719bb4b8 100644 --- a/site/src/components/BuildIcon/BuildIcon.stories.tsx +++ b/site/src/components/BuildIcon/BuildIcon.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { BuildIcon } from "./BuildIcon"; const meta: Meta = { diff --git a/site/src/components/Button/Button.stories.tsx b/site/src/components/Button/Button.stories.tsx index ceeb395cf8006..0cfd4707f5f85 100644 --- a/site/src/components/Button/Button.stories.tsx +++ b/site/src/components/Button/Button.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { PlusIcon } from "lucide-react"; import { Button } from "./Button"; diff --git a/site/src/components/Button/Button.tsx b/site/src/components/Button/Button.tsx index 1f2c6b3b3416b..ff5200edb5883 100644 --- a/site/src/components/Button/Button.tsx +++ b/site/src/components/Button/Button.tsx @@ -3,7 +3,7 @@ * @see {@link https://ui.shadcn.com/docs/components/button} */ import { Slot } from "@radix-ui/react-slot"; -import { type VariantProps, cva } from "class-variance-authority"; +import { cva, type VariantProps } from "class-variance-authority"; import { forwardRef } from "react"; import { cn } from "utils/cn"; diff --git a/site/src/components/Chart/Chart.stories.tsx b/site/src/components/Chart/Chart.stories.tsx index 74fded80d2b4d..a351ba3f24ed6 100644 --- a/site/src/components/Chart/Chart.stories.tsx +++ b/site/src/components/Chart/Chart.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { type ChartConfig, diff --git a/site/src/components/Chart/Chart.tsx b/site/src/components/Chart/Chart.tsx index c68967afe6e91..dd418bfef76c3 100644 --- a/site/src/components/Chart/Chart.tsx +++ b/site/src/components/Chart/Chart.tsx @@ -271,7 +271,7 @@ export const ChartTooltipContent = React.forwardRef< ); ChartTooltipContent.displayName = "ChartTooltip"; -const ChartLegend = RechartsPrimitive.Legend; +const _ChartLegend = RechartsPrimitive.Legend; const ChartLegendContent = React.forwardRef< HTMLDivElement, diff --git a/site/src/components/Checkbox/Checkbox.stories.tsx b/site/src/components/Checkbox/Checkbox.stories.tsx index 2c7582dcfe901..b4cceb5ea535d 100644 --- a/site/src/components/Checkbox/Checkbox.stories.tsx +++ b/site/src/components/Checkbox/Checkbox.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import React from "react"; import { Checkbox } from "./Checkbox"; diff --git a/site/src/components/CodeExample/CodeExample.stories.tsx b/site/src/components/CodeExample/CodeExample.stories.tsx index 93283e4df74a3..61f129f448a73 100644 --- a/site/src/components/CodeExample/CodeExample.stories.tsx +++ b/site/src/components/CodeExample/CodeExample.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { CodeExample } from "./CodeExample"; const meta: Meta = { @@ -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/Collapsible/Collapsible.stories.tsx b/site/src/components/Collapsible/Collapsible.stories.tsx index cb391c4d83135..ad099b03c23f0 100644 --- a/site/src/components/Collapsible/Collapsible.stories.tsx +++ b/site/src/components/Collapsible/Collapsible.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { Button } from "components/Button/Button"; import { ChevronsUpDown } from "lucide-react"; import { diff --git a/site/src/components/CollapsibleSummary/CollapsibleSummary.stories.tsx b/site/src/components/CollapsibleSummary/CollapsibleSummary.stories.tsx index 98f63c24ccbc7..c33a151774532 100644 --- a/site/src/components/CollapsibleSummary/CollapsibleSummary.stories.tsx +++ b/site/src/components/CollapsibleSummary/CollapsibleSummary.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { Button } from "../Button/Button"; import { CollapsibleSummary } from "./CollapsibleSummary"; diff --git a/site/src/components/CollapsibleSummary/CollapsibleSummary.tsx b/site/src/components/CollapsibleSummary/CollapsibleSummary.tsx index ef68d1816dbcf..9cf45dc9d445b 100644 --- a/site/src/components/CollapsibleSummary/CollapsibleSummary.tsx +++ b/site/src/components/CollapsibleSummary/CollapsibleSummary.tsx @@ -1,4 +1,4 @@ -import { type VariantProps, cva } from "class-variance-authority"; +import { cva, type VariantProps } from "class-variance-authority"; import { ChevronRightIcon } from "lucide-react"; import { type FC, type ReactNode, useState } from "react"; import { cn } from "utils/cn"; diff --git a/site/src/components/Combobox/Combobox.stories.tsx b/site/src/components/Combobox/Combobox.stories.tsx index 2207f4e64686f..49fafd9ab3c79 100644 --- a/site/src/components/Combobox/Combobox.stories.tsx +++ b/site/src/components/Combobox/Combobox.stories.tsx @@ -1,6 +1,6 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { expect, screen, userEvent, waitFor, within } from "@storybook/test"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; import { Combobox } from "./Combobox"; const simpleOptions = ["Go", "Gleam", "Kotlin", "Rust"]; @@ -31,7 +31,9 @@ const advancedOptions = [ const ComboboxWithHooks = ({ options = advancedOptions, -}: { options?: React.ComponentProps["options"] }) => { +}: { + options?: React.ComponentProps["options"]; +}) => { const [value, setValue] = useState(""); const [open, setOpen] = useState(false); const [inputValue, setInputValue] = useState(""); diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx index f2db25b1ef31c..2d522d6e6397c 100644 --- a/site/src/components/Combobox/Combobox.tsx +++ b/site/src/components/Combobox/Combobox.tsx @@ -18,8 +18,7 @@ import { TooltipProvider, TooltipTrigger, } from "components/Tooltip/Tooltip"; -import { Check, ChevronDown, CornerDownLeft } from "lucide-react"; -import { Info } from "lucide-react"; +import { Check, ChevronDown, CornerDownLeft, Info } from "lucide-react"; import { type FC, type KeyboardEventHandler, useState } from "react"; import { cn } from "utils/cn"; import { ExternalImage } from "../ExternalImage/ExternalImage"; @@ -34,6 +33,7 @@ interface ComboboxProps { onInputChange?: (value: string) => void; onKeyDown?: KeyboardEventHandler; onSelect: (value: string) => void; + id?: string; } type ComboboxOption = { @@ -53,6 +53,7 @@ export const Combobox: FC = ({ onInputChange, onKeyDown, onSelect, + id, }) => { const [managedOpen, setManagedOpen] = useState(false); const [managedInputValue, setManagedInputValue] = useState(""); @@ -78,6 +79,7 @@ export const Combobox: FC = ({
@@ -336,7 +343,7 @@ export const AgentRow: FC = ({ Logs - + )} diff --git a/site/src/modules/resources/AgentRowPreview.stories.tsx b/site/src/modules/resources/AgentRowPreview.stories.tsx index fdcd84093bddb..8cd4f6bd7b78d 100644 --- a/site/src/modules/resources/AgentRowPreview.stories.tsx +++ b/site/src/modules/resources/AgentRowPreview.stories.tsx @@ -1,5 +1,5 @@ -import type { Meta, StoryObj } from "@storybook/react"; import { MockWorkspaceAgent, MockWorkspaceApp } from "testHelpers/entities"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { AgentRowPreview } from "./AgentRowPreview"; const meta: Meta = { diff --git a/site/src/modules/resources/AgentRowPreview.test.tsx b/site/src/modules/resources/AgentRowPreview.test.tsx index c1b876b72ef3b..23b34dc8c7fc8 100644 --- a/site/src/modules/resources/AgentRowPreview.test.tsx +++ b/site/src/modules/resources/AgentRowPreview.test.tsx @@ -1,11 +1,11 @@ +import { MockWorkspaceAgent } from "testHelpers/entities"; +import { renderComponent } from "testHelpers/renderHelpers"; import { screen } from "@testing-library/react"; import { type DisplayApp, DisplayApps, type WorkspaceAgent, } from "api/typesGenerated"; -import { MockWorkspaceAgent } from "testHelpers/entities"; -import { renderComponent } from "testHelpers/renderHelpers"; import { AgentRowPreview } from "./AgentRowPreview"; import { DisplayAppNameMap } from "./AppLink/AppLink"; diff --git a/site/src/modules/resources/AgentRowPreview.tsx b/site/src/modules/resources/AgentRowPreview.tsx index 70de1450322da..2df8b3a4d5460 100644 --- a/site/src/modules/resources/AgentRowPreview.tsx +++ b/site/src/modules/resources/AgentRowPreview.tsx @@ -93,10 +93,8 @@ export const AgentRowPreview: FC = ({ {/* We display all modules returned in agent.apps */} {agent.apps.map((app) => ( - <> - - {app.display_name} - + + {app.display_name} ))} {/* Additionally, we display any apps that are visible, e.g. diff --git a/site/src/modules/resources/AgentStatus.tsx b/site/src/modules/resources/AgentStatus.tsx index 7eb165d19f8c2..8f6b923e70d68 100644 --- a/site/src/modules/resources/AgentStatus.tsx +++ b/site/src/modules/resources/AgentStatus.tsx @@ -6,13 +6,13 @@ import type { WorkspaceAgentDevcontainer, } from "api/typesGenerated"; import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; +import { PopoverTrigger } from "components/deprecated/Popover/Popover"; import { HelpTooltip, HelpTooltipContent, HelpTooltipText, HelpTooltipTitle, } from "components/HelpTooltip/HelpTooltip"; -import { PopoverTrigger } from "components/deprecated/Popover/Popover"; import { TriangleAlertIcon } from "lucide-react"; import type { FC } from "react"; diff --git a/site/src/modules/resources/AppLink/AppLink.stories.tsx b/site/src/modules/resources/AppLink/AppLink.stories.tsx index 891ddd3c2af7d..c9355c8801281 100644 --- a/site/src/modules/resources/AppLink/AppLink.stories.tsx +++ b/site/src/modules/resources/AppLink/AppLink.stories.tsx @@ -1,5 +1,3 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { getPreferredProxy } from "contexts/ProxyContext"; import { MockPrimaryWorkspaceProxy, MockWorkspace, @@ -8,6 +6,8 @@ import { MockWorkspaceProxies, } from "testHelpers/entities"; import { withGlobalSnackbar, withProxyProvider } from "testHelpers/storybook"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { getPreferredProxy } from "contexts/ProxyContext"; import { AppLink } from "./AppLink"; const meta: Meta = { @@ -168,6 +168,21 @@ export const InternalApp: Story = { }, }; +export const InternalAppHostnameTooLong: Story = { + args: { + workspace: MockWorkspace, + app: { + ...MockWorkspaceApp, + display_name: "Check my URL", + subdomain: true, + subdomain_name: + // 64 characters long; surpasses DNS hostname limit of 63 characters + "app_name_makes_subdomain64--agent_name--workspace_name--username", + }, + agent: MockWorkspaceAgent, + }, +}; + export const BlockingStartupScriptRunning: Story = { args: { workspace: MockWorkspace, diff --git a/site/src/modules/resources/AppLink/AppLink.tsx b/site/src/modules/resources/AppLink/AppLink.tsx index 637f0287a4088..d757a5f31743b 100644 --- a/site/src/modules/resources/AppLink/AppLink.tsx +++ b/site/src/modules/resources/AppLink/AppLink.tsx @@ -1,6 +1,6 @@ -import { useTheme } from "@emotion/react"; import type * as TypesGen from "api/typesGenerated"; import { DropdownMenuItem } from "components/DropdownMenu/DropdownMenu"; +import { Link } from "components/Link/Link"; import { Spinner } from "components/Spinner/Spinner"; import { Tooltip, @@ -12,7 +12,7 @@ import { useProxy } from "contexts/ProxyContext"; import { CircleAlertIcon } from "lucide-react"; import { isExternalApp, needsSessionToken } from "modules/apps/apps"; import { useAppLink } from "modules/apps/useAppLink"; -import { type FC, useState } from "react"; +import { type FC, type ReactNode, useState } from "react"; import { AgentButton } from "../AgentButton"; import { BaseIcon } from "./BaseIcon"; import { ShareIcon } from "./ShareIcon"; @@ -41,7 +41,6 @@ export const AppLink: FC = ({ const { proxy } = useProxy(); const host = proxy.preferredWildcardHostname; const [iconError, setIconError] = useState(false); - const theme = useTheme(); const link = useAppLink(app, { agent, workspace }); // canClick is ONLY false when it's a subdomain app and the admin hasn't @@ -50,7 +49,7 @@ export const AppLink: FC = ({ // To avoid bugs in the healthcheck code locking users out of apps, we no // longer block access to apps if they are unhealthy/initializing. let canClick = true; - let primaryTooltip = ""; + let primaryTooltip: ReactNode = ""; let icon = !iconError && ( setIconError(true)} /> ); @@ -64,8 +63,7 @@ export const AppLink: FC = ({ icon = (