Merge branch 'main' into fix/codagt-517-testagent-stats-ssh

This commit is contained in:
Mathias Fredriksson
2026-06-02 11:24:24 +03:00
committed by GitHub
297 changed files with 11385 additions and 4723 deletions
+35
View File
@@ -41,6 +41,41 @@
dimension.
- Avoid `ByX` names for grouped queries.
### Enum Changes Run in a Single Transaction
All migrations run inside one transaction (`pgTxnDriver`). Postgres forbids
*using* an enum value added by `ALTER TYPE ... ADD VALUE` within the same
transaction that added it, so it fails with `unsafe use of new value`.
Adding the value is fine; using it in the same batch is not. "Using it"
includes a later migration that casts to it (`col::my_enum`), inserts or
updates a row with it, or sets it as a column default. This only fails when a
row actually materializes the new value, so fresh databases and CI pass while
deployments with existing data break.
**MUST DO**: If any migration uses a newly added enum value, recreate the type
instead of using `ADD VALUE`. A freshly created enum's values are usable
immediately in the same transaction. Precedent: `000144_user_status_dormant`.
```sql
CREATE TYPE new_my_enum AS ENUM ('existing', 'value', 'new_value');
ALTER TABLE my_table
ALTER COLUMN col TYPE new_my_enum USING (col::text::new_my_enum);
DROP TYPE my_enum;
ALTER TYPE new_my_enum RENAME TO my_enum;
```
Recreating produces an identical schema, so `make gen` yields no `dump.sql`
diff and databases that already applied the migration see no drift.
**Testing**: `migrations.Stepper` commits each migration separately, so tests
built on it cannot surface this. To catch it, seed a row using the new value,
then apply the affected migrations in a single transaction (see
`TestMigration000504AIProvidersBackfillEnumInSingleTxn`).
## Handling Nullable Fields
Use `sql.NullString`, `sql.NullBool`, etc. for optional database fields:
+76
View File
@@ -0,0 +1,76 @@
name: "Go cache"
description: Restore and save Go build and module caches.
inputs:
cache-path:
description: "Optional newline-delimited cache paths. Defaults to go env GOCACHE and GOMODCACHE."
required: false
default: ""
key-prefix:
description: "Prefix for the cache key."
required: false
default: "go"
download-modules:
description: "Whether to run go mod download after restoring cache."
required: false
default: "true"
runs:
using: "composite"
steps:
- name: Compute Go cache key
id: go-cache
shell: bash
run: |
set -euo pipefail
if [[ -n "${INPUT_CACHE_PATH}" ]]; then
paths="${INPUT_CACHE_PATH}"
else
paths="$(printf '%s\n%s' "$(go env GOCACHE)" "$(go env GOMODCACHE)")"
fi
go_version="$(go env GOVERSION)"
paths_hash="$(printf '%s\n' "${paths}" | git hash-object --stdin)"
hash="$(
{
printf '%s\n' "${go_version}"
for file in go.mod go.sum; do
if [[ -f "${file}" ]]; then
git hash-object "${file}"
fi
done
} | git hash-object --stdin
)"
{
echo "path<<EOF"
echo "${paths}"
echo "EOF"
echo "key=${INPUT_KEY_PREFIX}-${RUNNER_OS}-${RUNNER_ARCH}-${paths_hash}-${hash}"
echo "restore-key=${INPUT_KEY_PREFIX}-${RUNNER_OS}-${RUNNER_ARCH}-${paths_hash}-"
} >> "$GITHUB_OUTPUT"
env:
INPUT_CACHE_PATH: ${{ inputs.cache-path }}
INPUT_KEY_PREFIX: ${{ inputs.key-prefix }}
- name: Restore Go cache, save on main
if: ${{ github.ref == 'refs/heads/main' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.go-cache.outputs.path }}
key: ${{ steps.go-cache.outputs.key }}
restore-keys: |
${{ steps.go-cache.outputs.restore-key }}
- name: Restore Go cache read-only
if: ${{ github.ref != 'refs/heads/main' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.go-cache.outputs.path }}
key: ${{ steps.go-cache.outputs.key }}
restore-keys: |
${{ steps.go-cache.outputs.restore-key }}
- name: Download Go modules
if: ${{ inputs.download-modules == 'true' }}
shell: bash
run: ./.github/scripts/retry.sh -- go mod download -x
@@ -1,10 +0,0 @@
name: "Install cosign"
description: |
Cosign Github Action.
runs:
using: "composite"
steps:
- name: Install cosign
uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1
with:
cosign-release: "v2.4.3"
-10
View File
@@ -1,10 +0,0 @@
name: "Install syft"
description: |
Downloads Syft to the Action tool cache and provides a reference.
runs:
using: "composite"
steps:
- name: Install syft
uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
syft-version: "v1.26.1"
+59
View File
@@ -0,0 +1,59 @@
name: "pnpm install"
description: Restore pnpm store cache and install root plus workspace dependencies.
inputs:
directory:
description: "Workspace directory to install after the repository root."
required: false
default: "site"
runs:
using: "composite"
steps:
- name: Compute pnpm cache key
id: pnpm-cache
shell: bash
run: |
set -euo pipefail
store_path="$(pnpm store path --silent)"
hash="$(
for file in pnpm-lock.yaml "${INPUT_DIRECTORY}/pnpm-lock.yaml"; do
if [[ -f "${file}" ]]; then
git hash-object "${file}"
fi
done | git hash-object --stdin
)"
{
echo "store-path=${store_path}"
echo "key=pnpm-${RUNNER_OS}-${RUNNER_ARCH}-${INPUT_DIRECTORY}-${hash}"
echo "restore-key=pnpm-${RUNNER_OS}-${RUNNER_ARCH}-${INPUT_DIRECTORY}-"
} >> "$GITHUB_OUTPUT"
env:
INPUT_DIRECTORY: ${{ inputs.directory }}
- name: Restore and save pnpm cache
if: ${{ github.ref == 'refs/heads/main' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-cache.outputs.store-path }}
key: ${{ steps.pnpm-cache.outputs.key }}
restore-keys: |
${{ steps.pnpm-cache.outputs.restore-key }}
- name: Restore pnpm cache
if: ${{ github.ref != 'refs/heads/main' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-cache.outputs.store-path }}
key: ${{ steps.pnpm-cache.outputs.key }}
restore-keys: |
${{ steps.pnpm-cache.outputs.restore-key }}
- name: Install root node_modules
shell: bash
run: ./scripts/pnpm_install.sh
- name: Install node_modules
shell: bash
run: "${GITHUB_WORKSPACE}/scripts/pnpm_install.sh"
working-directory: ${{ github.workspace }}/${{ inputs.directory }}
@@ -1,12 +0,0 @@
name: "Setup Go tools"
description: |
Set up tools for `make gen`, `offlinedocs` and Schmoder CI.
runs:
using: "composite"
steps:
- name: go install tools
shell: bash
run: |
./.github/scripts/retry.sh -- go install tool
# NOTE: protoc-gen-go cannot be installed with `go get`
./.github/scripts/retry.sh -- go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30
-32
View File
@@ -1,32 +0,0 @@
name: "Setup Go"
description: |
Sets up the Go environment for tests, builds, etc.
inputs:
version:
description: "The Go version to use."
default: "1.26.2"
use-cache:
description: "Whether to use the cache."
default: "true"
runs:
using: "composite"
steps:
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: ${{ inputs.version }}
cache: ${{ inputs.use-cache }}
- name: Install gotestsum
shell: bash
run: ./.github/scripts/retry.sh -- go install gotest.tools/gotestsum@0d9599e513d70e5792bb9334869f82f6e8b53d4d # main as of 2025-05-15
- name: Install mtimehash
shell: bash
run: ./.github/scripts/retry.sh -- go install github.com/slsyy/mtimehash/cmd/mtimehash@a6b5da4ed2c4a40e7b805534b004e9fde7b53ce0 # v1.0.0
# It isn't necessary that we ever do this, but it helps
# separate the "setup" from the "run" times.
- name: go mod download
shell: bash
run: ./.github/scripts/retry.sh -- go mod download -x
+188
View File
@@ -0,0 +1,188 @@
name: Setup mise
description: Install mise tools from SHA256-pinned binaries, with CI-layer caching.
inputs:
install-args:
description: Tool names or extra arguments passed to mise install. --locked is added by default.
required: false
default: ""
locked:
description: Whether to pass --locked to mise install.
required: false
default: "true"
cache-key-prefix:
description: Prefix for mise tool cache keys.
required: false
default: mise-ci-v1
mise-version:
description: mise version to install.
required: false
default: "2026.5.12"
mise-sha256:
description: SHA256 checksum for the mise binary.
required: false
default: ""
use-cache:
description: Whether to restore and save mise tool caches.
required: false
default: "true"
runs:
using: composite
steps:
- name: Compute mise cache key
id: cache-key
shell: bash
env:
CACHE_KEY_PREFIX: ${{ inputs.cache-key-prefix }}
INPUT_INSTALL_ARGS: ${{ inputs.install-args }}
INPUT_LOCKED: ${{ inputs.locked }}
MISE_VERSION: ${{ inputs.mise-version }}
RUNNER_ARCH: ${{ runner.arch }}
RUNNER_OS: ${{ runner.os }}
run: |
set -euo pipefail
case "${INPUT_LOCKED}" in
true)
if [[ -n "${INPUT_INSTALL_ARGS}" ]]; then
install_args="--locked ${INPUT_INSTALL_ARGS}"
else
install_args="--locked"
fi
;;
false)
install_args="${INPUT_INSTALL_ARGS}"
;;
*)
echo "::error::locked must be true or false."
exit 1
;;
esac
install_args_hash="$(printf '%s' "$install_args" | git hash-object --stdin)"
files_hash="$(git hash-object mise.toml mise.lock | git hash-object --stdin)"
key="${CACHE_KEY_PREFIX}-${RUNNER_OS}-${RUNNER_ARCH}-${MISE_VERSION}-${install_args_hash}-${files_hash}"
restore_key="${CACHE_KEY_PREFIX}-${RUNNER_OS}-${RUNNER_ARCH}-${MISE_VERSION}-${install_args_hash}-"
{
echo "install-args<<EOF"
echo "${install_args}"
echo "EOF"
echo "key=$key"
echo "restore-key=$restore_key"
} >> "$GITHUB_OUTPUT"
- name: Select mise checksum
id: checksum
shell: bash
env:
CHECKSUMS_FILE: ${{ github.action_path }}/checksums.toml
INPUT_MISE_SHA256: ${{ inputs.mise-sha256 }}
MISE_CHECKSUM_SCRIPT: ${{ github.workspace }}/scripts/mise_checksum.sh
MISE_VERSION: ${{ inputs.mise-version }}
RUNNER_ARCH: ${{ runner.arch }}
RUNNER_OS: ${{ runner.os }}
run: |
set -euo pipefail
checksum="${INPUT_MISE_SHA256}"
if [[ -z "${checksum}" ]]; then
case "${RUNNER_OS}-${RUNNER_ARCH}" in
Linux-X64)
target="linux-x64"
;;
Linux-ARM64)
target="linux-arm64"
;;
macOS-X64)
target="macos-x64"
;;
macOS-ARM64)
target="macos-arm64"
;;
Windows-X64)
target="windows-x64"
;;
*)
echo "::error::No mise checksum is pinned for ${RUNNER_OS}-${RUNNER_ARCH}."
exit 1
;;
esac
checksum="$("${MISE_CHECKSUM_SCRIPT}" "${CHECKSUMS_FILE}" "${MISE_VERSION}" "${target}")"
if [[ -z "${checksum}" ]]; then
echo "::error::No mise checksum is pinned for mise ${MISE_VERSION} on ${target}."
exit 1
fi
fi
echo "sha256=${checksum}" >> "$GITHUB_OUTPUT"
- name: Configure mise data directory
id: mise-data-dir
shell: bash
env:
RUNNER_OS: ${{ runner.os }}
run: | # zizmor: ignore[github-env] MISE_DATA_DIR uses only runner-provided paths.
set -euo pipefail
if [[ "${RUNNER_OS}" == "Windows" ]]; then
data_dir="${LOCALAPPDATA:-${USERPROFILE}\\AppData\\Local}\\mise"
else
data_dir="${RUNNER_TEMP}/mise-data"
fi
{
printf 'path=%s\n' "${data_dir}"
} >> "$GITHUB_OUTPUT"
printf 'MISE_DATA_DIR=%s\n' "${data_dir}" >> "$GITHUB_ENV"
- name: Cache mise tools
if: ${{ inputs.use-cache == 'true' && github.ref == 'refs/heads/main' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/mise
${{ steps.mise-data-dir.outputs.path }}
key: ${{ steps.cache-key.outputs.key }}
restore-keys: |
${{ steps.cache-key.outputs.restore-key }}
- name: Restore mise tools
if: ${{ inputs.use-cache == 'true' && github.ref != 'refs/heads/main' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/mise
${{ steps.mise-data-dir.outputs.path }}
key: ${{ steps.cache-key.outputs.key }}
restore-keys: |
${{ steps.cache-key.outputs.restore-key }}
- name: Install mise tools
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with:
version: ${{ inputs.mise-version }}
sha256: ${{ steps.checksum.outputs.sha256 }}
mise_dir: ${{ steps.mise-data-dir.outputs.path }}
install_args: ${{ steps.cache-key.outputs.install-args }}
cache: "false"
- name: Ensure Git usr/bin is in PATH (Windows)
if: runner.os == 'Windows'
shell: pwsh
# jdx/mise-action exports "Path" via GITHUB_ENV which may
# collide with bash's "PATH". Ensure Git usr/bin is present
# and remove any duplicate Path/PATH entries from GITHUB_ENV
# by writing both forms.
run: | # zizmor: ignore[github-env]
$gitdir = "C:\Program Files\Git\usr\bin"
$current = $env:Path
if ($current -notlike "*$gitdir*") {
$current = "$gitdir;$current"
}
# Write both Path and PATH to GITHUB_ENV so that both
# cmd.exe (uses Path) and bash/Go (uses PATH) see the
# same value including Git usr/bin.
"Path=$current" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8
"PATH=$current" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8
@@ -0,0 +1,9 @@
# SHA256 hashes of the extracted mise binary verified by jdx/mise-action.
# Keys use the GitHub runner target for each release artifact.
["2026.5.12"]
linux-x64 = "a238972a3162d710b85b28c324372e96ca4e4b486c81fe78695000d9fbc77c48"
linux-arm64 = "fd2d5227a8ad0b1e359c70527a8345a9ada72077f8dcbb559371653c3d95464f"
macos-x64 = "de57e8dc82bbd880a69c9bc8aee06b9dcc578184b3e5cf86fcef80635d6a90b4"
macos-arm64 = "e777070540ffe22cf8b2b9f88aed88b461d0887d940c4f1c1a97359463cde6e1"
windows-x64 = "adf1b4c9f51e7d15cff723056fcd8fd51f40ebacadcca97fd5758c44d469d5ea"
-44
View File
@@ -1,44 +0,0 @@
name: "Setup Node"
description: |
Sets up the node environment for tests, builds, etc.
inputs:
directory:
description: |
The directory to run the setup in.
required: false
default: "site"
runs:
using: "composite"
steps:
- name: Install pnpm
uses: pnpm/action-setup@739bfe42ca9233c5e6aca07c1a25a9d34aca49b0 # v6.0.7
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.19.0
# See https://github.com/actions/setup-node#caching-global-packages-data
cache: "pnpm"
cache-dependency-path: ${{ inputs.directory }}/pnpm-lock.yaml
- name: Verify Node
shell: bash
run: |
set -euo pipefail
expected="v22.19.0"
actual="$(node --version)"
if [[ "$actual" != "$expected" ]]; then
echo "::error::Expected Node.js $expected, but got $actual from $(command -v node)."
exit 1
fi
echo "Node.js $actual is active at $(command -v node)."
- name: Install root node_modules
shell: bash
run: ./scripts/pnpm_install.sh
- name: Install node_modules
shell: bash
run: ../scripts/pnpm_install.sh
working-directory: ${{ inputs.directory }}
-17
View File
@@ -1,17 +0,0 @@
name: Setup sqlc
description: |
Sets up the sqlc environment for tests, builds, etc.
runs:
using: "composite"
steps:
- name: Setup sqlc
# uses: sqlc-dev/setup-sqlc@c0209b9199cd1cce6a14fc27cabcec491b651761 # v4.0.0
# with:
# sqlc-version: "1.30.0"
# Switched to coder/sqlc fork to fix ambiguous column bug, see:
# - https://github.com/coder/sqlc/pull/1
# - https://github.com/sqlc-dev/sqlc/pull/4159
shell: bash
run: |
./.github/scripts/retry.sh -- env CGO_ENABLED=1 go install github.com/coder/sqlc/cmd/sqlc@337309bfb9524f38466a5090e310040fc7af0203
-11
View File
@@ -1,11 +0,0 @@
name: "Setup Terraform"
description: |
Sets up Terraform for tests, builds, etc.
runs:
using: "composite"
steps:
- name: Install Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
with:
terraform_version: 1.15.5
terraform_wrapper: false
+155 -149
View File
@@ -151,8 +151,13 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "node pnpm"
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Check docs
run: pnpm check-docs
@@ -171,8 +176,10 @@ jobs:
# # See: https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#commits-made-by-this-action-do-not-trigger-new-workflow-runs
# token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
# - name: Setup Go
# uses: ./.github/actions/setup-go
# - name: Set up mise tools
# uses: ./.github/actions/setup-mise
# with:
# install-args: "go"
# - name: Update Nix Flake SRI Hash
# run: ./scripts/update-flake.sh
@@ -208,18 +215,22 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm helm actionlint aqua:crate-ci/typos"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/golangci/golangci-lint/cmd/golangci-lint go:github.com/coder/paralleltestctx/cmd/paralleltestctx
- name: Get golangci-lint cache dir
run: |
# mise.toml is the source of truth for tool versions baked into
# the dogfood image; pull the same version for the lint job.
linter_ver=$(grep -Eo '^golangci-lint = "[^"]+"' mise.toml | sed -E 's/.*"([^"]+)"/\1/')
./.github/scripts/retry.sh -- 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"
@@ -239,35 +250,13 @@ jobs:
# Check for any typos
- name: Check for typos
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
with:
config: .github/workflows/typos.toml
run: typos --config .github/workflows/typos.toml
- name: Fix the typos
if: ${{ failure() }}
run: |
echo "::notice:: you can automatically fix typos from your CLI:
cargo install typos-cli
typos -c .github/workflows/typos.toml -w"
# Needed for helm chart linting
- name: Install helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: v3.9.2
continue-on-error: true
id: setup-helm
- name: Install helm (fallback)
if: steps.setup-helm.outcome == 'failure'
# Fallback to Buildkite's apt repository if get.helm.sh is down.
# See: https://github.com/coder/internal/issues/1109
run: |
set -euo pipefail
curl -fsSL https://packages.buildkite.com/helm-linux/helm-debian/gpgkey | gpg --dearmor | sudo tee /usr/share/keyrings/helm.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/helm.gpg] https://packages.buildkite.com/helm-linux/helm-debian/any/ any main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list
sudo apt-get update
sudo apt-get install -y helm=3.9.2-1
mise exec aqua:crate-ci/typos -- typos -c .github/workflows/typos.toml -w"
- name: Verify helm version
run: helm version --short
@@ -287,15 +276,11 @@ jobs:
key: ${{ steps.golangci-lint-cache.outputs.cache-primary-key }}
- name: Check workflow files
run: |
bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 1.7.4
./actionlint -color -shellcheck= -ignore "set-output"
run: actionlint -color -shellcheck= -ignore "set-output"
shell: bash
- name: Check for unstaged files
run: |
rm -f ./actionlint ./typos
./scripts/check_unstaged.sh
run: ./scripts/check_unstaged.sh
shell: bash
lint-actions:
@@ -303,7 +288,7 @@ jobs:
# Only run this job if changes to CI workflow files are detected. This job
# can flake as it reaches out to GitHub to check referenced actions.
if: needs.changes.outputs.ci == 'true'
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-24.04-8' || 'ubuntu-24.04' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
@@ -316,8 +301,10 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "actionlint zizmor"
- name: make lint/actions
run: make --output-sync=line -j lint/actions
@@ -341,30 +328,19 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm terraform protoc protoc-gen-go"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: go install tools
uses: ./.github/actions/setup-go-tools
- name: Install Protoc
run: |
mkdir -p /tmp/proto
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
sudo cp -r ./bin/* /usr/local/bin
sudo cp -r ./include /usr/local/bin/include
popd
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:storj.io/drpc/cmd/protoc-gen-go-drpc go:github.com/coder/sqlc/cmd/sqlc
- name: make gen
timeout-minutes: 8
@@ -396,24 +372,26 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Check Go version
run: IGNORE_NIX=true ./scripts/check_go_versions.sh
# Use default Go version
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm terraform"
- name: Install shfmt
run: ./.github/scripts/retry.sh -- go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:mvdan.cc/sh/v3/cmd/shfmt
- name: make fmt
timeout-minutes: 7
run: |
PATH="${PATH}:$(go env GOPATH)/bin" \
make --output-sync -j -B fmt
run: make --output-sync -j -B fmt
- name: Check for unstaged files
run: ./scripts/check_unstaged.sh
@@ -476,13 +454,18 @@ jobs:
- name: Setup GNU tools (macOS)
uses: ./.github/actions/setup-gnu-tools
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
use-cache: true
install-args: "go terraform"
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Restore Go cache
uses: ./.github/actions/go-cache
with:
cache-path: ${{ steps.go-paths.outputs.cached-dirs }}
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:gotest.tools/gotestsum go:github.com/slsyy/mtimehash/cmd/mtimehash
- name: Download Test Cache
id: download-cache
@@ -651,11 +634,16 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go terraform"
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:gotest.tools/gotestsum
- name: Download Test Cache
id: download-cache
@@ -720,11 +708,16 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go terraform"
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:gotest.tools/gotestsum
- name: Download Test Cache
id: download-cache
@@ -799,8 +792,13 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go"
- name: Restore Go cache
uses: ./.github/actions/go-cache
# Used by some integration tests.
- name: Install Nginx
@@ -826,8 +824,13 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "node pnpm"
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- run: pnpm test:ci --max-workers "$(nproc)"
working-directory: site
@@ -859,11 +862,16 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Restore Go cache
uses: ./.github/actions/go-cache
# Assume that the checked-in versions are up-to-date
- run: make gen/mark-fresh
@@ -951,8 +959,13 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "node pnpm"
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
# This step is not meant for mainline because any detected changes to
# storybook snapshots will require manual approval/review in order for
@@ -1030,29 +1043,21 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm protoc protoc-gen-go"
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
with:
directory: offlinedocs
- name: Install Protoc
run: |
mkdir -p /tmp/proto
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
sudo cp -r ./bin/* /usr/local/bin
sudo cp -r ./include /usr/local/bin/include
popd
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install go tools
uses: ./.github/actions/setup-go-tools
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:storj.io/drpc/cmd/protoc-gen-go-drpc go:github.com/coder/sqlc/cmd/sqlc
- name: Format
run: |
@@ -1144,17 +1149,19 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Install go-winres
run: ./.github/scripts/retry.sh -- go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install nfpm
run: ./.github/scripts/retry.sh -- go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.35.1
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/tc-hib/go-winres go:github.com/goreleaser/nfpm/v2/cmd/nfpm
- name: Install zstd
run: sudo apt-get install -y zstd
@@ -1205,13 +1212,19 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
use-cache: false
install-args: "go node pnpm cosign syft"
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/tc-hib/go-winres go:github.com/goreleaser/nfpm/v2/cmd/nfpm
- name: Install rcodesign
run: |
@@ -1241,21 +1254,9 @@ jobs:
distribution: "zulu"
java-version: "11.0"
- name: Install go-winres
run: ./.github/scripts/retry.sh -- go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3
- name: Install nfpm
run: ./.github/scripts/retry.sh -- go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.35.1
- name: Install zstd
run: sudo apt-get install -y zstd
- name: Install cosign
uses: ./.github/actions/install-cosign
- name: Install syft
uses: ./.github/actions/install-syft
- name: Setup Windows EV Signing Certificate
run: |
set -euo pipefail
@@ -1579,11 +1580,16 @@ jobs:
with:
fetch-depth: 1
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go"
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/coder/sqlc/cmd/sqlc
- name: Setup and run sqlc vet
run: |
+7 -50
View File
@@ -71,9 +71,6 @@ jobs:
packages: write # push the dogfood base image to ghcr.io/coder/oss-dogfood-base
env:
# MISE_EXPERIMENTAL opts into the experimental `oci` subcommand.
# Trust is set via a config file (see the Install mise step
# below) rather than MISE_TRUSTED_CONFIG_PATHS so the workspace
# template can keep parity with the same file-based approach.
MISE_EXPERIMENTAL: "1"
steps:
- name: Harden Runner
@@ -135,32 +132,9 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
if: matrix.image-version != 'nix'
- name: Install mise
if: matrix.image-version != 'nix'
# MISE_VERSION + MISE_SHA256 match dogfood/coder/ubuntu-*/Dockerfile.base
# so the mise binary baking the image is the same one a workspace
# ships with. `min_version` in mise.toml catches downgrades.
# Write trust config to ~/.config/mise/conf.d/ instead of using
# MISE_TRUSTED_CONFIG_PATHS so the same file-based approach
# works in workspaces (where the user owns the file).
env:
MISE_VERSION: v2026.5.12
MISE_SHA256: a238972a3162d710b85b28c324372e96ca4e4b486c81fe78695000d9fbc77c48
WORKSPACE: ${{ github.workspace }}
run: |
set -euo pipefail
curl --silent --show-error --location --fail \
"https://github.com/jdx/mise/releases/download/${MISE_VERSION}/mise-${MISE_VERSION}-linux-x64" \
--output /tmp/mise
echo "${MISE_SHA256} /tmp/mise" | sha256sum -c
sudo install -m 0755 /tmp/mise /usr/local/bin/mise
rm /tmp/mise
mise --version
mkdir -p "$HOME/.config/mise/conf.d"
cat > "$HOME/.config/mise/conf.d/00-ci-trust.toml" <<EOF
[settings]
trusted_config_paths = ["$WORKSPACE"]
EOF
- name: Set up mise tools
if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
uses: ./.github/actions/setup-mise
- name: Compute image SHAs
# Match the fork guard on the downstream consumers of these
@@ -216,25 +190,6 @@ jobs:
ghcr.io/coder/oss-dogfood-base:${{ matrix.image-version }}-${{ steps.shas.outputs.base_sha }}
ghcr.io/coder/oss-dogfood-base:${{ matrix.image-version }}-${{ steps.docker-tag-name.outputs.tag }}
- name: Install mise tools
if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
# `mise oci build` packages already-installed tools into OCI
# layers; it does not install them. Run `mise install` first so
# the tools land in MISE_DATA_DIR on the runner.
# github_token raises aqua's API quota during tool installs.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# --locked refuses to resolve URLs at install time and forces
# the runner to consume what mise.lock already committed,
# so a forgotten lockfile entry fails CI instead of silently
# being added on next run.
mise install --yes --locked
# Put mise's shims dir on PATH for subsequent steps so
# `mise oci push --tool crane` can find crane (and any other
# mise-managed binary it shells out to).
echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH"
- name: Build mise oci layer
if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
env:
@@ -360,8 +315,10 @@ jobs:
with:
persist-credentials: false
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "terraform"
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
+13 -7
View File
@@ -39,12 +39,16 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up Go
uses: ./.github/actions/setup-mise
with:
install-args: "go"
- name: Install whichtests
shell: bash
run: ./.github/scripts/retry.sh -- go install github.com/coder/whichtests@ec33bab1ec04cd86beb7a61a069db4463dba63f5
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/coder/whichtests go:gotest.tools/gotestsum
- name: Select changed tests
id: selector
@@ -57,9 +61,11 @@ jobs:
--coalesce \
--out-matrix "$RUNNER_TEMP/flake-matrix.json"
- name: Setup Terraform
- name: Set up Terraform
if: ${{ fromJSON(steps.selector.outputs.matrix).include[0] != null }}
uses: ./.github/actions/setup-tf
uses: ./.github/actions/setup-mise
with:
install-args: "terraform"
- name: Run targeted Go flake checks
id: flake_check
+9 -4
View File
@@ -62,11 +62,16 @@ jobs:
- name: Setup GNU tools (macOS)
uses: ./.github/actions/setup-gnu-tools
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go terraform"
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:gotest.tools/gotestsum
- name: Setup Embedded Postgres Cache Paths
id: embedded-pg-cache
+11 -6
View File
@@ -238,14 +238,19 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/coder/sqlc/cmd/sqlc
- name: GHCR Login
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+8 -21
View File
@@ -172,13 +172,16 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
use-cache: false
install-args: "go node pnpm helm cosign syft"
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/tc-hib/go-winres go:github.com/goreleaser/nfpm/v2/cmd/nfpm
# Necessary for signing Windows binaries.
- name: Setup Java
@@ -187,19 +190,9 @@ jobs:
distribution: "zulu"
java-version: "11.0"
- name: Install go-winres
run: ./.github/scripts/retry.sh -- go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3
- name: Install nsis and zstd
run: sudo apt-get install -y nsis zstd
- name: Install nfpm
run: |
set -euo pipefail
wget -O /tmp/nfpm.deb https://github.com/goreleaser/nfpm/releases/download/v2.35.1/nfpm_2.35.1_amd64.deb
sudo dpkg -i /tmp/nfpm.deb
rm /tmp/nfpm.deb
- name: Install rcodesign
run: |
set -euo pipefail
@@ -210,12 +203,6 @@ jobs:
apple-codesign-0.22.0-x86_64-unknown-linux-musl/rcodesign
rm /tmp/rcodesign.tar.gz
- name: Install cosign
uses: ./.github/actions/install-cosign
- name: Install syft
uses: ./.github/actions/install-syft
- name: Setup Apple Developer certificate and API key
run: |
set -euo pipefail
+7 -2
View File
@@ -36,8 +36,13 @@ jobs:
with:
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go"
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v3.29.5
+59 -1
View File
@@ -14,7 +14,54 @@ permissions:
contents: read
jobs:
prepare-linkspector-browser:
# later versions of Ubuntu have disabled unprivileged user namespaces, which are required by the action
runs-on: ubuntu-22.04
permissions:
contents: read
env:
CHROME_BUILD_ID: "145.0.7632.77"
outputs:
browser-cache-key: ${{ steps.browser-versions.outputs.cache-key }}
chrome-path: ${{ steps.install-chrome.outputs.path }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "node npm:@puppeteer/browsers"
- name: Get browser versions
id: browser-versions
run: |
set -euo pipefail
installer_version="$(mise current npm:@puppeteer/browsers)"
echo "cache-key=puppeteer-${RUNNER_OS}-${RUNNER_ARCH}-browsers-${installer_version}-chrome-${CHROME_BUILD_ID}" >> "$GITHUB_OUTPUT"
- name: Restore Puppeteer browser cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/puppeteer
key: ${{ steps.browser-versions.outputs.cache-key }}
- name: Install Linkspector Chrome
id: install-chrome
run: |
set -euo pipefail
chrome_path="$(browsers install "chrome@${CHROME_BUILD_ID}" --path "${HOME}/.cache/puppeteer" --format '{{path}}')"
echo "path=${chrome_path}" >> "$GITHUB_OUTPUT"
check-docs:
needs: prepare-linkspector-browser
# later versions of Ubuntu have disabled unprivileged user namespaces, which are required by the action
runs-on: ubuntu-22.04
permissions:
@@ -54,10 +101,21 @@ jobs:
corepack enable pnpm
mkdir -p "$(pnpm store path --silent)"
- name: Restore Puppeteer browser cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/puppeteer
key: ${{ needs.prepare-linkspector-browser.outputs.browser-cache-key }}
- name: Check Markdown links
uses: umbrelladocs/action-linkspector@37c85bcde51b30bf929936502bac6bfb7e8f0a4d # v1.4.1
uses: umbrelladocs/action-linkspector@036f295d12b67b0c4b445bc83db0538afb78db69 # v1.5.2
id: markdown-link-check
# checks all markdown files from /docs including all subfolders
env:
# Use the Chrome build prepared from mise-pinned Puppeteer instead
# of letting linkspector download a mutable browser at runtime.
# See: https://github.com/UmbrellaDocs/action-linkspector/issues/62
PUPPETEER_EXECUTABLE_PATH: ${{ needs.prepare-linkspector-browser.outputs.chrome-path }}
with:
reporter: github-pr-review
config_file: ".github/.linkspector.yml"
+20 -30
View File
@@ -728,11 +728,11 @@ endif
# GitHub Actions linters are run in a separate CI job (lint-actions) that only
# triggers when workflow files change, so we skip them here when CI=true.
LINT_ACTIONS_TARGETS := $(if $(CI),,lint/actions/actionlint)
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/check-scopes lint/migrations lint/bootstrap lint/architecture lint/emdash lint/agents $(LINT_ACTIONS_TARGETS)
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/check-scopes lint/migrations lint/bootstrap lint/architecture lint/emdash lint/agents lint/mise-versions $(LINT_ACTIONS_TARGETS)
.PHONY: lint
# Subset of lint that does not require Go or Node toolchains.
lint-light: lint/shellcheck lint/markdown lint/helm lint/bootstrap lint/migrations lint/actions/actionlint lint/typos lint/emdash
# Fast lint subset for lightweight hooks. Some targets use mise-managed tools.
lint-light: lint/shellcheck lint/markdown lint/helm lint/bootstrap lint/migrations lint/actions/actionlint lint/typos lint/emdash lint/mise-versions
.PHONY: lint-light
lint/site-icons:
@@ -745,9 +745,8 @@ lint/ts: site/node_modules/.installed
.PHONY: lint/ts
lint/go:
linter_ver=$$(grep -Eo '^golangci-lint = "[^"]+"' mise.toml | sed -E 's/.*"([^"]+)"/\1/')
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
go tool github.com/coder/paralleltestctx/cmd/paralleltestctx -custom-funcs="testutil.Context,chatdTestContext" ./...
golangci-lint run
paralleltestctx -custom-funcs="testutil.Context,chatdTestContext" ./...
go run ./scripts/intxcheck ./...
.PHONY: lint/go
@@ -790,16 +789,27 @@ lint/actions: lint/actions/actionlint lint/actions/zizmor
.PHONY: lint/actions
lint/actions/actionlint:
go tool github.com/rhysd/actionlint/cmd/actionlint
mise exec actionlint -- actionlint
.PHONY: lint/actions/actionlint
# zizmor uses GH_TOKEN to fetch imported workflows from GitHub; without it,
# external action references are skipped silently.
lint/actions/zizmor:
./scripts/zizmor.sh \
@set -euo pipefail; \
if [ -z "$${GH_TOKEN:-}" ] && command -v gh >/dev/null 2>&1; then \
GH_TOKEN="$$(gh auth token 2>/dev/null || true)"; \
export GH_TOKEN; \
fi; \
mise exec zizmor -- zizmor \
--strict-collection \
--persona=regular \
.
.PHONY: lint/actions/zizmor
lint/mise-versions:
./scripts/check_mise_versions.sh
.PHONY: lint/mise-versions
# Verify api_key_scope enum contains all RBAC <resource>:<action> values.
lint/check-scopes: coderd/database/dump.sql | _gen/bin/check-scopes
_gen/bin/check-scopes
@@ -811,28 +821,8 @@ lint/migrations:
./scripts/check_pg_schema.sh "Fixtures" $(FIXTURE_FILES)
.PHONY: lint/migrations
TYPOS_VERSION := $(shell grep -oP 'crate-ci/typos@\S+\s+\#\s+v\K[0-9.]+' .github/workflows/ci.yaml)
# Map uname values to typos release asset names.
TYPOS_ARCH := $(shell uname -m)
# typos release assets use aarch64, but macOS ARM reports arm64 via uname -m.
ifeq ($(TYPOS_ARCH),arm64)
TYPOS_ARCH := aarch64
endif
ifeq ($(shell uname -s),Darwin)
TYPOS_OS := apple-darwin
else
TYPOS_OS := unknown-linux-musl
endif
build/typos-$(TYPOS_VERSION):
mkdir -p build/
curl -sSfL "https://github.com/crate-ci/typos/releases/download/v$(TYPOS_VERSION)/typos-v$(TYPOS_VERSION)-$(TYPOS_ARCH)-$(TYPOS_OS).tar.gz" \
| tar -xzf - -C build/ ./typos
mv build/typos "$@"
lint/typos: build/typos-$(TYPOS_VERSION)
build/typos-$(TYPOS_VERSION) --config .github/workflows/typos.toml
lint/typos:
typos --config .github/workflows/typos.toml
.PHONY: lint/typos
# pre-commit and pre-push mirror CI checks locally.
+12 -8
View File
@@ -975,15 +975,19 @@ func (m *Manager) createTransport(ctx context.Context, cfg ServerConfig) (transp
}),
), nil
case "http", "":
return transport.NewStreamableHTTP(
cfg.URL,
transport.WithHTTPHeaders(cfg.Headers),
)
var opts []transport.StreamableHTTPCOption
opts = append(opts, transport.WithHTTPHeaders(cfg.Headers))
if c := mcpHTTPClient(); c != nil {
opts = append(opts, transport.WithHTTPBasicClient(c))
}
return transport.NewStreamableHTTP(cfg.URL, opts...)
case "sse":
return transport.NewSSE(
cfg.URL,
transport.WithHeaders(cfg.Headers),
)
var sseOpts []transport.ClientOption
sseOpts = append(sseOpts, transport.WithHeaders(cfg.Headers))
if c := mcpHTTPClient(); c != nil {
sseOpts = append(sseOpts, transport.WithHTTPClient(c))
}
return transport.NewSSE(cfg.URL, sseOpts...)
default:
return nil, xerrors.Errorf("unsupported transport %q", cfg.Transport)
}
+25
View File
@@ -0,0 +1,25 @@
package agentmcp
import (
"net/http"
"testing"
)
// mcpHTTPClient returns an isolated *http.Client when running
// inside tests, or nil for production. During tests,
// httptest.Server.Close() calls
// http.DefaultTransport.CloseIdleConnections(), which disrupts
// any MCP client sharing that transport. When DefaultTransport
// is a *http.Transport it is cloned; otherwise a minimal
// transport with ProxyFromEnvironment is created as a fallback.
func mcpHTTPClient() *http.Client {
if !testing.Testing() {
return nil
}
if dt, ok := http.DefaultTransport.(*http.Transport); ok {
return &http.Client{Transport: dt.Clone()}
}
return &http.Client{Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
}}
}
+8
View File
@@ -57,6 +57,14 @@ func NewCopilotProvider(cfg config.Copilot) provider.Provider {
return provider.NewCopilot(cfg)
}
// NewDisabledProviderStub returns a Provider that reports Enabled() ==
// false and has no-op implementations for all other methods. Use this
// instead of constructing a concrete provider for disabled rows so that
// adding a new provider type does not require updating a switch here.
func NewDisabledProviderStub(name, providerType string) provider.Provider {
return provider.NewDisabledStub(name, providerType)
}
func NewMetrics(reg prometheus.Registerer) *metrics.Metrics {
return metrics.NewMetrics(reg)
}
+52 -9
View File
@@ -20,6 +20,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/aibridge/circuitbreaker"
aibcontext "github.com/coder/coder/v2/aibridge/context"
"github.com/coder/coder/v2/aibridge/intercept"
"github.com/coder/coder/v2/aibridge/mcp"
"github.com/coder/coder/v2/aibridge/metrics"
"github.com/coder/coder/v2/aibridge/provider"
@@ -30,6 +31,11 @@ import (
const (
// The duration after which an async recording will be aborted.
recordingTimeout = time.Second * 5
// ErrorCodeProviderDisabled is the code written in the response
// body when a request targets a configured-but-disabled provider.
// Paired with HTTP 503.
ErrorCodeProviderDisabled = "provider_disabled"
)
// RequestBridge is an [http.Handler] which is capable of masquerading as AI providers' APIs;
@@ -96,6 +102,14 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re
mux := http.NewServeMux()
for _, prov := range providers {
// Disabled providers serve a 503 sentinel on every path under
// "/<name>/". Bound to the bare name (not RoutePrefix) so paths
// outside the provider's normal "/v1" subtree are also caught.
if !prov.Enabled() {
prefix := fmt.Sprintf("/%s/", prov.Name())
mux.HandleFunc(prefix, disabledProviderHandler(prov.Name(), logger))
continue
}
// Create per-provider circuit breaker if configured
cfg := prov.CircuitBreakerConfig()
providerName := prov.Name()
@@ -170,6 +184,20 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re
}, nil
}
// disabledProviderHandler returns 503 with a body containing
// [ErrorCodeProviderDisabled] and the provider name for every request
// targeting name.
func disabledProviderHandler(name string, logger slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger.Debug(r.Context(), "refusing request for disabled ai provider",
slog.F("provider", name),
slog.F("path", r.URL.Path),
slog.F("method", r.Method),
)
http.Error(w, fmt.Sprintf("%s: AI provider %q is disabled", ErrorCodeProviderDisabled, name), http.StatusServiceUnavailable)
}
}
// newInterceptionProcessor returns an [http.HandlerFunc] which is capable of creating a new interceptor and processing a given request
// using [Provider] p, recording all usage events using [Recorder] rec.
// If cbs is non-nil, circuit breaker protection is applied per endpoint/model tuple.
@@ -248,11 +276,18 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC
slog.F("user_agent", r.UserAgent()),
slog.F("streaming", interceptor.Streaming()),
slog.F("credential_kind", string(cred.Kind)),
slog.F("credential_hint", cred.Hint),
slog.F("credential_length", cred.Length),
)
log.Debug(ctx, "interception started")
// Log BYOK credentials. Centralized credentials are set by
// the key failover loop.
credLogFields := []slog.Field{}
if cred.Kind == intercept.CredentialKindBYOK {
credLogFields = append(credLogFields,
slog.F("credential_hint", cred.Hint),
slog.F("credential_length", cred.Length),
)
}
log.Debug(ctx, "interception started", credLogFields...)
if m != nil {
m.InterceptionsInflight.WithLabelValues(p.Name(), interceptor.Model(), route).Add(1)
defer func() {
@@ -261,22 +296,30 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC
}
// Process request with circuit breaker protection if configured
if err := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error {
execErr := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error {
return interceptor.ProcessRequest(rw, r)
}); err != nil {
})
// For centralized, the hint now reflects the last attempted
// key from the failover loop.
credHint := interceptor.Credential().Hint
credLen := interceptor.Credential().Length
if execErr != nil {
if m != nil {
m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusFailed, route, r.Method, actor.ID, string(client)).Add(1)
}
span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", err))
log.Warn(ctx, "interception failed", slog.Error(err))
span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", execErr))
log.Warn(ctx, "interception failed", slog.Error(execErr), slog.F("credential_hint", credHint), slog.F("credential_length", credLen))
} else {
if m != nil {
m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusCompleted, route, r.Method, actor.ID, string(client)).Add(1)
}
log.Debug(ctx, "interception ended")
log.Debug(ctx, "interception ended", slog.F("credential_hint", credHint), slog.F("credential_length", credLen))
}
_ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{ID: interceptor.ID().String()})
_ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{
ID: interceptor.ID().String(),
CredentialHint: credHint,
})
// Ensure all recording have completed before completing request.
asyncRecorder.Wait()
+55
View File
@@ -205,3 +205,58 @@ func TestPassthroughRoutesForProviders(t *testing.T) {
})
}
}
// TestDisabledProviderHandler asserts that requests to a disabled
// provider return a 503 with an ErrorCodeProviderDisabled body and
// that a sibling enabled provider keeps routing normally.
func TestDisabledProviderHandler(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("upstream-reached"))
}))
t.Cleanup(upstream.Close)
enabled := aibridge.NewOpenAIProvider(config.OpenAI{Name: "enabled-openai", BaseURL: upstream.URL})
disabled := aibridge.NewDisabledProviderStub("disabled-openai", "openai")
bridge, err := aibridge.NewRequestBridge(
t.Context(),
[]provider.Provider{enabled, disabled},
nil, nil, logger, nil, bridgeTestTracer,
)
require.NoError(t, err)
for _, tc := range []struct {
name string
path string
}{
{name: "Bridged", path: "/disabled-openai/v1/chat/completions"},
{name: "Passthrough", path: "/disabled-openai/v1/models"},
{name: "Unknown", path: "/disabled-openai/anything/else"},
} {
t.Run("DisabledProviderReturnsSentinel/"+tc.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodPost, tc.path, nil)
resp := httptest.NewRecorder()
bridge.ServeHTTP(resp, req)
assert.Equal(t, http.StatusServiceUnavailable, resp.Code)
assert.Contains(t, resp.Body.String(), aibridge.ErrorCodeProviderDisabled)
assert.Contains(t, resp.Body.String(), "disabled-openai")
})
}
t.Run("EnabledProviderUnaffected", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/enabled-openai/v1/models", nil)
resp := httptest.NewRecorder()
bridge.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, "upstream-reached", resp.Body.String())
})
}
@@ -291,15 +291,16 @@ func (i *BlockingInterception) newChatCompletionWithKey(ctx context.Context, svc
// 401/403. Errors that aren't key-specific don't trigger
// failover and are returned to the caller.
func (i *BlockingInterception) newChatCompletionWithKeyFailover(ctx context.Context, svc openai.ChatCompletionService, opts []option.RequestOption) (*openai.ChatCompletion, error) {
// TODO(ssncferreira): update the interception's credential
// hint with the actually-used key (the successful key on
// success, the last tried key on failure) in the upstack PR.
walker := i.cfg.KeyPool.Walker()
for {
key, keyPoolErr := walker.Next()
if keyPoolErr != nil {
return nil, keyPoolErr
}
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
i.logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
requestOpts := append([]option.RequestOption{}, opts...)
requestOpts = append(requestOpts,
@@ -72,31 +72,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning 200.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {statusCode: http.StatusOK, body: successBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -104,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -120,15 +125,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -136,25 +142,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s.
// Then: 3 requests, 429 response with smallest Retry-After,
// all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -168,15 +175,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -184,14 +192,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -199,6 +208,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -219,9 +229,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -252,6 +263,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
var pool *keypool.Pool
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
if len(tc.keys) > 0 {
var err error
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
@@ -259,6 +271,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
interceptor := NewBlockingInterceptor(
@@ -269,7 +282,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
http.Header{},
"Authorization",
otel.Tracer("blocking_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -288,6 +301,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
@@ -309,6 +323,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
expectedSeenKeys []string
expectedStatusCode int
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -319,12 +336,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: textCompleteBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -342,12 +360,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: textCompleteBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -369,12 +388,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusTooManyRequests,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -409,7 +429,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.OpenAI{
@@ -459,6 +479,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
defer seenKeysMu.Unlock()
assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys")
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
@@ -164,6 +164,11 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re
break
}
currentKey = key
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
opts = append(opts,
option.WithAPIKey(key.Value()),
// Disable SDK retries because the failover
@@ -144,36 +144,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning a successful stream.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -185,16 +189,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -206,15 +211,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -226,6 +232,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 pre-stream with
@@ -233,19 +240,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
// Then: 3 requests, 429 response with smallest
// Retry-After, all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -259,15 +266,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401 pre-stream.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -275,14 +283,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500 pre-stream.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -290,6 +299,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -310,9 +320,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -342,6 +353,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
var pool *keypool.Pool
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
if len(tc.keys) > 0 {
var err error
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
@@ -349,6 +361,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
interceptor := NewStreamingInterceptor(
@@ -359,7 +372,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
http.Header{},
"Authorization",
otel.Tracer("streaming_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -378,6 +391,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
@@ -435,6 +449,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
// error (e.g. all keys exhausted).
expectedErr bool
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -445,13 +462,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedBodyContains: "done",
expectErrorAsSSEEvent: false,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -469,13 +487,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "done",
expectErrorAsSSEEvent: false,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -497,7 +516,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "all configured keys are rate-limited",
expectErrorAsSSEEvent: true,
expectedErr: true,
@@ -505,6 +524,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -538,7 +558,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.OpenAI{
@@ -596,6 +616,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
defer seenKeysMu.Unlock()
assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys")
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
+4 -3
View File
@@ -367,15 +367,16 @@ func (i *BlockingInterception) newMessageWithKey(ctx context.Context, svc anthro
// Errors that aren't key-specific don't trigger failover and
// are returned to the caller.
func (i *BlockingInterception) newMessageWithKeyFailover(ctx context.Context, svc anthropic.MessageService) (*anthropic.Message, error) {
// TODO(ssncferreira): update the interception's credential
// hint with the actually-used key (the successful key on
// success, the last tried key on failure) in the upstack PR.
walker := i.cfg.KeyPool.Walker()
for {
key, keyPoolErr := walker.Next()
if keyPoolErr != nil {
return nil, keyPoolErr
}
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
i.logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
msg, err := i.newMessageWithKey(ctx, svc,
option.WithAPIKey(key.Value()),
@@ -19,6 +19,7 @@ import (
"github.com/coder/coder/v2/aibridge/internal/testutil"
"github.com/coder/coder/v2/aibridge/keypool"
"github.com/coder/coder/v2/aibridge/mcp"
"github.com/coder/coder/v2/aibridge/utils"
"github.com/coder/quartz"
)
@@ -54,31 +55,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning 200.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {statusCode: http.StatusOK, body: successBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -86,15 +91,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -102,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -118,25 +125,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s.
// Then: 3 requests, 429 response with smallest Retry-After,
// all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -150,15 +158,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -166,14 +175,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -181,6 +191,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -201,9 +212,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -234,6 +246,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
cfg := config.Anthropic{BaseURL: upstream.URL + "/"}
var pool *keypool.Pool
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
if len(tc.keys) > 0 {
var err error
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
@@ -241,6 +254,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
payload, err := NewRequestPayload([]byte(requestBody))
@@ -255,7 +269,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
http.Header{},
"X-Api-Key",
otel.Tracer("blocking_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -271,6 +285,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
@@ -296,6 +311,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
expectedStatusCode int
expectedRetryAfter string
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -306,12 +324,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -329,12 +348,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -356,13 +376,14 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "3",
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -397,7 +418,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.Anthropic{
@@ -447,6 +468,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
seenKeysMu.Lock()
defer seenKeysMu.Unlock()
+5
View File
@@ -195,6 +195,11 @@ newStream:
break
}
currentKey = key
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
streamOpts = append(streamOpts,
option.WithAPIKey(key.Value()),
// Disable SDK retries because the failover
@@ -21,6 +21,7 @@ import (
"github.com/coder/coder/v2/aibridge/internal/testutil"
"github.com/coder/coder/v2/aibridge/keypool"
"github.com/coder/coder/v2/aibridge/mcp"
"github.com/coder/coder/v2/aibridge/utils"
"github.com/coder/quartz"
)
@@ -60,36 +61,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning a successful stream.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -101,16 +106,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -122,15 +128,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -142,6 +149,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 pre-stream with
@@ -149,19 +157,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
// Then: 3 requests, 429 response with smallest
// Retry-After, all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -175,15 +183,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401 pre-stream.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -191,14 +200,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500 pre-stream.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -206,6 +216,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -226,9 +237,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -258,6 +270,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
cfg := config.Anthropic{BaseURL: upstream.URL + "/"}
var pool *keypool.Pool
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
if len(tc.keys) > 0 {
var err error
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
@@ -265,6 +278,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
payload, err := NewRequestPayload([]byte(requestBody))
@@ -279,7 +293,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
http.Header{},
"X-Api-Key",
otel.Tracer("streaming_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -301,6 +315,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
@@ -387,6 +402,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
// error (e.g. all keys exhausted).
expectedErr bool
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -397,13 +415,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedBodyContains: "done",
expectErrorAsSSEEvent: false,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -421,13 +440,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "done",
expectErrorAsSSEEvent: false,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -453,7 +473,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "all configured keys are rate-limited",
expectErrorAsSSEEvent: true,
expectedErr: true,
@@ -461,6 +481,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -494,7 +515,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.Anthropic{
@@ -553,6 +574,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
defer seenKeysMu.Unlock()
assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys")
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
+4 -3
View File
@@ -171,15 +171,16 @@ func (i *BlockingResponsesInterceptor) newResponseWithKey(ctx context.Context, s
// Errors that aren't key-specific don't trigger failover and
// are returned to the caller.
func (i *BlockingResponsesInterceptor) newResponseWithKeyFailover(ctx context.Context, srv responses.ResponseService, opts []option.RequestOption) (*responses.Response, error) {
// TODO(ssncferreira): update the interception's credential
// hint with the actually-used key (the successful key on
// success, the last tried key on failure) in the upstack PR.
walker := i.cfg.KeyPool.Walker()
for {
key, keyPoolErr := walker.Next()
if keyPoolErr != nil {
return nil, keyPoolErr
}
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
i.logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
requestOpts := append([]option.RequestOption{}, opts...)
requestOpts = append(requestOpts,
@@ -58,31 +58,35 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning 200.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {statusCode: http.StatusOK, body: successBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -90,15 +94,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -106,15 +111,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -122,25 +128,26 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s.
// Then: 3 requests, 429 response with smallest Retry-After,
// all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -154,15 +161,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -170,14 +178,15 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -185,6 +194,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -204,8 +214,9 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -235,6 +246,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
t.Cleanup(upstream.Close)
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
var pool *keypool.Pool
if len(tc.keys) > 0 {
var err error
@@ -243,6 +255,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
payload, err := NewRequestPayload([]byte(requestBody))
@@ -256,7 +269,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
http.Header{},
"Authorization",
otel.Tracer("blocking_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -272,6 +285,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
@@ -296,6 +310,9 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
expectedSeenKeys []string
expectedStatusCode int
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -306,12 +323,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: textCompleteBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -329,12 +347,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: textCompleteBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -356,12 +375,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusTooManyRequests,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -396,7 +416,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.OpenAI{
@@ -444,6 +464,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
seenKeysMu.Lock()
defer seenKeysMu.Unlock()
@@ -144,6 +144,11 @@ func (i *StreamingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r
return xerrors.Errorf("key pool exhausted: %w", keyPoolErr)
}
currentKey = key
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
i.logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
opts = append(opts,
option.WithAPIKey(key.Value()),
// Disable SDK retries because the failover
@@ -51,36 +51,40 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning a successful stream.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -92,16 +96,17 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -113,15 +118,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -133,6 +139,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 pre-stream with
@@ -140,19 +147,19 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
// Then: 3 requests, 429 response with smallest
// Retry-After, all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -166,15 +173,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401 pre-stream.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -182,14 +190,15 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500 pre-stream.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -197,6 +206,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -216,8 +226,9 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -246,6 +257,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
t.Cleanup(upstream.Close)
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
var pool *keypool.Pool
if len(tc.keys) > 0 {
var err error
@@ -254,6 +266,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
payload, err := NewRequestPayload([]byte(streamingRequestBody))
@@ -267,7 +280,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
http.Header{},
"Authorization",
otel.Tracer("streaming_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -283,6 +296,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
@@ -339,6 +353,9 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
// error (e.g. all keys exhausted).
expectedErr bool
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -349,12 +366,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedBodyContains: "done",
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -372,12 +390,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "done",
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -399,13 +418,14 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "all configured keys are rate-limited",
expectedErr: true,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -439,7 +459,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.OpenAI{
@@ -489,6 +509,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
body := w.Body.String()
assert.Contains(t, body, tc.expectedBodyContains, "response body")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
seenKeysMu.Lock()
defer seenKeysMu.Unlock()
@@ -15,6 +15,7 @@ import (
type MockProvider struct {
NameStr string
URL string
Disabled bool
Bridged []string
Passthrough []string
InterceptorFunc func(w http.ResponseWriter, r *http.Request, tracer trace.Tracer) (intercept.Interceptor, error)
@@ -22,6 +23,7 @@ type MockProvider struct {
func (m *MockProvider) Type() string { return m.NameStr }
func (m *MockProvider) Name() string { return m.NameStr }
func (m *MockProvider) Enabled() bool { return !m.Disabled }
func (m *MockProvider) BaseURL() string { return m.URL }
func (m *MockProvider) RoutePrefix() string { return fmt.Sprintf("/%s", m.NameStr) }
func (m *MockProvider) BridgedRoutes() []string { return m.Bridged }
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"net/http"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/aibridge/utils"
)
// MarkKeyOnStatus marks key based on a key-specific HTTP
@@ -32,7 +31,7 @@ func MarkKeyOnStatus(
if key.MarkTemporary(cooldown) {
logger.Info(ctx, "key marked temporary",
slog.F("provider", providerName),
slog.F("api_key_hint", utils.MaskSecret(key.Value())),
slog.F("api_key_hint", key.Hint()),
slog.F("status", statusCode),
slog.F("cooldown", cooldown))
}
@@ -41,7 +40,7 @@ func MarkKeyOnStatus(
if key.MarkPermanent() {
logger.Warn(ctx, "key marked permanent",
slog.F("provider", providerName),
slog.F("api_key_hint", utils.MaskSecret(key.Value())),
slog.F("api_key_hint", key.Hint()),
slog.F("status", statusCode))
}
return true
+7
View File
@@ -7,6 +7,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/aibridge/utils"
"github.com/coder/quartz"
)
@@ -116,6 +117,12 @@ func (k *Key) Value() string {
return k.value
}
// Hint returns a masked, identifiable fragment of the key, suitable
// for logs and persisted records.
func (k *Key) Hint() string {
return utils.MaskSecret(k.value)
}
// State returns the current state of the key, derived from its
// permanent flag and cooldown deadline.
func (k *Key) State() KeyState {
+25
View File
@@ -0,0 +1,25 @@
package mcp
import (
"net/http"
"testing"
)
// mcpHTTPClient returns an isolated *http.Client when running
// inside tests, or nil for production. During tests,
// httptest.Server.Close() calls
// http.DefaultTransport.CloseIdleConnections(), which disrupts
// any MCP client sharing that transport. When DefaultTransport
// is a *http.Transport it is cloned; otherwise a minimal
// transport with ProxyFromEnvironment is created as a fallback.
func mcpHTTPClient() *http.Client {
if !testing.Testing() {
return nil
}
if dt, ok := http.DefaultTransport.(*http.Transport); ok {
return &http.Client{Transport: dt.Clone()}
}
return &http.Client{Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
}}
}
+11
View File
@@ -39,6 +39,17 @@ func NewStreamableHTTPServerProxy(serverName, serverURL string, headers map[stri
opts = append(opts, transport.WithHTTPHeaders(headers))
}
// Prepend an isolated HTTP client when running in tests so
// httptest.Server.Close() does not disrupt this proxy's
// connections via http.DefaultTransport.CloseIdleConnections().
// Caller-provided WithHTTPBasicClient in opts overrides this
// (last-wins).
if c := mcpHTTPClient(); c != nil {
opts = append([]transport.StreamableHTTPCOption{
transport.WithHTTPBasicClient(c),
}, opts...)
}
mcpClient, err := client.NewStreamableHttpClient(serverURL, opts...)
if err != nil {
return nil, xerrors.Errorf("create streamable http client: %w", err)
+5 -8
View File
@@ -95,6 +95,8 @@ func (p *Anthropic) Name() string {
return p.cfg.Name
}
func (*Anthropic) Enabled() bool { return true }
func (p *Anthropic) RoutePrefix() string {
return fmt.Sprintf("/%s", p.Name())
}
@@ -168,15 +170,10 @@ func (p *Anthropic) CreateInterceptor(_ http.ResponseWriter, r *http.Request, tr
authHeaderName = "Authorization"
credKind = intercept.CredentialKindBYOK
credSecret = token
} else if cfg.KeyPool != nil {
// Centralized: use the first key as a placeholder hint.
// TODO(ssncferreira): record the actually-used key in
// the interception record to reflect failover.
if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil {
credSecret = key.Value()
}
}
// Centralized leaves credSecret empty: the hint is set by the
// failover loop on each key attempt and persisted at
// end-of-interception.
cred := intercept.NewCredentialInfo(credKind, credSecret)
var interceptor intercept.Interceptor
+3 -1
View File
@@ -257,7 +257,9 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) {
setHeaders: map[string]string{},
wantXApiKey: "test-key",
wantCredentialKind: intercept.CredentialKindCentralized,
wantCredentialHint: "t...y",
// Centralized hint is empty at CreateInterceptor; set
// by the key failover loop during ProcessRequest.
wantCredentialHint: "",
},
{
name: "Messages_BYOK_BearerToken_And_APIKey",
+2
View File
@@ -78,6 +78,8 @@ func (p *Copilot) Name() string {
return p.cfg.Name
}
func (*Copilot) Enabled() bool { return true }
func (p *Copilot) BaseURL() string {
return p.cfg.BaseURL
}
+47
View File
@@ -0,0 +1,47 @@
package provider
import (
"fmt"
"net/http"
"go.opentelemetry.io/otel/trace"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/aibridge/config"
"github.com/coder/coder/v2/aibridge/intercept"
"github.com/coder/coder/v2/aibridge/keypool"
)
// DisabledStub is a Provider placeholder for a configured-but-disabled
// provider. Only Name and Enabled return meaningful values; all other
// methods return empty/nil so the stub never influences routing.
type DisabledStub struct {
name string
providerType string
}
// NewDisabledStub returns a Provider stub that reports Enabled() == false.
// The type string is preserved so callers can distinguish provider families.
func NewDisabledStub(name, providerType string) *DisabledStub {
return &DisabledStub{name: name, providerType: providerType}
}
func (d *DisabledStub) Type() string { return d.providerType }
func (d *DisabledStub) Name() string { return d.name }
func (*DisabledStub) Enabled() bool { return false }
func (*DisabledStub) BaseURL() string { return "" }
func (d *DisabledStub) RoutePrefix() string {
return fmt.Sprintf("/%s", d.name)
}
func (*DisabledStub) BridgedRoutes() []string { return nil }
func (*DisabledStub) PassthroughRoutes() []string { return nil }
func (*DisabledStub) AuthHeader() string { return "" }
func (*DisabledStub) KeyFailoverConfig(_ slog.Logger) keypool.KeyFailoverConfig {
return keypool.KeyFailoverConfig{}
}
func (*DisabledStub) CircuitBreakerConfig() *config.CircuitBreaker { return nil }
func (*DisabledStub) APIDumpDir() string { return "" }
func (*DisabledStub) CreateInterceptor(_ http.ResponseWriter, _ *http.Request, _ trace.Tracer) (intercept.Interceptor, error) {
//nolint:nilnil // disabled providers never reach the interceptor.
return nil, nil
}
+5 -7
View File
@@ -84,6 +84,8 @@ func (p *OpenAI) Name() string {
return p.cfg.Name
}
func (*OpenAI) Enabled() bool { return true }
func (p *OpenAI) RoutePrefix() string {
// Route prefix includes version to match default OpenAI base URL.
// More detailed explanation: https://github.com/coder/aibridge/pull/174#discussion_r2782320152
@@ -141,14 +143,10 @@ func (p *OpenAI) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trace
cfg.KeyPool = nil
credKind = intercept.CredentialKindBYOK
credSecret = token
} else if cfg.KeyPool != nil {
// Centralized: use the first key as a placeholder hint.
// TODO(ssncferreira): record the actually-used key in
// the interception record to reflect failover.
if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil {
credSecret = key.Value()
}
}
// Centralized leaves credSecret empty: the hint is set by the
// failover loop on each key attempt and persisted at
// end-of-interception.
cred := intercept.NewCredentialInfo(credKind, credSecret)
path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix())
+6 -2
View File
@@ -229,7 +229,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) {
setHeaders: map[string]string{},
wantAuthorization: "Bearer centralized-key",
wantCredentialKind: intercept.CredentialKindCentralized,
wantCredentialHint: "ce...ey",
// Centralized hint is empty at CreateInterceptor; set
// by the key failover loop during ProcessRequest.
wantCredentialHint: "",
},
{
name: "Responses_BYOK",
@@ -249,7 +251,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) {
setHeaders: map[string]string{},
wantAuthorization: "Bearer centralized-key",
wantCredentialKind: intercept.CredentialKindCentralized,
wantCredentialHint: "ce...ey",
// Centralized hint is empty at CreateInterceptor; set
// by the key failover loop during ProcessRequest.
wantCredentialHint: "",
},
// X-Api-Key should not appear in production since clients use Authorization,
// but ensure it is stripped if it does arrive.
+2
View File
@@ -53,6 +53,8 @@ type Provider interface {
// Name returns the provider instance name.
// Defaults to Type() when not explicitly configured.
Name() string
// Enabled reports whether the provider should serve requests.
Enabled() bool
// BaseURL defines the base URL endpoint for this provider's API.
BaseURL() string
+9 -2
View File
@@ -39,13 +39,20 @@ type InterceptionRecord struct {
Client string
UserAgent string
CorrelatingToolCallID *string
CredentialKind string
CredentialHint string
// CredentialKind is always set: either BYOK or centralized.
CredentialKind string
// CredentialHint is only set for BYOK, where the key is known
// from the request. Centralized uses key failover, so the hint
// can only be determined at end-of-interception.
CredentialHint string
}
type InterceptionRecordEnded struct {
ID string
EndedAt time.Time
// CredentialHint is the hint observed at end-of-interception.
// Only applied to the DB row for centralized; ignored for BYOK.
CredentialHint string
}
type TokenUsageRecord struct {
+3 -1
View File
@@ -146,8 +146,10 @@ func TestWorkspaceAgent(t *testing.T) {
}).WithAgent().Do()
coderURLEnv := "$CODER_URL"
headerCmd := "printf X-Process-Testing=very-wow-" + coderURLEnv + "'\\r\\n'X-Process-Testing2=more-wow"
if runtime.GOOS == "windows" {
coderURLEnv = "%CODER_URL%"
headerCmd = "echo X-Process-Testing=very-wow-" + coderURLEnv + "& echo X-Process-Testing2=more-wow"
}
logDir := t.TempDir()
@@ -159,7 +161,7 @@ func TestWorkspaceAgent(t *testing.T) {
"--log-dir", logDir,
"--agent-header", "X-Testing=agent",
"--agent-header", "Cool-Header=Ethan was Here!",
"--agent-header-command", "printf X-Process-Testing=very-wow-"+coderURLEnv+"'\\r\\n'X-Process-Testing2=more-wow",
"--agent-header-command", headerCmd,
"--socket-path", testutil.AgentSocketPath(t),
)
clitest.Start(t, agentInv)
+37 -12
View File
@@ -4,6 +4,7 @@ package cli
import (
"context"
"slices"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
@@ -101,10 +102,18 @@ func (r *poolDBReloader) Reload(ctx context.Context) error {
return nil
}
// BuildProviders loads every ai_providers row (including disabled)
// and returns the active provider list plus per-row outcomes. Per-row
// build errors are logged and excluded from providers but recorded in
// outcomes; only DB query failures propagate.
// BuildProviders loads all ai_providers rows (enabled and disabled),
// attaches keys to enabled rows, and constructs the equivalent
// [aibridge.Provider] instances. The database is the single source of
// truth for runtime provider configuration.
//
// Disabled rows produce a Provider stub with Enabled() == false so the
// bridge can answer requests targeting them with a 503 sentinel.
//
// Per-provider construction errors are logged and the offending row is
// excluded from the returned snapshot; only a failure of the DB query
// itself is propagated. This keeps a single misconfigured row from
// taking the whole daemon down.
func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridgeConfig, logger slog.Logger) ([]aibridge.Provider, []aibridged.ProviderOutcome, error) {
//nolint:gocritic // AsAIBridged has a minimal permission set for this purpose.
authCtx := dbauthz.AsAIBridged(ctx)
@@ -160,12 +169,9 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
Name: row.Name,
Type: string(row.Type),
}
if !row.Enabled {
outcome.Status = aibridged.ProviderStatusDisabled
outcomes = append(outcomes, outcome)
continue
if row.Enabled {
enabledCount++
}
enabledCount++
prov, err := buildAIProviderFromRow(row, keysByProvider[row.ID], cfg)
if err != nil {
outcome.Status = aibridged.ProviderStatusError
@@ -179,13 +185,17 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
)
continue
}
outcome.Status = aibridged.ProviderStatusEnabled
if row.Enabled {
outcome.Status = aibridged.ProviderStatusEnabled
} else {
outcome.Status = aibridged.ProviderStatusDisabled
}
outcomes = append(outcomes, outcome)
providers = append(providers, prov)
}
if enabledCount > 0 && len(providers) == 0 {
logger.Warn(ctx, "all enabled ai providers failed to build; daemon will start with zero providers")
if enabledCount > 0 && !slices.ContainsFunc(providers, func(p aibridge.Provider) bool { return p.Enabled() }) {
logger.Warn(ctx, "all enabled ai providers failed to build; only disabled providers remain")
}
return providers, outcomes, nil
@@ -193,11 +203,18 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
// buildAIProviderFromRow decodes the settings blob and constructs the
// appropriate [aibridge.Provider] for a single ai_providers row.
// Disabled rows return a Provider stub carrying only Name and
// Disabled: true; settings decode, key loading, and credential checks
// are skipped because the provider will never call upstream.
func buildAIProviderFromRow(
row database.AIProvider,
keys []database.AIProviderKey,
cfg codersdk.AIBridgeConfig,
) (aibridge.Provider, error) {
if !row.Enabled {
return disabledProviderFromRow(row)
}
settings, err := db2sdk.AIProviderSettings(row.Settings)
if err != nil {
return nil, xerrors.Errorf("decode settings: %w", err)
@@ -287,6 +304,14 @@ func buildAIProviderFromRow(
}
}
// disabledProviderFromRow builds a Provider stub for a disabled row.
// Using provider.DisabledStub rather than a concrete provider avoids
// duplicating the row.Type switch and ensures that a new AiProviderType
// value is automatically handled without requiring a matching case here.
func disabledProviderFromRow(row database.AIProvider) (aibridge.Provider, error) {
return aibridge.NewDisabledProviderStub(row.Name, string(row.Type)), nil
}
// buildAIProviderKeyPool builds a [keypool.Pool]. Callers must check
// len(keys) > 0 first; keypool.New rejects empty input.
func buildAIProviderKeyPool(keys []database.AIProviderKey) (*keypool.Pool, error) {
+52 -17
View File
@@ -393,25 +393,60 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) {
t.Run("DisabledRowClassifiedAsDisabled", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, nil)
dbgen.AIProvider(t, db, database.AIProvider{
Type: database.AiProviderTypeOpenai,
Name: "openai-off",
BaseUrl: "https://api.openai.com/",
}, func(p *database.InsertAIProviderParams) {
p.Enabled = false
})
for _, tc := range []struct {
name string
row database.AIProvider
}{
{
name: "OpenAI",
row: database.AIProvider{
Type: database.AiProviderTypeOpenai,
Name: "openai-off",
BaseUrl: "https://api.openai.com/",
},
},
{
// Anthropic and Bedrock have stricter credential checks
// than the OpenAI family; the disabled short-circuit
// must reach them too. No keys, no bedrock settings.
name: "Anthropic",
row: database.AIProvider{
Type: database.AiProviderTypeAnthropic,
Name: "anthropic-off",
BaseUrl: "https://api.anthropic.com/",
},
},
{
name: "Bedrock",
row: database.AIProvider{
Type: database.AiProviderTypeBedrock,
Name: "bedrock-off",
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",
},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, nil)
providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
require.NoError(t, err)
assert.Empty(t, providers, "disabled providers must not be in the active snapshot")
require.Len(t, outcomes, 1)
assert.Equal(t, "openai-off", outcomes[0].Name)
assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status)
assert.NoError(t, outcomes[0].Err)
dbgen.AIProvider(t, db, tc.row, func(p *database.InsertAIProviderParams) {
p.Enabled = false
})
providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
require.NoError(t, err)
require.Len(t, providers, 1, "disabled providers stay in the snapshot so the bridge can serve a 503 sentinel")
assert.Equal(t, tc.row.Name, providers[0].Name())
assert.False(t, providers[0].Enabled())
require.Len(t, outcomes, 1)
assert.Equal(t, tc.row.Name, outcomes[0].Name)
assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status)
assert.NoError(t, outcomes[0].Err)
})
}
})
}
+4 -3
View File
@@ -7,8 +7,8 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestMain(m *testing.M) {
@@ -17,11 +17,12 @@ func TestMain(m *testing.M) {
func TestCli(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
clitest.CreateTemplateVersionSource(t, nil)
client := coderdtest.New(t, nil)
i, config := clitest.New(t)
clitest.SetupConfig(t, client, config)
pty := ptytest.New(t).Attach(i)
stdout := expecter.NewAttachedToInvocation(t, i)
clitest.Start(t, i)
pty.ExpectMatch("coder")
stdout.ExpectMatchContext(ctx, "coder")
}
+5 -6
View File
@@ -10,8 +10,8 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/serpent"
)
@@ -21,7 +21,6 @@ func TestExternalAuth(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
ptty := ptytest.New(t)
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
var fetched atomic.Bool
@@ -42,16 +41,16 @@ func TestExternalAuth(t *testing.T) {
}
inv := cmd.Invoke().WithContext(ctx)
stdout := expecter.NewAttachedToInvocation(t, inv)
ptty.Attach(inv)
done := make(chan struct{})
go func() {
defer close(done)
err := inv.Run()
assert.NoError(t, err)
}()
ptty.ExpectMatchContext(ctx, "You must authenticate with")
ptty.ExpectMatchContext(ctx, "https://example.com/gitauth/github")
ptty.ExpectMatchContext(ctx, "Successfully authenticated with GitHub")
stdout.ExpectMatchContext(ctx, "You must authenticate with")
stdout.ExpectMatchContext(ctx, "https://example.com/gitauth/github")
stdout.ExpectMatchContext(ctx, "Successfully authenticated with GitHub")
<-done
}
+19 -20
View File
@@ -16,8 +16,8 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/serpent"
)
@@ -48,12 +48,12 @@ func TestProvisionerJob(t *testing.T) {
test.JobMutex.Unlock()
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateRunning)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateRunning)
return true
}, testutil.IntervalFast)
})
@@ -85,12 +85,12 @@ func TestProvisionerJob(t *testing.T) {
test.JobMutex.Unlock()
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.PTY.ExpectMatch("Something")
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, "Something")
test.Next <- struct{}{}
test.PTY.ExpectMatch("Something")
test.Stdout.ExpectMatchContext(ctx, "Something")
return true
}, testutil.IntervalFast)
})
@@ -151,12 +151,12 @@ func TestProvisionerJob(t *testing.T) {
test.JobMutex.Unlock()
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectRegexMatch(tc.expected)
test.Stdout.ExpectRegexMatchContext(ctx, tc.expected)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued) // step completed
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued) // step completed
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateRunning)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateRunning)
return true
}, testutil.IntervalFast)
})
@@ -193,11 +193,11 @@ func TestProvisionerJob(t *testing.T) {
test.JobMutex.Unlock()
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
test.Next <- struct{}{}
test.PTY.ExpectMatch("Gracefully canceling")
test.Stdout.ExpectMatchContext(ctx, "Gracefully canceling")
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
return true
}, testutil.IntervalFast)
})
@@ -208,7 +208,7 @@ type provisionerJobTest struct {
Job *codersdk.ProvisionerJob
JobMutex *sync.Mutex
Logs chan codersdk.ProvisionerJobLog
PTY *ptytest.PTY
Stdout *expecter.Expecter
}
func newProvisionerJob(t *testing.T) provisionerJobTest {
@@ -240,8 +240,7 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
}
inv := cmd.Invoke()
ptty := ptytest.New(t)
ptty.Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
done := make(chan struct{})
go func() {
defer close(done)
@@ -258,7 +257,7 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
Job: job,
JobMutex: &jobLock,
Logs: logs,
PTY: ptty,
Stdout: stdout,
}
}
+7 -13
View File
@@ -8,7 +8,6 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/serpent"
)
@@ -16,10 +15,9 @@ func TestSelect(t *testing.T) {
t.Parallel()
t.Run("Select", func(t *testing.T) {
t.Parallel()
ptty := ptytest.New(t)
msgChan := make(chan string)
go func() {
resp, err := newSelect(ptty, cliui.SelectOptions{
resp, err := newSelect(cliui.SelectOptions{
Options: []string{"First", "Second"},
})
assert.NoError(t, err)
@@ -29,7 +27,7 @@ func TestSelect(t *testing.T) {
})
}
func newSelect(ptty *ptytest.PTY, opts cliui.SelectOptions) (string, error) {
func newSelect(opts cliui.SelectOptions) (string, error) {
value := ""
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
@@ -39,7 +37,6 @@ func newSelect(ptty *ptytest.PTY, opts cliui.SelectOptions) (string, error) {
},
}
inv := cmd.Invoke()
ptty.Attach(inv)
return value, inv.Run()
}
@@ -47,10 +44,10 @@ func TestRichSelect(t *testing.T) {
t.Parallel()
t.Run("RichSelect", func(t *testing.T) {
t.Parallel()
ptty := ptytest.New(t)
msgChan := make(chan string)
go func() {
resp, err := newRichSelect(ptty, cliui.RichSelectOptions{
resp, err := newRichSelect(cliui.RichSelectOptions{
Options: []codersdk.TemplateVersionParameterOption{
{Name: "A-Name", Value: "A-Value", Description: "A-Description."},
{Name: "B-Name", Value: "B-Value", Description: "B-Description."},
@@ -63,7 +60,7 @@ func TestRichSelect(t *testing.T) {
})
}
func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, error) {
func newRichSelect(opts cliui.RichSelectOptions) (string, error) {
value := ""
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
@@ -75,7 +72,6 @@ func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, err
},
}
inv := cmd.Invoke()
ptty.Attach(inv)
return value, inv.Run()
}
@@ -181,11 +177,10 @@ func TestMultiSelect(t *testing.T) {
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)
resp, err := newMultiSelect(tt.items, tt.allowCustom)
assert.NoError(t, err)
msgChan <- resp
}()
@@ -195,7 +190,7 @@ func TestMultiSelect(t *testing.T) {
}
}
func newMultiSelect(pty *ptytest.PTY, items []string, custom bool) ([]string, error) {
func newMultiSelect(items []string, custom bool) ([]string, error) {
var values []string
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
@@ -211,6 +206,5 @@ func newMultiSelect(pty *ptytest.PTY, items []string, custom bool) ([]string, er
},
}
inv := cmd.Invoke()
pty.Attach(inv)
return values, inv.Run()
}
+9 -2
View File
@@ -229,8 +229,15 @@ func Test_sshConfigMatchExecEscape(t *testing.T) {
// OpenSSH processes %% escape sequences into %
escaped = strings.ReplaceAll(escaped, "%%", "%")
b, err := exec.Command(cmd, arg, escaped).CombinedOutput() //nolint:gosec
require.NoError(t, err)
c := exec.Command(cmd, arg, escaped) //nolint:gosec
if runtime.GOOS == "windows" {
// Deduplicate Path/PATH env vars so cmd.exe
// subprocesses (like powershell.exe used for
// paths with spaces) resolve correctly.
c.Env = appendAndDedupEnv(os.Environ())
}
b, err := c.CombinedOutput()
require.NoError(t, err, "command output: %s", string(b))
got := strings.TrimSpace(string(b))
require.Equal(t, "yay", got)
})
+13 -12
View File
@@ -24,8 +24,8 @@ import (
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func sshConfigFileName(t *testing.T) (sshConfig string) {
@@ -64,6 +64,8 @@ func TestConfigSSH(t *testing.T) {
t.Skip("See coder/internal#117")
}
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
const hostname = "test-coder."
const expectedKey = "ConnectionAttempts"
const removeKey = "ConnectTimeout"
@@ -131,9 +133,8 @@ func TestConfigSSH(t *testing.T) {
"--ssh-config-file", sshConfigFile,
"--skip-proxy-command")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t)
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
waiter := clitest.StartWithWaiter(t, inv)
@@ -143,8 +144,8 @@ func TestConfigSSH(t *testing.T) {
{match: "Continue?", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
pty.WriteLine(m.write)
stdout.ExpectMatchContext(ctx, m.match)
stdin.WriteLine(m.write)
}
waiter.RequireSuccess()
@@ -157,10 +158,8 @@ func TestConfigSSH(t *testing.T) {
home := filepath.Dir(filepath.Dir(sshConfigFile))
// #nosec
sshCmd := exec.Command("ssh", "-F", sshConfigFile, hostname+r.Workspace.Name, "echo", "test")
pty = ptytest.New(t)
// Set HOME because coder config is included from ~/.ssh/coder.
sshCmd.Env = append(sshCmd.Env, fmt.Sprintf("HOME=%s", home))
inv.Stderr = pty.Output()
data, err := sshCmd.Output()
require.NoError(t, err)
require.Equal(t, "test", strings.TrimSpace(string(data)))
@@ -693,6 +692,8 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client, db := coderdtest.NewWithDatabase(t, nil)
user := coderdtest.CreateFirstUser(t, client)
@@ -718,8 +719,8 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
//nolint:gocritic // This has always ran with the admin user.
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
pty.Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
done := tGo(t, func() {
err := inv.Run()
if !tt.wantErr {
@@ -730,8 +731,8 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
})
for _, m := range tt.matches {
pty.ExpectMatch(m.match)
pty.WriteLine(m.write)
stdout.ExpectMatchContext(ctx, m.match)
stdin.WriteLine(m.write)
}
<-done
+9 -1
View File
@@ -4,6 +4,8 @@ package cli
import (
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/xerrors"
@@ -50,7 +52,13 @@ func sshConfigMatchExecEscape(path string) (string, error) {
if strings.ContainsAny(path, " ") {
// c.f. function comment for how this works.
path = fmt.Sprintf("for /f %%%%a in ('powershell.exe -Command [char]34') do @cmd.exe /c %%%%a%s%%%%a", path) //nolint:gocritic // We don't want %q here.
// Use absolute paths for powershell.exe and cmd.exe
// to avoid PATH resolution issues when both Path and
// PATH (MSYS-translated) exist in the environment.
sysRoot := os.Getenv("SYSTEMROOT")
pwsh := filepath.Join(sysRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
cmd := filepath.Join(sysRoot, "System32", "cmd.exe")
path = fmt.Sprintf("for /f %%%%a in ('%s -Command [char]34') do @%s /c %%%%a%s%%%%a", pwsh, cmd, path) //nolint:gocritic // We don't want %q here.
}
return path, nil
}
+184 -175
View File
@@ -20,8 +20,8 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestCreateDynamic(t *testing.T) {
@@ -74,14 +74,14 @@ func TestCreateDynamic(t *testing.T) {
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
doneChan := make(chan error)
go func() {
doneChan <- inv.Run()
}()
pty.ExpectMatchContext(ctx, "has been created")
stdout.ExpectMatchContext(ctx, "has been created")
err := testutil.RequireReceive(ctx, t, doneChan)
require.NoError(t, err)
@@ -103,14 +103,14 @@ func TestCreateDynamic(t *testing.T) {
}
inv, root = clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
pty = ptytest.New(t).Attach(inv)
stdout = expecter.NewAttachedToInvocation(t, inv)
doneChan = make(chan error)
go func() {
doneChan <- inv.Run()
}()
pty.ExpectMatchContext(ctx, "has been created")
stdout.ExpectMatchContext(ctx, "has been created")
err = testutil.RequireReceive(ctx, t, doneChan)
require.NoError(t, err)
@@ -129,7 +129,8 @@ func TestCreateDynamic(t *testing.T) {
// When enable_region=true, the region parameter becomes required and CLI should prompt.
t.Run("PromptForConditionalParam", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
ctx := testutil.Context(t, time.Hour)
logger := testutil.Logger(t)
template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
MainTF: conditionalParamTF,
@@ -143,7 +144,8 @@ func TestCreateDynamic(t *testing.T) {
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
doneChan := make(chan error)
go func() {
@@ -151,14 +153,14 @@ func TestCreateDynamic(t *testing.T) {
}()
// CLI should prompt for the region parameter since enable_region=true
pty.ExpectMatchContext(ctx, "region")
pty.WriteLine("eu-west")
stdout.ExpectMatchContext(ctx, "region")
stdin.WriteLine("eu-west")
// Confirm creation
pty.ExpectMatchContext(ctx, "Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
pty.ExpectMatchContext(ctx, "has been created")
stdout.ExpectMatchContext(ctx, "has been created")
err := <-doneChan
require.NoError(t, err)
@@ -305,14 +307,14 @@ func TestCreateDynamic(t *testing.T) {
"-y",
)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
doneChan := make(chan error)
go func() {
doneChan <- inv.Run()
}()
pty.ExpectMatchContext(ctx, "has been created")
stdout.ExpectMatchContext(ctx, "has been created")
err = <-doneChan
require.NoError(t, err, "slider=8 should succeed when max_slider=10")
@@ -331,6 +333,8 @@ func TestCreate(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -348,7 +352,8 @@ func TestCreate(t *testing.T) {
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -363,9 +368,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
stdin.WriteLine(m.write)
}
}
<-doneChan
@@ -385,6 +390,8 @@ func TestCreate(t *testing.T) {
t.Run("CreateForOtherUser", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent())
@@ -403,7 +410,8 @@ func TestCreate(t *testing.T) {
//nolint:gocritic // Creating a workspace for another user requires owner permissions.
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -418,9 +426,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
stdin.WriteLine(m.write)
}
}
<-doneChan
@@ -439,6 +447,8 @@ func TestCreate(t *testing.T) {
t.Run("CreateWithSpecificTemplateVersion", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -467,7 +477,8 @@ func TestCreate(t *testing.T) {
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -482,9 +493,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
stdin.WriteLine(m.write)
}
}
<-doneChan
@@ -506,6 +517,8 @@ func TestCreate(t *testing.T) {
t.Run("InheritStopAfterFromTemplate", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -522,7 +535,8 @@ func TestCreate(t *testing.T) {
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
waiter := clitest.StartWithWaiter(t, inv)
matches := []struct {
match string
@@ -533,9 +547,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
stdin.WriteLine(m.write)
}
}
waiter.RequireSuccess()
@@ -570,6 +584,8 @@ func TestCreate(t *testing.T) {
t.Run("FromNothing", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -579,7 +595,8 @@ func TestCreate(t *testing.T) {
inv, root := clitest.New(t, "create", "")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -592,8 +609,8 @@ func TestCreate(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
<-doneChan
@@ -621,14 +638,14 @@ func TestCreate(t *testing.T) {
)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatchContext(ctx, "building in the background")
stdout.ExpectMatchContext(ctx, "building in the background")
_ = testutil.TryReceive(ctx, t, doneChan)
// Verify workspace was actually created.
@@ -658,14 +675,14 @@ func TestCreate(t *testing.T) {
)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatchContext(ctx, "building in the background")
stdout.ExpectMatchContext(ctx, "building in the background")
_ = testutil.TryReceive(ctx, t, doneChan)
// Verify workspace was created and parameters were applied.
@@ -706,14 +723,14 @@ func TestCreate(t *testing.T) {
)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatchContext(ctx, "building in the background")
stdout.ExpectMatchContext(ctx, "building in the background")
_ = testutil.TryReceive(ctx, t, doneChan)
ws, err := member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
@@ -801,7 +818,7 @@ func TestCreateWithRichParameters(t *testing.T) {
setup func() []string
// handlePty optionally runs after the command is started. It should handle
// all expected prompts from the pty.
handlePty func(pty *ptytest.PTY)
handlePty func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer)
// postRun runs after the command has finished but before the workspace is
// verified. It must return the workspace name to check (used for the copy
// workspace tests).
@@ -818,15 +835,15 @@ func TestCreateWithRichParameters(t *testing.T) {
}{
{
name: "ValuesFromPrompt",
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Enter the value for each parameter as prompted.
for _, param := range params {
pty.ExpectMatch(param.name)
pty.WriteLine(param.value)
stdout.ExpectMatchContext(ctx, param.name)
stdin.WriteLine(param.value)
}
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
},
{
@@ -839,16 +856,16 @@ func TestCreateWithRichParameters(t *testing.T) {
}
return args
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Simply accept the defaults.
for _, param := range params {
pty.ExpectMatch(param.name)
pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
pty.WriteLine("")
stdout.ExpectMatchContext(ctx, param.name)
stdout.ExpectMatchContext(ctx, `Enter a value (default: "`+param.value+`")`)
stdin.WriteLine("")
}
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
},
{
@@ -865,10 +882,10 @@ func TestCreateWithRichParameters(t *testing.T) {
return []string{"--rich-parameter-file", parameterFile.Name()}
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
},
{
@@ -881,10 +898,10 @@ func TestCreateWithRichParameters(t *testing.T) {
}
return args
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
},
{
@@ -920,9 +937,6 @@ func TestCreateWithRichParameters(t *testing.T) {
postRun: func(t *testing.T, tctx testContext) string {
inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y")
clitest.SetupConfig(t, tctx.member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err := inv.Run()
require.NoError(t, err, "failed to create a workspace based on the source workspace")
return "other-workspace"
@@ -952,9 +966,6 @@ func TestCreateWithRichParameters(t *testing.T) {
// Then create the copy. It should use the old template version.
inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y")
clitest.SetupConfig(t, tctx.member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err := inv.Run()
require.NoError(t, err, "failed to create a workspace based on the source workspace")
return "other-workspace"
@@ -962,16 +973,16 @@ func TestCreateWithRichParameters(t *testing.T) {
},
{
name: "ValuesFromTemplateDefaults",
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Simply accept the defaults.
for _, param := range params {
pty.ExpectMatch(param.name)
pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
pty.WriteLine("")
stdout.ExpectMatchContext(ctx, param.name)
stdout.ExpectMatchContext(ctx, `Enter a value (default: "`+param.value+`")`)
stdin.WriteLine("")
}
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
withDefaults: true,
},
@@ -980,14 +991,14 @@ func TestCreateWithRichParameters(t *testing.T) {
setup: func() []string {
return []string{"--use-parameter-defaults"}
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Default values should get printed.
for _, param := range params {
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", param.name, param.value))
}
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
withDefaults: true,
},
@@ -1001,14 +1012,14 @@ func TestCreateWithRichParameters(t *testing.T) {
}
return args
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Default values should get printed.
for _, param := range params {
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", param.name, param.value))
}
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
},
{
@@ -1031,14 +1042,14 @@ cli_param: from file`)
"--parameter", "cli_param=from cli",
}
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Should get prompted for the input param since it has no default.
pty.ExpectMatch("input_param")
pty.WriteLine("from input")
stdout.ExpectMatchContext(ctx, "input_param")
stdin.WriteLine("from input")
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
withDefaults: true,
inputParameters: []param{
@@ -1082,6 +1093,8 @@ cli_param: from file`)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
parameters := params
if len(tt.inputParameters) > 0 {
@@ -1122,14 +1135,15 @@ cli_param: from file`)
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan error)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
doneChan <- inv.Run()
}()
// The test may do something with the pty.
if tt.handlePty != nil {
tt.handlePty(pty)
tt.handlePty(ctx, stdout, stdin)
}
// Wait for the command to exit.
@@ -1235,6 +1249,7 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI uses the specified preset instead of the default
t.Run("PresetFlag", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1263,17 +1278,15 @@ func TestCreateWithPreset(t *testing.T) {
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", preset.Name)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the selected preset as well as its parameters
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
pty.ExpectMatch(presetName)
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
stdout.ExpectMatchContext(ctx, presetName)
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
@@ -1312,6 +1325,7 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI automatically uses the default preset to create the workspace
t.Run("DefaultPreset", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1340,22 +1354,17 @@ func TestCreateWithPreset(t *testing.T) {
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the default preset as well as its parameters
presetName := fmt.Sprintf("Preset '%s' (default) applied:", defaultPreset.Name)
pty.ExpectMatch(presetName)
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
stdout.ExpectMatchContext(ctx, presetName)
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 2)
@@ -1389,12 +1398,14 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI prompts the user to select a preset.
t.Run("NoDefaultPresetPromptUser", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Given: a template and a template version with two presets
// Given: a template and a template version with a single, non-default preset.
preset := proto.Preset{
Name: "preset-test",
Description: "Preset Test.",
@@ -1414,7 +1425,8 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1422,18 +1434,16 @@ func TestCreateWithPreset(t *testing.T) {
}()
// Should: prompt the user for the preset
pty.ExpectMatch("Select a preset below:")
pty.WriteLine("\n")
pty.ExpectMatch("Preset 'preset-test' applied")
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Select a preset below:")
// We don't actually have to respond to the selector, since we hardcode the cliui.Select to return the
// first option in test scenarios (c.f. cliui/select.go)
stdout.ExpectMatchContext(ctx, "Preset 'preset-test' applied")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
<-doneChan
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1460,6 +1470,7 @@ func TestCreateWithPreset(t *testing.T) {
// with workspace creation without applying any preset.
t.Run("TemplateVersionWithoutPresets", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1476,17 +1487,12 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
pty.ExpectMatch("No preset applied.")
stdout.ExpectMatchContext(ctx, "No preset applied.")
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Name: workspaceName,
})
@@ -1509,6 +1515,7 @@ func TestCreateWithPreset(t *testing.T) {
// The workspace should be created without using any preset-defined parameters.
t.Run("PresetFlagNone", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1533,17 +1540,12 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
pty.ExpectMatch("No preset applied.")
stdout.ExpectMatchContext(ctx, "No preset applied.")
// Verify that the new workspace doesn't use the preset parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1591,9 +1593,6 @@ func TestCreateWithPreset(t *testing.T) {
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", "invalid-preset")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err := inv.Run()
// Should: fail with an error indicating the preset was not found
@@ -1610,6 +1609,7 @@ func TestCreateWithPreset(t *testing.T) {
// - and the value of parameter B from the parameter flag.
t.Run("PresetOverridesParameterFlagValues", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1633,21 +1633,16 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the selected preset as well as its parameter
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
pty.ExpectMatch(presetName)
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
stdout.ExpectMatchContext(ctx, presetName)
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1679,6 +1674,7 @@ func TestCreateWithPreset(t *testing.T) {
// - and the value of parameter B from the file.
t.Run("PresetOverridesParameterFileValues", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1707,21 +1703,16 @@ func TestCreateWithPreset(t *testing.T) {
"--preset", preset.Name,
"--rich-parameter-file", parameterFile.Name())
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the selected preset as well as its parameter
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
pty.ExpectMatch(presetName)
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
stdout.ExpectMatchContext(ctx, presetName)
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1748,7 +1739,8 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI prompts the user for input to fill in the missing parameters.
t.Run("PromptsForMissingParametersWhenPresetIsIncomplete", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -1769,7 +1761,8 @@ func TestCreateWithPreset(t *testing.T) {
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "--preset", preset.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1778,21 +1771,18 @@ func TestCreateWithPreset(t *testing.T) {
// Should: display the selected preset as well as its parameters
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
pty.ExpectMatch(presetName)
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
stdout.ExpectMatchContext(ctx, presetName)
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
// Should: prompt for the missing parameter
pty.ExpectMatch(thirdParameterDescription)
pty.WriteLine(thirdParameterValue)
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, thirdParameterDescription)
stdin.WriteLine(thirdParameterValue)
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
<-doneChan
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1857,7 +1847,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateString", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -1869,7 +1860,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1885,9 +1877,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
@@ -1895,6 +1887,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateNumber", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1907,7 +1901,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1923,9 +1918,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
@@ -1933,6 +1928,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateNumber_CustomError", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1945,7 +1942,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1961,9 +1959,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
@@ -1971,6 +1969,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateBool", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1983,7 +1983,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1999,9 +2000,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
@@ -2018,15 +2019,18 @@ func TestCreateValidateRichParameters(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
t.Run("Prompt", func(t *testing.T) {
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "create", "my-workspace-1", "--template", template.Name)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
pty.ExpectMatch(listOfStringsParameterName)
pty.ExpectMatch("aaa, bbb, ccc")
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, listOfStringsParameterName)
stdout.ExpectMatchContext(ctx, "aaa, bbb, ccc")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
})
t.Run("Default", func(t *testing.T) {
@@ -2049,6 +2053,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateListOfStrings_YAMLFile", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -2066,8 +2072,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
- fff`)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
matches := []string{
@@ -2076,9 +2082,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
})
@@ -2086,6 +2092,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
func TestCreateWithGitAuth(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
echoResponses := &echo.Responses{
Parse: echo.ParseComplete,
ProvisionInit: echo.InitComplete,
@@ -2120,13 +2128,14 @@ func TestCreateWithGitAuth(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
pty.ExpectMatch("You must authenticate with GitHub to create a workspace")
stdout.ExpectMatchContext(ctx, "You must authenticate with GitHub to create a workspace")
resp := coderdtest.RequestExternalAuthCallback(t, "github", member)
_ = resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
}
+15 -15
View File
@@ -22,8 +22,8 @@ import (
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/quartz"
)
@@ -31,6 +31,7 @@ func TestDelete(t *testing.T) {
t.Parallel()
t.Run("WithParameter", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -42,7 +43,7 @@ func TestDelete(t *testing.T) {
inv, root := clitest.New(t, "delete", workspace.Name, "-y")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -51,7 +52,7 @@ func TestDelete(t *testing.T) {
assert.ErrorIs(t, err, io.EOF)
}
}()
pty.ExpectMatch("has been deleted")
stdout.ExpectMatchContext(ctx, "has been deleted")
<-doneChan
})
@@ -71,8 +72,7 @@ func TestDelete(t *testing.T) {
clitest.SetupConfig(t, templateAdmin, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.WithContext(ctx).Run()
@@ -81,7 +81,7 @@ func TestDelete(t *testing.T) {
assert.ErrorIs(t, err, io.EOF)
}
}()
pty.ExpectMatch("has been deleted")
stdout.ExpectMatchContext(ctx, "has been deleted")
testutil.TryReceive(ctx, t, doneChan)
_, err := client.Workspace(ctx, workspace.ID)
@@ -117,8 +117,7 @@ func TestDelete(t *testing.T) {
//nolint:gocritic // Deleting orphaned workspaces requires an admin.
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -127,7 +126,7 @@ func TestDelete(t *testing.T) {
assert.ErrorIs(t, err, io.EOF)
}
}()
pty.ExpectMatch("has been deleted")
stdout.ExpectMatchContext(ctx, "has been deleted")
<-doneChan
})
@@ -146,11 +145,12 @@ func TestDelete(t *testing.T) {
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "delete", user.Username+"/"+workspace.Name, "-y")
//nolint:gocritic // This requires an admin.
clitest.SetupConfig(t, adminClient, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -160,7 +160,7 @@ func TestDelete(t *testing.T) {
}
}()
pty.ExpectMatch("has been deleted")
stdout.ExpectMatchContext(ctx, "has been deleted")
<-doneChan
workspace, err = client.Workspace(context.Background(), workspace.ID)
@@ -207,7 +207,7 @@ func TestDelete(t *testing.T) {
// Then: the workspace deletion should warn about no provisioners
inv, root := clitest.New(t, "delete", workspace.Name, "-y")
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.SetupConfig(t, templateAdmin, root)
doneChan := make(chan struct{})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
@@ -216,7 +216,7 @@ func TestDelete(t *testing.T) {
defer close(doneChan)
_ = inv.WithContext(ctx).Run()
}()
pty.ExpectMatch("there are no provisioners that accept the required tags")
stdout.ExpectMatchContext(ctx, "there are no provisioners that accept the required tags")
cancel()
<-doneChan
})
@@ -311,7 +311,7 @@ func TestDelete(t *testing.T) {
inv, root := clitest.New(t, "delete", workspaceOwner+"/"+workspace.Name, "-y")
clitest.SetupConfig(t, runClient, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
var runErr error
go func() {
defer close(doneChan)
@@ -324,7 +324,7 @@ func TestDelete(t *testing.T) {
require.Error(t, runErr)
require.Contains(t, runErr.Error(), expectedErr)
} else {
pty.ExpectMatch("has been deleted")
stdout.ExpectMatchContext(ctx, "has been deleted")
<-doneChan
// When running with the race detector on, we sometimes get an EOF.
+12 -10
View File
@@ -15,8 +15,8 @@ import (
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestExpRpty(t *testing.T) {
@@ -28,7 +28,7 @@ func TestExpRpty(t *testing.T) {
client, workspace, agentToken := setupWorkspaceForAgent(t)
inv, root := clitest.New(t, "exp", "rpty", workspace.Name)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdin := testutil.NewWriterAttachedToInvocation(t, testutil.Logger(t), inv)
ctx := testutil.Context(t, testutil.WaitLong)
@@ -40,7 +40,7 @@ func TestExpRpty(t *testing.T) {
assert.NoError(t, err)
})
pty.WriteLine("exit")
stdin.WriteLine("exit")
<-cmdDone
})
@@ -51,7 +51,7 @@ func TestExpRpty(t *testing.T) {
randStr := uuid.NewString()
inv, root := clitest.New(t, "exp", "rpty", workspace.Name, "echo", randStr)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitLong)
@@ -63,7 +63,7 @@ func TestExpRpty(t *testing.T) {
assert.NoError(t, err)
})
pty.ExpectMatch(randStr)
stdout.ExpectMatchContext(ctx, randStr)
<-cmdDone
})
@@ -86,6 +86,7 @@ func TestExpRpty(t *testing.T) {
t.Skip("Skipping test on non-Linux platform")
}
logger := testutil.Logger(t)
wantLabel := "coder.devcontainers.TestExpRpty.Container"
client, workspace, agentToken := setupWorkspaceForAgent(t)
@@ -124,7 +125,8 @@ func TestExpRpty(t *testing.T) {
inv, root := clitest.New(t, "exp", "rpty", workspace.Name, "-c", ct.Container.ID)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
ctx := testutil.Context(t, testutil.WaitLong)
cmdDone := tGo(t, func() {
@@ -132,10 +134,10 @@ func TestExpRpty(t *testing.T) {
assert.NoError(t, err)
})
pty.ExpectMatchContext(ctx, " #")
pty.WriteLine("hostname")
pty.ExpectMatchContext(ctx, ct.Container.Config.Hostname)
pty.WriteLine("exit")
stdout.ExpectMatchContext(ctx, " #")
stdin.WriteLine("hostname")
stdout.ExpectMatchContext(ctx, ct.Container.Config.Hostname)
stdin.WriteLine("exit")
<-cmdDone
})
}
+4 -4
View File
@@ -15,8 +15,8 @@ import (
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestList(t *testing.T) {
@@ -34,7 +34,7 @@ func TestList(t *testing.T) {
inv, root := clitest.New(t, "ls")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancelFunc()
@@ -44,8 +44,8 @@ func TestList(t *testing.T) {
assert.NoError(t, errC)
close(done)
}()
pty.ExpectMatch(r.Workspace.Name)
pty.ExpectMatch("Started")
stdout.ExpectMatchContext(ctx, r.Workspace.Name)
stdout.ExpectMatchContext(ctx, "Started")
cancelFunc()
<-done
})
+113 -97
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
@@ -15,8 +14,8 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/pretty"
)
@@ -74,13 +73,16 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserTTY", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
// The --force-tty flag is required on Windows, because the `isatty` library does not
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
ctx := testutil.Context(t, testutil.WaitMedium)
go func() {
defer close(doneChan)
err := root.Run()
@@ -105,12 +107,11 @@ func TestLogin(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
<-doneChan
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -126,13 +127,16 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserTTYWithNoTrial", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
// The --force-tty flag is required on Windows, because the `isatty` library does not
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
ctx := testutil.Context(t, testutil.WaitMedium)
go func() {
defer close(doneChan)
err := root.Run()
@@ -151,12 +155,11 @@ func TestLogin(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
<-doneChan
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -172,13 +175,16 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserTTYNameOptional", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
// The --force-tty flag is required on Windows, because the `isatty` library does not
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
ctx := testutil.Context(t, testutil.WaitMedium)
go func() {
defer close(doneChan)
err := root.Run()
@@ -203,12 +209,11 @@ func TestLogin(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
<-doneChan
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -224,16 +229,19 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserTTYFlag", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
// The --force-tty flag is required on Windows, because the `isatty` library does not
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
inv, _ := clitest.New(t, "--url", client.URL.String(), "login", "--force-tty")
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
ctx := testutil.Context(t, testutil.WaitMedium)
clitest.Start(t, inv)
pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with flag URL: '%s'", client.URL.String()))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Attempting to authenticate with flag URL: '%s'", client.URL.String()))
matches := []string{
"first user?", "yes",
"username", coderdtest.FirstUserParams.Username,
@@ -252,11 +260,10 @@ func TestLogin(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
ctx := testutil.Context(t, testutil.WaitShort)
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -272,6 +279,7 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserFlags", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
inv, _ := clitest.New(
t, "login", client.URL.String(),
@@ -281,22 +289,23 @@ func TestLogin(t *testing.T) {
"--first-user-password", coderdtest.FirstUserParams.Password,
"--first-user-trial",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
ctx := testutil.Context(t, testutil.WaitMedium)
w := clitest.StartWithWaiter(t, inv)
pty.ExpectMatch("firstName")
pty.WriteLine(coderdtest.TrialUserParams.FirstName)
pty.ExpectMatch("lastName")
pty.WriteLine(coderdtest.TrialUserParams.LastName)
pty.ExpectMatch("phoneNumber")
pty.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
pty.ExpectMatch("jobTitle")
pty.WriteLine(coderdtest.TrialUserParams.JobTitle)
pty.ExpectMatch("companyName")
pty.WriteLine(coderdtest.TrialUserParams.CompanyName)
stdout.ExpectMatchContext(ctx, "firstName")
stdin.WriteLine(coderdtest.TrialUserParams.FirstName)
stdout.ExpectMatchContext(ctx, "lastName")
stdin.WriteLine(coderdtest.TrialUserParams.LastName)
stdout.ExpectMatchContext(ctx, "phoneNumber")
stdin.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
stdout.ExpectMatchContext(ctx, "jobTitle")
stdin.WriteLine(coderdtest.TrialUserParams.JobTitle)
stdout.ExpectMatchContext(ctx, "companyName")
stdin.WriteLine(coderdtest.TrialUserParams.CompanyName)
// `developers` and `country` `cliui.Select` automatically selects the first option during tests.
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
w.RequireSuccess()
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -312,6 +321,7 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserFlagsNameOptional", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
inv, _ := clitest.New(
t, "login", client.URL.String(),
@@ -320,22 +330,23 @@ func TestLogin(t *testing.T) {
"--first-user-password", coderdtest.FirstUserParams.Password,
"--first-user-trial",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
ctx := testutil.Context(t, testutil.WaitMedium)
w := clitest.StartWithWaiter(t, inv)
pty.ExpectMatch("firstName")
pty.WriteLine(coderdtest.TrialUserParams.FirstName)
pty.ExpectMatch("lastName")
pty.WriteLine(coderdtest.TrialUserParams.LastName)
pty.ExpectMatch("phoneNumber")
pty.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
pty.ExpectMatch("jobTitle")
pty.WriteLine(coderdtest.TrialUserParams.JobTitle)
pty.ExpectMatch("companyName")
pty.WriteLine(coderdtest.TrialUserParams.CompanyName)
stdout.ExpectMatchContext(ctx, "firstName")
stdin.WriteLine(coderdtest.TrialUserParams.FirstName)
stdout.ExpectMatchContext(ctx, "lastName")
stdin.WriteLine(coderdtest.TrialUserParams.LastName)
stdout.ExpectMatchContext(ctx, "phoneNumber")
stdin.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
stdout.ExpectMatchContext(ctx, "jobTitle")
stdin.WriteLine(coderdtest.TrialUserParams.JobTitle)
stdout.ExpectMatchContext(ctx, "companyName")
stdin.WriteLine(coderdtest.TrialUserParams.CompanyName)
// `developers` and `country` `cliui.Select` automatically selects the first option during tests.
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
w.RequireSuccess()
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -351,6 +362,7 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
client := coderdtest.New(t, nil)
@@ -359,7 +371,8 @@ func TestLogin(t *testing.T) {
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
go func() {
defer close(doneChan)
err := root.WithContext(ctx).Run()
@@ -377,59 +390,60 @@ func TestLogin(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
// Validate that we reprompt for matching passwords.
pty.ExpectMatch("Passwords do not match")
pty.ExpectMatch("Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password"))
pty.WriteLine(coderdtest.FirstUserParams.Password)
pty.ExpectMatch("Confirm")
pty.WriteLine(coderdtest.FirstUserParams.Password)
pty.ExpectMatch("trial")
pty.WriteLine("yes")
pty.ExpectMatch("firstName")
pty.WriteLine(coderdtest.TrialUserParams.FirstName)
pty.ExpectMatch("lastName")
pty.WriteLine(coderdtest.TrialUserParams.LastName)
pty.ExpectMatch("phoneNumber")
pty.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
pty.ExpectMatch("jobTitle")
pty.WriteLine(coderdtest.TrialUserParams.JobTitle)
pty.ExpectMatch("companyName")
pty.WriteLine(coderdtest.TrialUserParams.CompanyName)
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Passwords do not match")
stdout.ExpectMatchContext(ctx, "Enter a "+pretty.Sprint(cliui.DefaultStyles.Field, "password"))
stdin.WriteLine(coderdtest.FirstUserParams.Password)
stdout.ExpectMatchContext(ctx, "Confirm")
stdin.WriteLine(coderdtest.FirstUserParams.Password)
stdout.ExpectMatchContext(ctx, "trial")
stdin.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "firstName")
stdin.WriteLine(coderdtest.TrialUserParams.FirstName)
stdout.ExpectMatchContext(ctx, "lastName")
stdin.WriteLine(coderdtest.TrialUserParams.LastName)
stdout.ExpectMatchContext(ctx, "phoneNumber")
stdin.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
stdout.ExpectMatchContext(ctx, "jobTitle")
stdin.WriteLine(coderdtest.TrialUserParams.JobTitle)
stdout.ExpectMatchContext(ctx, "companyName")
stdin.WriteLine(coderdtest.TrialUserParams.CompanyName)
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
<-doneChan
})
t.Run("ExistingUserValidTokenTTY", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitMedium)
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String(), "--no-open")
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
go func() {
defer close(doneChan)
err := root.Run()
assert.NoError(t, err)
}()
pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with argument URL: '%s'", client.URL.String()))
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
if runtime.GOOS != "windows" {
// For some reason, the match does not show up on Windows.
pty.ExpectMatch(client.SessionToken())
}
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Attempting to authenticate with argument URL: '%s'", client.URL.String()))
stdout.ExpectMatchContext(ctx, "Paste your token here:")
stdin.WriteLine(client.SessionToken())
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
<-doneChan
})
t.Run("ExistingUserURLSavedInConfig", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, nil)
url := client.URL.String()
coderdtest.CreateFirstUser(t, client)
@@ -438,21 +452,24 @@ func TestLogin(t *testing.T) {
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with config URL: '%s'", url))
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Attempting to authenticate with config URL: '%s'", url))
stdout.ExpectMatchContext(ctx, "Paste your token here:")
stdin.WriteLine(client.SessionToken())
<-doneChan
})
t.Run("ExistingUserURLSavedInEnv", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, nil)
url := client.URL.String()
coderdtest.CreateFirstUser(t, client)
@@ -461,21 +478,23 @@ func TestLogin(t *testing.T) {
inv.Environ.Set("CODER_URL", url)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with environment URL: '%s'", url))
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Attempting to authenticate with environment URL: '%s'", url))
stdout.ExpectMatchContext(ctx, "Paste your token here:")
stdin.WriteLine(client.SessionToken())
<-doneChan
})
t.Run("ExistingUserInvalidTokenTTY", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
@@ -483,7 +502,8 @@ func TestLogin(t *testing.T) {
defer cancelFunc()
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", client.URL.String(), "--no-open")
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
go func() {
defer close(doneChan)
err := root.WithContext(ctx).Run()
@@ -491,13 +511,9 @@ func TestLogin(t *testing.T) {
assert.Error(t, err)
}()
pty.ExpectMatch("Paste your token here:")
pty.WriteLine("an-invalid-token")
if runtime.GOOS != "windows" {
// For some reason, the match does not show up on Windows.
pty.ExpectMatch("an-invalid-token")
}
pty.ExpectMatch("That's not a valid token!")
stdout.ExpectMatchContext(ctx, "Paste your token here:")
stdin.WriteLine("an-invalid-token")
stdout.ExpectMatchContext(ctx, "That's not a valid token!")
cancelFunc()
<-doneChan
})
@@ -582,12 +598,12 @@ func TestLoginToken(t *testing.T) {
inv, root := clitest.New(t, "login", "token", "--url", client.URL.String())
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
pty.ExpectMatch(client.SessionToken())
stdout.ExpectMatchContext(ctx, client.SessionToken())
})
t.Run("NoTokenStored", func(t *testing.T) {
+11 -6
View File
@@ -17,7 +17,8 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/pretty"
)
@@ -29,6 +30,7 @@ func TestCurrentOrganization(t *testing.T) {
// 2. The user is connecting to an older Coder instance.
t.Run("no-default", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
orgID := uuid.New()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -49,13 +51,13 @@ func TestCurrentOrganization(t *testing.T) {
client := codersdk.New(must(url.Parse(srv.URL)))
inv, root := clitest.New(t, "organizations", "show", "selected")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
errC := make(chan error)
go func() {
errC <- inv.Run()
}()
require.NoError(t, <-errC)
pty.ExpectMatch(orgID.String())
stdout.ExpectMatchContext(ctx, orgID.String())
})
}
@@ -140,6 +142,8 @@ func TestOrganizationDelete(t *testing.T) {
t.Run("Prompted", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
orgID := uuid.New()
var deleteCalled atomic.Bool
@@ -167,15 +171,16 @@ func TestOrganizationDelete(t *testing.T) {
client := codersdk.New(must(url.Parse(server.URL)))
inv, root := clitest.New(t, "organizations", "delete", "my-org")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
execDone := make(chan error)
go func() {
execDone <- inv.Run()
}()
pty.ExpectMatch(fmt.Sprintf("Delete organization %s?", pretty.Sprint(cliui.DefaultStyles.Code, "my-org")))
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Delete organization %s?", pretty.Sprint(cliui.DefaultStyles.Code, "my-org")))
stdin.WriteLine("yes")
require.NoError(t, <-execDone)
require.True(t, deleteCalled.Load(), "expected delete request")
+9 -19
View File
@@ -25,8 +25,8 @@ import (
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestPortForward_None(t *testing.T) {
@@ -160,10 +160,7 @@ func TestPortForward(t *testing.T) {
// the "local" listener.
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t)
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
iNet := testutil.NewInProcNet()
inv.Net = iNet
@@ -175,7 +172,7 @@ func TestPortForward(t *testing.T) {
t.Logf("command complete; err=%s", err.Error())
errC <- err
}()
pty.ExpectMatchContext(ctx, "Ready!")
stdout.ExpectMatchContext(ctx, "Ready!")
// Open two connections simultaneously and test them out of
// sync.
@@ -216,10 +213,7 @@ func TestPortForward(t *testing.T) {
// the "local" listeners.
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag1, flag2)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t)
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
iNet := testutil.NewInProcNet()
inv.Net = iNet
@@ -229,7 +223,7 @@ func TestPortForward(t *testing.T) {
go func() {
errC <- inv.WithContext(ctx).Run()
}()
pty.ExpectMatchContext(ctx, "Ready!")
stdout.ExpectMatchContext(ctx, "Ready!")
// Open a connection to both listener 1 and 2 simultaneously and
// then test them out of order.
@@ -277,8 +271,7 @@ func TestPortForward(t *testing.T) {
// the "local" listeners.
inv, root := clitest.New(t, append([]string{"-v", "port-forward", workspace.Name}, flags...)...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
iNet := testutil.NewInProcNet()
inv.Net = iNet
@@ -288,7 +281,7 @@ func TestPortForward(t *testing.T) {
go func() {
errC <- inv.WithContext(ctx).Run()
}()
pty.ExpectMatchContext(ctx, "Ready!")
stdout.ExpectMatchContext(ctx, "Ready!")
// Open connections to all items in the "dial" array.
var (
@@ -338,10 +331,7 @@ func TestPortForward(t *testing.T) {
// the "local" listener.
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t)
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
iNet := testutil.NewInProcNet()
inv.Net = iNet
@@ -359,7 +349,7 @@ func TestPortForward(t *testing.T) {
t.Logf("command complete; err=%s", err.Error())
errC <- err
}()
pty.ExpectMatchContext(ctx, "Ready!")
stdout.ExpectMatchContext(ctx, "Ready!")
// Test IPv4 still works
dialCtx, dialCtxCancel := context.WithTimeout(ctx, testutil.WaitShort)
+7 -6
View File
@@ -8,12 +8,13 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestRename(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, AllowWorkspaceRenames: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -30,13 +31,13 @@ func TestRename(t *testing.T) {
want := coderdtest.RandomUsername(t)
inv, root := clitest.New(t, "rename", workspace.Name, want, "--yes")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t)
pty.Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
pty.ExpectMatch("confirm rename:")
pty.WriteLine(workspace.Name)
pty.ExpectMatch("renamed to")
stdout.ExpectMatchContext(ctx, "confirm rename:")
stdin.WriteLine(workspace.Name)
stdout.ExpectMatchContext(ctx, "renamed to")
ws, err := client.Workspace(ctx, workspace.ID)
assert.NoError(t, err)
+39 -42
View File
@@ -1,7 +1,6 @@
package cli_test
import (
"context"
"fmt"
"testing"
@@ -14,8 +13,8 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestRestart(t *testing.T) {
@@ -49,15 +48,15 @@ func TestRestart(t *testing.T) {
inv, root := clitest.New(t, "restart", workspace.Name, "--yes")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
done := make(chan error, 1)
go func() {
done <- inv.WithContext(ctx).Run()
}()
pty.ExpectMatch("Stopping workspace")
pty.ExpectMatch("Starting workspace")
pty.ExpectMatch("workspace has been restarted")
stdout.ExpectMatchContext(ctx, "Stopping workspace")
stdout.ExpectMatchContext(ctx, "Starting workspace")
stdout.ExpectMatchContext(ctx, "workspace has been restarted")
err := <-done
require.NoError(t, err, "execute failed")
@@ -66,6 +65,7 @@ func TestRestart(t *testing.T) {
t.Run("PromptEphemeralParameters", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -84,13 +84,15 @@ func TestRestart(t *testing.T) {
inv, root := clitest.New(t, "restart", workspace.Name, "--prompt-ephemeral-parameters")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
matches := []string{
ephemeralParameterDescription, ephemeralParameterValue,
"Restart workspace?", "yes",
@@ -101,18 +103,15 @@ func TestRestart(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
// Verify if build option is set
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
@@ -126,6 +125,7 @@ func TestRestart(t *testing.T) {
t.Run("EphemeralParameterFlags", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -143,13 +143,15 @@ func TestRestart(t *testing.T) {
"--ephemeral-parameter", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue))
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
matches := []string{
"Restart workspace?", "yes",
"Stopping workspace", "",
@@ -159,18 +161,15 @@ func TestRestart(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
// Verify if build option is set
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
@@ -184,6 +183,7 @@ func TestRestart(t *testing.T) {
t.Run("with deprecated build-options flag", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -202,13 +202,15 @@ func TestRestart(t *testing.T) {
inv, root := clitest.New(t, "restart", workspace.Name, "--build-options")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
matches := []string{
ephemeralParameterDescription, ephemeralParameterValue,
"Restart workspace?", "yes",
@@ -219,18 +221,15 @@ func TestRestart(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
// Verify if build option is set
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
@@ -244,6 +243,7 @@ func TestRestart(t *testing.T) {
t.Run("with deprecated build-option flag", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -261,13 +261,15 @@ func TestRestart(t *testing.T) {
"--build-option", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue))
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
matches := []string{
"Restart workspace?", "yes",
"Stopping workspace", "",
@@ -277,18 +279,15 @@ func TestRestart(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
// Verify if build option is set
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
@@ -349,20 +348,18 @@ func TestRestartWithParameters(t *testing.T) {
inv, root := clitest.New(t, "restart", workspace.Name, "-y")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
pty.ExpectMatch("workspace has been restarted")
stdout.ExpectMatchContext(ctx, "workspace has been restarted")
<-doneChan
// Verify if immutable parameter is set
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
@@ -376,6 +373,7 @@ func TestRestartWithParameters(t *testing.T) {
t.Run("AlwaysPrompt", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
// Create the workspace
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -396,24 +394,23 @@ func TestRestartWithParameters(t *testing.T) {
inv, root := clitest.New(t, "restart", workspace.Name, "-y", "--always-prompt")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
// We should be prompted for the parameters again.
newValue := "xyz"
pty.ExpectMatch(mutableParameterName)
pty.WriteLine(newValue)
pty.ExpectMatch("workspace has been restarted")
stdout.ExpectMatchContext(ctx, mutableParameterName)
stdin.WriteLine(newValue)
stdout.ExpectMatchContext(ctx, "workspace has been restarted")
<-doneChan
// Verify that the updated values are persisted.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
+39 -2
View File
@@ -1701,7 +1701,44 @@ func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return r(req)
}
// HeaderTransport creates a new transport that executes `--header-command`
// appendAndDedupEnv appends extra environment variables and
// deduplicates entries with the same key (case-insensitive on
// Windows). For the PATH variable specifically, it prefers the
// value that contains native Windows paths (with backslashes)
// over MSYS-translated paths (with forward slashes). For all
// other variables, the last value wins.
func appendAndDedupEnv(env []string, extra ...string) []string {
env = append(env, extra...)
if runtime.GOOS != "windows" {
return env
}
seen := make(map[string]int, len(env))
result := make([]string, 0, len(env))
for _, e := range env {
key, val, ok := strings.Cut(e, "=")
if !ok {
result = append(result, e)
continue
}
upper := strings.ToUpper(key)
if idx, exists := seen[upper]; exists {
if upper == "PATH" {
// Prefer the value with native Windows paths.
existingVal := result[idx][len(key)+1:]
if strings.Contains(existingVal, "\\") && !strings.Contains(val, "\\") {
continue
}
}
result[idx] = e
continue
}
seen[upper] = len(result)
result = append(result, e)
}
return result
}
// headerTransport creates a new transport that executes `--header-command`
// if it is set to add headers for all outbound requests.
func headerTransport(ctx context.Context, serverURL *url.URL, header []string, headerCommand string) (*codersdk.HeaderTransport, error) {
transport := &codersdk.HeaderTransport{
@@ -1719,7 +1756,7 @@ func headerTransport(ctx context.Context, serverURL *url.URL, header []string, h
var outBuf bytes.Buffer
// #nosec
cmd := exec.CommandContext(ctx, shell, caller, headerCommand)
cmd.Env = append(os.Environ(), "CODER_URL="+serverURL.String())
cmd.Env = appendAndDedupEnv(os.Environ(), "CODER_URL="+serverURL.String())
cmd.Stdout = &outBuf
cmd.Stderr = io.Discard
err := cmd.Run()
+4 -2
View File
@@ -177,15 +177,17 @@ func TestRoot(t *testing.T) {
url = srv.URL
buf := new(bytes.Buffer)
coderURLEnv := "$CODER_URL"
headerCmd := "printf X-Process-Testing=very-wow-" + coderURLEnv + "'\\r\\n'X-Process-Testing2=more-wow"
if runtime.GOOS == "windows" {
coderURLEnv = "%CODER_URL%"
headerCmd = "echo X-Process-Testing=very-wow-" + coderURLEnv + "& echo X-Process-Testing2=more-wow"
}
inv, _ := clitest.New(t,
"--no-feature-warning",
"--no-version-warning",
"--header", "X-Testing=wow",
"--header", "Cool-Header=Dean was Here!",
"--header-command", "printf X-Process-Testing=very-wow-"+coderURLEnv+"'\\r\\n'X-Process-Testing2=more-wow",
"--header-command", headerCmd,
"login", srv.URL,
)
inv.Stdout = buf
@@ -266,7 +268,7 @@ func TestDERPHeaders(t *testing.T) {
"--no-version-warning",
"ping", workspace.Name,
"-n", "1",
"--header-command", "printf X-Process-Testing=very-wow",
"--header-command", "echo X-Process-Testing=very-wow",
}
for k, v := range expectedHeaders {
if k != "X-Process-Testing" {
+73 -62
View File
@@ -19,8 +19,8 @@ import (
"github.com/coder/coder/v2/coderd/schedule/cron"
"github.com/coder/coder/v2/coderd/util/tz"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
// setupTestSchedule creates 4 workspaces:
@@ -97,20 +97,21 @@ func TestScheduleShow(t *testing.T) {
inv, root := clitest.New(t, "schedule", "show")
//nolint:gocritic // Testing that owner user sees all
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: they should see their own workspaces.
// 1st workspace: a-owner-ws1 has both autostart and autostop enabled.
pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[0].OwnerName+"/"+ws[0].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
// 2nd workspace: b-owner-ws2 has only autostart enabled.
pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[1].OwnerName+"/"+ws[1].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
})
t.Run("OwnerAll", func(t *testing.T) {
@@ -118,26 +119,27 @@ func TestScheduleShow(t *testing.T) {
inv, root := clitest.New(t, "schedule", "show", "--all")
//nolint:gocritic // Testing that owner user sees all
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: they should see all workspaces
// 1st workspace: a-owner-ws1 has both autostart and autostop enabled.
pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[0].OwnerName+"/"+ws[0].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
// 2nd workspace: b-owner-ws2 has only autostart enabled.
pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[1].OwnerName+"/"+ws[1].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
// 3rd workspace: c-member-ws3 has only autostop enabled.
pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[2].OwnerName+"/"+ws[2].Name)
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
// 4th workspace: d-member-ws4 has neither autostart nor autostop enabled.
pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name)
stdout.ExpectMatchContext(ctx, ws[3].OwnerName+"/"+ws[3].Name)
})
t.Run("OwnerSearchByName", func(t *testing.T) {
@@ -145,14 +147,15 @@ func TestScheduleShow(t *testing.T) {
inv, root := clitest.New(t, "schedule", "show", "--search", "name:"+ws[1].Name)
//nolint:gocritic // Testing that owner user sees all
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: they should see workspaces matching that query
// 2nd workspace: b-owner-ws2 has only autostart enabled.
pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[1].OwnerName+"/"+ws[1].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
})
t.Run("OwnerOneArg", func(t *testing.T) {
@@ -160,37 +163,39 @@ func TestScheduleShow(t *testing.T) {
inv, root := clitest.New(t, "schedule", "show", ws[2].OwnerName+"/"+ws[2].Name)
//nolint:gocritic // Testing that owner user sees all
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: they should see that workspace
// 3rd workspace: c-member-ws3 has only autostop enabled.
pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[2].OwnerName+"/"+ws[2].Name)
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
})
t.Run("MemberNoArgs", func(t *testing.T) {
// When: a member specifies no args
inv, root := clitest.New(t, "schedule", "show")
clitest.SetupConfig(t, memberClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: they should see their own workspaces
// 1st workspace: c-member-ws3 has only autostop enabled.
pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[2].OwnerName+"/"+ws[2].Name)
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
// 2nd workspace: d-member-ws4 has neither autostart nor autostop enabled.
pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name)
stdout.ExpectMatchContext(ctx, ws[3].OwnerName+"/"+ws[3].Name)
})
t.Run("MemberAll", func(t *testing.T) {
// When: a member lists all workspaces
inv, root := clitest.New(t, "schedule", "show", "--all")
clitest.SetupConfig(t, memberClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
errC := make(chan error)
go func() {
@@ -200,11 +205,11 @@ func TestScheduleShow(t *testing.T) {
// Then: they should only see their own
// 1st workspace: c-member-ws3 has only autostop enabled.
pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[2].OwnerName+"/"+ws[2].Name)
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
// 2nd workspace: d-member-ws4 has neither autostart nor autostop enabled.
pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name)
stdout.ExpectMatchContext(ctx, ws[3].OwnerName+"/"+ws[3].Name)
})
t.Run("JSON", func(t *testing.T) {
@@ -276,13 +281,14 @@ func TestScheduleModify(t *testing.T) {
)
//nolint:gocritic // this workspace is not owned by the same user
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[3].OwnerName+"/"+ws[3].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
})
t.Run("SetStop", func(t *testing.T) {
@@ -292,13 +298,14 @@ func TestScheduleModify(t *testing.T) {
)
//nolint:gocritic // this workspace is not owned by the same user
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
pty.ExpectMatch("8h30m")
pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[2].OwnerName+"/"+ws[2].Name)
stdout.ExpectMatchContext(ctx, "8h30m")
stdout.ExpectMatchContext(ctx, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
})
t.Run("UnsetStart", func(t *testing.T) {
@@ -308,11 +315,12 @@ func TestScheduleModify(t *testing.T) {
)
//nolint:gocritic // this workspace is owned by owner
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
stdout.ExpectMatchContext(ctx, ws[1].OwnerName+"/"+ws[1].Name)
})
t.Run("UnsetStop", func(t *testing.T) {
@@ -322,11 +330,12 @@ func TestScheduleModify(t *testing.T) {
)
//nolint:gocritic // this workspace is owned by owner
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
stdout.ExpectMatchContext(ctx, ws[0].OwnerName+"/"+ws[0].Name)
})
}
@@ -359,7 +368,8 @@ func TestScheduleOverride(t *testing.T) {
)
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Fetch the workspace to get the actual deadline set by the
@@ -376,11 +386,11 @@ func TestScheduleOverride(t *testing.T) {
expectedDeadline := updated.LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
pty.ExpectMatch("8h")
pty.ExpectMatch(expectedDeadline)
stdout.ExpectMatchContext(ctx, ws[0].OwnerName+"/"+ws[0].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, expectedDeadline)
})
}
}
@@ -422,13 +432,14 @@ func TestScheduleStart_TemplateAutostartRequirement(t *testing.T) {
"schedule", "start", workspace.Name, "9:30AM", "Mon-Fri",
)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: warning should be shown
// In AGPL, this will show all days (enterprise feature defaults to all days allowed)
pty.ExpectMatch("Warning")
pty.ExpectMatch("may only autostart")
stdout.ExpectMatchContext(ctx, "Warning")
stdout.ExpectMatchContext(ctx, "may only autostart")
})
t.Run("NoWarningWhenManual", func(t *testing.T) {
+14 -10
View File
@@ -14,8 +14,8 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestSecretCreate(t *testing.T) {
@@ -501,6 +501,7 @@ func TestSecretDelete(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
@@ -516,12 +517,13 @@ func TestSecretDelete(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
inv = inv.WithContext(ctx)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
waiter := clitest.StartWithWaiter(t, inv)
pty.ExpectMatchContext(ctx, "Delete secret")
pty.ExpectMatchContext(ctx, "service-token")
pty.WriteLine("yes")
pty.ExpectMatchContext(ctx, "Deleted secret")
stdout.ExpectMatchContext(ctx, "Delete secret")
stdout.ExpectMatchContext(ctx, "service-token")
stdin.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Deleted secret")
require.NoError(t, waiter.Wait())
@@ -566,6 +568,7 @@ func TestSecretDelete(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
@@ -574,11 +577,12 @@ func TestSecretDelete(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
inv = inv.WithContext(ctx)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
waiter := clitest.StartWithWaiter(t, inv)
pty.ExpectMatchContext(ctx, "Delete secret")
pty.ExpectMatchContext(ctx, "missing-secret")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Delete secret")
stdout.ExpectMatchContext(ctx, "missing-secret")
stdin.WriteLine("yes")
err := waiter.Wait()
require.ErrorContains(t, err, `delete secret "missing-secret"`)
+32 -3
View File
@@ -2979,6 +2979,11 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder
return providers, nil
}
const (
aiGatewayProviderEnvPrefix = "CODER_AI_GATEWAY_PROVIDER_"
aiBridgeProviderEnvPrefix = "CODER_AIBRIDGE_PROVIDER_"
)
// ReadAIProvidersFromEnv parses CODER_AI_GATEWAY_PROVIDER_<N>_<KEY>
// environment variables into a slice of AIProviderConfig.
// Deprecated alias env vars with the CODER_AIBRIDGE_PROVIDER_<N>_<KEY>
@@ -2986,16 +2991,22 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder
//
// This follows the same indexed pattern as ReadExternalAuthProvidersFromEnv.
func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AIProviderConfig, error) {
providers, err := readAIProvidersForPrefix(logger, environ, "CODER_AIBRIDGE_PROVIDER_")
providers, err := readAIProvidersForPrefix(logger, environ, aiBridgeProviderEnvPrefix)
if err != nil {
return nil, err
}
gatewayProviders, err := readAIProvidersForPrefix(logger, environ, "CODER_AI_GATEWAY_PROVIDER_")
gatewayProviders, err := readAIProvidersForPrefix(logger, environ, aiGatewayProviderEnvPrefix)
if err != nil {
return nil, err
}
if len(providers) > 0 && len(gatewayProviders) > 0 {
return nil, xerrors.New("cannot mix CODER_AIBRIDGE_PROVIDER_* and CODER_AI_GATEWAY_PROVIDER_* environment variables, please consolidate onto CODER_AI_GATEWAY_PROVIDER_*")
return nil, xerrors.Errorf("cannot mix %s* and %s* environment variables, please consolidate onto %s*", aiBridgeProviderEnvPrefix, aiGatewayProviderEnvPrefix, aiGatewayProviderEnvPrefix)
}
var activePrefix string
if len(providers) > 0 {
activePrefix = aiBridgeProviderEnvPrefix
} else if len(gatewayProviders) > 0 {
activePrefix = aiGatewayProviderEnvPrefix
}
providers = append(providers, gatewayProviders...)
@@ -3077,9 +3088,27 @@ func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AI
names[p.Name] = i
}
warnIfAIProvidersConfiguredFromEnv(context.Background(), logger, activePrefix, providers)
return providers, nil
}
func warnIfAIProvidersConfiguredFromEnv(ctx context.Context, logger slog.Logger, prefix string, providers []codersdk.AIProviderConfig) {
if len(providers) == 0 {
return
}
if prefix == "" {
return
}
logger.Warn(ctx,
"ai provider environment variables are deprecated for provider management and only seed provider configuration at startup",
slog.F("env_prefix", prefix),
slog.F("replacement", "Manage AI Providers from the Coder UI or HTTP API."),
)
}
// readAIProvidersForPrefix parses provider env vars under a single
// indexed prefix (e.g. CODER_AI_GATEWAY_PROVIDER_) into a slice of
// AIProviderConfig. Per-field syntax errors and unknown keys are
+74
View File
@@ -1,6 +1,7 @@
package cli
import (
"context"
"database/sql"
"encoding/json"
"fmt"
@@ -575,6 +576,58 @@ func TestValidateLegacyAIBridgeConfig(t *testing.T) {
}
}
func TestWarnIfAIProvidersConfiguredFromEnv(t *testing.T) {
t.Parallel()
t.Run("NoProviders", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiGatewayProviderEnvPrefix, nil)
require.Empty(t, sink.Entries())
})
t.Run("EmptyPrefix", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), "", []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}})
require.Empty(t, sink.Entries())
})
t.Run("AIGatewayPrefix", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiGatewayProviderEnvPrefix, []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}})
entries := sink.Entries(func(e slog.SinkEntry) bool {
return e.Message == "ai provider environment variables are deprecated for provider management and only seed provider configuration at startup"
})
require.Len(t, entries, 1)
require.Len(t, entries[0].Fields, 2)
assertFieldValue(t, entries[0].Fields, "env_prefix", aiGatewayProviderEnvPrefix)
assertFieldValue(t, entries[0].Fields, "replacement", "Manage AI Providers from the Coder UI or HTTP API.")
})
t.Run("AIBridgePrefix", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiBridgeProviderEnvPrefix, []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}})
entries := sink.Entries(func(e slog.SinkEntry) bool {
return e.Message == "ai provider environment variables are deprecated for provider management and only seed provider configuration at startup"
})
require.Len(t, entries, 1)
require.Len(t, entries[0].Fields, 2)
assertFieldValue(t, entries[0].Fields, "env_prefix", aiBridgeProviderEnvPrefix)
assertFieldValue(t, entries[0].Fields, "replacement", "Manage AI Providers from the Coder UI or HTTP API.")
})
}
func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
t.Parallel()
@@ -588,6 +641,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "OpenAI",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeOpenai,
Name: "openai",
BaseUrl: "https://api.openai.com/",
@@ -597,6 +651,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Anthropic",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeAnthropic,
Name: "anthropic",
BaseUrl: "https://api.anthropic.com/",
@@ -606,6 +661,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Copilot",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeCopilot,
Name: "copilot",
BaseUrl: "https://api.githubcopilot.com/",
@@ -615,6 +671,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Azure",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeAzure,
Name: "azure",
BaseUrl: "https://example.openai.azure.com/",
@@ -624,6 +681,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Google",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeGoogle,
Name: "google",
BaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
@@ -633,6 +691,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "OpenAICompat",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeOpenaiCompat,
Name: "openai-compat",
BaseUrl: "https://compat.example.com/v1/",
@@ -642,6 +701,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "OpenRouter",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeOpenrouter,
Name: "openrouter",
BaseUrl: "https://openrouter.ai/api/v1/",
@@ -651,6 +711,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Vercel",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeVercel,
Name: "vercel",
BaseUrl: "https://api.v0.dev/v1/",
@@ -660,6 +721,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Bedrock",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeBedrock,
Name: "bedrock",
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",
@@ -694,6 +756,7 @@ func TestBuildAIProviderFromRowBedrockWithoutSettings(t *testing.T) {
t.Parallel()
_, err := buildAIProviderFromRow(database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeBedrock,
Name: "bedrock-no-settings",
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",
@@ -711,3 +774,14 @@ func mustMarshalSettings(s codersdk.AIProviderSettings) sql.NullString {
}
return sql.NullString{String: string(data), Valid: true}
}
func assertFieldValue(t *testing.T, fields slog.Map, name string, expected interface{}) {
t.Helper()
for _, f := range fields {
if f.Name == name {
assert.Equal(t, expected, f.Value)
return
}
}
t.Errorf("field %q not found", name)
}
+25 -24
View File
@@ -21,6 +21,7 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
//nolint:paralleltest, tparallel
@@ -128,19 +129,17 @@ func TestServerCreateAdminUser(t *testing.T) {
"--email", email,
"--password", password,
)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv)
pty.ExpectMatchContext(ctx, "Creating user...")
pty.ExpectMatchContext(ctx, "Generating user SSH key...")
pty.ExpectMatchContext(ctx, fmt.Sprintf("Adding user to organization %q (%s) as admin...", org1Name, org1ID.String()))
pty.ExpectMatchContext(ctx, fmt.Sprintf("Adding user to organization %q (%s) as admin...", org2Name, org2ID.String()))
pty.ExpectMatchContext(ctx, "User created successfully.")
pty.ExpectMatchContext(ctx, username)
pty.ExpectMatchContext(ctx, email)
pty.ExpectMatchContext(ctx, "****")
stdout.ExpectMatchContext(ctx, "Creating user...")
stdout.ExpectMatchContext(ctx, "Generating user SSH key...")
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Adding user to organization %q (%s) as admin...", org1Name, org1ID.String()))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Adding user to organization %q (%s) as admin...", org2Name, org2ID.String()))
stdout.ExpectMatchContext(ctx, "User created successfully.")
stdout.ExpectMatchContext(ctx, username)
stdout.ExpectMatchContext(ctx, email)
stdout.ExpectMatchContext(ctx, "****")
verifyUser(t, connectionURL, username, email, password)
})
@@ -184,6 +183,7 @@ func TestServerCreateAdminUser(t *testing.T) {
// Skip on non-Linux because it spawns a PostgreSQL instance.
t.SkipNow()
}
logger := testutil.Logger(t)
connectionURL, err := dbtestutil.Open(t)
require.NoError(t, err)
@@ -195,23 +195,24 @@ func TestServerCreateAdminUser(t *testing.T) {
"--postgres-url", connectionURL,
"--ssh-keygen-algorithm", "ed25519",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
pty.ExpectMatchContext(ctx, "Username")
pty.WriteLine(username)
pty.ExpectMatchContext(ctx, "Email")
pty.WriteLine(email)
pty.ExpectMatchContext(ctx, "Password")
pty.WriteLine(password)
pty.ExpectMatchContext(ctx, "Confirm password")
pty.WriteLine(password)
stdout.ExpectMatchContext(ctx, "Username")
stdin.WriteLine(username)
stdout.ExpectMatchContext(ctx, "Email")
stdin.WriteLine(email)
stdout.ExpectMatchContext(ctx, "Password")
stdin.WriteLine(password)
stdout.ExpectMatchContext(ctx, "Confirm password")
stdin.WriteLine(password)
pty.ExpectMatchContext(ctx, "User created successfully.")
pty.ExpectMatchContext(ctx, username)
pty.ExpectMatchContext(ctx, email)
pty.ExpectMatchContext(ctx, "****")
stdout.ExpectMatchContext(ctx, "User created successfully.")
stdout.ExpectMatchContext(ctx, username)
stdout.ExpectMatchContext(ctx, email)
stdout.ExpectMatchContext(ctx, "****")
verifyUser(t, connectionURL, username, email, password)
})
+62 -69
View File
@@ -59,6 +59,7 @@ import (
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/tailnet/tailnettest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/serpent"
)
@@ -229,7 +230,7 @@ func TestServer(t *testing.T) {
"--access-url", "http://example.com",
"--ephemeral",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Embedded postgres takes a while to fire up.
const superDuperLong = testutil.WaitSuperLong * 3
@@ -240,7 +241,7 @@ func TestServer(t *testing.T) {
}()
matchCh1 := make(chan string, 1)
go func() {
matchCh1 <- pty.ExpectMatchContext(ctx, "Using an ephemeral deployment directory")
matchCh1 <- stdout.ExpectMatchContext(ctx, "Using an ephemeral deployment directory")
}()
select {
case err := <-errCh:
@@ -248,7 +249,7 @@ func TestServer(t *testing.T) {
case <-matchCh1:
// OK!
}
rootDirLine := pty.ReadLine(ctx)
rootDirLine := stdout.ReadLine(ctx)
rootDir := strings.TrimPrefix(rootDirLine, "Using an ephemeral deployment directory")
rootDir = strings.TrimSpace(rootDir)
rootDir = strings.TrimPrefix(rootDir, "(")
@@ -259,7 +260,7 @@ func TestServer(t *testing.T) {
matchCh2 := make(chan string, 1)
go func() {
// The "View the Web UI" log is a decent indicator that the server was successfully started.
matchCh2 <- pty.ExpectMatchContext(ctx, "View the Web UI")
matchCh2 <- stdout.ExpectMatchContext(ctx, "View the Web UI")
}()
select {
case err := <-errCh:
@@ -276,24 +277,23 @@ func TestServer(t *testing.T) {
t.Run("BuiltinPostgresURL", func(t *testing.T) {
t.Parallel()
root, _ := clitest.New(t, "server", "postgres-builtin-url")
pty := ptytest.New(t)
root.Stdout = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, root)
ctx := testutil.Context(t, testutil.WaitShort)
err := root.Run()
require.NoError(t, err)
pty.ExpectMatch("psql")
stdout.ExpectMatchContext(ctx, "psql")
})
t.Run("BuiltinPostgresURLRaw", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
root, _ := clitest.New(t, "server", "postgres-builtin-url", "--raw-url")
pty := ptytest.New(t)
root.Stdout = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, root)
err := root.WithContext(ctx).Run()
require.NoError(t, err)
got := pty.ReadLine(ctx)
got := stdout.ReadLine(ctx)
if !strings.HasPrefix(got, "postgres://") {
t.Fatalf("expected postgres URL to start with \"postgres://\", got %q", got)
}
@@ -506,6 +506,7 @@ func TestServer(t *testing.T) {
// reachable.
t.Run("LocalAccessURL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
inv, cfg := clitest.New(t,
"server",
dbArg(t),
@@ -513,7 +514,7 @@ func TestServer(t *testing.T) {
"--access-url", "http://localhost:3000/",
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Since we end the test after seeing the log lines about the access url, we could cancel the test before
// our initial interactions with PostgreSQL are complete. So, ignore errors of that type for this test.
startIgnoringPostgresQueryCancel(t, inv)
@@ -521,9 +522,9 @@ func TestServer(t *testing.T) {
// Just wait for startup
_ = waitAccessURL(t, cfg)
pty.ExpectMatch("this may cause unexpected problems when creating workspaces")
pty.ExpectMatch("View the Web UI:")
pty.ExpectMatch("http://localhost:3000/")
stdout.ExpectMatchContext(ctx, "this may cause unexpected problems when creating workspaces")
stdout.ExpectMatchContext(ctx, "View the Web UI:")
stdout.ExpectMatchContext(ctx, "http://localhost:3000/")
})
// Validate that an https scheme is prepended to a remote access URL
@@ -531,6 +532,7 @@ func TestServer(t *testing.T) {
t.Run("RemoteAccessURL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
inv, cfg := clitest.New(t,
"server",
dbArg(t),
@@ -538,7 +540,7 @@ func TestServer(t *testing.T) {
"--access-url", "https://foobarbaz.mydomain",
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Since we end the test after seeing the log lines about the access url, we could cancel the test before
// our initial interactions with PostgreSQL are complete. So, ignore errors of that type for this test.
@@ -547,13 +549,14 @@ func TestServer(t *testing.T) {
// Just wait for startup
_ = waitAccessURL(t, cfg)
pty.ExpectMatch("this may cause unexpected problems when creating workspaces")
pty.ExpectMatch("View the Web UI:")
pty.ExpectMatch("https://foobarbaz.mydomain")
stdout.ExpectMatchContext(ctx, "this may cause unexpected problems when creating workspaces")
stdout.ExpectMatchContext(ctx, "View the Web UI:")
stdout.ExpectMatchContext(ctx, "https://foobarbaz.mydomain")
})
t.Run("NoWarningWithRemoteAccessURL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
inv, cfg := clitest.New(t,
"server",
dbArg(t),
@@ -561,7 +564,7 @@ func TestServer(t *testing.T) {
"--access-url", "https://google.com",
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Since we end the test after seeing the log lines about the access url, we could cancel the test before
// our initial interactions with PostgreSQL are complete. So, ignore errors of that type for this test.
startIgnoringPostgresQueryCancel(t, inv)
@@ -569,8 +572,8 @@ func TestServer(t *testing.T) {
// Just wait for startup
_ = waitAccessURL(t, cfg)
pty.ExpectMatch("View the Web UI:")
pty.ExpectMatch("https://google.com")
stdout.ExpectMatchContext(ctx, "View the Web UI:")
stdout.ExpectMatchContext(ctx, "https://google.com")
})
t.Run("NoSchemeAccessURL", func(t *testing.T) {
@@ -735,8 +738,6 @@ func TestServer(t *testing.T) {
"--tls-key-file", key2Path,
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t)
root.Stdout = pty.Output()
clitest.Start(t, root.WithContext(ctx))
accessURL := waitAccessURL(t, cfg)
@@ -814,18 +815,18 @@ func TestServer(t *testing.T) {
"--tls-key-file", keyPath,
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv)
// We can't use waitAccessURL as it will only return the HTTP URL.
const httpLinePrefix = "Started HTTP listener at"
pty.ExpectMatch(httpLinePrefix)
httpLine := pty.ReadLine(ctx)
stdout.ExpectMatchContext(ctx, httpLinePrefix)
httpLine := stdout.ReadLine(ctx)
httpAddr := strings.TrimSpace(strings.TrimPrefix(httpLine, httpLinePrefix))
require.NotEmpty(t, httpAddr)
const tlsLinePrefix = "Started TLS/HTTPS listener at "
pty.ExpectMatch(tlsLinePrefix)
tlsLine := pty.ReadLine(ctx)
stdout.ExpectMatchContext(ctx, tlsLinePrefix)
tlsLine := stdout.ReadLine(ctx)
tlsAddr := strings.TrimSpace(strings.TrimPrefix(tlsLine, tlsLinePrefix))
require.NotEmpty(t, tlsAddr)
@@ -951,8 +952,7 @@ func TestServer(t *testing.T) {
}
inv, _ := clitest.New(t, flags...)
pty := ptytest.New(t)
pty.Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv)
@@ -963,15 +963,15 @@ func TestServer(t *testing.T) {
// We can't use waitAccessURL as it will only return the HTTP URL.
if c.httpListener {
const httpLinePrefix = "Started HTTP listener at"
pty.ExpectMatch(httpLinePrefix)
httpLine := pty.ReadLine(ctx)
stdout.ExpectMatchContext(ctx, httpLinePrefix)
httpLine := stdout.ReadLine(ctx)
httpAddr = strings.TrimSpace(strings.TrimPrefix(httpLine, httpLinePrefix))
require.NotEmpty(t, httpAddr)
}
if c.tlsListener {
const tlsLinePrefix = "Started TLS/HTTPS listener at"
pty.ExpectMatch(tlsLinePrefix)
tlsLine := pty.ReadLine(ctx)
stdout.ExpectMatchContext(ctx, tlsLinePrefix)
tlsLine := stdout.ReadLine(ctx)
tlsAddr = strings.TrimSpace(strings.TrimPrefix(tlsLine, tlsLinePrefix))
require.NotEmpty(t, tlsAddr)
}
@@ -1041,6 +1041,7 @@ func TestServer(t *testing.T) {
t.Run("CanListenUnspecifiedv4", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
inv, _ := clitest.New(t,
"server",
dbArg(t),
@@ -1048,18 +1049,19 @@ func TestServer(t *testing.T) {
"--access-url", "http://example.com",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Since we end the test after seeing the log lines about the HTTP listener, we could cancel the test before
// our initial interactions with PostgreSQL are complete. So, ignore errors of that type for this test.
startIgnoringPostgresQueryCancel(t, inv)
pty.ExpectMatch("Started HTTP listener")
pty.ExpectMatch("http://0.0.0.0:")
stdout.ExpectMatchContext(ctx, "Started HTTP listener")
stdout.ExpectMatchContext(ctx, "http://0.0.0.0:")
})
t.Run("CanListenUnspecifiedv6", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
inv, _ := clitest.New(t,
"server",
dbArg(t),
@@ -1067,13 +1069,13 @@ func TestServer(t *testing.T) {
"--access-url", "http://example.com",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Since we end the test after seeing the log lines about the HTTP listener, we could cancel the test before
// our initial interactions with PostgreSQL are complete. So, ignore errors of that type for this test.
startIgnoringPostgresQueryCancel(t, inv)
pty.ExpectMatch("Started HTTP listener at")
pty.ExpectMatch("http://[::]:")
stdout.ExpectMatchContext(ctx, "Started HTTP listener at")
stdout.ExpectMatchContext(ctx, "http://[::]:")
})
t.Run("NoAddress", func(t *testing.T) {
@@ -1128,12 +1130,10 @@ func TestServer(t *testing.T) {
"--access-url", "http://example.com",
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv.WithContext(ctx))
pty.ExpectMatch("is deprecated")
stdout.ExpectMatchContext(ctx, "is deprecated")
accessURL := waitAccessURL(t, cfg)
require.Equal(t, "http", accessURL.Scheme)
@@ -1158,12 +1158,10 @@ func TestServer(t *testing.T) {
"--tls-key-file", keyPath,
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t)
root.Stdout = pty.Output()
root.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, root)
clitest.Start(t, root.WithContext(ctx))
pty.ExpectMatch("is deprecated")
stdout.ExpectMatchContext(ctx, "is deprecated")
accessURL := waitAccessURL(t, cfg)
require.Equal(t, "https", accessURL.Scheme)
@@ -1259,15 +1257,13 @@ func TestServer(t *testing.T) {
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv)
// Wait until we see the prometheus address in the logs.
addrMatchExpr := `http server listening\s+addr=(\S+)\s+name=prometheus`
lineMatch := pty.ExpectRegexMatchContext(ctx, addrMatchExpr)
lineMatch := stdout.ExpectRegexMatchContext(ctx, addrMatchExpr)
promAddr := regexp.MustCompile(addrMatchExpr).FindStringSubmatch(lineMatch)[1]
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
@@ -1322,15 +1318,13 @@ func TestServer(t *testing.T) {
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv)
// Wait until we see the prometheus address in the logs.
addrMatchExpr := `http server listening\s+addr=(\S+)\s+name=prometheus`
lineMatch := pty.ExpectRegexMatchContext(ctx, addrMatchExpr)
lineMatch := stdout.ExpectRegexMatchContext(ctx, addrMatchExpr)
promAddr := regexp.MustCompile(addrMatchExpr).FindStringSubmatch(lineMatch)[1]
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
@@ -1751,7 +1745,6 @@ func TestServer(t *testing.T) {
inv, cfg := clitest.New(t,
args...,
)
ptytest.New(t).Attach(inv)
inv = inv.WithContext(ctx)
w := clitest.StartWithWaiter(t, inv)
gotURL := waitAccessURL(t, cfg)
@@ -2019,15 +2012,15 @@ func TestServer_Logging_NoParallel(t *testing.T) {
"--provisioner-types=echo",
"--log-stackdriver", fi,
)
// Attach pty so we get debug output from the command if this test
// Attach expecter so we get debug output from the command if this test
// fails.
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
startIgnoringPostgresQueryCancel(t, inv.WithContext(ctx))
// Wait for server to listen on HTTP, this is a good
// starting point for expecting logs.
_ = pty.ExpectMatchContext(ctx, "Started HTTP listener at")
_ = stdout.ExpectMatchContext(ctx, "Started HTTP listener at")
loggingWaitFile(t, fi, testutil.WaitSuperLong)
})
@@ -2056,15 +2049,15 @@ func TestServer_Logging_NoParallel(t *testing.T) {
"--log-json", fi2,
"--log-stackdriver", fi3,
)
// Attach pty so we get debug output from the command if this test
// Attach expecter so we get debug output from the command if this test
// fails.
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
startIgnoringPostgresQueryCancel(t, inv)
// Wait for server to listen on HTTP, this is a good
// starting point for expecting logs.
_ = pty.ExpectMatchContext(ctx, "Started HTTP listener at")
_ = stdout.ExpectMatchContext(ctx, "Started HTTP listener at")
loggingWaitFile(t, fi1, testutil.WaitSuperLong)
loggingWaitFile(t, fi2, testutil.WaitSuperLong)
@@ -2258,7 +2251,7 @@ func TestServer_GracefulShutdown(t *testing.T) {
return ctx, stopFunc
})
serverErr := make(chan error, 1)
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
go func() {
serverErr <- root.WithContext(ctx).Run()
}()
@@ -2266,7 +2259,7 @@ func TestServer_GracefulShutdown(t *testing.T) {
// It's fair to assume `stopFunc` isn't nil here, because the server
// has started and access URL is propagated.
stopFunc()
pty.ExpectMatch("waiting for provisioner jobs to complete")
stdout.ExpectMatchContext(ctx, "waiting for provisioner jobs to complete")
err := <-serverErr
require.NoError(t, err)
}
@@ -2501,19 +2494,19 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
inv.Logger = inv.Logger.Named(opts.name)
errChan := make(chan error, 1)
pty := ptytest.New(t).Named(opts.name).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
errChan <- inv.WithContext(ctx).Run()
// close the pty here so that we can start tearing down resources. This test creates multiple servers with
// associated ptys. There is a `t.Cleanup()` that does this, but it waits until the whole test is complete.
_ = pty.Close()
stdout.Close("invocation complete")
}()
if opts.waitForSnapshot {
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "submitted snapshot")
stdout.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "submitted snapshot")
}
if opts.waitForTelemetryDisabledCheck {
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "finished telemetry status check")
stdout.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "finished telemetry status check")
}
return errChan, cancelFunc
}
+51 -12
View File
@@ -237,7 +237,10 @@ func Test_TaskSend(t *testing.T) {
t.Parallel()
// Given: An initializing task (workspace running, no agent
// connected).
// connected). Close the agent, pause, then resume so the
// workspace is started but no agent is connected. The
// command enters waitForTaskIdle directly (initializing
// path), where we verify it handles an external pause.
setupCtx := testutil.Context(t, testutil.WaitLong)
setup := setupCLITaskTest(setupCtx, t, nil)
@@ -245,8 +248,13 @@ func Test_TaskSend(t *testing.T) {
pauseTask(setupCtx, t, setup.userClient, setup.task)
resumeTask(setupCtx, t, setup.userClient, setup.task)
// Set up mock clock and traps before starting the command.
mClock := quartz.NewMock(t)
tickTrap := mClock.Trap().NewTicker("task_send", "poll")
resetTrap := mClock.Trap().TickerReset("task_send", "poll")
// When: We attempt to send input to the initializing task.
inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input")
inv, root := clitest.NewWithClock(t, mClock, "task", "send", setup.task.Name, "some task input")
clitest.SetupConfig(t, setup.userClient, root)
ctx := testutil.Context(t, testutil.WaitLong)
@@ -259,11 +267,34 @@ func Test_TaskSend(t *testing.T) {
// of waitForTaskIdle.
pty.ExpectMatchContext(ctx, "Waiting for task to become idle")
// Pause the task while waitForTaskIdle is polling. Since
// no agent is connected, the task stays initializing until
// we pause it, at which point the status becomes paused.
// Wait for ticker creation and release it.
tickCall := tickTrap.MustWait(ctx)
tickCall.MustRelease(ctx)
tickTrap.Close()
// Fire the first poll. The goroutine calls ticker.Reset
// which the trap catches, freezing the goroutine BEFORE
// client.TaskByID runs. Release it so the first poll
// sees 'initializing' and continues.
mClock.Advance(time.Nanosecond).MustWait(ctx)
resetCall := resetTrap.MustWait(ctx)
resetCall.MustRelease(ctx)
// Fire the second poll. The goroutine is again frozen at
// ticker.Reset by the trap.
mClock.Advance(5 * time.Second).MustWait(ctx)
resetCall = resetTrap.MustWait(ctx)
// While the goroutine is frozen (before client.TaskByID),
// pause the task. The stop build completes, so the DB has
// (stop, succeeded) = 'paused'.
pauseTask(ctx, t, setup.userClient, setup.task)
// Release the trap. The goroutine unfreezes and
// client.TaskByID deterministically sees 'paused'.
resetCall.MustRelease(ctx)
resetTrap.Close()
// Then: The command should fail because the task was paused.
err := w.Wait()
require.Error(t, err)
@@ -303,23 +334,31 @@ func Test_TaskSend(t *testing.T) {
tickCall.MustRelease(ctx)
tickTrap.Close()
// Fire the immediate first poll (time.Nanosecond initial interval).
// Fire the first poll. The goroutine calls ticker.Reset
// which the trap catches, freezing the goroutine BEFORE
// client.TaskByID runs. Release it so the first poll
// sees "working" and continues.
mClock.Advance(time.Nanosecond).MustWait(ctx)
// Wait for Reset (confirms first poll completed and saw "working").
resetCall := resetTrap.MustWait(ctx)
resetCall.MustRelease(ctx)
resetTrap.Close()
// Transition the app back to idle so waitForTaskIdle proceeds.
// Fire the second poll. The goroutine is again frozen
// at ticker.Reset by the trap.
mClock.Advance(5 * time.Second).MustWait(ctx)
resetCall = resetTrap.MustWait(ctx)
// While the goroutine is frozen (before client.TaskByID),
// transition the app to idle.
require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
AppSlug: "task-sidebar",
State: codersdk.WorkspaceAppStatusStateIdle,
Message: "ready",
}))
// Fire second poll at the regular 5s interval.
mClock.Advance(5 * time.Second).MustWait(ctx)
// Release the trap. The goroutine unfreezes and
// client.TaskByID deterministically sees "idle".
resetCall.MustRelease(ctx)
resetTrap.Close()
// Then: The command should complete successfully.
require.NoError(t, w.Wait())
+44 -18
View File
@@ -124,35 +124,55 @@ AI GATEWAY OPTIONS:
disabled, only centralized key authentication is permitted.
--ai-gateway-anthropic-base-url string, $CODER_AI_GATEWAY_ANTHROPIC_BASE_URL (default: https://api.anthropic.com/)
The base URL of the Anthropic API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The base URL of the Anthropic
API.
--ai-gateway-anthropic-key string, $CODER_AI_GATEWAY_ANTHROPIC_KEY
The key to authenticate against the Anthropic API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The key to authenticate
against the Anthropic API.
--ai-gateway-bedrock-access-key string, $CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY
The access key to authenticate against the AWS Bedrock API.
--ai-gateway-bedrock-access-key-secret string, $CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY_SECRET
The access key secret to use with the access key to authenticate
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The access key to authenticate
against the AWS Bedrock API.
--ai-gateway-bedrock-access-key-secret string, $CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY_SECRET
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The access key secret to use
with the access key to authenticate against the AWS Bedrock API.
--ai-gateway-bedrock-base-url string, $CODER_AI_GATEWAY_BEDROCK_BASE_URL
The base URL to use for the AWS Bedrock API. Use this setting to
specify an exact URL to use. Takes precedence over
CODER_AI_GATEWAY_BEDROCK_REGION.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The base URL to use for the
AWS Bedrock API. Use this setting to specify an exact URL to use.
Takes precedence over CODER_AI_GATEWAY_BEDROCK_REGION.
--ai-gateway-bedrock-model string, $CODER_AI_GATEWAY_BEDROCK_MODEL (default: global.anthropic.claude-sonnet-4-5-20250929-v1:0)
The model to use when making requests to the AWS Bedrock API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The model to use when making
requests to the AWS Bedrock API.
--ai-gateway-bedrock-region string, $CODER_AI_GATEWAY_BEDROCK_REGION
The AWS Bedrock API region to use. Constructs a base URL to use for
the AWS Bedrock API in the form of
'https://bedrock-runtime.<region>.amazonaws.com'.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The AWS Bedrock API region to
use. Constructs a base URL to use for the AWS Bedrock API in the form
of 'https://bedrock-runtime.<region>.amazonaws.com'.
--ai-gateway-bedrock-small-fastmodel string, $CODER_AI_GATEWAY_BEDROCK_SMALL_FAST_MODEL (default: global.anthropic.claude-haiku-4-5-20251001-v1:0)
The small fast model to use when making requests to the AWS Bedrock
API. Claude Code uses Haiku-class models to perform background tasks.
See
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The small fast model to use
when making requests to the AWS Bedrock API. Claude Code uses
Haiku-class models to perform background tasks. See
https://docs.claude.com/en/docs/claude-code/settings#environment-variables.
--ai-gateway-circuit-breaker-enabled bool, $CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED (default: false)
@@ -171,10 +191,16 @@ AI GATEWAY OPTIONS:
to disable (unlimited).
--ai-gateway-openai-base-url string, $CODER_AI_GATEWAY_OPENAI_BASE_URL (default: https://api.openai.com/v1/)
The base URL of the OpenAI API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The base URL of the OpenAI
API.
--ai-gateway-openai-key string, $CODER_AI_GATEWAY_OPENAI_KEY
The key to authenticate against the OpenAI API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The key to authenticate
against the OpenAI API.
--ai-gateway-rate-limit int, $CODER_AI_GATEWAY_RATE_LIMIT (default: 0)
Maximum number of AI Gateway requests per second per replica. Set to 0
+25 -9
View File
@@ -874,25 +874,41 @@ ai_gateway:
# Whether to start an in-memory AI Gateway instance.
# (default: true, type: bool)
enabled: true
# The base URL of the OpenAI API.
# Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this
# option seeds provider configuration at startup only exactly once. It will not be
# used in service runtime. The base URL of the OpenAI API.
# (default: https://api.openai.com/v1/, type: string)
openai_base_url: https://api.openai.com/v1/
# The base URL of the Anthropic API.
# Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this
# option seeds provider configuration at startup only exactly once. It will not be
# used in service runtime. The base URL of the Anthropic API.
# (default: https://api.anthropic.com/, type: string)
anthropic_base_url: https://api.anthropic.com/
# The base URL to use for the AWS Bedrock API. Use this setting to specify an
# exact URL to use. Takes precedence over CODER_AI_GATEWAY_BEDROCK_REGION.
# Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this
# option seeds provider configuration at startup only exactly once. It will not be
# used in service runtime. The base URL to use for the AWS Bedrock API. Use this
# setting to specify an exact URL to use. Takes precedence over
# CODER_AI_GATEWAY_BEDROCK_REGION.
# (default: <unset>, type: string)
bedrock_base_url: ""
# The AWS Bedrock API region to use. Constructs a base URL to use for the AWS
# Bedrock API in the form of 'https://bedrock-runtime.<region>.amazonaws.com'.
# Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this
# option seeds provider configuration at startup only exactly once. It will not be
# used in service runtime. The AWS Bedrock API region to use. Constructs a base
# URL to use for the AWS Bedrock API in the form of
# 'https://bedrock-runtime.<region>.amazonaws.com'.
# (default: <unset>, type: string)
bedrock_region: ""
# The model to use when making requests to the AWS Bedrock API.
# Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this
# option seeds provider configuration at startup only exactly once. It will not be
# used in service runtime. The model to use when making requests to the AWS
# Bedrock API.
# (default: global.anthropic.claude-sonnet-4-5-20250929-v1:0, type: string)
bedrock_model: global.anthropic.claude-sonnet-4-5-20250929-v1:0
# The small fast model to use when making requests to the AWS Bedrock API. Claude
# Code uses Haiku-class models to perform background tasks. See
# Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this
# option seeds provider configuration at startup only exactly once. It will not be
# used in service runtime. The small fast model to use when making requests to the
# AWS Bedrock API. Claude Code uses Haiku-class models to perform background
# tasks. See
# https://docs.claude.com/en/docs/claude-code/settings#environment-variables.
# (default: global.anthropic.claude-haiku-4-5-20251001-v1:0, type: string)
bedrock_small_fast_model: global.anthropic.claude-haiku-4-5-20251001-v1:0
+16
View File
@@ -340,6 +340,10 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
return errBedrockRejectsAPIKeys
}
if req.APIKeys != nil && old.Type == database.AiProviderTypeCopilot && len(*req.APIKeys) > 0 {
return errCopilotRejectsAPIKeys
}
displayName := old.DisplayName
if req.DisplayName != nil {
// Empty string clears the column.
@@ -383,6 +387,12 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
})
return
}
if errors.Is(err, errCopilotRejectsAPIKeys) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Copilot providers do not accept api_keys; they authenticate via request-time GitHub OAuth tokens.",
})
return
}
if errors.Is(err, errAIProviderBedrockTypeMismatch) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Bedrock settings are only valid for type=anthropic or type=bedrock.",
@@ -483,6 +493,12 @@ func (api *API) publishAIProvidersChanged(ctx context.Context) {
// Bedrock-typed provider; the outer handler translates it into a 400.
var errBedrockRejectsAPIKeys = xerrors.New("bedrock providers do not accept api_keys")
// errCopilotRejectsAPIKeys is the sentinel returned from inside the
// update transaction when a caller attempts to attach api_keys to a
// Copilot-typed provider; the outer handler translates it into a 400.
// Copilot authenticates via request-time GitHub OAuth tokens.
var errCopilotRejectsAPIKeys = xerrors.New("copilot providers do not accept api_keys")
// errAIProviderBedrockTypeMismatch is the sentinel returned from
// inside the update transaction when the post-merge settings carry a
// Bedrock block but the provider is not anthropic- or bedrock-typed;
+44 -6
View File
@@ -116,10 +116,21 @@ func SeedAIProvidersFromEnv(
if err != nil {
return xerrors.Errorf("decode existing settings for %q: %w", dp.Name, err)
}
// Load existing bearer keys so the canonical hash
// includes credentials for comparison.
existingKeyRows, err := tx.GetAIProviderKeysByProviderID(sysCtx, existing.ID)
if err != nil {
return xerrors.Errorf("load existing keys for %q: %w", dp.Name, err)
}
existingKeys := make([]string, 0, len(existingKeyRows))
for _, k := range existingKeyRows {
existingKeys = append(existingKeys, k.APIKey)
}
existingDP := desiredAIProvider{
Type: existing.Type,
BaseURL: existing.BaseUrl,
Bedrock: existingSettings.Bedrock,
Keys: existingKeys,
}
existingHash := computeProviderHash(existingDP.canonical())
if existingHash == dp.Hash {
@@ -196,18 +207,15 @@ func SeedAIProvidersFromEnv(
// canonicalAIProvider is the shape we hash to detect drift between the
// configured environment and the row stored in the database. The fields
// we hash are exactly the operator-controllable inputs that affect
// runtime behavior. Credentials are intentionally NOT part of the hash
// so operators can rotate them via the API without forcing a server
// restart. This applies to both bearer API keys (stored in
// ai_provider_keys) and to Bedrock access key/secret pairs (stored in
// the settings blob because Bedrock authenticates via settings rather
// than a bearer token).
// runtime behavior, including credentials.
//
// Model and SmallFastModel are excluded: they're tunables, and their
// serpent defaults shift across releases.
type canonicalAIProvider struct {
Type string `json:"type"`
BaseURL string `json:"base_url"`
BedrockRegion string `json:"bedrock_region"`
KeysHash string `json:"keys_hash"`
}
// desiredAIProvider is a normalized provider description sourced from
@@ -235,9 +243,39 @@ func (d desiredAIProvider) canonical() canonicalAIProvider {
if d.Bedrock != nil {
c.BedrockRegion = d.Bedrock.Region
}
c.KeysHash = computeKeysHash(d.Keys, d.Bedrock)
return c
}
// computeKeysHash produces a deterministic hash over the bearer API
// keys and, for Bedrock providers, the access key and secret.
func computeKeysHash(bearerKeys []string, bedrock *codersdk.AIProviderBedrockSettings) string {
// Collect all credential material in a deterministic order.
// Bearer keys are sorted so reordering in env vars does not
// trigger a false-positive drift.
sorted := make([]string, len(bearerKeys))
copy(sorted, bearerKeys)
slices.Sort(sorted)
h := sha256.New()
for _, k := range sorted {
_, _ = h.Write([]byte(k))
// Separator so "ab"+"c" != "a"+"bc".
_, _ = h.Write([]byte{0})
}
if bedrock != nil {
if bedrock.AccessKey != nil {
_, _ = h.Write([]byte(*bedrock.AccessKey))
}
_, _ = h.Write([]byte{0})
if bedrock.AccessKeySecret != nil {
_, _ = h.Write([]byte(*bedrock.AccessKeySecret))
}
_, _ = h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
func computeProviderHash(c canonicalAIProvider) string {
// json.Marshal is deterministic for structs because field order is
// fixed by the struct definition.
+113 -21
View File
@@ -91,21 +91,23 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
}
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
// Changing the API key alone does NOT count as drift: keys
// live in a separate table and operators rotate them via the
// API. Only changes to non-credential provider-level fields
// (base_url, type, Bedrock region/model) trip the drift check.
// Changing the API key counts as drift: keys are included
// in the canonical hash so operators notice when env-var
// credential changes are ignored by an existing provider.
cfg.LegacyOpenAI.Key = serpent.String("sk-rotated")
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
// Changing the base URL is real drift.
cfg.LegacyOpenAI.BaseURL = serpent.String("https://api.openai.com/v2")
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
require.Error(t, err)
require.Contains(t, err.Error(), "differs from the current environment configuration")
// Changing the base URL is also real drift.
cfg.LegacyOpenAI.Key = serpent.String("sk-original")
cfg.LegacyOpenAI.BaseURL = serpent.String("https://api.openai.com/v2")
err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
require.Error(t, err)
require.Contains(t, err.Error(), "differs from the current environment configuration")
})
t.Run("BedrockCredentialRotationIsNotDrift", func(t *testing.T) {
t.Run("BedrockCredentialChangeIsDrift", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
@@ -120,17 +122,20 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
}
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
// Rotating the Bedrock access key and secret in env must NOT
// trip the drift check: they're credentials, equivalent to
// bearer API keys, and operators rotate them via the API.
// Rotating the Bedrock access key in env trips the drift
// check so operators know the change did not take effect.
cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-rotated")
cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-rotated")
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
require.Error(t, err)
require.Contains(t, err.Error(), "differs from the current environment configuration")
// Changing the Bedrock region (a non-credential field) is
// real drift.
// also real drift.
cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-original")
cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-original")
cfg.LegacyBedrock.Region = serpent.String("us-west-2")
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
require.Error(t, err)
require.Contains(t, err.Error(), "differs from the current environment configuration")
})
@@ -293,6 +298,57 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
require.Equal(t, "sk-ant-1", anKeys[0].APIKey)
})
t.Run("IndexedProvidersKeyDriftWithMultipleKeysAndProviders", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
cfg := codersdk.AIBridgeConfig{
Providers: []codersdk.AIProviderConfig{
{
Type: "openai",
Name: "primary-openai",
BaseURL: "https://api.openai.com/v1",
Keys: []string{"sk-openai-1", "sk-openai-2"},
},
{
Type: "anthropic",
Name: "primary-anthropic",
BaseURL: "https://api.anthropic.com/",
Keys: []string{"sk-ant-1", "sk-ant-2"},
},
},
}
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
// Reordering keys must not count as drift. The canonical hash
// sorts keys before hashing, so equivalent key sets remain
// stable across restarts.
cfg.Providers[0].Keys = []string{"sk-openai-2", "sk-openai-1"}
cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-1"}
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
// Changing one key on one provider must block startup even
// when multiple providers are configured.
cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-rotated"}
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
require.Error(t, err)
require.Contains(t, err.Error(), "differs from the current environment configuration")
require.Contains(t, err.Error(), `"primary-anthropic"`)
oa, err := db.GetAIProviderByName(ctx, "primary-openai")
require.NoError(t, err)
oaKeys, err := db.GetAIProviderKeysByProviderID(ctx, oa.ID)
require.NoError(t, err)
require.ElementsMatch(t, []string{"sk-openai-1", "sk-openai-2"}, []string{oaKeys[0].APIKey, oaKeys[1].APIKey})
an, err := db.GetAIProviderByName(ctx, "primary-anthropic")
require.NoError(t, err)
anKeys, err := db.GetAIProviderKeysByProviderID(ctx, an.ID)
require.NoError(t, err)
require.ElementsMatch(t, []string{"sk-ant-1", "sk-ant-2"}, []string{anKeys[0].APIKey, anKeys[1].APIKey})
})
t.Run("BedrockIndexedProviderHasNoKeys", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
@@ -424,7 +480,7 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
require.Empty(t, all, "expected no active rows after soft-delete + re-seed")
})
t.Run("ExistingKeysArePreserved", func(t *testing.T) {
t.Run("ExistingKeysBlockOnDrift", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
@@ -440,15 +496,17 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
row, err := db.GetAIProviderByName(ctx, "openai")
require.NoError(t, err)
// Operator rotates the env key. The seed must not duplicate
// keys on a row that already exists; the new key is only
// installed via the API/CRUD layer in this flow.
// Operator rotates the env key. The seed now blocks startup
// because the keys differ, alerting the operator.
cfg.LegacyOpenAI.Key = serpent.String("sk-rotated")
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
require.Error(t, err)
require.Contains(t, err.Error(), "differs from the current environment configuration")
// The original key is still in the database.
keys, err := db.GetAIProviderKeysByProviderID(ctx, row.ID)
require.NoError(t, err)
require.Len(t, keys, 1, "env reseed must not duplicate keys on existing rows")
require.Len(t, keys, 1)
require.Equal(t, "sk-original", keys[0].APIKey)
})
@@ -482,6 +540,40 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
require.Len(t, all, 1, "duplicate indexed entries with matching hash must produce a single row")
})
t.Run("IndexedDuplicateNameMatchingHashDedupesReorderedKeys", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
// Key order should not affect the canonical hash. Reordered
// duplicates under the same name should still dedupe.
cfg := codersdk.AIBridgeConfig{
Providers: []codersdk.AIProviderConfig{
{
Type: "openai",
Name: "shared",
BaseURL: "https://api.openai.com/v1",
Keys: []string{"sk-1", "sk-2"},
},
{
Type: "openai",
Name: "shared",
BaseURL: "https://api.openai.com/v1",
Keys: []string{"sk-2", "sk-1"},
},
},
}
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
all, err := db.GetAIProviders(ctx, database.GetAIProvidersParams{})
require.NoError(t, err)
require.Len(t, all, 1)
keys, err := db.GetAIProviderKeysByProviderID(ctx, all[0].ID)
require.NoError(t, err)
require.Len(t, keys, 2)
require.ElementsMatch(t, []string{"sk-1", "sk-2"}, []string{keys[0].APIKey, keys[1].APIKey})
})
t.Run("IndexedDuplicateNameMismatchingHashFails", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
+69
View File
@@ -889,6 +889,75 @@ func TestAIProvidersKeyManagement(t *testing.T) {
require.Contains(t, sdkErr.Message, "Bedrock providers do not accept api_keys")
})
t.Run("CopilotCreateWithoutKeys", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Owner role is the audience for this endpoint.
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeCopilot,
Name: "keys-copilot",
Enabled: true,
BaseURL: "https://api.business.githubcopilot.com",
})
require.NoError(t, err)
require.Equal(t, codersdk.AIProviderTypeCopilot, provider.Type)
require.Empty(t, provider.APIKeys)
})
t.Run("CopilotRejectsCreateWithKeys", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Owner role is the audience for this endpoint.
_, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeCopilot,
Name: "keys-copilot-create",
Enabled: true,
BaseURL: "https://api.business.githubcopilot.com",
APIKeys: []string{"sk-should-be-rejected"}, //nolint:gosec // test fixture, not a real credential
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "api_keys", sdkErr.Validations[0].Field)
require.Contains(t, sdkErr.Validations[0].Detail, "type=copilot does not accept api_keys")
})
t.Run("CopilotRejectsUpdateWithKeys", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Owner role is the audience for this endpoint.
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeCopilot,
Name: "keys-copilot-update",
Enabled: true,
BaseURL: "https://api.business.githubcopilot.com",
})
require.NoError(t, err)
rejected := []codersdk.AIProviderKeyMutation{
{APIKey: ptr.Ref("sk-copilot-no")}, //nolint:gosec // test fixture, not a real credential
}
_, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
APIKeys: &rejected,
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "Copilot providers do not accept api_keys")
})
t.Run("EmptyKeyRejected", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
+5 -2
View File
@@ -30,7 +30,9 @@ const (
type Pooler interface {
Acquire(ctx context.Context, req Request, clientFn ClientFunc, mcpBootstrapper MCPProxyBuilder) (http.Handler, error)
// ReplaceProviders swaps the providers used to construct future
// RequestBridge instances and clears the cache.
// RequestBridge instances and clears the cache. Disabled providers
// must be included; the bridge serves a 503 sentinel on their
// routes.
ReplaceProviders(providers []aibridge.Provider)
Shutdown(ctx context.Context) error
}
@@ -53,7 +55,8 @@ var _ Pooler = &CachedBridgePool{}
type CachedBridgePool struct {
cache *ristretto.Cache[string, *aibridge.RequestBridge]
// providers is the live provider set used by new RequestBridge instances.
// providers is the live provider set used by new RequestBridge
// instances. Includes disabled providers.
providers atomic.Pointer[[]aibridge.Provider]
providerVersion atomic.Int64
logger slog.Logger
+239 -228
View File
@@ -216,8 +216,9 @@ type RecordInterceptionEndedRequest struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID.
EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID.
EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"`
CredentialHint string `protobuf:"bytes,3,opt,name=credential_hint,json=credentialHint,proto3" json:"credential_hint,omitempty"`
}
func (x *RecordInterceptionEndedRequest) Reset() {
@@ -266,6 +267,13 @@ func (x *RecordInterceptionEndedRequest) GetEndedAt() *timestamppb.Timestamp {
return nil
}
func (x *RecordInterceptionEndedRequest) GetCredentialHint() string {
if x != nil {
return x.CredentialHint
}
return ""
}
type RecordInterceptionEndedResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -1295,249 +1303,252 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{
0x42, 0x14, 0x0a, 0x12, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x73, 0x73,
0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x22, 0x67, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e,
0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x5f,
0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73,
0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x22, 0x21, 0x0a,
0x1f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f,
0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c,
0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12,
0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73,
0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f,
0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52,
0x6f, 0x6e, 0x73, 0x65, 0x22, 0x90, 0x01, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49,
0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64,
0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65,
0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x12, 0x27,
0x0a, 0x0f, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x68, 0x69, 0x6e,
0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74,
0x69, 0x61, 0x6c, 0x48, 0x69, 0x6e, 0x74, 0x22, 0x21, 0x0a, 0x1f, 0x52, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64,
0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52,
0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45,
0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39,
0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09,
0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63,
0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f,
0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68,
0x65, 0x52, 0x65, 0x61, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73,
0x12, 0x37, 0x0a, 0x18, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f,
0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01,
0x28, 0x03, 0x52, 0x15, 0x63, 0x61, 0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e,
0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74,
0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05,
0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e,
0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63,
0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e,
0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15,
0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18,
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a,
0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72,
0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63,
0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12,
0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f,
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e,
0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74,
0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48,
0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54,
0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08,
0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61,
0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54,
0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65,
0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45,
0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61,
0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07,
0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68, 0x65, 0x52, 0x65, 0x61, 0x64, 0x49,
0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x63, 0x61,
0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f,
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x63, 0x61,
0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b,
0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45,
0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c,
0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x22, 0x8f, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f,
0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27,
0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69,
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22,
0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01,
0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88,
0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18,
0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08,
0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08,
0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f,
0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01,
0x28, 0x09, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x45, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61,
0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61,
0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18,
0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c,
0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51,
0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,
0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38,
0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c,
0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54,
0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x22, 0xb8, 0x02, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c,
0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27,
0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65,
0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e,
0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f,
0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e,
0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a,
0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63,
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61,
0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f,
0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63,
0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f,
0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12,
0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64,
0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73,
0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64,
0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61,
0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74,
0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a,
0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01,
0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74,
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x8f, 0x04,
0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67,
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65,
0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49,
0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76,
0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09,
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04,
0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c,
0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74,
0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74,
0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0f,
0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x88,
0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08,
0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63,
0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72,
0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63,
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65,
0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63,
0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f,
0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61,
0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76,
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f,
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79,
0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52,
0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f,
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69,
0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22,
0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61,
0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x02, 0x0a, 0x19, 0x52,
0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68,
0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47, 0x65, 0x74,
0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64,
0x22, 0xb2, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x12, 0x51, 0x0a, 0x19, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61,
0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18,
0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43,
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65,
0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, 0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72,
0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74,
0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18,
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77,
0x52, 0x65, 0x67, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65,
0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d,
0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a,
0x24, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63,
0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31,
0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d,
0x63, 0x70, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64,
0x73, 0x22, 0xda, 0x02, 0x0a, 0x25, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76,
0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61,
0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61,
0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x3e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43,
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b,
0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74,
0x72, 0x79, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73,
0x12, 0x50, 0x0a, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x38, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45,
0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f,
0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65,
0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74,
0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3e,
0x0a, 0x13, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69,
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x6b,
0x0a, 0x14, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49,
0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12,
0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f,
0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20,
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74,
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49,
0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74,
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25,
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74,
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65,
0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e,
0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a,
0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67,
0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d,
0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67,
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61,
0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65,
0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c,
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55,
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67,
0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63,
0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47,
0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65,
0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49,
0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d,
0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65,
0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e,
0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d,
0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74,
0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69,
0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e,
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d,
0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72,
0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xb2, 0x01, 0x0a, 0x1b, 0x47,
0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43,
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65,
0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74,
0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54,
0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41,
0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50,
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x51, 0x0a, 0x19,
0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63,
0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61,
0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22,
0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c,
0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x67, 0x65, 0x78, 0x12,
0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67,
0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65,
0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a, 0x24, 0x47, 0x65, 0x74, 0x4d, 0x43,
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b,
0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31, 0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f,
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x69, 0x64,
0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d, 0x63, 0x70, 0x53, 0x65, 0x72, 0x76,
0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64, 0x73, 0x22, 0xda, 0x02, 0x0a, 0x25,
0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65,
0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75,
0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72,
0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30,
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64,
0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f,
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3e, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74,
0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73,
0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x61, 0x63,
0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x50, 0x0a, 0x06, 0x65, 0x72,
0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41,
0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45,
0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11,
0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72,
0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a,
0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03,
0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14,
0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76,
0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3e, 0x0a, 0x13, 0x49, 0x73, 0x41, 0x75,
0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x6b, 0x0a, 0x14, 0x49, 0x73, 0x41, 0x75,
0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61,
0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65,
0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65,
0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65,
0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a,
0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e,
0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55,
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55,
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67,
0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f,
0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52,
0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c,
0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21,
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64,
0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75,
0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65,
0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72,
0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42,
0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74,
0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54,
0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50,
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65,
0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32,
0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a,
0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a,
0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62,
0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69,
0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x33,
}
var (
+1
View File
@@ -58,6 +58,7 @@ message RecordInterceptionResponse {}
message RecordInterceptionEndedRequest {
string id = 1; // UUID.
google.protobuf.Timestamp ended_at = 2;
string credential_hint = 3;
}
message RecordInterceptionEndedResponse {}
+3 -3
View File
@@ -17,9 +17,9 @@ const (
)
// ProviderOutcome classifies one ai_providers row, including disabled
// and errored rows the pool excludes. Err is populated only when
// Status == ProviderStatusError; the build error is already logged at
// the call site.
// rows (which the pool keeps as 503 stubs) and errored rows (which the
// pool excludes). Err is populated only when Status == ProviderStatusError;
// the build error is already logged at the call site.
type ProviderOutcome struct {
Name string
Type string
+3 -2
View File
@@ -45,8 +45,9 @@ func (t *recorderTranslation) RecordInterception(ctx context.Context, req *aibri
func (t *recorderTranslation) RecordInterceptionEnded(ctx context.Context, req *aibridge.InterceptionRecordEnded) error {
_, err := t.client.RecordInterceptionEnded(ctx, &proto.RecordInterceptionEndedRequest{
Id: req.ID,
EndedAt: timestamppb.New(req.EndedAt),
Id: req.ID,
EndedAt: timestamppb.New(req.EndedAt),
CredentialHint: req.CredentialHint,
})
return err
}
+3 -2
View File
@@ -222,8 +222,9 @@ func (s *Server) RecordInterceptionEnded(ctx context.Context, in *proto.RecordIn
}
_, err = s.store.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{
ID: intcID,
EndedAt: in.EndedAt.AsTime(),
ID: intcID,
EndedAt: in.EndedAt.AsTime(),
CredentialHint: in.CredentialHint,
})
if err != nil {
return nil, xerrors.Errorf("end interception: %w", err)
+13 -10
View File
@@ -944,23 +944,26 @@ func TestRecordInterceptionEnded(t *testing.T) {
{
name: "ok",
request: &proto.RecordInterceptionEndedRequest{
Id: uuid.UUID{1}.String(),
EndedAt: timestamppb.Now(),
Id: uuid.UUID{1}.String(),
EndedAt: timestamppb.Now(),
CredentialHint: "sk-a...efgh",
},
setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionEndedRequest) {
interceptionID, err := uuid.Parse(req.GetId())
assert.NoError(t, err, "parse interception UUID")
db.EXPECT().UpdateAIBridgeInterceptionEnded(gomock.Any(), database.UpdateAIBridgeInterceptionEndedParams{
ID: interceptionID,
EndedAt: req.EndedAt.AsTime(),
ID: interceptionID,
EndedAt: req.EndedAt.AsTime(),
CredentialHint: req.CredentialHint,
}).Return(database.AIBridgeInterception{
ID: interceptionID,
InitiatorID: uuid.UUID{2},
Provider: "prov",
Model: "mod",
StartedAt: time.Now(),
EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true},
ID: interceptionID,
InitiatorID: uuid.UUID{2},
Provider: "prov",
Model: "mod",
StartedAt: time.Now(),
EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true},
CredentialHint: req.CredentialHint,
}, nil)
},
},
+171 -2
View File
@@ -9171,6 +9171,110 @@ const docTemplate = `{
]
}
},
"/api/v2/users/{user}/ai/budget": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get user AI budget override",
"operationId": "get-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Upsert user AI budget override",
"operationId": "upsert-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"description": "Upsert user AI budget override request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"delete": {
"tags": [
"Enterprise"
],
"summary": "Delete user AI budget override",
"operationId": "delete-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/users/{user}/appearance": {
"get": {
"produces": [
@@ -15165,6 +15269,10 @@ const docTemplate = `{
"enum": [
"all",
"application_connect",
"ai_gateway_key:*",
"ai_gateway_key:create",
"ai_gateway_key:delete",
"ai_gateway_key:read",
"ai_model_price:*",
"ai_model_price:read",
"ai_model_price:update",
@@ -15199,6 +15307,10 @@ const docTemplate = `{
"audit_log:*",
"audit_log:create",
"audit_log:read",
"boundary_log:*",
"boundary_log:create",
"boundary_log:delete",
"boundary_log:read",
"boundary_usage:*",
"boundary_usage:delete",
"boundary_usage:read",
@@ -15391,6 +15503,10 @@ const docTemplate = `{
"x-enum-varnames": [
"APIKeyScopeAll",
"APIKeyScopeApplicationConnect",
"APIKeyScopeAiGatewayKeyAll",
"APIKeyScopeAiGatewayKeyCreate",
"APIKeyScopeAiGatewayKeyDelete",
"APIKeyScopeAiGatewayKeyRead",
"APIKeyScopeAiModelPriceAll",
"APIKeyScopeAiModelPriceRead",
"APIKeyScopeAiModelPriceUpdate",
@@ -15425,6 +15541,10 @@ const docTemplate = `{
"APIKeyScopeAuditLogAll",
"APIKeyScopeAuditLogCreate",
"APIKeyScopeAuditLogRead",
"APIKeyScopeBoundaryLogAll",
"APIKeyScopeBoundaryLogCreate",
"APIKeyScopeBoundaryLogDelete",
"APIKeyScopeBoundaryLogRead",
"APIKeyScopeBoundaryUsageAll",
"APIKeyScopeBoundaryUsageDelete",
"APIKeyScopeBoundaryUsageRead",
@@ -16490,7 +16610,8 @@ const docTemplate = `{
"auth",
"config",
"usage_limit",
"missing_key"
"missing_key",
"provider_disabled"
],
"x-enum-varnames": [
"ChatErrorKindGeneric",
@@ -16501,7 +16622,8 @@ const docTemplate = `{
"ChatErrorKindAuth",
"ChatErrorKindConfig",
"ChatErrorKindUsageLimit",
"ChatErrorKindMissingKey"
"ChatErrorKindMissingKey",
"ChatErrorKindProviderDisabled"
]
},
"codersdk.ChatFileMetadata": {
@@ -22215,6 +22337,7 @@ const docTemplate = `{
"type": "string",
"enum": [
"*",
"ai_gateway_key",
"ai_model_price",
"ai_provider",
"ai_seat",
@@ -22223,6 +22346,7 @@ const docTemplate = `{
"assign_org_role",
"assign_role",
"audit_log",
"boundary_log",
"boundary_usage",
"chat",
"connection_log",
@@ -22265,6 +22389,7 @@ const docTemplate = `{
],
"x-enum-varnames": [
"ResourceWildcard",
"ResourceAIGatewayKey",
"ResourceAiModelPrice",
"ResourceAIProvider",
"ResourceAiSeat",
@@ -22273,6 +22398,7 @@ const docTemplate = `{
"ResourceAssignOrgRole",
"ResourceAssignRole",
"ResourceAuditLog",
"ResourceBoundaryLog",
"ResourceBoundaryUsage",
"ResourceChat",
"ResourceConnectionLog",
@@ -22525,6 +22651,7 @@ const docTemplate = `{
"ai_seat",
"ai_provider",
"ai_provider_key",
"ai_gateway_key",
"group_ai_budget",
"chat",
"user_secret",
@@ -22560,6 +22687,7 @@ const docTemplate = `{
"ResourceTypeAISeat",
"ResourceTypeAIProvider",
"ResourceTypeAIProviderKey",
"ResourceTypeAIGatewayKey",
"ResourceTypeGroupAIBudget",
"ResourceTypeChat",
"ResourceTypeUserSecret",
@@ -24651,6 +24779,23 @@ const docTemplate = `{
}
}
},
"codersdk.UpsertUserAIBudgetOverrideRequest": {
"type": "object",
"required": [
"group_id"
],
"properties": {
"group_id": {
"description": "GroupID is the group the user's spend is attributed to. The user must\nbe a member of this group.",
"type": "string",
"format": "uuid"
},
"spend_limit_micros": {
"type": "integer",
"minimum": 0
}
}
},
"codersdk.UpsertWorkspaceAgentPortShareRequest": {
"type": "object",
"properties": {
@@ -24805,6 +24950,30 @@ const docTemplate = `{
}
}
},
"codersdk.UserAIBudgetOverride": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"group_id": {
"type": "string",
"format": "uuid"
},
"spend_limit_micros": {
"type": "integer"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"user_id": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.UserActivity": {
"type": "object",
"properties": {
+157 -2
View File
@@ -8132,6 +8132,98 @@
]
}
},
"/api/v2/users/{user}/ai/budget": {
"get": {
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get user AI budget override",
"operationId": "get-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"put": {
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Upsert user AI budget override",
"operationId": "upsert-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"description": "Upsert user AI budget override request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"delete": {
"tags": ["Enterprise"],
"summary": "Delete user AI budget override",
"operationId": "delete-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/users/{user}/appearance": {
"get": {
"produces": ["application/json"],
@@ -13561,6 +13653,10 @@
"enum": [
"all",
"application_connect",
"ai_gateway_key:*",
"ai_gateway_key:create",
"ai_gateway_key:delete",
"ai_gateway_key:read",
"ai_model_price:*",
"ai_model_price:read",
"ai_model_price:update",
@@ -13595,6 +13691,10 @@
"audit_log:*",
"audit_log:create",
"audit_log:read",
"boundary_log:*",
"boundary_log:create",
"boundary_log:delete",
"boundary_log:read",
"boundary_usage:*",
"boundary_usage:delete",
"boundary_usage:read",
@@ -13787,6 +13887,10 @@
"x-enum-varnames": [
"APIKeyScopeAll",
"APIKeyScopeApplicationConnect",
"APIKeyScopeAiGatewayKeyAll",
"APIKeyScopeAiGatewayKeyCreate",
"APIKeyScopeAiGatewayKeyDelete",
"APIKeyScopeAiGatewayKeyRead",
"APIKeyScopeAiModelPriceAll",
"APIKeyScopeAiModelPriceRead",
"APIKeyScopeAiModelPriceUpdate",
@@ -13821,6 +13925,10 @@
"APIKeyScopeAuditLogAll",
"APIKeyScopeAuditLogCreate",
"APIKeyScopeAuditLogRead",
"APIKeyScopeBoundaryLogAll",
"APIKeyScopeBoundaryLogCreate",
"APIKeyScopeBoundaryLogDelete",
"APIKeyScopeBoundaryLogRead",
"APIKeyScopeBoundaryUsageAll",
"APIKeyScopeBoundaryUsageDelete",
"APIKeyScopeBoundaryUsageRead",
@@ -14840,7 +14948,8 @@
"auth",
"config",
"usage_limit",
"missing_key"
"missing_key",
"provider_disabled"
],
"x-enum-varnames": [
"ChatErrorKindGeneric",
@@ -14851,7 +14960,8 @@
"ChatErrorKindAuth",
"ChatErrorKindConfig",
"ChatErrorKindUsageLimit",
"ChatErrorKindMissingKey"
"ChatErrorKindMissingKey",
"ChatErrorKindProviderDisabled"
]
},
"codersdk.ChatFileMetadata": {
@@ -20358,6 +20468,7 @@
"type": "string",
"enum": [
"*",
"ai_gateway_key",
"ai_model_price",
"ai_provider",
"ai_seat",
@@ -20366,6 +20477,7 @@
"assign_org_role",
"assign_role",
"audit_log",
"boundary_log",
"boundary_usage",
"chat",
"connection_log",
@@ -20408,6 +20520,7 @@
],
"x-enum-varnames": [
"ResourceWildcard",
"ResourceAIGatewayKey",
"ResourceAiModelPrice",
"ResourceAIProvider",
"ResourceAiSeat",
@@ -20416,6 +20529,7 @@
"ResourceAssignOrgRole",
"ResourceAssignRole",
"ResourceAuditLog",
"ResourceBoundaryLog",
"ResourceBoundaryUsage",
"ResourceChat",
"ResourceConnectionLog",
@@ -20658,6 +20772,7 @@
"ai_seat",
"ai_provider",
"ai_provider_key",
"ai_gateway_key",
"group_ai_budget",
"chat",
"user_secret",
@@ -20693,6 +20808,7 @@
"ResourceTypeAISeat",
"ResourceTypeAIProvider",
"ResourceTypeAIProviderKey",
"ResourceTypeAIGatewayKey",
"ResourceTypeGroupAIBudget",
"ResourceTypeChat",
"ResourceTypeUserSecret",
@@ -22684,6 +22800,21 @@
}
}
},
"codersdk.UpsertUserAIBudgetOverrideRequest": {
"type": "object",
"required": ["group_id"],
"properties": {
"group_id": {
"description": "GroupID is the group the user's spend is attributed to. The user must\nbe a member of this group.",
"type": "string",
"format": "uuid"
},
"spend_limit_micros": {
"type": "integer",
"minimum": 0
}
}
},
"codersdk.UpsertWorkspaceAgentPortShareRequest": {
"type": "object",
"properties": {
@@ -22817,6 +22948,30 @@
}
}
},
"codersdk.UserAIBudgetOverride": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"group_id": {
"type": "string",
"format": "uuid"
},
"spend_limit_micros": {
"type": "integer"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"user_id": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.UserActivity": {
"type": "object",
"properties": {
+1
View File
@@ -36,6 +36,7 @@ type Auditable interface {
database.AiSeatState |
database.AIProvider |
database.AIProviderKey |
database.AIGatewayKey |
database.Chat |
database.AuditableGroupAiBudget |
database.UserSecret |
+9
View File
@@ -138,6 +138,8 @@ func ResourceTarget[T Auditable](tgt T) string {
return typed.Name
case database.AIProviderKey:
return typed.ID.String()
case database.AIGatewayKey:
return typed.Name
case database.AuditableGroupAiBudget:
return typed.GroupName
case database.Chat:
@@ -222,6 +224,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.ID
case database.AIProviderKey:
return typed.ID
case database.AIGatewayKey:
return typed.ID
case database.AuditableGroupAiBudget:
return typed.GroupID
case database.Chat:
@@ -291,6 +295,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeAIProvider
case database.AIProviderKey:
return database.ResourceTypeAIProviderKey
case database.AIGatewayKey:
return database.ResourceTypeAIGatewayKey
case database.AuditableGroupAiBudget:
return database.ResourceTypeGroupAiBudget
case database.Chat:
@@ -366,6 +372,9 @@ func ResourceRequiresOrgID[T Auditable]() bool {
// AI provider keys inherit the deployment scope of their parent
// provider.
return false
case database.AIGatewayKey:
// AI Gateway keys are deployment-scoped, not org-scoped.
return false
case database.AuditableGroupAiBudget:
// Group AI budgets are org-scoped through their parent group.
return true
+17
View File
@@ -422,6 +422,23 @@ func (e *Executor) runOnce(t time.Time) Stats {
Isolation: sql.LevelRepeatableRead,
TxIdentifier: "lifecycle",
})
// A concurrent build (e.g. from the API or another lifecycle
// executor) may have already inserted a build with the same
// number. This is a benign race; the other actor's build
// will take effect. Clear the error so downstream checks
// (audit, notification, stats) treat this as a no-op.
if database.IsUniqueViolation(err, database.UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey) {
log.Info(e.ctx, "skipping workspace: concurrent build already inserted", slog.Error(err))
err = nil
// Reset notification flags set before builder.Build.
// The build was rolled back, so this executor did not
// perform the transition. The concurrent actor handles
// both the build and any notifications. Without these
// resets, downstream code would send duplicate or
// incorrect notifications.
didAutoUpdate = false
shouldNotifyTaskPause = false
}
if auditLog != nil {
// If the transition didn't succeed then updating the workspace
// to indicate dormant didn't either.
@@ -4,10 +4,12 @@ import (
"context"
"database/sql"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
@@ -160,6 +162,92 @@ func TestMultipleLifecycleExecutors(t *testing.T) {
assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID])
}
// uniqueViolationStore wraps a database.Store and injects a unique violation
// error from InsertWorkspaceBuild after a configurable number of successful
// calls. This simulates a concurrent build race (e.g. an API-driven start
// racing with the lifecycle executor autostart).
type uniqueViolationStore struct {
database.Store
insertCount *atomic.Int32 // pointer: shared across InTx copies
failAfterN int32
}
func newUniqueViolationStore(db database.Store, failAfterN int32) *uniqueViolationStore {
return &uniqueViolationStore{
Store: db,
insertCount: &atomic.Int32{},
failAfterN: failAfterN,
}
}
func (s *uniqueViolationStore) InTx(fn func(database.Store) error, opts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return fn(&uniqueViolationStore{
Store: tx,
insertCount: s.insertCount, // shared pointer
failAfterN: s.failAfterN,
})
}, opts)
}
func (s *uniqueViolationStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
n := s.insertCount.Add(1)
if n > s.failAfterN {
return &pq.Error{
Code: pq.ErrorCode("23505"),
Constraint: string(database.UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey),
Message: `duplicate key value violates unique constraint "workspace_builds_workspace_id_build_number_key"`,
}
}
return s.Store.InsertWorkspaceBuild(ctx, arg)
}
func TestExecutorBuildNumberRaceIsHandled(t *testing.T) {
t.Parallel()
// The lifecycle executor must handle a unique-violation from
// InsertWorkspaceBuild gracefully. This error occurs when a concurrent
// actor (API handler, another executor, prebuilds reconciler) inserts a
// build with the same number before the executor's INSERT lands.
//
// We inject the error via a store wrapper. The first two
// InsertWorkspaceBuild calls succeed (setup builds), then the third
// (the lifecycle executor's autostart build) gets a unique violation.
realDB, ps := dbtestutil.NewDB(t)
wrappedDB := newUniqueViolationStore(realDB, 2) // Allow builds 1 (start) and 2 (stop); fail build 3 (autostart)
var (
sched, _ = cron.Weekly("CRON_TZ=UTC 0 * * * *")
tickCh = make(chan time.Time)
statsCh = make(chan autobuild.Stats)
client = coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
AutobuildTicker: tickCh,
AutobuildStats: statsCh,
Database: wrappedDB,
Pubsub: ps,
})
workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutostartSchedule = ptr.Ref(sched.String())
})
)
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
p, err := coderdtest.GetProvisionerForTags(realDB, time.Now(), workspace.OrganizationID, nil)
require.NoError(t, err)
next := sched.Next(workspace.LatestBuild.CreatedAt)
coderdtest.UpdateProvisionerLastSeenAt(t, realDB, p.ID, next)
tickCh <- next
stats := <-statsCh
// The lifecycle executor should treat the unique violation as a benign
// race, not as a hard error.
assert.Empty(t, stats.Errors, "lifecycle executor should not report unique-violation as error")
}
func TestExecutorAutostartTemplateUpdated(t *testing.T) {
t.Parallel()
+4
View File
@@ -6,6 +6,9 @@ type CheckConstraint string
// CheckConstraint enums.
const (
CheckAiGatewayKeysHashedSecretCheck CheckConstraint = "ai_gateway_keys_hashed_secret_check" // ai_gateway_keys
CheckAiGatewayKeysNameCheck CheckConstraint = "ai_gateway_keys_name_check" // ai_gateway_keys
CheckAiGatewayKeysSecretPrefixCheck CheckConstraint = "ai_gateway_keys_secret_prefix_check" // ai_gateway_keys
CheckAiModelPricesCacheReadPriceCheck CheckConstraint = "ai_model_prices_cache_read_price_check" // ai_model_prices
CheckAiModelPricesCacheWritePriceCheck CheckConstraint = "ai_model_prices_cache_write_price_check" // ai_model_prices
CheckAiModelPricesInputPriceCheck CheckConstraint = "ai_model_prices_input_price_check" // ai_model_prices
@@ -44,6 +47,7 @@ const (
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
CheckUserAiBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides
CheckUserAiProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys
CheckUserSkillsContentSize CheckConstraint = "user_skills_content_size" // user_skills
CheckUserSkillsDescriptionSize CheckConstraint = "user_skills_description_size" // user_skills
+10
View File
@@ -1509,6 +1509,16 @@ func GroupAIBudget(b database.GroupAiBudget) codersdk.GroupAIBudget {
}
}
func UserAIBudgetOverride(o database.UserAiBudgetOverride) codersdk.UserAIBudgetOverride {
return codersdk.UserAIBudgetOverride{
UserID: o.UserID,
GroupID: o.GroupID,
SpendLimitMicros: o.SpendLimitMicros,
CreatedAt: o.CreatedAt,
UpdatedAt: o.UpdatedAt,
}
}
func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidatedAtRow) []codersdk.InvalidatedPreset {
var presets []codersdk.InvalidatedPreset
for _, p := range invalidatedPresets {

Some files were not shown because too many files have changed in this diff Show More