mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
Merge branch 'main' into fix/codagt-517-testagent-stats-ssh
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -58,6 +58,7 @@ message RecordInterceptionResponse {}
|
||||
message RecordInterceptionEndedRequest {
|
||||
string id = 1; // UUID.
|
||||
google.protobuf.Timestamp ended_at = 2;
|
||||
string credential_hint = 3;
|
||||
}
|
||||
|
||||
message RecordInterceptionEndedResponse {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+171
-2
@@ -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": {
|
||||
|
||||
Generated
+157
-2
@@ -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": {
|
||||
|
||||
@@ -36,6 +36,7 @@ type Auditable interface {
|
||||
database.AiSeatState |
|
||||
database.AIProvider |
|
||||
database.AIProviderKey |
|
||||
database.AIGatewayKey |
|
||||
database.Chat |
|
||||
database.AuditableGroupAiBudget |
|
||||
database.UserSecret |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Generated
+4
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user