mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
Merge branch 'main' into workspace-bdje
This commit is contained in:
@@ -8,6 +8,21 @@ docs/reference/api/*.md linguist-generated=true
|
||||
docs/reference/cli/*.md linguist-generated=true
|
||||
coderd/apidoc/swagger.json linguist-generated=true
|
||||
coderd/database/dump.sql linguist-generated=true
|
||||
|
||||
# Database codegen (sqlc)
|
||||
coderd/database/queries.sql.go linguist-generated=true
|
||||
coderd/database/models.go linguist-generated=true
|
||||
coderd/database/querier.go linguist-generated=true
|
||||
|
||||
# Database codegen (gomock)
|
||||
coderd/database/dbmock/dbmock.go linguist-generated=true
|
||||
|
||||
# Database codegen (dbgen)
|
||||
coderd/database/dbmetrics/querymetrics.go linguist-generated=true
|
||||
coderd/database/unique_constraint.go linguist-generated=true
|
||||
coderd/database/foreign_key_constraint.go linguist-generated=true
|
||||
coderd/database/check_constraint.go linguist-generated=true
|
||||
|
||||
peerbroker/proto/*.go linguist-generated=true
|
||||
provisionerd/proto/*.go linguist-generated=true
|
||||
provisionerd/proto/version.go linguist-generated=false
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: "Go Test Failure Report"
|
||||
description: "Publish Go test failure summaries and upload failure artifacts"
|
||||
|
||||
inputs:
|
||||
json-file:
|
||||
description: "Path to the gotestsum JSON file. Use default for RUNNER_TEMP/go-test.json."
|
||||
required: false
|
||||
default: "default"
|
||||
failures-file:
|
||||
description: "Path to write newline-delimited failure details. Use default for RUNNER_TEMP/go-test-failures.ndjson."
|
||||
required: false
|
||||
default: "default"
|
||||
artifact-name:
|
||||
description: "Artifact name for uploaded failure details"
|
||||
required: true
|
||||
retention-days:
|
||||
description: "Artifact retention in days"
|
||||
required: false
|
||||
default: "7"
|
||||
max-output-bytes:
|
||||
description: "Maximum bytes to include in the markdown summary"
|
||||
required: false
|
||||
default: "16384"
|
||||
max-failures:
|
||||
description: "Maximum failures to include in the summary output"
|
||||
required: false
|
||||
default: "50"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Resolve Go test report paths
|
||||
id: paths
|
||||
shell: bash
|
||||
env:
|
||||
JSON_FILE: ${{ inputs.json-file }}
|
||||
FAILURES_FILE: ${{ inputs.failures-file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
json_file="$JSON_FILE"
|
||||
if [[ "$json_file" == "default" ]]; then
|
||||
json_file="${RUNNER_TEMP}/go-test.json"
|
||||
fi
|
||||
failures_file="$FAILURES_FILE"
|
||||
if [[ "$failures_file" == "default" ]]; then
|
||||
failures_file="${RUNNER_TEMP}/go-test-failures.ndjson"
|
||||
fi
|
||||
{
|
||||
echo "json-file=${json_file}"
|
||||
echo "failures-file=${failures_file}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish Go test failure summary
|
||||
shell: bash
|
||||
env:
|
||||
JSON_FILE: ${{ steps.paths.outputs.json-file }}
|
||||
FAILURES_FILE: ${{ steps.paths.outputs.failures-file }}
|
||||
MAX_OUTPUT_BYTES: ${{ inputs.max-output-bytes }}
|
||||
MAX_FAILURES: ${{ inputs.max-failures }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go run ./scripts/gotestsummary \
|
||||
--jsonfile "${JSON_FILE}" \
|
||||
--markdown-out - \
|
||||
--failures-out "${FAILURES_FILE}" \
|
||||
--max-output-bytes "${MAX_OUTPUT_BYTES}" \
|
||||
--max-failures "${MAX_FAILURES}" \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload Go test failures
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ steps.paths.outputs.failures-file }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
@@ -7,5 +7,5 @@ runs:
|
||||
- name: Install Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
with:
|
||||
terraform_version: 1.15.2
|
||||
terraform_version: 1.15.5
|
||||
terraform_wrapper: false
|
||||
|
||||
@@ -26,6 +26,18 @@ inputs:
|
||||
description: "Packages to test (default: ./...)"
|
||||
required: false
|
||||
default: "./..."
|
||||
run-regex:
|
||||
description: "Go test name regex passed via RUN"
|
||||
required: false
|
||||
default: ""
|
||||
test-shuffle:
|
||||
description: "Go test shuffle mode passed via TEST_SHUFFLE"
|
||||
required: false
|
||||
default: ""
|
||||
gotestsum-json-file:
|
||||
description: "Optional Linux path for gotestsum --jsonfile output. Use default for RUNNER_TEMP/go-test.json."
|
||||
required: false
|
||||
default: ""
|
||||
embedded-pg-path:
|
||||
description: "Path for embedded postgres data (Windows/macOS only)"
|
||||
required: false
|
||||
@@ -61,8 +73,11 @@ runs:
|
||||
TEST_NUM_PARALLEL_PACKAGES: ${{ inputs.test-parallelism-packages }}
|
||||
TEST_NUM_PARALLEL_TESTS: ${{ inputs.test-parallelism-tests }}
|
||||
TEST_COUNT: ${{ inputs.test-count }}
|
||||
RUN: ${{ inputs.run-regex }}
|
||||
TEST_SHUFFLE: ${{ inputs.test-shuffle }}
|
||||
TEST_PACKAGES: ${{ inputs.test-packages }}
|
||||
RACE_DETECTION: ${{ inputs.race-detection }}
|
||||
GOTESTSUM_JSONFILE_INPUT: ${{ inputs.gotestsum-json-file }}
|
||||
TS_DEBUG_DISCO: "true"
|
||||
TS_DEBUG_DERP: "true"
|
||||
LC_CTYPE: "en_US.UTF-8"
|
||||
@@ -70,6 +85,18 @@ runs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# gotestsum natively reads GOTESTSUM_JSONFILE; set it directly instead
|
||||
# of writing a PATH shim. "default" is the historical
|
||||
# ${RUNNER_TEMP}/go-test.json location consumed by
|
||||
# ./.github/actions/go-test-failure-report.
|
||||
if [[ -n "${GOTESTSUM_JSONFILE_INPUT}" ]]; then
|
||||
if [[ "${GOTESTSUM_JSONFILE_INPUT}" == "default" ]]; then
|
||||
export GOTESTSUM_JSONFILE="${RUNNER_TEMP}/go-test.json"
|
||||
else
|
||||
export GOTESTSUM_JSONFILE="${GOTESTSUM_JSONFILE_INPUT}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ${RACE_DETECTION} == true ]]; then
|
||||
make test-race
|
||||
else
|
||||
|
||||
@@ -154,4 +154,5 @@ jobs:
|
||||
if [ "$CONFLICT" = true ]; then
|
||||
COMMENT="${COMMENT} (⚠️ conflicts need manual resolution)"
|
||||
fi
|
||||
gh pr comment "$PR_NUMBER" --body "$COMMENT"
|
||||
# Don't fail the job if commenting fails (e.g. the original PR is locked).
|
||||
gh pr comment "$PR_NUMBER" --body "$COMMENT" || echo "::warning::Failed to comment on #${PR_NUMBER} (PR may be locked)."
|
||||
|
||||
+29
-105
@@ -6,6 +6,13 @@ on:
|
||||
- main
|
||||
- release/*
|
||||
|
||||
# GitHub Actions does not reliably trigger push-based CI when a new
|
||||
# branch is created at a commit that already has a workflow run (e.g.
|
||||
# from main). The create event fires separately and ensures CI runs
|
||||
# on newly cut release branches. Non-release branch creations are
|
||||
# filtered out by the changes job condition.
|
||||
create:
|
||||
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -21,6 +28,13 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
# For create events, only run on release branches to avoid
|
||||
# triggering CI for every feature branch creation.
|
||||
if: |
|
||||
github.event_name != 'create' || (
|
||||
github.event.ref_type == 'branch' &&
|
||||
startsWith(github.event.ref, 'release/')
|
||||
)
|
||||
outputs:
|
||||
docs-only: ${{ steps.filter.outputs.docs_count == steps.filter.outputs.all_count }}
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
@@ -505,24 +519,6 @@ jobs:
|
||||
source scripts/normalize_path.sh
|
||||
normalize_path_with_symlinks "$RUNNER_TEMP/sym" "$(dirname "$(which terraform)")"
|
||||
|
||||
- name: Configure Go test JSON capture
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin_dir="${RUNNER_TEMP}/go-test-json-bin"
|
||||
mkdir -p "$bin_dir"
|
||||
|
||||
real_gotestsum="$(command -v gotestsum)"
|
||||
real_gotestsum_quoted="$(printf '%q' "$real_gotestsum")"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'set -euo pipefail' \
|
||||
"exec ${real_gotestsum_quoted} --jsonfile \"\${RUNNER_TEMP}/go-test.json\" \"\$@\"" \
|
||||
> "${bin_dir}/gotestsum"
|
||||
chmod +x "${bin_dir}/gotestsum"
|
||||
echo "$bin_dir" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Setup RAM disk for Embedded Postgres (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
@@ -560,6 +556,7 @@ jobs:
|
||||
# By default, run tests with cache for improved speed (possibly at the expense of correctness).
|
||||
# On main, run tests without cache for the inverse.
|
||||
test-count: ${{ github.ref == 'refs/heads/main' && '1' || '' }}
|
||||
gotestsum-json-file: default
|
||||
|
||||
- name: Test with PostgreSQL Database (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
@@ -599,24 +596,11 @@ jobs:
|
||||
embedded-pg-path: "R:/temp/embedded-pg"
|
||||
embedded-pg-cache: ${{ steps.embedded-pg-cache.outputs.embedded-pg-cache }}
|
||||
|
||||
- name: Publish Go test failure summary
|
||||
if: failure() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
run: |
|
||||
go run ./scripts/gotestsummary \
|
||||
--jsonfile "${RUNNER_TEMP}/go-test.json" \
|
||||
--markdown-out - \
|
||||
--failures-out "${RUNNER_TEMP}/go-test-failures.ndjson" \
|
||||
--max-output-bytes 16384 \
|
||||
--max-failures 50 \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload Go test failures
|
||||
if: failure() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- name: Publish Go test failure report
|
||||
if: failure() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork)
|
||||
uses: ./.github/actions/go-test-failure-report
|
||||
with:
|
||||
name: go-test-failures-${{ github.job }}-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/go-test-failures.ndjson
|
||||
retention-days: 7
|
||||
artifact-name: go-test-failures-${{ github.job }}-${{ github.sha }}
|
||||
|
||||
- name: Upload failed test db dumps
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -688,24 +672,6 @@ jobs:
|
||||
source scripts/normalize_path.sh
|
||||
normalize_path_with_symlinks "$RUNNER_TEMP/sym" "$(dirname "$(which terraform)")"
|
||||
|
||||
- name: Configure Go test JSON capture
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin_dir="${RUNNER_TEMP}/go-test-json-bin"
|
||||
mkdir -p "$bin_dir"
|
||||
|
||||
real_gotestsum="$(command -v gotestsum)"
|
||||
real_gotestsum_quoted="$(printf '%q' "$real_gotestsum")"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'set -euo pipefail' \
|
||||
"exec ${real_gotestsum_quoted} --jsonfile \"\${RUNNER_TEMP}/go-test.json\" \"\$@\"" \
|
||||
> "${bin_dir}/gotestsum"
|
||||
chmod +x "${bin_dir}/gotestsum"
|
||||
echo "$bin_dir" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
uses: ./.github/actions/test-go-pg
|
||||
with:
|
||||
@@ -716,25 +682,13 @@ jobs:
|
||||
# By default, run tests with cache for improved speed (possibly at the expense of correctness).
|
||||
# On main, run tests without cache for the inverse.
|
||||
test-count: ${{ github.ref == 'refs/heads/main' && '1' || '' }}
|
||||
gotestsum-json-file: default
|
||||
|
||||
- name: Publish Go test failure summary
|
||||
if: failure() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
run: |
|
||||
go run ./scripts/gotestsummary \
|
||||
--jsonfile "${RUNNER_TEMP}/go-test.json" \
|
||||
--markdown-out - \
|
||||
--failures-out "${RUNNER_TEMP}/go-test-failures.ndjson" \
|
||||
--max-output-bytes 16384 \
|
||||
--max-failures 50 \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload Go test failures
|
||||
if: failure() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- name: Publish Go test failure report
|
||||
if: failure() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork)
|
||||
uses: ./.github/actions/go-test-failure-report
|
||||
with:
|
||||
name: go-test-failures-${{ github.job }}-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/go-test-failures.ndjson
|
||||
retention-days: 7
|
||||
artifact-name: go-test-failures-${{ github.job }}-${{ github.sha }}
|
||||
|
||||
- name: Upload Test Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
@@ -793,24 +747,6 @@ jobs:
|
||||
# c.f. discussion on https://github.com/coder/coder/pull/15106
|
||||
# Our Linux runners have 16 cores, but we reduce parallelism since race detection adds a lot of overhead.
|
||||
# We aim to have parallelism match CPU count (4*4=16) to avoid making flakes worse.
|
||||
- name: Configure Go test JSON capture
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin_dir="${RUNNER_TEMP}/go-test-json-bin"
|
||||
mkdir -p "$bin_dir"
|
||||
|
||||
real_gotestsum="$(command -v gotestsum)"
|
||||
real_gotestsum_quoted="$(printf '%q' "$real_gotestsum")"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'set -euo pipefail' \
|
||||
"exec ${real_gotestsum_quoted} --jsonfile \"\${RUNNER_TEMP}/go-test.json\" \"\$@\"" \
|
||||
> "${bin_dir}/gotestsum"
|
||||
chmod +x "${bin_dir}/gotestsum"
|
||||
echo "$bin_dir" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run Tests
|
||||
uses: ./.github/actions/test-go-pg
|
||||
with:
|
||||
@@ -818,25 +754,13 @@ jobs:
|
||||
test-parallelism-packages: "4"
|
||||
test-parallelism-tests: "4"
|
||||
race-detection: "true"
|
||||
gotestsum-json-file: default
|
||||
|
||||
- name: Publish Go test failure summary
|
||||
if: failure() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
run: |
|
||||
go run ./scripts/gotestsummary \
|
||||
--jsonfile "${RUNNER_TEMP}/go-test.json" \
|
||||
--markdown-out - \
|
||||
--failures-out "${RUNNER_TEMP}/go-test-failures.ndjson" \
|
||||
--max-output-bytes 16384 \
|
||||
--max-failures 50 \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload Go test failures
|
||||
if: failure() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- name: Publish Go test failure report
|
||||
if: failure() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork)
|
||||
uses: ./.github/actions/go-test-failure-report
|
||||
with:
|
||||
name: go-test-failures-${{ github.job }}-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/go-test-failures.ndjson
|
||||
retention-days: 7
|
||||
artifact-name: go-test-failures-${{ github.job }}-${{ github.sha }}
|
||||
|
||||
- name: Upload Test Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
|
||||
- name: Run doc-check via Coder Agent Chat
|
||||
if: steps.check-secrets.outputs.skip != 'true'
|
||||
uses: coder/agents-chat-action@f0b975f503d3ff3e4478517baae290d4d01a2c7e # v0
|
||||
uses: coder/agents-chat-action@b3fc81d7dae5006dd124e98ef6fada1a36cdd86e # v0.3.0
|
||||
with:
|
||||
coder-url: ${{ secrets.DOC_CHECK_CODER_URL }}
|
||||
coder-token: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# This workflow posts a docs preview link as a PR comment whenever a
|
||||
# pull request that touches files under docs/ is opened. The preview
|
||||
# pull request that touches docs/ is opened or updated. The preview
|
||||
# is served by coder.com's branch-preview feature at /docs/@<branch>.
|
||||
#
|
||||
# The link deep-links to the first added/modified/renamed Markdown file
|
||||
@@ -7,8 +7,12 @@
|
||||
# Branch names are URL-encoded so that names containing slashes or
|
||||
# other special characters produce working links.
|
||||
#
|
||||
# If the PR only deletes Markdown files (or only changes non-Markdown
|
||||
# files such as images or manifest.json), no comment is posted.
|
||||
# On subsequent pushes (synchronize) the existing comment is updated
|
||||
# rather than creating a duplicate. If a previous push had a Markdown
|
||||
# file but the current push has none, the stale comment is deleted so
|
||||
# readers don't follow a dead deep-link. If the PR only deletes
|
||||
# Markdown files (or only changes non-Markdown files such as images or
|
||||
# manifest.json), no comment is posted.
|
||||
|
||||
name: docs-preview
|
||||
|
||||
@@ -16,9 +20,15 @@ on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths:
|
||||
- "docs/**"
|
||||
|
||||
concurrency:
|
||||
group: docs-preview-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -35,6 +45,22 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# Marker embedded in the comment body so we can find this
|
||||
# workflow's own comments later. Keep this in one place so
|
||||
# later refactors don't drift between the body construction
|
||||
# and the jq selectors used to find existing comments.
|
||||
DOCS_PREVIEW_MARKER='<!-- docs-preview -->'
|
||||
|
||||
# Returns IDs of github-actions[bot] comments on the PR whose
|
||||
# body contains DOCS_PREVIEW_MARKER. Used by both the stale-
|
||||
# comment-cleanup branch (when this push has no Markdown
|
||||
# changes) and the upsert branch below.
|
||||
list_docs_preview_comments() {
|
||||
gh api --paginate \
|
||||
"repos/${REPO}/issues/${PR_NUMBER}/comments" \
|
||||
--jq ".[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"${DOCS_PREVIEW_MARKER}\")) | .id"
|
||||
}
|
||||
|
||||
# Fetch the list of non-deleted files from the PR. This is
|
||||
# intentionally not piped into grep so that a gh-api failure
|
||||
# (network, auth, rate-limit) propagates immediately instead
|
||||
@@ -51,7 +77,38 @@ jobs:
|
||||
| head -n 1) || true
|
||||
|
||||
if [ -z "$first_doc" ]; then
|
||||
echo "No added/modified Markdown files under docs/, skipping preview comment."
|
||||
echo "No added/modified Markdown files under docs/ on this push."
|
||||
|
||||
# Now that the workflow fires on synchronize, this branch
|
||||
# is reachable on pushes that drop all Markdown while still
|
||||
# touching docs/ (e.g. a push that removes the file an
|
||||
# earlier push had previewed but adds a new image). The
|
||||
# previous preview comment now points at a deleted page;
|
||||
# delete it so readers don't follow a dead deep-link.
|
||||
#
|
||||
# Intentionally decoupled from head so that a gh-api failure
|
||||
# propagates here instead of being swallowed by `|| true`. In
|
||||
# this branch the workflow has no preview link to post anyway
|
||||
# (no Markdown in the push), so a transient list failure is a
|
||||
# cosmetic miss; log and exit cleanly rather than red-checking
|
||||
# every docs-touching PR during a comments-endpoint hiccup.
|
||||
# The next push will retry the cleanup. The upsert path below
|
||||
# uses strict propagation by contrast, because silent failure
|
||||
# there would create duplicate comments.
|
||||
stale_comment_ids=$(list_docs_preview_comments) || {
|
||||
echo "Could not list preview comments; skipping cleanup."
|
||||
exit 0
|
||||
}
|
||||
stale_id=$(printf '%s\n' "$stale_comment_ids" | head -n 1) || true
|
||||
|
||||
if [ -n "$stale_id" ]; then
|
||||
if gh api --method DELETE \
|
||||
"repos/${REPO}/issues/comments/${stale_id}"; then
|
||||
echo "Deleted stale docs preview comment (id=${stale_id})."
|
||||
else
|
||||
echo "Failed to delete stale docs preview comment (id=${stale_id}); leaving in place."
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -97,9 +154,37 @@ jobs:
|
||||
url="${url}/${page_path}"
|
||||
fi
|
||||
|
||||
gh pr comment "${PR_NUMBER}" \
|
||||
--repo "${REPO}" \
|
||||
--body "## Docs preview
|
||||
# The literal backticks around ${first_doc} are escaped so
|
||||
# they survive the double-quoted string as Markdown inline
|
||||
# code; ${url} and ${first_doc} expand normally.
|
||||
comment_body="## Docs preview
|
||||
[:book: View docs preview](${url}) for \`${first_doc}\`
|
||||
|
||||
<!-- docs-preview -->"
|
||||
${DOCS_PREVIEW_MARKER}"
|
||||
|
||||
# Upsert: update the existing docs-preview comment if one
|
||||
# exists, otherwise create a new one. This prevents duplicate
|
||||
# preview comments on every push to the PR.
|
||||
#
|
||||
# Intentionally not piped into head so that a gh-api failure
|
||||
# (network, auth, rate-limit) propagates immediately instead
|
||||
# of being swallowed by `|| true`.
|
||||
all_comment_ids=$(list_docs_preview_comments)
|
||||
existing_id=$(printf '%s\n' "$all_comment_ids" | head -n 1) || true
|
||||
|
||||
if [ -n "$existing_id" ]; then
|
||||
if ! gh api --method PATCH \
|
||||
"repos/${REPO}/issues/comments/${existing_id}" \
|
||||
--field body="$comment_body"; then
|
||||
echo "PATCH failed (comment may have been deleted); creating a new comment."
|
||||
existing_id=""
|
||||
else
|
||||
echo "Updated existing docs preview comment (id=${existing_id})."
|
||||
fi
|
||||
fi
|
||||
if [ -z "$existing_id" ]; then
|
||||
gh pr comment "${PR_NUMBER}" \
|
||||
--repo "${REPO}" \
|
||||
--body "$comment_body"
|
||||
echo "Created new docs preview comment."
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
name: flake-go
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_sha:
|
||||
description: "Base commit to diff against. Defaults to merge-base against origin/main."
|
||||
required: false
|
||||
type: string
|
||||
head_sha:
|
||||
description: "Head commit to analyze. Defaults to the checked out HEAD."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
flake_go:
|
||||
name: Flake Check
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
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:
|
||||
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.event.inputs.head_sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Install whichtests
|
||||
shell: bash
|
||||
run: ./.github/scripts/retry.sh -- go install github.com/coder/whichtests@ec33bab1ec04cd86beb7a61a069db4463dba63f5
|
||||
|
||||
- name: Select changed tests
|
||||
id: selector
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
whichtests \
|
||||
--repo-root . \
|
||||
--github-actions \
|
||||
--coalesce \
|
||||
--out-matrix "$RUNNER_TEMP/flake-matrix.json"
|
||||
|
||||
- name: Setup Terraform
|
||||
if: ${{ fromJSON(steps.selector.outputs.matrix).include[0] != null }}
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Run targeted Go flake checks
|
||||
id: flake_check
|
||||
if: ${{ fromJSON(steps.selector.outputs.matrix).include[0] != null }}
|
||||
uses: ./.github/actions/test-go-pg
|
||||
with:
|
||||
postgres-version: "13"
|
||||
test-parallelism-packages: "4"
|
||||
test-parallelism-tests: "16"
|
||||
test-count: "25"
|
||||
test-packages: ${{ fromJSON(steps.selector.outputs.matrix).include[0].package }}
|
||||
run-regex: ${{ fromJSON(steps.selector.outputs.matrix).include[0].run_regex }}
|
||||
test-shuffle: "on"
|
||||
gotestsum-json-file: default
|
||||
|
||||
- name: Publish Go test failure report
|
||||
if: failure() && steps.flake_check.outcome == 'failure' && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork)
|
||||
uses: ./.github/actions/go-test-failure-report
|
||||
with:
|
||||
artifact-name: go-test-failures-${{ github.job }}-${{ github.sha }}
|
||||
@@ -55,9 +55,14 @@ jobs:
|
||||
mkdir -p "$(pnpm store path --silent)"
|
||||
|
||||
- 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 runner-provided Chrome instead of letting linkspector's
|
||||
# puppeteer download a specific version that may not match the
|
||||
# runner's puppeteer cache. See: https://github.com/UmbrellaDocs/action-linkspector/issues/62
|
||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/google-chrome
|
||||
with:
|
||||
reporter: github-pr-review
|
||||
config_file: ".github/.linkspector.yml"
|
||||
|
||||
@@ -1446,8 +1446,16 @@ ifdef TEST_SHORT
|
||||
GOTEST_FLAGS += -short
|
||||
endif
|
||||
|
||||
# RUN is single-quoted for the shell so regex metacharacters survive make.
|
||||
# Embedded single quotes are not supported; whichtests only emits RUN values
|
||||
# built from ASCII test names so generated regexes stay within this contract.
|
||||
ifdef RUN
|
||||
GOTEST_FLAGS += -run $(RUN)
|
||||
GOTEST_FLAGS += -run '$(RUN)'
|
||||
endif
|
||||
|
||||
# TEST_SHUFFLE values must be off, on, or an integer seed.
|
||||
ifdef TEST_SHUFFLE
|
||||
GOTEST_FLAGS += -shuffle=$(TEST_SHUFFLE)
|
||||
endif
|
||||
|
||||
ifdef TEST_CPUPROFILE
|
||||
|
||||
+16
-11
@@ -387,17 +387,17 @@ func (api *API) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Duplicate entries both read the same file and race to write;
|
||||
// the first entry's edits are silently lost. Resolve symlinks
|
||||
// before comparing so two paths that alias the same real file
|
||||
// (e.g. one via a symlink, one direct) don't slip past as
|
||||
// distinct keys. prepareFileEdit resolves the path again for
|
||||
// its own use; the double lstat cost is cheap compared to the
|
||||
// data-loss risk of silent aliasing.
|
||||
// Merge duplicate entries that refer to the same literal path
|
||||
// so callers don't have to pre-coalesce. Two different paths
|
||||
// that resolve to the same real file via symlinks are still
|
||||
// rejected: silently merging edits the caller addressed to
|
||||
// different paths would hide accidental aliasing.
|
||||
type seenEntry struct {
|
||||
caller string
|
||||
index int // position in merged slice
|
||||
}
|
||||
seenPaths := make(map[string]seenEntry, len(req.Files))
|
||||
var merged []workspacesdk.FileEdits
|
||||
for _, f := range req.Files {
|
||||
// On resolve error, use the raw path; phase 1 surfaces
|
||||
// the error with its proper status code.
|
||||
@@ -406,17 +406,22 @@ func (api *API) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
|
||||
key = resolved
|
||||
}
|
||||
if prev, dup := seenPaths[key]; dup {
|
||||
msg := fmt.Sprintf("duplicate file path %q: combine edits into a single entry's \"edits\" list", f.Path)
|
||||
if prev.caller != f.Path {
|
||||
msg = fmt.Sprintf("duplicate file path %q aliases %q (same real file): combine edits into a single entry's \"edits\" list", f.Path, prev.caller)
|
||||
// Same literal path: merge edits.
|
||||
if filepath.Clean(prev.caller) == filepath.Clean(f.Path) {
|
||||
merged[prev.index].Edits = append(merged[prev.index].Edits, f.Edits...)
|
||||
continue
|
||||
}
|
||||
// Different paths, same real file (symlink alias).
|
||||
msg := fmt.Sprintf("duplicate file path %q aliases %q (same real file): combine edits into a single entry's \"edits\" list", f.Path, prev.caller)
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
seenPaths[key] = seenEntry{caller: f.Path}
|
||||
seenPaths[key] = seenEntry{caller: f.Path, index: len(merged)}
|
||||
merged = append(merged, f)
|
||||
}
|
||||
req.Files = merged
|
||||
|
||||
// Phase 1: compute all edits in memory. If any file fails
|
||||
// (bad path, search miss, permission error), bail before
|
||||
|
||||
@@ -2622,11 +2622,10 @@ func TestFuzzyReplace_Rejects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditFiles_DuplicatePath_Rejects pins that duplicate paths in
|
||||
// one request are rejected with 400 and the file on disk is
|
||||
// unchanged. The pre-fix behavior silently dropped the first
|
||||
// entry's edits while reporting success (last write wins).
|
||||
func TestEditFiles_DuplicatePath_Rejects(t *testing.T) {
|
||||
// TestEditFiles_DuplicatePath_Merges verifies that duplicate paths in
|
||||
// one request are merged: edits from all entries for the same path are
|
||||
// concatenated and applied in order.
|
||||
func TestEditFiles_DuplicatePath_Merges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpdir := os.TempDir()
|
||||
@@ -2637,10 +2636,12 @@ func TestEditFiles_DuplicatePath_Rejects(t *testing.T) {
|
||||
original := "one\ntwo\nthree\n"
|
||||
require.NoError(t, afero.WriteFile(fs, path, []byte(original), 0o644))
|
||||
|
||||
// Entry 2 searches for the output of entry 1, proving edits
|
||||
// are applied in the order they appear across entries.
|
||||
req := workspacesdk.FileEditRequest{
|
||||
Files: []workspacesdk.FileEdits{
|
||||
{Path: path, Edits: []workspacesdk.FileEdit{{Search: "one", Replace: "ONE"}}},
|
||||
{Path: path, Edits: []workspacesdk.FileEdit{{Search: "three", Replace: "THREE"}}},
|
||||
{Path: path, Edits: []workspacesdk.FileEdit{{Search: "one", Replace: "CHANGED"}}},
|
||||
{Path: path, Edits: []workspacesdk.FileEdit{{Search: "CHANGED", Replace: "FINAL"}}},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2653,15 +2654,49 @@ func TestEditFiles_DuplicatePath_Rejects(t *testing.T) {
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
||||
api.Routes().ServeHTTP(w, r)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
||||
got := &codersdk.Error{}
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(got))
|
||||
require.ErrorContains(t, got, "duplicate file path")
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
// File on disk must be untouched: no partial edits.
|
||||
data, err := afero.ReadFile(fs, path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, original, string(data))
|
||||
require.Equal(t, "FINAL\ntwo\nthree\n", string(data))
|
||||
}
|
||||
|
||||
// TestEditFiles_DuplicatePath_NonCanonicalMerges verifies that
|
||||
// non-canonical paths normalizing to the same file are merged,
|
||||
// not rejected as symlink aliases.
|
||||
func TestEditFiles_DuplicatePath_NonCanonicalMerges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpdir := os.TempDir()
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
fs := afero.NewMemMapFs()
|
||||
api := agentfiles.NewAPI(logger, fs, nil)
|
||||
canonical := filepath.Join(tmpdir, "noncanon")
|
||||
nonCanonical := canonical[:len(tmpdir)] + "/./noncanon"
|
||||
original := "one\ntwo\nthree\n"
|
||||
require.NoError(t, afero.WriteFile(fs, canonical, []byte(original), 0o644))
|
||||
|
||||
req := workspacesdk.FileEditRequest{
|
||||
Files: []workspacesdk.FileEdits{
|
||||
{Path: canonical, Edits: []workspacesdk.FileEdit{{Search: "one", Replace: "ONE"}}},
|
||||
{Path: nonCanonical, Edits: []workspacesdk.FileEdit{{Search: "three", Replace: "THREE"}}},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
buf := bytes.NewBuffer(nil)
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
require.NoError(t, enc.Encode(req))
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
||||
api.Routes().ServeHTTP(w, r)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
||||
|
||||
data, err := afero.ReadFile(fs, canonical)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ONE\ntwo\nTHREE\n", string(data))
|
||||
}
|
||||
|
||||
// TestEditFiles_DuplicatePath_SymlinkAliasRejects pins that two
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +329,12 @@ func (*interceptionBase) withAWSBedrockOptions(ctx context.Context, cfg *aibconf
|
||||
}
|
||||
|
||||
var out []option.RequestOption
|
||||
out = append(out, option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
|
||||
if ua := req.Header.Get("User-Agent"); ua != "" {
|
||||
req.Header.Set("User-Agent", ua+" sdk-ua-app-id/APN_1.1%2Fpc_cdfmjwn8i6u8l9fwz8h82e4w3%24")
|
||||
}
|
||||
return next(req)
|
||||
}))
|
||||
out = append(out, bedrock.WithConfig(awsCfg))
|
||||
|
||||
// If a custom base URL is set, override the default endpoint constructed by the bedrock middleware.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -131,6 +131,13 @@ func TestAnthropicMessages(t *testing.T) {
|
||||
require.Len(t, promptUsages, 1)
|
||||
assert.Equal(t, "read the foo file", promptUsages[0].Prompt)
|
||||
|
||||
// Verify PRM attribution is NOT present on non-Bedrock Anthropic requests.
|
||||
received := upstream.receivedRequests()
|
||||
require.Len(t, received, 1)
|
||||
ua := received[0].Header.Get("User-Agent")
|
||||
assert.NotContains(t, ua, "sdk-ua-app-id",
|
||||
"PRM attribution should not be present on non-Bedrock requests")
|
||||
|
||||
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
||||
})
|
||||
}
|
||||
@@ -327,6 +334,11 @@ func TestAWSBedrockIntegration(t *testing.T) {
|
||||
require.False(t, gjson.GetBytes(received[0].Body, "model").Exists(), "model should be stripped from body")
|
||||
require.False(t, gjson.GetBytes(received[0].Body, "stream").Exists(), "stream should be stripped from body")
|
||||
|
||||
// Verify PRM attribution is appended to the User-Agent header.
|
||||
ua := received[0].Header.Get("User-Agent")
|
||||
require.Contains(t, ua, "sdk-ua-app-id/APN_1.1%2Fpc_cdfmjwn8i6u8l9fwz8h82e4w3%24",
|
||||
"expected AWS PRM attribution in User-Agent header")
|
||||
|
||||
interceptions := bridgeServer.Recorder.RecordedInterceptions()
|
||||
require.Len(t, interceptions, 1)
|
||||
require.Equal(t, interceptions[0].Model, bedrockCfg.Model)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+94
-28
@@ -4,6 +4,7 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
@@ -37,6 +38,7 @@ func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider, cfg
|
||||
|
||||
reg := prometheus.WrapRegistererWithPrefix("coder_aibridged_", coderAPI.PrometheusRegistry)
|
||||
metrics := aibridge.NewMetrics(reg)
|
||||
providerMetrics := aibridged.NewMetrics(reg)
|
||||
tracer := coderAPI.TracerProvider.Tracer(tracing.TracerName)
|
||||
|
||||
// Create pool for reusable stateful [aibridge.RequestBridge] instances (one per user).
|
||||
@@ -50,10 +52,11 @@ func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider, cfg
|
||||
// derives from env config and serves as a fallback if the database
|
||||
// load fails inside the reloader.
|
||||
reloader := &poolDBReloader{
|
||||
pool: pool,
|
||||
db: coderAPI.Database,
|
||||
cfg: cfg,
|
||||
logger: logger.Named("provider-loader"),
|
||||
pool: pool,
|
||||
db: coderAPI.Database,
|
||||
cfg: cfg,
|
||||
logger: logger.Named("provider-loader"),
|
||||
metrics: providerMetrics,
|
||||
}
|
||||
unsubscribe, err := aibridged.SubscribeProviderReload(ctx, coderAPI.Pubsub, reloader, logger.Named("provider-reload"))
|
||||
if err != nil {
|
||||
@@ -78,14 +81,16 @@ func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider, cfg
|
||||
// the live provider set from the database and forwarding it to the
|
||||
// pool.
|
||||
type poolDBReloader struct {
|
||||
pool *aibridged.CachedBridgePool
|
||||
db database.Store
|
||||
cfg codersdk.AIBridgeConfig
|
||||
logger slog.Logger
|
||||
pool *aibridged.CachedBridgePool
|
||||
db database.Store
|
||||
cfg codersdk.AIBridgeConfig
|
||||
logger slog.Logger
|
||||
metrics *aibridged.Metrics
|
||||
}
|
||||
|
||||
func (r *poolDBReloader) Reload(ctx context.Context) error {
|
||||
providers, err := BuildProviders(ctx, r.db, r.cfg, r.logger)
|
||||
r.metrics.RecordReloadAttempt()
|
||||
providers, outcomes, err := BuildProviders(ctx, r.db, r.cfg, r.logger)
|
||||
if err != nil {
|
||||
// Keep the previous snapshot in place: dropping all providers
|
||||
// because the DB read failed would compound the visible failure
|
||||
@@ -93,19 +98,23 @@ func (r *poolDBReloader) Reload(ctx context.Context) error {
|
||||
return xerrors.Errorf("load ai providers from database: %w", err)
|
||||
}
|
||||
r.pool.ReplaceProviders(providers)
|
||||
r.metrics.RecordReloadSuccess(outcomes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildProviders loads every enabled ai_providers row, attaches its
|
||||
// keys, and constructs the equivalent [aibridge.Provider] instances.
|
||||
// The database is the single source of truth for runtime provider
|
||||
// configuration.
|
||||
// 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, error) {
|
||||
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)
|
||||
|
||||
@@ -117,7 +126,7 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
|
||||
err := db.InTx(func(tx database.Store) error {
|
||||
var err error
|
||||
rows, err = tx.GetAIProviders(authCtx, database.GetAIProvidersParams{
|
||||
IncludeDisabled: false,
|
||||
IncludeDisabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("load ai providers: %w", err)
|
||||
@@ -129,9 +138,15 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
|
||||
|
||||
// Load keys only for the enabled providers to avoid materializing
|
||||
// secrets for disabled rows.
|
||||
ids := make([]uuid.UUID, len(rows))
|
||||
for i, r := range rows {
|
||||
ids[i] = r.ID
|
||||
ids := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
keyRows, err := tx.GetAIProviderKeysByProviderIDs(authCtx, ids)
|
||||
if err != nil {
|
||||
@@ -143,13 +158,25 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
|
||||
return nil
|
||||
}, &database.TxOptions{ReadOnly: true, TxIdentifier: "build_ai_providers"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
out := make([]aibridge.Provider, 0, len(rows))
|
||||
providers := make([]aibridge.Provider, 0, len(rows))
|
||||
outcomes := make([]aibridged.ProviderOutcome, 0, len(rows))
|
||||
enabledCount := 0
|
||||
for _, row := range rows {
|
||||
outcome := aibridged.ProviderOutcome{
|
||||
Name: row.Name,
|
||||
Type: string(row.Type),
|
||||
}
|
||||
if row.Enabled {
|
||||
enabledCount++
|
||||
}
|
||||
prov, err := buildAIProviderFromRow(row, keysByProvider[row.ID], cfg)
|
||||
if err != nil {
|
||||
outcome.Status = aibridged.ProviderStatusError
|
||||
outcome.Err = err
|
||||
outcomes = append(outcomes, outcome)
|
||||
logger.Error(ctx, "skipping misconfigured ai provider",
|
||||
slog.F("provider_id", row.ID),
|
||||
slog.F("provider_name", row.Name),
|
||||
@@ -158,23 +185,36 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
|
||||
)
|
||||
continue
|
||||
}
|
||||
out = append(out, prov)
|
||||
if row.Enabled {
|
||||
outcome.Status = aibridged.ProviderStatusEnabled
|
||||
} else {
|
||||
outcome.Status = aibridged.ProviderStatusDisabled
|
||||
}
|
||||
outcomes = append(outcomes, outcome)
|
||||
providers = append(providers, prov)
|
||||
}
|
||||
|
||||
if len(rows) > 0 && len(out) == 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 out, nil
|
||||
return providers, outcomes, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -184,17 +224,28 @@ func buildAIProviderFromRow(
|
||||
sendActorHeaders := cfg.SendActorHeaders.Value()
|
||||
dumpDir := cfg.APIDumpDir.Value()
|
||||
|
||||
// aibridge currently has native support for OpenAI and Anthropic
|
||||
// only. The other ai_provider_type values (azure, google,
|
||||
// openai-compat, openrouter, vercel) route through the OpenAI
|
||||
// provider because chatd configures them against their
|
||||
// OpenAI-compatible endpoints. Bedrock routes through the Anthropic
|
||||
// provider with a Bedrock discriminator in Settings.
|
||||
switch row.Type {
|
||||
case database.AiProviderTypeOpenai:
|
||||
case database.AiProviderTypeOpenai,
|
||||
database.AiProviderTypeAzure,
|
||||
database.AiProviderTypeGoogle,
|
||||
database.AiProviderTypeOpenaiCompat,
|
||||
database.AiProviderTypeOpenrouter,
|
||||
database.AiProviderTypeVercel:
|
||||
if len(keys) == 0 && !cfg.AllowBYOK.Value() {
|
||||
return nil, xerrors.New("openai provider has no api keys configured and BYOK is not enabled")
|
||||
return nil, xerrors.Errorf("%s provider has no api keys configured and BYOK is not enabled", row.Type)
|
||||
}
|
||||
var pool *keypool.Pool
|
||||
if len(keys) > 0 {
|
||||
var err error
|
||||
pool, err = buildAIProviderKeyPool(keys)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("openai key pool: %w", err)
|
||||
return nil, xerrors.Errorf("%s key pool: %w", row.Type, err)
|
||||
}
|
||||
}
|
||||
return aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
|
||||
@@ -206,8 +257,15 @@ func buildAIProviderFromRow(
|
||||
SendActorHeaders: sendActorHeaders,
|
||||
}), nil
|
||||
|
||||
case database.AiProviderTypeAnthropic:
|
||||
case database.AiProviderTypeAnthropic, database.AiProviderTypeBedrock:
|
||||
bedrock := bedrockConfigFromRow(row, settings)
|
||||
// A row typed 'bedrock' authenticates exclusively via settings;
|
||||
// without populated Bedrock credentials it cannot make upstream
|
||||
// calls, so refuse rather than falling back to an unsigned
|
||||
// Anthropic client.
|
||||
if row.Type == database.AiProviderTypeBedrock && bedrock == nil {
|
||||
return nil, xerrors.New("bedrock provider has no bedrock credentials configured")
|
||||
}
|
||||
// Bedrock-backed Anthropic authenticates via AWS credentials in
|
||||
// the settings blob, not the api_keys table. A bearer-token
|
||||
// Anthropic without any key cannot make upstream calls.
|
||||
@@ -246,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) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/coder/coder/v2/aibridge"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
agplaibridge "github.com/coder/coder/v2/coderd/aibridge"
|
||||
"github.com/coder/coder/v2/coderd/aibridged"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
@@ -35,7 +36,8 @@ func buildFromEnv(t *testing.T, cfg codersdk.AIBridgeConfig) ([]aibridge.Provide
|
||||
if err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, logger); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BuildProviders(ctx, db, cfg, logger)
|
||||
providers, _, err := BuildProviders(ctx, db, cfg, logger)
|
||||
return providers, err
|
||||
}
|
||||
|
||||
func TestBuildProviders(t *testing.T) {
|
||||
@@ -323,28 +325,35 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) {
|
||||
Settings: sql.NullString{String: "not-json", Valid: true},
|
||||
})
|
||||
|
||||
providers, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
|
||||
providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, providers)
|
||||
require.Len(t, outcomes, 1)
|
||||
assert.Equal(t, "anthropic-broken", outcomes[0].Name)
|
||||
assert.Equal(t, aibridged.ProviderStatusError, outcomes[0].Status)
|
||||
assert.Error(t, outcomes[0].Err)
|
||||
})
|
||||
|
||||
t.Run("UnsupportedType", func(t *testing.T) {
|
||||
t.Run("EnabledButNoKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
|
||||
// Azure is a valid DB-level provider type but has no runtime
|
||||
// builder yet; it must hit the default branch and be skipped.
|
||||
// Azure routes through the OpenAI-family builder, which rejects
|
||||
// rows without keys when BYOK is disabled. The row must be
|
||||
// classified as error and excluded from the snapshot.
|
||||
dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AiProviderTypeAzure,
|
||||
Name: "azure-openai",
|
||||
BaseUrl: "https://example.openai.azure.com/",
|
||||
})
|
||||
|
||||
providers, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
|
||||
providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, providers)
|
||||
require.Len(t, outcomes, 1)
|
||||
assert.Equal(t, aibridged.ProviderStatusError, outcomes[0].Status)
|
||||
})
|
||||
|
||||
t.Run("BadRowDoesNotBlockGoodRow", func(t *testing.T) {
|
||||
@@ -369,10 +378,75 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) {
|
||||
APIKey: "sk-good",
|
||||
})
|
||||
|
||||
providers, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
|
||||
providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, providers, 1)
|
||||
assert.Equal(t, "openai-good", providers[0].Name())
|
||||
require.Len(t, outcomes, 2)
|
||||
byName := map[string]aibridged.ProviderOutcome{}
|
||||
for _, o := range outcomes {
|
||||
byName[o.Name] = o
|
||||
}
|
||||
assert.Equal(t, aibridged.ProviderStatusError, byName["anthropic-broken"].Status)
|
||||
assert.Equal(t, aibridged.ProviderStatusEnabled, byName["openai-good"].Status)
|
||||
})
|
||||
|
||||
t.Run("DisabledRowClassifiedAsDisabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+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)
|
||||
|
||||
+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"`)
|
||||
|
||||
+26
-13
@@ -56,7 +56,6 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/aibridge"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/cli/clilog"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
@@ -1042,7 +1041,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
// unconditionally when the bridge feature is enabled by config so
|
||||
// chatd can use it regardless of license entitlement.
|
||||
if vals.AI.BridgeConfig.Enabled.Value() {
|
||||
aibridgeProviders, err := BuildProviders(aibridgeInitCtx, options.Database, vals.AI.BridgeConfig, logger.Named("aibridge.providers"))
|
||||
aibridgeProviders, _, err := BuildProviders(aibridgeInitCtx, options.Database, vals.AI.BridgeConfig, logger.Named("aibridge.providers"))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("build AI providers: %w", err)
|
||||
}
|
||||
@@ -3008,11 +3007,10 @@ func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AI
|
||||
return nil, xerrors.Errorf("provider %d: TYPE is required", i)
|
||||
}
|
||||
|
||||
switch p.Type {
|
||||
case aibridge.ProviderOpenAI, aibridge.ProviderAnthropic, aibridge.ProviderCopilot:
|
||||
default:
|
||||
return nil, xerrors.Errorf("provider %d: unknown TYPE %q (must be %s, %s, or %s)",
|
||||
i, p.Type, aibridge.ProviderOpenAI, aibridge.ProviderAnthropic, aibridge.ProviderCopilot)
|
||||
providerType := database.AIProviderType(p.Type)
|
||||
if !providerType.Valid() {
|
||||
return nil, xerrors.Errorf("provider %d: unknown TYPE %q (must be one of: %v)",
|
||||
i, p.Type, database.AllAIProviderTypeValues())
|
||||
}
|
||||
|
||||
var bedrockKey, bedrockSecret string
|
||||
@@ -3028,21 +3026,36 @@ func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AI
|
||||
)
|
||||
isBedrock := codersdk.IsBedrockConfigured(p.BedrockBaseURL, settings)
|
||||
|
||||
if p.Type != aibridge.ProviderAnthropic && isBedrock {
|
||||
return nil, xerrors.Errorf("provider %d (%s): BEDROCK_* fields are only supported with TYPE %q",
|
||||
i, p.Type, aibridge.ProviderAnthropic)
|
||||
// BEDROCK_* fields are accepted on anthropic (mutually exclusive
|
||||
// with KEYS) and required on bedrock. Any other TYPE rejecting
|
||||
// them prevents silently-ignored credentials.
|
||||
isBedrockType := providerType == database.AiProviderTypeBedrock
|
||||
isAnthropicType := providerType == database.AiProviderTypeAnthropic
|
||||
if !isAnthropicType && !isBedrockType && isBedrock {
|
||||
return nil, xerrors.Errorf("provider %d (%s): BEDROCK_* fields are only supported with TYPE %q or %q",
|
||||
i, p.Type, database.AiProviderTypeAnthropic, database.AiProviderTypeBedrock)
|
||||
}
|
||||
|
||||
if p.Type == aibridge.ProviderCopilot && len(p.Keys) > 0 {
|
||||
if isBedrockType && !isBedrock {
|
||||
return nil, xerrors.Errorf("provider %d (%s): TYPE %q requires BEDROCK_* fields to be configured",
|
||||
i, p.Type, database.AiProviderTypeBedrock)
|
||||
}
|
||||
|
||||
if isBedrockType && len(p.Keys) > 0 {
|
||||
return nil, xerrors.Errorf("provider %d (%s): KEY/KEYS are not supported for TYPE %q (use BEDROCK_* fields)",
|
||||
i, p.Type, database.AiProviderTypeBedrock)
|
||||
}
|
||||
|
||||
if providerType == database.AiProviderTypeCopilot && len(p.Keys) > 0 {
|
||||
return nil, xerrors.Errorf("provider %d (%s): KEY/KEYS are not supported for TYPE %q",
|
||||
i, p.Type, aibridge.ProviderCopilot)
|
||||
i, p.Type, database.AiProviderTypeCopilot)
|
||||
}
|
||||
|
||||
// An Anthropic provider authenticates either via a bearer
|
||||
// token (KEYS) or via Bedrock (BEDROCK_*), not both. Surface
|
||||
// the conflict here so misconfigured deployments fail before
|
||||
// any DB work happens at server startup.
|
||||
if p.Type == aibridge.ProviderAnthropic && len(p.Keys) > 0 && isBedrock {
|
||||
if isAnthropicType && len(p.Keys) > 0 && isBedrock {
|
||||
return nil, xerrors.Errorf("provider %d (%s): KEY/KEYS and BEDROCK_* fields are mutually exclusive",
|
||||
i, p.Type)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/aibridge"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/serpent"
|
||||
@@ -362,6 +365,40 @@ func TestReadAIProvidersFromEnv(t *testing.T) {
|
||||
},
|
||||
errContains: "cannot mix CODER_AIBRIDGE_PROVIDER_* and CODER_AI_GATEWAY_PROVIDER_* environment variables",
|
||||
},
|
||||
{
|
||||
name: "BedrockTypeHappyPath",
|
||||
env: []string{
|
||||
"CODER_AIBRIDGE_PROVIDER_0_TYPE=bedrock",
|
||||
"CODER_AIBRIDGE_PROVIDER_0_NAME=bedrock-prod",
|
||||
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_REGION=us-east-1",
|
||||
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY=AKID",
|
||||
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRET=secret",
|
||||
},
|
||||
expected: []codersdk.AIProviderConfig{
|
||||
{
|
||||
Type: string(database.AiProviderTypeBedrock),
|
||||
Name: "bedrock-prod",
|
||||
BedrockRegion: "us-east-1",
|
||||
BedrockAccessKeys: []string{"AKID"},
|
||||
BedrockAccessKeySecrets: []string{"secret"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BedrockTypeWithoutBedrockFields",
|
||||
env: []string{"CODER_AIBRIDGE_PROVIDER_0_TYPE=bedrock", "CODER_AIBRIDGE_PROVIDER_0_NAME=bedrock-prod"},
|
||||
errContains: "requires BEDROCK_* fields to be configured",
|
||||
},
|
||||
{
|
||||
name: "BedrockTypeRejectsAPIKeys",
|
||||
env: []string{
|
||||
"CODER_AIBRIDGE_PROVIDER_0_TYPE=bedrock",
|
||||
"CODER_AIBRIDGE_PROVIDER_0_NAME=bedrock-prod",
|
||||
"CODER_AIBRIDGE_PROVIDER_0_BEDROCK_REGION=us-east-1",
|
||||
"CODER_AIBRIDGE_PROVIDER_0_KEY=sk-should-fail",
|
||||
},
|
||||
errContains: "KEY/KEYS are not supported for TYPE",
|
||||
},
|
||||
{
|
||||
name: "BedrockKeysTooMany",
|
||||
env: []string{
|
||||
@@ -544,32 +581,106 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
|
||||
const dumpDir = "/tmp/coder-aibridge-dumps"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
row database.AIProvider
|
||||
name string
|
||||
row database.AIProvider
|
||||
expectedType string
|
||||
}{
|
||||
{
|
||||
name: "OpenAI",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeOpenai,
|
||||
Name: "openai",
|
||||
BaseUrl: "https://api.openai.com/",
|
||||
},
|
||||
expectedType: aibridge.ProviderOpenAI,
|
||||
},
|
||||
{
|
||||
name: "Anthropic",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeAnthropic,
|
||||
Name: "anthropic",
|
||||
BaseUrl: "https://api.anthropic.com/",
|
||||
},
|
||||
expectedType: aibridge.ProviderAnthropic,
|
||||
},
|
||||
{
|
||||
name: "Copilot",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeCopilot,
|
||||
Name: "copilot",
|
||||
BaseUrl: "https://api.githubcopilot.com/",
|
||||
},
|
||||
expectedType: aibridge.ProviderCopilot,
|
||||
},
|
||||
{
|
||||
name: "Azure",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeAzure,
|
||||
Name: "azure",
|
||||
BaseUrl: "https://example.openai.azure.com/",
|
||||
},
|
||||
expectedType: aibridge.ProviderOpenAI,
|
||||
},
|
||||
{
|
||||
name: "Google",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeGoogle,
|
||||
Name: "google",
|
||||
BaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
},
|
||||
expectedType: aibridge.ProviderOpenAI,
|
||||
},
|
||||
{
|
||||
name: "OpenAICompat",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeOpenaiCompat,
|
||||
Name: "openai-compat",
|
||||
BaseUrl: "https://compat.example.com/v1/",
|
||||
},
|
||||
expectedType: aibridge.ProviderOpenAI,
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeOpenrouter,
|
||||
Name: "openrouter",
|
||||
BaseUrl: "https://openrouter.ai/api/v1/",
|
||||
},
|
||||
expectedType: aibridge.ProviderOpenAI,
|
||||
},
|
||||
{
|
||||
name: "Vercel",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeVercel,
|
||||
Name: "vercel",
|
||||
BaseUrl: "https://api.v0.dev/v1/",
|
||||
},
|
||||
expectedType: aibridge.ProviderOpenAI,
|
||||
},
|
||||
{
|
||||
name: "Bedrock",
|
||||
row: database.AIProvider{
|
||||
Enabled: true,
|
||||
Type: database.AiProviderTypeBedrock,
|
||||
Name: "bedrock",
|
||||
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",
|
||||
Settings: mustMarshalSettings(codersdk.AIProviderSettings{
|
||||
Bedrock: &codersdk.AIProviderBedrockSettings{
|
||||
Region: "us-east-1",
|
||||
AccessKey: ptr.Ref("AKID"),
|
||||
AccessKeySecret: ptr.Ref("secret"),
|
||||
},
|
||||
}),
|
||||
},
|
||||
expectedType: aibridge.ProviderAnthropic,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -583,6 +694,30 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dumpDir, provider.APIDumpDir())
|
||||
assert.Equal(t, tt.expectedType, provider.Type())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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/",
|
||||
}, nil, codersdk.AIBridgeConfig{
|
||||
AllowBYOK: serpent.Bool(true),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "bedrock provider has no bedrock credentials configured")
|
||||
}
|
||||
|
||||
func mustMarshalSettings(s codersdk.AIProviderSettings) sql.NullString {
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sql.NullString{String: string(data), Valid: true}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
+11
-8
@@ -320,10 +320,13 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
|
||||
if req.Settings != nil {
|
||||
existing = mergeAIProviderSettings(existing, *req.Settings)
|
||||
}
|
||||
// Bedrock settings are only meaningful for anthropic-typed
|
||||
// providers; rejecting the mismatch keeps a misconfiguration
|
||||
// from sitting silently in the encrypted blob.
|
||||
if existing.Bedrock != nil && old.Type != database.AiProviderTypeAnthropic {
|
||||
// Bedrock settings are only meaningful for anthropic- or
|
||||
// bedrock-typed providers; rejecting the mismatch keeps a
|
||||
// misconfiguration from sitting silently in the encrypted
|
||||
// blob.
|
||||
if existing.Bedrock != nil &&
|
||||
old.Type != database.AiProviderTypeAnthropic &&
|
||||
old.Type != database.AiProviderTypeBedrock {
|
||||
return errAIProviderBedrockTypeMismatch
|
||||
}
|
||||
settings, err := encodeAIProviderSettings(existing)
|
||||
@@ -382,7 +385,7 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if errors.Is(err, errAIProviderBedrockTypeMismatch) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Bedrock settings are only valid for type=anthropic.",
|
||||
Message: "Bedrock settings are only valid for type=anthropic or type=bedrock.",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -482,9 +485,9 @@ var errBedrockRejectsAPIKeys = xerrors.New("bedrock providers do not accept api_
|
||||
|
||||
// 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-typed; the outer
|
||||
// handler translates it into a 400.
|
||||
var errAIProviderBedrockTypeMismatch = xerrors.New("bedrock settings are only valid for type=anthropic")
|
||||
// Bedrock block but the provider is not anthropic- or bedrock-typed;
|
||||
// the outer handler translates it into a 400.
|
||||
var errAIProviderBedrockTypeMismatch = xerrors.New("bedrock settings are only valid for type=anthropic or type=bedrock")
|
||||
|
||||
// errAIProviderInvalidName is returned from lookupAIProvider when the
|
||||
// idOrName parameter is neither a UUID nor a syntactically-valid name.
|
||||
|
||||
@@ -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.
|
||||
@@ -327,28 +365,23 @@ func providersFromEnv(ctx context.Context, cfg codersdk.AIBridgeConfig, logger s
|
||||
dp := desiredAIProvider{
|
||||
Name: name,
|
||||
}
|
||||
switch p.Type {
|
||||
case aibridge.ProviderOpenAI:
|
||||
dp.Type = database.AiProviderTypeOpenai
|
||||
case aibridge.ProviderAnthropic:
|
||||
dp.Type = database.AiProviderTypeAnthropic
|
||||
case aibridge.ProviderCopilot:
|
||||
dp.Type = database.AiProviderTypeCopilot
|
||||
default:
|
||||
providerType := database.AIProviderType(p.Type)
|
||||
if !providerType.Valid() {
|
||||
logger.Warn(ctx, "skipping indexed AI provider with unsupported type",
|
||||
slog.F("name", name),
|
||||
slog.F("type", p.Type),
|
||||
)
|
||||
continue
|
||||
}
|
||||
dp.Type = providerType
|
||||
|
||||
dp.BaseURL = p.BaseURL
|
||||
// Bedrock fields only apply to Anthropic. Detection goes
|
||||
// through AIProviderBedrockSettings.IsConfigured() so the
|
||||
// legacy and indexed paths agree on what counts as a Bedrock
|
||||
// provider.
|
||||
// Bedrock fields apply to Anthropic and the dedicated Bedrock
|
||||
// type. Detection goes through
|
||||
// AIProviderBedrockSettings.IsConfigured() so the legacy and
|
||||
// indexed paths agree on what counts as a Bedrock provider.
|
||||
isBedrock := false
|
||||
if dp.Type == database.AiProviderTypeAnthropic {
|
||||
if dp.Type == database.AiProviderTypeAnthropic || dp.Type == database.AiProviderTypeBedrock {
|
||||
var accessKey, accessKeySecret string
|
||||
if len(p.BedrockAccessKeys) > 0 {
|
||||
accessKey = p.BedrockAccessKeys[0]
|
||||
|
||||
@@ -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)
|
||||
@@ -371,14 +427,15 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// vercel is a valid ai_provider_type DB value but the aibridge
|
||||
// runtime has no constructor for it, so the seed switch falls
|
||||
// into the default branch and skips the row.
|
||||
// A TYPE that isn't part of the ai_provider_type enum falls
|
||||
// into the default branch and the row is skipped rather than
|
||||
// rejected, so deployments don't fail to start over a single
|
||||
// typo'd provider.
|
||||
cfg := codersdk.AIBridgeConfig{
|
||||
Providers: []codersdk.AIProviderConfig{
|
||||
{
|
||||
Type: "vercel",
|
||||
Name: "vercel-instance",
|
||||
Type: "not-a-real-provider",
|
||||
Name: "ghost",
|
||||
BaseURL: "https://example.com",
|
||||
},
|
||||
{
|
||||
@@ -423,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)
|
||||
@@ -439,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)
|
||||
})
|
||||
|
||||
@@ -481,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)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package aibridged
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
// Metrics is the prometheus surface for aibridged provider reloads.
|
||||
type Metrics struct {
|
||||
registerer prometheus.Registerer
|
||||
|
||||
// ProviderInfo is one series per configured provider; value is
|
||||
// always 1 and the status label carries the alertable signal.
|
||||
// Labels: provider_name, provider_type, status.
|
||||
ProviderInfo *prometheus.GaugeVec
|
||||
|
||||
// ProvidersLastReloadTimestampSeconds is the unix timestamp of the
|
||||
// last reload attempt, success or failure.
|
||||
ProvidersLastReloadTimestampSeconds prometheus.Gauge
|
||||
|
||||
// ProvidersLastReloadSuccessTimestampSeconds is the unix timestamp
|
||||
// of the last reload that successfully refreshed the pool. A gap
|
||||
// against ProvidersLastReloadTimestampSeconds means the loop is
|
||||
// firing but the refresh function is failing.
|
||||
ProvidersLastReloadSuccessTimestampSeconds prometheus.Gauge
|
||||
}
|
||||
|
||||
// NewMetrics registers the provider metrics against reg.
|
||||
func NewMetrics(reg prometheus.Registerer) *Metrics {
|
||||
factory := promauto.With(reg)
|
||||
|
||||
return &Metrics{
|
||||
registerer: reg,
|
||||
|
||||
ProviderInfo: factory.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "provider_info",
|
||||
Help: "One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error) carries the alertable signal.",
|
||||
}, []string{"provider_name", "provider_type", "status"}),
|
||||
|
||||
ProvidersLastReloadTimestampSeconds: factory.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "providers_last_reload_timestamp_seconds",
|
||||
Help: "Unix timestamp of the last provider reload attempt, success or failure.",
|
||||
}),
|
||||
|
||||
ProvidersLastReloadSuccessTimestampSeconds: factory.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "providers_last_reload_success_timestamp_seconds",
|
||||
Help: "Unix timestamp of the last provider reload that successfully refreshed the pool. A gap against coder_aibridged_providers_last_reload_timestamp_seconds means the loop is firing but the refresh function is failing.",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister removes the provider metrics from the registerer.
|
||||
func (m *Metrics) Unregister() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.registerer.Unregister(m.ProviderInfo)
|
||||
m.registerer.Unregister(m.ProvidersLastReloadTimestampSeconds)
|
||||
m.registerer.Unregister(m.ProvidersLastReloadSuccessTimestampSeconds)
|
||||
}
|
||||
|
||||
// RecordReloadAttempt stamps the attempt-time gauge at the start of a
|
||||
// reload. A reload that hangs mid-flight is detected by watching the
|
||||
// gap between this gauge and ProvidersLastReloadSuccessTimestampSeconds.
|
||||
func (m *Metrics) RecordReloadAttempt() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.ProvidersLastReloadTimestampSeconds.Set(float64(time.Now().Unix()))
|
||||
}
|
||||
|
||||
// RecordReloadSuccess rewrites the ProviderInfo GaugeVec from the
|
||||
// outcomes and stamps the success-time gauge. Reset clears series for
|
||||
// providers that have left the configuration so they don't linger as
|
||||
// stale.
|
||||
func (m *Metrics) RecordReloadSuccess(outcomes []ProviderOutcome) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
WriteProviderInfoSnapshot(m.ProviderInfo, outcomes)
|
||||
m.ProvidersLastReloadSuccessTimestampSeconds.Set(float64(time.Now().Unix()))
|
||||
}
|
||||
|
||||
// WriteProviderInfoSnapshot Resets info and writes one series per
|
||||
// outcome. Both aibridged and aibridgeproxyd use this so the
|
||||
// provider_info recording contract stays in one place.
|
||||
func WriteProviderInfoSnapshot(info *prometheus.GaugeVec, outcomes []ProviderOutcome) {
|
||||
info.Reset()
|
||||
for _, o := range outcomes {
|
||||
info.WithLabelValues(o.Name, o.Type, string(o.Status)).Set(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package aibridged_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
promtest "github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/aibridged"
|
||||
)
|
||||
|
||||
// TestMetricsRecordReloadSuccess covers the provider_info GaugeVec
|
||||
// surface: every reload pass rewrites the series for the current
|
||||
// outcomes and the Reset on each pass drops stale series.
|
||||
func TestMetricsRecordReloadSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
m := aibridged.NewMetrics(reg)
|
||||
|
||||
outcomes := []aibridged.ProviderOutcome{
|
||||
{Name: "alpha", Type: "openai", Status: aibridged.ProviderStatusEnabled},
|
||||
{Name: "beta", Type: "anthropic", Status: aibridged.ProviderStatusDisabled},
|
||||
{Name: "gamma", Type: "openai", Status: aibridged.ProviderStatusError, Err: xerrors.New("bad config")},
|
||||
}
|
||||
|
||||
before := time.Now().Unix()
|
||||
m.RecordReloadAttempt()
|
||||
m.RecordReloadSuccess(outcomes)
|
||||
after := time.Now().Unix()
|
||||
|
||||
assert.Equal(t, 1.0, promtest.ToFloat64(m.ProviderInfo.WithLabelValues("alpha", "openai", "enabled")))
|
||||
assert.Equal(t, 1.0, promtest.ToFloat64(m.ProviderInfo.WithLabelValues("beta", "anthropic", "disabled")))
|
||||
assert.Equal(t, 1.0, promtest.ToFloat64(m.ProviderInfo.WithLabelValues("gamma", "openai", "error")))
|
||||
|
||||
attemptTS := int64(promtest.ToFloat64(m.ProvidersLastReloadTimestampSeconds))
|
||||
successTS := int64(promtest.ToFloat64(m.ProvidersLastReloadSuccessTimestampSeconds))
|
||||
assert.GreaterOrEqual(t, attemptTS, before)
|
||||
assert.LessOrEqual(t, attemptTS, after)
|
||||
assert.GreaterOrEqual(t, successTS, before)
|
||||
assert.LessOrEqual(t, successTS, after)
|
||||
}
|
||||
|
||||
// TestMetricsResetsStaleProviderSeries verifies that providers removed
|
||||
// from the outcome set between reloads do not leave behind stale
|
||||
// series.
|
||||
func TestMetricsResetsStaleProviderSeries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
m := aibridged.NewMetrics(reg)
|
||||
|
||||
m.RecordReloadSuccess([]aibridged.ProviderOutcome{
|
||||
{Name: "alpha", Type: "openai", Status: aibridged.ProviderStatusEnabled},
|
||||
{Name: "beta", Type: "anthropic", Status: aibridged.ProviderStatusEnabled},
|
||||
})
|
||||
require.Equal(t, 2, promtest.CollectAndCount(m.ProviderInfo))
|
||||
|
||||
m.RecordReloadSuccess([]aibridged.ProviderOutcome{
|
||||
{Name: "alpha", Type: "openai", Status: aibridged.ProviderStatusEnabled},
|
||||
})
|
||||
|
||||
assert.Equal(t, 1, promtest.CollectAndCount(m.ProviderInfo),
|
||||
"beta should have been Reset out of the GaugeVec")
|
||||
assert.Equal(t, 1.0, promtest.ToFloat64(m.ProviderInfo.WithLabelValues("alpha", "openai", "enabled")))
|
||||
}
|
||||
|
||||
// TestMetricsNilSafe asserts the helpers tolerate a nil receiver so
|
||||
// callers can pass `nil` to disable metric updates without guarding
|
||||
// every call site.
|
||||
func TestMetricsNilSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var m *aibridged.Metrics
|
||||
require.NotPanics(t, func() {
|
||||
m.RecordReloadAttempt()
|
||||
m.RecordReloadSuccess(nil)
|
||||
m.Unregister()
|
||||
})
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package aibridged
|
||||
|
||||
// ProviderStatus is the lifecycle state of a configured AI provider.
|
||||
type ProviderStatus string
|
||||
|
||||
const (
|
||||
// ProviderStatusEnabled indicates the provider is configured and
|
||||
// valid, and is included in the active pool snapshot.
|
||||
ProviderStatusEnabled ProviderStatus = "enabled"
|
||||
// ProviderStatusDisabled indicates the provider is configured but
|
||||
// intentionally turned off by an operator.
|
||||
ProviderStatusDisabled ProviderStatus = "disabled"
|
||||
// ProviderStatusError indicates the provider is configured but
|
||||
// cannot be constructed (missing keys, unsupported type, malformed
|
||||
// settings).
|
||||
ProviderStatusError ProviderStatus = "error"
|
||||
)
|
||||
|
||||
// ProviderOutcome classifies one ai_providers row, including disabled
|
||||
// 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
|
||||
Status ProviderStatus
|
||||
Err error
|
||||
}
|
||||
@@ -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
+234
-72
@@ -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": [
|
||||
@@ -13961,7 +14065,7 @@ const docTemplate = `{
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/coderd.SCIMUser"
|
||||
"$ref": "#/definitions/legacyscim.SCIMUser"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -13969,7 +14073,7 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/coderd.SCIMUser"
|
||||
"$ref": "#/definitions/legacyscim.SCIMUser"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -14035,7 +14139,7 @@ const docTemplate = `{
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/coderd.SCIMUser"
|
||||
"$ref": "#/definitions/legacyscim.SCIMUser"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -14077,7 +14181,7 @@ const docTemplate = `{
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/coderd.SCIMUser"
|
||||
"$ref": "#/definitions/legacyscim.SCIMUser"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -14288,71 +14392,6 @@ const docTemplate = `{
|
||||
"ReinitializeReasonPrebuildClaimed"
|
||||
]
|
||||
},
|
||||
"coderd.SCIMUser": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active": {
|
||||
"description": "Active is a ptr to prevent the empty value from being interpreted as false.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"emails": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display": {
|
||||
"type": "string"
|
||||
},
|
||||
"primary": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resourceType": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"familyName": {
|
||||
"type": "string"
|
||||
},
|
||||
"givenName": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"userName": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"coderd.cspViolation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15071,7 +15110,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "Type is the provider type: \"openai\", \"anthropic\", or \"copilot\".",
|
||||
"description": "Type is the provider type. Valid values are: \"openai\",\n\"anthropic\", \"azure\", \"bedrock\", \"google\", \"openai-compat\",\n\"openrouter\", \"vercel\", \"copilot\".",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -15264,6 +15303,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",
|
||||
@@ -15490,6 +15533,10 @@ const docTemplate = `{
|
||||
"APIKeyScopeAuditLogAll",
|
||||
"APIKeyScopeAuditLogCreate",
|
||||
"APIKeyScopeAuditLogRead",
|
||||
"APIKeyScopeBoundaryLogAll",
|
||||
"APIKeyScopeBoundaryLogCreate",
|
||||
"APIKeyScopeBoundaryLogDelete",
|
||||
"APIKeyScopeBoundaryLogRead",
|
||||
"APIKeyScopeBoundaryUsageAll",
|
||||
"APIKeyScopeBoundaryUsageDelete",
|
||||
"APIKeyScopeBoundaryUsageRead",
|
||||
@@ -16554,7 +16601,9 @@ const docTemplate = `{
|
||||
"startup_timeout",
|
||||
"auth",
|
||||
"config",
|
||||
"usage_limit"
|
||||
"usage_limit",
|
||||
"missing_key",
|
||||
"provider_disabled"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ChatErrorKindGeneric",
|
||||
@@ -16564,7 +16613,9 @@ const docTemplate = `{
|
||||
"ChatErrorKindStartupTimeout",
|
||||
"ChatErrorKindAuth",
|
||||
"ChatErrorKindConfig",
|
||||
"ChatErrorKindUsageLimit"
|
||||
"ChatErrorKindUsageLimit",
|
||||
"ChatErrorKindMissingKey",
|
||||
"ChatErrorKindProviderDisabled"
|
||||
]
|
||||
},
|
||||
"codersdk.ChatFileMetadata": {
|
||||
@@ -18789,6 +18840,9 @@ const docTemplate = `{
|
||||
"scim_api_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"scim_use_legacy": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"session_lifetime": {
|
||||
"$ref": "#/definitions/codersdk.SessionLifetime"
|
||||
},
|
||||
@@ -22283,6 +22337,7 @@ const docTemplate = `{
|
||||
"assign_org_role",
|
||||
"assign_role",
|
||||
"audit_log",
|
||||
"boundary_log",
|
||||
"boundary_usage",
|
||||
"chat",
|
||||
"connection_log",
|
||||
@@ -22333,6 +22388,7 @@ const docTemplate = `{
|
||||
"ResourceAssignOrgRole",
|
||||
"ResourceAssignRole",
|
||||
"ResourceAuditLog",
|
||||
"ResourceBoundaryLog",
|
||||
"ResourceBoundaryUsage",
|
||||
"ResourceChat",
|
||||
"ResourceConnectionLog",
|
||||
@@ -24711,6 +24767,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": {
|
||||
@@ -24865,6 +24938,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": {
|
||||
@@ -27406,6 +27503,71 @@ const docTemplate = `{
|
||||
"key.NodePublic": {
|
||||
"type": "object"
|
||||
},
|
||||
"legacyscim.SCIMUser": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active": {
|
||||
"description": "Active is a ptr to prevent the empty value from being interpreted as false.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"emails": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display": {
|
||||
"type": "string"
|
||||
},
|
||||
"primary": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resourceType": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"familyName": {
|
||||
"type": "string"
|
||||
},
|
||||
"givenName": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"userName": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"netcheck.Report": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+220
-72
@@ -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"],
|
||||
@@ -12389,7 +12481,7 @@
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/coderd.SCIMUser"
|
||||
"$ref": "#/definitions/legacyscim.SCIMUser"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -12397,7 +12489,7 @@
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/coderd.SCIMUser"
|
||||
"$ref": "#/definitions/legacyscim.SCIMUser"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12455,7 +12547,7 @@
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/coderd.SCIMUser"
|
||||
"$ref": "#/definitions/legacyscim.SCIMUser"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -12493,7 +12585,7 @@
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/coderd.SCIMUser"
|
||||
"$ref": "#/definitions/legacyscim.SCIMUser"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -12692,71 +12784,6 @@
|
||||
"enum": ["prebuild_claimed"],
|
||||
"x-enum-varnames": ["ReinitializeReasonPrebuildClaimed"]
|
||||
},
|
||||
"coderd.SCIMUser": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active": {
|
||||
"description": "Active is a ptr to prevent the empty value from being interpreted as false.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"emails": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display": {
|
||||
"type": "string"
|
||||
},
|
||||
"primary": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resourceType": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"familyName": {
|
||||
"type": "string"
|
||||
},
|
||||
"givenName": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"userName": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"coderd.cspViolation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13475,7 +13502,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "Type is the provider type: \"openai\", \"anthropic\", or \"copilot\".",
|
||||
"description": "Type is the provider type. Valid values are: \"openai\",\n\"anthropic\", \"azure\", \"bedrock\", \"google\", \"openai-compat\",\n\"openrouter\", \"vercel\", \"copilot\".",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -13660,6 +13687,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",
|
||||
@@ -13886,6 +13917,10 @@
|
||||
"APIKeyScopeAuditLogAll",
|
||||
"APIKeyScopeAuditLogCreate",
|
||||
"APIKeyScopeAuditLogRead",
|
||||
"APIKeyScopeBoundaryLogAll",
|
||||
"APIKeyScopeBoundaryLogCreate",
|
||||
"APIKeyScopeBoundaryLogDelete",
|
||||
"APIKeyScopeBoundaryLogRead",
|
||||
"APIKeyScopeBoundaryUsageAll",
|
||||
"APIKeyScopeBoundaryUsageDelete",
|
||||
"APIKeyScopeBoundaryUsageRead",
|
||||
@@ -14904,7 +14939,9 @@
|
||||
"startup_timeout",
|
||||
"auth",
|
||||
"config",
|
||||
"usage_limit"
|
||||
"usage_limit",
|
||||
"missing_key",
|
||||
"provider_disabled"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ChatErrorKindGeneric",
|
||||
@@ -14914,7 +14951,9 @@
|
||||
"ChatErrorKindStartupTimeout",
|
||||
"ChatErrorKindAuth",
|
||||
"ChatErrorKindConfig",
|
||||
"ChatErrorKindUsageLimit"
|
||||
"ChatErrorKindUsageLimit",
|
||||
"ChatErrorKindMissingKey",
|
||||
"ChatErrorKindProviderDisabled"
|
||||
]
|
||||
},
|
||||
"codersdk.ChatFileMetadata": {
|
||||
@@ -17060,6 +17099,9 @@
|
||||
"scim_api_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"scim_use_legacy": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"session_lifetime": {
|
||||
"$ref": "#/definitions/codersdk.SessionLifetime"
|
||||
},
|
||||
@@ -20426,6 +20468,7 @@
|
||||
"assign_org_role",
|
||||
"assign_role",
|
||||
"audit_log",
|
||||
"boundary_log",
|
||||
"boundary_usage",
|
||||
"chat",
|
||||
"connection_log",
|
||||
@@ -20476,6 +20519,7 @@
|
||||
"ResourceAssignOrgRole",
|
||||
"ResourceAssignRole",
|
||||
"ResourceAuditLog",
|
||||
"ResourceBoundaryLog",
|
||||
"ResourceBoundaryUsage",
|
||||
"ResourceChat",
|
||||
"ResourceConnectionLog",
|
||||
@@ -22744,6 +22788,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": {
|
||||
@@ -22877,6 +22936,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": {
|
||||
@@ -25271,6 +25354,71 @@
|
||||
"key.NodePublic": {
|
||||
"type": "object"
|
||||
},
|
||||
"legacyscim.SCIMUser": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active": {
|
||||
"description": "Active is a ptr to prevent the empty value from being interpreted as false.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"emails": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display": {
|
||||
"type": "string"
|
||||
},
|
||||
"primary": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resourceType": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"familyName": {
|
||||
"type": "string"
|
||||
},
|
||||
"givenName": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"userName": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"netcheck.Report": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -303,6 +303,12 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string {
|
||||
_, _ = b.WriteString("{user} ")
|
||||
}
|
||||
|
||||
// Chat write operations get semantic descriptions derived from the diff.
|
||||
if desc, ok := chatAuditLogDescription(alog); ok {
|
||||
_, _ = b.WriteString(desc)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
switch {
|
||||
case alog.AuditLog.StatusCode == int32(http.StatusSeeOther):
|
||||
_, _ = b.WriteString("was redirected attempting to ")
|
||||
@@ -345,6 +351,56 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// chatAuditLogDescription returns a description for successful chat write
|
||||
// operations based on the diff contents. It returns false for non-chat
|
||||
// resources, non-write actions, or error/redirect status codes, letting
|
||||
// the caller fall through to the generic description.
|
||||
func chatAuditLogDescription(alog database.GetAuditLogsOffsetRow) (string, bool) {
|
||||
if alog.AuditLog.ResourceType != database.ResourceTypeChat ||
|
||||
alog.AuditLog.Action != database.AuditActionWrite ||
|
||||
alog.AuditLog.StatusCode >= 400 ||
|
||||
alog.AuditLog.StatusCode == int32(http.StatusSeeOther) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var diff codersdk.AuditDiff
|
||||
if err := json.Unmarshal(alog.AuditLog.Diff, &diff); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Single "archived" field: archive or unarchive.
|
||||
if len(diff) == 1 {
|
||||
if field, ok := diff["archived"]; ok {
|
||||
oldVal, oldOK := field.Old.(bool)
|
||||
newVal, newOK := field.New.(bool)
|
||||
if oldOK && newOK {
|
||||
if !oldVal && newVal {
|
||||
return "archived chat {target}", true
|
||||
}
|
||||
if oldVal && !newVal {
|
||||
return "unarchived chat {target}", true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All fields are ACL changes: sharing update.
|
||||
if len(diff) > 0 {
|
||||
aclOnly := true
|
||||
for field := range diff {
|
||||
if field != "user_acl" && field != "group_acl" {
|
||||
aclOnly = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if aclOnly {
|
||||
return "updated sharing for chat {target}", true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.GetAuditLogsOffsetRow) bool {
|
||||
switch alog.AuditLog.ResourceType {
|
||||
case database.ResourceTypeTemplate:
|
||||
|
||||
@@ -3,6 +3,7 @@ package coderd
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestAuditLogIsResourceDeleted(t *testing.T) {
|
||||
@@ -111,6 +113,91 @@ func TestAuditLogDescription(t *testing.T) {
|
||||
},
|
||||
want: "{user} deleted the git ssh key",
|
||||
},
|
||||
{
|
||||
name: "chat_archived",
|
||||
alog: chatAuditLogRow(t, codersdk.AuditDiff{
|
||||
"archived": {Old: false, New: true},
|
||||
}),
|
||||
want: "{user} archived chat {target}",
|
||||
},
|
||||
{
|
||||
name: "chat_unarchived",
|
||||
alog: chatAuditLogRow(t, codersdk.AuditDiff{
|
||||
"archived": {Old: true, New: false},
|
||||
}),
|
||||
want: "{user} unarchived chat {target}",
|
||||
},
|
||||
{
|
||||
name: "chat_sharing_user_acl",
|
||||
alog: chatAuditLogRow(t, codersdk.AuditDiff{
|
||||
"user_acl": {Old: map[string]any{}, New: map[string]any{"user-1": map[string]any{"permissions": []string{"read"}}}},
|
||||
}),
|
||||
want: "{user} updated sharing for chat {target}",
|
||||
},
|
||||
{
|
||||
name: "chat_sharing_group_acl",
|
||||
alog: chatAuditLogRow(t, codersdk.AuditDiff{
|
||||
"group_acl": {Old: map[string]any{}, New: map[string]any{"group-1": map[string]any{"permissions": []string{"read"}}}},
|
||||
}),
|
||||
want: "{user} updated sharing for chat {target}",
|
||||
},
|
||||
{
|
||||
name: "chat_sharing_both_acls",
|
||||
alog: chatAuditLogRow(t, codersdk.AuditDiff{
|
||||
"user_acl": {Old: map[string]any{}, New: map[string]any{"user-1": map[string]any{"permissions": []string{"read"}}}},
|
||||
"group_acl": {Old: map[string]any{}, New: map[string]any{"group-1": map[string]any{"permissions": []string{"read"}}}},
|
||||
}),
|
||||
want: "{user} updated sharing for chat {target}",
|
||||
},
|
||||
{
|
||||
name: "chat_mixed_diff_falls_through",
|
||||
alog: chatAuditLogRow(t, codersdk.AuditDiff{
|
||||
"archived": {Old: false, New: true},
|
||||
"pin_order": {Old: 1, New: 0},
|
||||
}),
|
||||
want: "{user} updated chat {target}",
|
||||
},
|
||||
{
|
||||
name: "chat_acl_with_extra_field_falls_through",
|
||||
alog: chatAuditLogRow(t, codersdk.AuditDiff{
|
||||
"user_acl": {Old: map[string]any{}, New: map[string]any{}},
|
||||
"pin_order": {Old: 1, New: 0},
|
||||
}),
|
||||
want: "{user} updated chat {target}",
|
||||
},
|
||||
{
|
||||
name: "chat_failed_write_no_override",
|
||||
alog: func() database.GetAuditLogsOffsetRow {
|
||||
row := chatAuditLogRow(t, codersdk.AuditDiff{
|
||||
"archived": {Old: false, New: true},
|
||||
})
|
||||
row.AuditLog.StatusCode = 400
|
||||
return row
|
||||
}(),
|
||||
want: "{user} unsuccessfully attempted to write chat {target}",
|
||||
},
|
||||
{
|
||||
name: "chat_redirect_no_override",
|
||||
alog: func() database.GetAuditLogsOffsetRow {
|
||||
row := chatAuditLogRow(t, codersdk.AuditDiff{
|
||||
"archived": {Old: false, New: true},
|
||||
})
|
||||
row.AuditLog.StatusCode = 303
|
||||
return row
|
||||
}(),
|
||||
want: "{user} was redirected attempting to write chat {target}",
|
||||
},
|
||||
{
|
||||
name: "chat_non_write_action_no_override",
|
||||
alog: func() database.GetAuditLogsOffsetRow {
|
||||
row := chatAuditLogRow(t, codersdk.AuditDiff{
|
||||
"user_acl": {Old: map[string]any{}, New: map[string]any{"user-1": map[string]any{"permissions": []string{"read"}}}},
|
||||
})
|
||||
row.AuditLog.Action = database.AuditActionCreate
|
||||
return row
|
||||
}(),
|
||||
want: "{user} created chat {target}",
|
||||
},
|
||||
}
|
||||
// nolint: paralleltest // no longer need to reinitialize loop vars in go 1.22
|
||||
for _, tc := range testCases {
|
||||
@@ -121,3 +208,19 @@ func TestAuditLogDescription(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// chatAuditLogRow builds a GetAuditLogsOffsetRow for a successful chat write
|
||||
// with the given diff, suitable for testing auditLogDescription.
|
||||
func chatAuditLogRow(t *testing.T, diff codersdk.AuditDiff) database.GetAuditLogsOffsetRow {
|
||||
t.Helper()
|
||||
rawDiff, err := json.Marshal(diff)
|
||||
require.NoError(t, err)
|
||||
return database.GetAuditLogsOffsetRow{
|
||||
AuditLog: database.AuditLog{
|
||||
Action: database.AuditActionWrite,
|
||||
StatusCode: 200,
|
||||
ResourceType: database.ResourceTypeChat,
|
||||
Diff: rawDiff,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+1
@@ -44,6 +44,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 {
|
||||
|
||||
@@ -651,6 +651,8 @@ var (
|
||||
rbac.ResourceAibridgeInterception.Type: {policy.ActionDelete},
|
||||
// Chat auto-archive sets archived=true on inactive chats.
|
||||
rbac.ResourceChat.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
// Purge old boundary logs past the retention period.
|
||||
rbac.ResourceBoundaryLog.Type: {policy.ActionDelete},
|
||||
}),
|
||||
User: []rbac.Permission{},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{},
|
||||
@@ -742,6 +744,29 @@ var (
|
||||
}),
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
subjectSCIM = rbac.Subject{
|
||||
Type: rbac.SubjectTypeSCIMProvisioner,
|
||||
FriendlyName: "SCIM Provisioner",
|
||||
ID: uuid.Nil.String(),
|
||||
Roles: rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleIdentifier{Name: "scim"},
|
||||
DisplayName: "SCIM",
|
||||
Site: rbac.Permissions(map[string][]policy.Action{
|
||||
rbac.ResourceSystem.Type: {policy.ActionRead}, // Required for idp config reads, this should be fixed
|
||||
rbac.ResourceAssignRole.Type: rbac.ResourceAssignRole.AvailableActions(),
|
||||
rbac.ResourceAssignOrgRole.Type: rbac.ResourceAssignOrgRole.AvailableActions(),
|
||||
rbac.ResourceUser.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionRead, policy.ActionUpdatePersonal},
|
||||
rbac.ResourceOrganization.Type: {policy.ActionRead},
|
||||
rbac.ResourceOrganizationMember.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate},
|
||||
}),
|
||||
User: []rbac.Permission{},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{},
|
||||
},
|
||||
}),
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
)
|
||||
|
||||
// AsProvisionerd returns a context with an actor that has permissions required
|
||||
@@ -872,6 +897,12 @@ func AsAIProviderMetadataReader(ctx context.Context) context.Context {
|
||||
return As(ctx, subjectAIProviderMetadataReader)
|
||||
}
|
||||
|
||||
// AsSCIMProvisioner returns a context with an actor that has permissions required for
|
||||
// handling the /scim/v2 routes and provisioning users via SCIM.
|
||||
func AsSCIMProvisioner(ctx context.Context) context.Context {
|
||||
return As(ctx, subjectSCIM)
|
||||
}
|
||||
|
||||
var AsRemoveActor = rbac.Subject{
|
||||
ID: "remove-actor",
|
||||
}
|
||||
@@ -2162,9 +2193,8 @@ func (q *querier) DeleteOldAuditLogs(ctx context.Context, arg database.DeleteOld
|
||||
return q.db.DeleteOldAuditLogs(ctx, arg)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceSystem with dedicated boundary_log resource type.
|
||||
func (q *querier) DeleteOldBoundaryLogs(ctx context.Context, arg database.DeleteOldBoundaryLogsParams) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceBoundaryLog); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return q.db.DeleteOldBoundaryLogs(ctx, arg)
|
||||
@@ -2293,6 +2323,32 @@ func (q *querier) DeleteTask(ctx context.Context, arg database.DeleteTaskParams)
|
||||
return q.db.DeleteTask(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
// Removing a user's AI budget override affects both the user (clearing
|
||||
// their per-user spend cap) and the group it was attributed to.
|
||||
u, err := q.db.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, u); err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
// Fetch the existing override to learn which group it attributes spend to,
|
||||
// so we can authorize the caller against that group as well.
|
||||
userOverride, err := q.db.GetUserAIBudgetOverride(ctx, userID)
|
||||
if err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
g, err := q.db.GetGroupByID(ctx, userOverride.GroupID)
|
||||
if err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, g); err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
return q.db.DeleteUserAIBudgetOverride(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error {
|
||||
u, err := q.db.GetUserByID(ctx, arg.UserID)
|
||||
if err != nil {
|
||||
@@ -2751,17 +2807,15 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI
|
||||
return q.db.GetAuthorizationUserRoles(ctx, userID)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type.
|
||||
func (q *querier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (database.BoundaryLog, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil {
|
||||
return database.BoundaryLog{}, err
|
||||
}
|
||||
return q.db.GetBoundaryLogByID(ctx, id)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type.
|
||||
func (q *querier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.BoundarySession, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil {
|
||||
return database.BoundarySession{}, err
|
||||
}
|
||||
return q.db.GetBoundarySessionByID(ctx, id)
|
||||
@@ -4508,6 +4562,13 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License,
|
||||
return q.db.GetUnexpiredLicenses(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
if _, err := q.GetUserByID(ctx, userID); err != nil { // AuthZ check
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
return q.db.GetUserAIBudgetOverride(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) {
|
||||
u, err := q.db.GetUserByID(ctx, arg.UserID)
|
||||
if err != nil {
|
||||
@@ -4659,7 +4720,8 @@ func (q *querier) GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UU
|
||||
}
|
||||
|
||||
func (q *querier) GetUserCount(ctx context.Context, includeSystem bool) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
// If you can read every user, then you can read the count of users.
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return q.db.GetUserCount(ctx, includeSystem)
|
||||
@@ -5451,14 +5513,29 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo
|
||||
return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertAuditLog)(ctx, arg)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type.
|
||||
func (q *querier) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) {
|
||||
return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertBoundaryLog)(ctx, arg)
|
||||
func (q *querier) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) {
|
||||
session, err := q.db.GetBoundarySessionByID(ctx, arg.SessionID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get boundary session for owner: %w", err)
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(session.OwnerID.UUID.String())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.InsertBoundaryLogs(ctx, arg)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type.
|
||||
func (q *querier) InsertBoundarySession(ctx context.Context, arg database.InsertBoundarySessionParams) (database.BoundarySession, error) {
|
||||
return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertBoundarySession)(ctx, arg)
|
||||
row, err := q.db.GetWorkspaceAgentAndWorkspaceByID(ctx, arg.WorkspaceAgentID)
|
||||
if err != nil {
|
||||
return database.BoundarySession{}, xerrors.Errorf("get workspace for boundary session owner: %w", err)
|
||||
}
|
||||
arg.OwnerID = uuid.NullUUID{UUID: row.WorkspaceTable.OwnerID, Valid: true}
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate,
|
||||
rbac.ResourceBoundaryLog.WithOwner(arg.OwnerID.UUID.String())); err != nil {
|
||||
return database.BoundarySession{}, err
|
||||
}
|
||||
return q.db.InsertBoundarySession(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) {
|
||||
@@ -6174,9 +6251,8 @@ func (q *querier) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context,
|
||||
return q.db.ListAIBridgeUserPromptsByInterceptionIDs(ctx, interceptionIDs)
|
||||
}
|
||||
|
||||
// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type.
|
||||
func (q *querier) ListBoundaryLogsBySessionID(ctx context.Context, arg database.ListBoundaryLogsBySessionIDParams) ([]database.BoundaryLog, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.ListBoundaryLogsBySessionID(ctx, arg)
|
||||
@@ -8312,6 +8388,26 @@ func (q *querier) UpsertTemplateUsageStats(ctx context.Context) error {
|
||||
return q.db.UpsertTemplateUsageStats(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) {
|
||||
// Setting a user's AI budget override affects both the user (their
|
||||
// per-user spend cap) and the group (spend attribution).
|
||||
u, err := q.db.GetUserByID(ctx, arg.UserID)
|
||||
if err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, u); err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
g, err := q.db.GetGroupByID(ctx, arg.GroupID)
|
||||
if err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, g); err != nil {
|
||||
return database.UserAiBudgetOverride{}, err
|
||||
}
|
||||
return q.db.UpsertUserAIBudgetOverride(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) {
|
||||
u, err := q.db.GetUserByID(ctx, arg.UserID)
|
||||
if err != nil {
|
||||
|
||||
@@ -440,35 +440,55 @@ func (s *MethodTestSuite) TestAuditLogs() {
|
||||
}))
|
||||
}
|
||||
|
||||
// TODO (PR #24810): These RBAC assertions use placeholder resource types.
|
||||
// They will be updated when the dedicated boundary_log resource type is added.
|
||||
func (s *MethodTestSuite) TestBoundaryLogs() {
|
||||
s.Run("InsertBoundarySession", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.InsertBoundarySessionParams{}
|
||||
dbm.EXPECT().InsertBoundarySession(gomock.Any(), arg).Return(database.BoundarySession{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionCreate)
|
||||
s.Run("InsertBoundarySession", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
aww := testutil.Fake(s.T(), faker, database.GetWorkspaceAgentAndWorkspaceByIDRow{})
|
||||
arg := database.InsertBoundarySessionParams{
|
||||
WorkspaceAgentID: aww.WorkspaceAgent.ID,
|
||||
}
|
||||
dbm.EXPECT().GetWorkspaceAgentAndWorkspaceByID(gomock.Any(), aww.WorkspaceAgent.ID).Return(aww, nil).AnyTimes()
|
||||
expectedArg := database.InsertBoundarySessionParams{
|
||||
WorkspaceAgentID: aww.WorkspaceAgent.ID,
|
||||
OwnerID: uuid.NullUUID{UUID: aww.WorkspaceTable.OwnerID, Valid: true},
|
||||
}
|
||||
dbm.EXPECT().InsertBoundarySession(gomock.Any(), expectedArg).Return(database.BoundarySession{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(
|
||||
rbac.ResourceBoundaryLog.WithOwner(aww.WorkspaceTable.OwnerID.String()), policy.ActionCreate,
|
||||
)
|
||||
}))
|
||||
s.Run("GetBoundarySessionByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), uuid.Nil).Return(database.BoundarySession{}, nil).AnyTimes()
|
||||
check.Args(uuid.Nil).Asserts(rbac.ResourceAuditLog, policy.ActionRead)
|
||||
check.Args(uuid.Nil).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead)
|
||||
}))
|
||||
s.Run("InsertBoundaryLog", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.InsertBoundaryLogParams{}
|
||||
dbm.EXPECT().InsertBoundaryLog(gomock.Any(), arg).Return(database.BoundaryLog{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionCreate)
|
||||
s.Run("InsertBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
ownerID := uuid.New()
|
||||
sessionID := uuid.New()
|
||||
session := database.BoundarySession{
|
||||
ID: sessionID,
|
||||
OwnerID: uuid.NullUUID{UUID: ownerID, Valid: true},
|
||||
}
|
||||
arg := database.InsertBoundaryLogsParams{
|
||||
SessionID: sessionID,
|
||||
ID: []uuid.UUID{uuid.New(), uuid.New()},
|
||||
}
|
||||
dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), sessionID).Return(session, nil).AnyTimes()
|
||||
dbm.EXPECT().InsertBoundaryLogs(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(
|
||||
rbac.ResourceBoundaryLog.WithOwner(ownerID.String()), policy.ActionCreate,
|
||||
)
|
||||
}))
|
||||
s.Run("GetBoundaryLogByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetBoundaryLogByID(gomock.Any(), uuid.Nil).Return(database.BoundaryLog{}, nil).AnyTimes()
|
||||
check.Args(uuid.Nil).Asserts(rbac.ResourceAuditLog, policy.ActionRead)
|
||||
check.Args(uuid.Nil).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead)
|
||||
}))
|
||||
s.Run("ListBoundaryLogsBySessionID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.ListBoundaryLogsBySessionIDParams{}
|
||||
dbm.EXPECT().ListBoundaryLogsBySessionID(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionRead)
|
||||
check.Args(arg).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead)
|
||||
}))
|
||||
s.Run("DeleteOldBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().DeleteOldBoundaryLogs(gomock.Any(), database.DeleteOldBoundaryLogsParams{}).Return(int64(0), nil).AnyTimes()
|
||||
check.Args(database.DeleteOldBoundaryLogsParams{}).Asserts(rbac.ResourceSystem, policy.ActionDelete)
|
||||
check.Args(database.DeleteOldBoundaryLogsParams{}).Asserts(rbac.ResourceBoundaryLog, policy.ActionDelete)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4587,7 +4607,7 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
}))
|
||||
s.Run("GetUserCount", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetUserCount(gomock.Any(), false).Return(int64(0), nil).AnyTimes()
|
||||
check.Args(false).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(int64(0))
|
||||
check.Args(false).Asserts(rbac.ResourceUser, policy.ActionRead).Returns(int64(0))
|
||||
}))
|
||||
s.Run("GetTemplates", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetTemplates(gomock.Any()).Return([]database.Template{}, nil).AnyTimes()
|
||||
@@ -6464,6 +6484,36 @@ func (s *MethodTestSuite) TestAIBridge() {
|
||||
check.Args(g.ID).Asserts(g, policy.ActionUpdate).Returns(b)
|
||||
}))
|
||||
|
||||
s.Run("GetUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID})
|
||||
dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes()
|
||||
dbm.EXPECT().GetUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes()
|
||||
check.Args(user.ID).Asserts(user, policy.ActionRead).Returns(override)
|
||||
}))
|
||||
|
||||
s.Run("UpsertUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
group := testutil.Fake(s.T(), faker, database.Group{})
|
||||
override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID, GroupID: group.ID})
|
||||
arg := database.UpsertUserAIBudgetOverrideParams{UserID: user.ID, GroupID: group.ID, SpendLimitMicros: override.SpendLimitMicros}
|
||||
dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes()
|
||||
dbm.EXPECT().GetGroupByID(gomock.Any(), group.ID).Return(group, nil).AnyTimes()
|
||||
dbm.EXPECT().UpsertUserAIBudgetOverride(gomock.Any(), arg).Return(override, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(user, policy.ActionUpdate, group, policy.ActionUpdate).Returns(override)
|
||||
}))
|
||||
|
||||
s.Run("DeleteUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
user := testutil.Fake(s.T(), faker, database.User{})
|
||||
group := testutil.Fake(s.T(), faker, database.Group{})
|
||||
override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID, GroupID: group.ID})
|
||||
dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes()
|
||||
dbm.EXPECT().GetUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes()
|
||||
dbm.EXPECT().GetGroupByID(gomock.Any(), group.ID).Return(group, nil).AnyTimes()
|
||||
dbm.EXPECT().DeleteUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes()
|
||||
check.Args(user.ID).Asserts(user, policy.ActionUpdate, group, policy.ActionUpdate).Returns(override)
|
||||
}))
|
||||
|
||||
s.Run("GetAIProviderByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
provider := testutil.Fake(s.T(), faker, database.AIProvider{})
|
||||
dbm.EXPECT().GetAIProviderByID(gomock.Any(), provider.ID).Return(provider, nil).AnyTimes()
|
||||
|
||||
@@ -458,6 +458,7 @@ func BoundarySession(t testing.TB, db database.Store, seed database.BoundarySess
|
||||
session, err := db.InsertBoundarySession(genCtx, database.InsertBoundarySessionParams{
|
||||
ID: takeFirst(seed.ID, uuid.New()),
|
||||
WorkspaceAgentID: takeFirst(seed.WorkspaceAgentID, uuid.New()),
|
||||
OwnerID: takeFirst(seed.OwnerID, uuid.NullUUID{UUID: uuid.New(), Valid: true}),
|
||||
ConfinedProcessName: takeFirst(seed.ConfinedProcessName, "claude-code"),
|
||||
StartedAt: takeFirst(seed.StartedAt, dbtime.Now()),
|
||||
UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()),
|
||||
@@ -466,20 +467,52 @@ func BoundarySession(t testing.TB, db database.Store, seed database.BoundarySess
|
||||
return session
|
||||
}
|
||||
|
||||
func BoundaryLog(t testing.TB, db database.Store, seed database.BoundaryLog) database.BoundaryLog {
|
||||
log, err := db.InsertBoundaryLog(genCtx, database.InsertBoundaryLogParams{
|
||||
ID: takeFirst(seed.ID, uuid.New()),
|
||||
SessionID: seed.SessionID,
|
||||
SequenceNumber: takeFirst(seed.SequenceNumber, 0),
|
||||
CapturedAt: takeFirst(seed.CapturedAt, dbtime.Now()),
|
||||
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
|
||||
Proto: takeFirst(seed.Proto, "http"),
|
||||
Method: takeFirst(seed.Method, "GET"),
|
||||
Detail: takeFirst(seed.Detail, "https://example.com"),
|
||||
MatchedRule: seed.MatchedRule,
|
||||
func BoundaryLogs(t testing.TB, db database.Store, seed []database.BoundaryLog) []database.BoundaryLog {
|
||||
ids := make([]uuid.UUID, 0, len(seed))
|
||||
sessionID := seed[0].SessionID
|
||||
sequenceNumbers := make([]int32, 0, len(seed))
|
||||
capturedAt := make([]time.Time, 0, len(seed))
|
||||
createdAt := make([]time.Time, 0, len(seed))
|
||||
protos := make([]string, 0, len(seed))
|
||||
method := make([]string, 0, len(seed))
|
||||
detail := make([]string, 0, len(seed))
|
||||
matchedRule := make([]string, 0, len(seed))
|
||||
for _, log := range seed {
|
||||
log = takeFirstBoundaryLog(log)
|
||||
ids = append(ids, log.ID)
|
||||
sequenceNumbers = append(sequenceNumbers, log.SequenceNumber)
|
||||
capturedAt = append(capturedAt, log.CapturedAt)
|
||||
createdAt = append(createdAt, log.CreatedAt)
|
||||
protos = append(protos, log.Proto)
|
||||
method = append(method, log.Method)
|
||||
detail = append(detail, log.Detail)
|
||||
matchedRule = append(matchedRule, log.MatchedRule.String)
|
||||
}
|
||||
logs, err := db.InsertBoundaryLogs(genCtx, database.InsertBoundaryLogsParams{
|
||||
ID: ids,
|
||||
SessionID: sessionID,
|
||||
SequenceNumber: sequenceNumbers,
|
||||
CapturedAt: capturedAt,
|
||||
CreatedAt: createdAt,
|
||||
Proto: protos,
|
||||
Method: method,
|
||||
Detail: detail,
|
||||
MatchedRule: matchedRule,
|
||||
})
|
||||
require.NoError(t, err, "insert boundary log")
|
||||
return log
|
||||
require.NoError(t, err, "insert boundary logs")
|
||||
return logs
|
||||
}
|
||||
|
||||
func takeFirstBoundaryLog(seed database.BoundaryLog) database.BoundaryLog {
|
||||
seed.ID = takeFirst(seed.ID, uuid.New())
|
||||
seed.SessionID = takeFirst(seed.SessionID, uuid.New())
|
||||
seed.SequenceNumber = takeFirst(seed.SequenceNumber, 0)
|
||||
seed.CapturedAt = takeFirst(seed.CapturedAt, dbtime.Now())
|
||||
seed.CreatedAt = takeFirst(seed.CreatedAt, dbtime.Now())
|
||||
seed.Proto = takeFirst(seed.Proto, "http")
|
||||
seed.Method = takeFirst(seed.Method, "GET")
|
||||
seed.Detail = takeFirst(seed.Detail, "https://example.com")
|
||||
return seed
|
||||
}
|
||||
|
||||
func Template(t testing.TB, db database.Store, seed database.Template) database.Template {
|
||||
@@ -1969,8 +2002,9 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA
|
||||
})
|
||||
if endedAt != nil {
|
||||
interception, err = db.UpdateAIBridgeInterceptionEnded(genCtx, database.UpdateAIBridgeInterceptionEndedParams{
|
||||
ID: interception.ID,
|
||||
EndedAt: *endedAt,
|
||||
ID: interception.ID,
|
||||
EndedAt: *endedAt,
|
||||
CredentialHint: takeFirst(seed.CredentialHint, ""),
|
||||
})
|
||||
require.NoError(t, err, "insert aibridge interception")
|
||||
}
|
||||
|
||||
+28
-4
@@ -793,6 +793,14 @@ func (m queryMetricsStore) DeleteTask(ctx context.Context, arg database.DeleteTa
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.DeleteUserAIBudgetOverride(ctx, userID)
|
||||
m.queryLatencies.WithLabelValues("DeleteUserAIBudgetOverride").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteUserAIBudgetOverride").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.DeleteUserAIProviderKey(ctx, arg)
|
||||
@@ -2905,6 +2913,14 @@ func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUserAIBudgetOverride(ctx, userID)
|
||||
m.queryLatencies.WithLabelValues("GetUserAIBudgetOverride").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserAIBudgetOverride").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUserAIProviderKeyByProviderID(ctx, arg)
|
||||
@@ -3753,11 +3769,11 @@ func (m queryMetricsStore) InsertAuditLog(ctx context.Context, arg database.Inse
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) {
|
||||
func (m queryMetricsStore) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.InsertBoundaryLog(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("InsertBoundaryLog").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertBoundaryLog").Inc()
|
||||
r0, r1 := m.s.InsertBoundaryLogs(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("InsertBoundaryLogs").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertBoundaryLogs").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
@@ -6057,6 +6073,14 @@ func (m queryMetricsStore) UpsertTemplateUsageStats(ctx context.Context) error {
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpsertUserAIBudgetOverride(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpsertUserAIBudgetOverride").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertUserAIBudgetOverride").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpsertUserAIProviderKey(ctx, arg)
|
||||
|
||||
Generated
+52
-7
@@ -1349,6 +1349,21 @@ func (mr *MockStoreMockRecorder) DeleteTask(ctx, arg any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTask", reflect.TypeOf((*MockStore)(nil).DeleteTask), ctx, arg)
|
||||
}
|
||||
|
||||
// DeleteUserAIBudgetOverride mocks base method.
|
||||
func (m *MockStore) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteUserAIBudgetOverride", ctx, userID)
|
||||
ret0, _ := ret[0].(database.UserAiBudgetOverride)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DeleteUserAIBudgetOverride indicates an expected call of DeleteUserAIBudgetOverride.
|
||||
func (mr *MockStoreMockRecorder) DeleteUserAIBudgetOverride(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).DeleteUserAIBudgetOverride), ctx, userID)
|
||||
}
|
||||
|
||||
// DeleteUserAIProviderKey mocks base method.
|
||||
func (m *MockStore) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -5445,6 +5460,21 @@ func (mr *MockStoreMockRecorder) GetUnexpiredLicenses(ctx any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnexpiredLicenses", reflect.TypeOf((*MockStore)(nil).GetUnexpiredLicenses), ctx)
|
||||
}
|
||||
|
||||
// GetUserAIBudgetOverride mocks base method.
|
||||
func (m *MockStore) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUserAIBudgetOverride", ctx, userID)
|
||||
ret0, _ := ret[0].(database.UserAiBudgetOverride)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUserAIBudgetOverride indicates an expected call of GetUserAIBudgetOverride.
|
||||
func (mr *MockStoreMockRecorder) GetUserAIBudgetOverride(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).GetUserAIBudgetOverride), ctx, userID)
|
||||
}
|
||||
|
||||
// GetUserAIProviderKeyByProviderID mocks base method.
|
||||
func (m *MockStore) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -7049,19 +7079,19 @@ func (mr *MockStoreMockRecorder) InsertAuditLog(ctx, arg any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAuditLog", reflect.TypeOf((*MockStore)(nil).InsertAuditLog), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertBoundaryLog mocks base method.
|
||||
func (m *MockStore) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) {
|
||||
// InsertBoundaryLogs mocks base method.
|
||||
func (m *MockStore) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "InsertBoundaryLog", ctx, arg)
|
||||
ret0, _ := ret[0].(database.BoundaryLog)
|
||||
ret := m.ctrl.Call(m, "InsertBoundaryLogs", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.BoundaryLog)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// InsertBoundaryLog indicates an expected call of InsertBoundaryLog.
|
||||
func (mr *MockStoreMockRecorder) InsertBoundaryLog(ctx, arg any) *gomock.Call {
|
||||
// InsertBoundaryLogs indicates an expected call of InsertBoundaryLogs.
|
||||
func (mr *MockStoreMockRecorder) InsertBoundaryLogs(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoundaryLog", reflect.TypeOf((*MockStore)(nil).InsertBoundaryLog), ctx, arg)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoundaryLogs", reflect.TypeOf((*MockStore)(nil).InsertBoundaryLogs), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertBoundarySession mocks base method.
|
||||
@@ -11359,6 +11389,21 @@ func (mr *MockStoreMockRecorder) UpsertTemplateUsageStats(ctx any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTemplateUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertTemplateUsageStats), ctx)
|
||||
}
|
||||
|
||||
// UpsertUserAIBudgetOverride mocks base method.
|
||||
func (m *MockStore) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpsertUserAIBudgetOverride", ctx, arg)
|
||||
ret0, _ := ret[0].(database.UserAiBudgetOverride)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpsertUserAIBudgetOverride indicates an expected call of UpsertUserAIBudgetOverride.
|
||||
func (mr *MockStoreMockRecorder) UpsertUserAIBudgetOverride(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).UpsertUserAIBudgetOverride), ctx, arg)
|
||||
}
|
||||
|
||||
// UpsertUserAIProviderKey mocks base method.
|
||||
func (m *MockStore) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+89
-2
@@ -249,7 +249,11 @@ CREATE TYPE api_key_scope AS ENUM (
|
||||
'user_skill:read',
|
||||
'user_skill:update',
|
||||
'user_skill:delete',
|
||||
'user_skill:*'
|
||||
'user_skill:*',
|
||||
'boundary_log:*',
|
||||
'boundary_log:create',
|
||||
'boundary_log:delete',
|
||||
'boundary_log:read'
|
||||
);
|
||||
|
||||
CREATE TYPE app_sharing_level AS ENUM (
|
||||
@@ -837,6 +841,42 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM user_ai_budget_overrides
|
||||
WHERE user_id = OLD.user_id AND group_id = OLD.group_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM user_ai_budget_overrides
|
||||
WHERE user_id = OLD.user_id AND group_id = OLD.organization_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM group_members_expanded
|
||||
WHERE user_id = NEW.user_id AND group_id = NEW.group_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'user % is not a member of group %', NEW.user_id, NEW.group_id
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_ai_budget_overrides_must_be_group_member';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION enforce_user_secrets_per_user_limits() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -1216,6 +1256,17 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION remove_mcp_server_config_id_from_chats() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE chats
|
||||
SET mcp_server_ids = array_remove(mcp_server_ids, OLD.id)
|
||||
WHERE OLD.id = ANY(mcp_server_ids);
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION remove_organization_member_role() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -1474,7 +1525,8 @@ CREATE TABLE boundary_sessions (
|
||||
workspace_agent_id uuid NOT NULL,
|
||||
confined_process_name text NOT NULL,
|
||||
started_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
owner_id uuid
|
||||
);
|
||||
|
||||
COMMENT ON TABLE boundary_sessions IS 'Boundary session metadata. Each row represents a single invocation of a Boundary process wrapping a confined agent.';
|
||||
@@ -1489,6 +1541,8 @@ COMMENT ON COLUMN boundary_sessions.started_at IS 'Time when the first log for t
|
||||
|
||||
COMMENT ON COLUMN boundary_sessions.updated_at IS 'Time when the session was last updated.';
|
||||
|
||||
COMMENT ON COLUMN boundary_sessions.owner_id IS 'The ID of the user who owns the workspace. NULL if the user has been deleted.';
|
||||
|
||||
CREATE TABLE boundary_usage_stats (
|
||||
replica_id uuid NOT NULL,
|
||||
unique_workspaces_count bigint DEFAULT 0 NOT NULL,
|
||||
@@ -3119,6 +3173,17 @@ COMMENT ON TABLE usage_events_daily IS 'usage_events_daily is a daily rollup of
|
||||
|
||||
COMMENT ON COLUMN usage_events_daily.day IS 'The date of the summed usage events, always in UTC.';
|
||||
|
||||
CREATE TABLE user_ai_budget_overrides (
|
||||
user_id uuid NOT NULL,
|
||||
group_id uuid NOT NULL,
|
||||
spend_limit_micros bigint NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT user_ai_budget_overrides_spend_limit_micros_check CHECK ((spend_limit_micros >= 0))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE user_ai_budget_overrides IS 'Per-user AI spend override that supersedes group budget resolution.';
|
||||
|
||||
CREATE TABLE user_ai_provider_keys (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
@@ -3968,6 +4033,9 @@ ALTER TABLE ONLY usage_events_daily
|
||||
ALTER TABLE ONLY usage_events
|
||||
ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY user_ai_budget_overrides
|
||||
ADD CONSTRAINT user_ai_budget_overrides_pkey PRIMARY KEY (user_id);
|
||||
|
||||
ALTER TABLE ONLY user_ai_provider_keys
|
||||
ADD CONSTRAINT user_ai_provider_keys_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -4435,6 +4503,10 @@ CREATE TRIGGER inhibit_enqueue_if_disabled BEFORE INSERT ON notification_message
|
||||
|
||||
CREATE TRIGGER protect_deleting_organizations BEFORE UPDATE ON organizations FOR EACH ROW WHEN (((new.deleted = true) AND (old.deleted = false))) EXECUTE FUNCTION protect_deleting_organizations();
|
||||
|
||||
CREATE TRIGGER remove_chat_mcp_server_config_id BEFORE DELETE ON mcp_server_configs FOR EACH ROW EXECUTE FUNCTION remove_mcp_server_config_id_from_chats();
|
||||
|
||||
COMMENT ON TRIGGER remove_chat_mcp_server_config_id ON mcp_server_configs IS 'When an MCP server config is deleted, this trigger removes its ID from all chats.';
|
||||
|
||||
CREATE TRIGGER remove_organization_member_custom_role BEFORE DELETE ON custom_roles FOR EACH ROW EXECUTE FUNCTION remove_organization_member_role();
|
||||
|
||||
COMMENT ON TRIGGER remove_organization_member_custom_role ON custom_roles IS 'When a custom_role is deleted, this trigger removes the role from all organization members.';
|
||||
@@ -4445,6 +4517,12 @@ CREATE TRIGGER trigger_delete_group_members_on_org_member_delete BEFORE DELETE O
|
||||
|
||||
CREATE TRIGGER trigger_delete_oauth2_provider_app_token AFTER DELETE ON oauth2_provider_app_tokens FOR EACH ROW EXECUTE FUNCTION delete_deleted_oauth2_provider_app_token_api_key();
|
||||
|
||||
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_group_member_delete BEFORE DELETE ON group_members FOR EACH ROW EXECUTE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete();
|
||||
|
||||
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_org_member_delete BEFORE DELETE ON organization_members FOR EACH ROW EXECUTE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete();
|
||||
|
||||
CREATE TRIGGER trigger_enforce_user_ai_budget_override_membership BEFORE INSERT OR UPDATE ON user_ai_budget_overrides FOR EACH ROW EXECUTE FUNCTION enforce_user_ai_budget_override_membership();
|
||||
|
||||
CREATE TRIGGER trigger_insert_apikeys BEFORE INSERT ON api_keys FOR EACH ROW EXECUTE FUNCTION insert_apikey_fail_if_user_deleted();
|
||||
|
||||
CREATE TRIGGER trigger_insert_organization_system_roles AFTER INSERT ON organizations FOR EACH ROW EXECUTE FUNCTION insert_organization_system_roles();
|
||||
@@ -4494,6 +4572,9 @@ ALTER TABLE ONLY api_keys
|
||||
ALTER TABLE ONLY boundary_logs
|
||||
ADD CONSTRAINT boundary_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY boundary_sessions
|
||||
ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY boundary_sessions
|
||||
ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id);
|
||||
|
||||
@@ -4767,6 +4848,12 @@ ALTER TABLE ONLY templates
|
||||
ALTER TABLE ONLY templates
|
||||
ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY user_ai_budget_overrides
|
||||
ADD CONSTRAINT user_ai_budget_overrides_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY user_ai_budget_overrides
|
||||
ADD CONSTRAINT user_ai_budget_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY user_ai_provider_keys
|
||||
ADD CONSTRAINT user_ai_provider_keys_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
+3
@@ -13,6 +13,7 @@ const (
|
||||
ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
|
||||
ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyBoundaryLogsSessionID ForeignKeyConstraint = "boundary_logs_session_id_fkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE;
|
||||
ForeignKeyBoundarySessionsOwnerID ForeignKeyConstraint = "boundary_sessions_owner_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
ForeignKeyBoundarySessionsWorkspaceAgentID ForeignKeyConstraint = "boundary_sessions_workspace_agent_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id);
|
||||
ForeignKeyChatDebugRunsChatID ForeignKeyConstraint = "chat_debug_runs_chat_id_fkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||
ForeignKeyChatDebugStepsChatID ForeignKeyConstraint = "chat_debug_steps_chat_id_fkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||
@@ -104,6 +105,8 @@ const (
|
||||
ForeignKeyTemplateVersionsTemplateID ForeignKeyConstraint = "template_versions_template_id_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE;
|
||||
ForeignKeyTemplatesCreatedBy ForeignKeyConstraint = "templates_created_by_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT;
|
||||
ForeignKeyTemplatesOrganizationID ForeignKeyConstraint = "templates_organization_id_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserAiBudgetOverridesGroupID ForeignKeyConstraint = "user_ai_budget_overrides_group_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserAiBudgetOverridesUserID ForeignKeyConstraint = "user_ai_budget_overrides_user_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserAiProviderKeysAiProviderID ForeignKeyConstraint = "user_ai_provider_keys_ai_provider_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE;
|
||||
ForeignKeyUserAiProviderKeysAPIKeyKeyID ForeignKeyConstraint = "user_ai_provider_keys_api_key_key_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest);
|
||||
ForeignKeyUserAiProviderKeysUserID ForeignKeyConstraint = "user_ai_provider_keys_user_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TRIGGER IF EXISTS remove_chat_mcp_server_config_id ON mcp_server_configs;
|
||||
DROP FUNCTION IF EXISTS remove_mcp_server_config_id_from_chats;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Remove already-stale MCP server references before future deletes are
|
||||
-- handled by the trigger below.
|
||||
UPDATE chats
|
||||
SET mcp_server_ids = (
|
||||
SELECT COALESCE(array_agg(ids.mcp_server_id ORDER BY ids.position), '{}'::uuid[])
|
||||
FROM unnest(chats.mcp_server_ids) WITH ORDINALITY AS ids(mcp_server_id, position)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM mcp_server_configs
|
||||
WHERE mcp_server_configs.id = ids.mcp_server_id
|
||||
)
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(chats.mcp_server_ids) AS ids(mcp_server_id)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM mcp_server_configs
|
||||
WHERE mcp_server_configs.id = ids.mcp_server_id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION remove_mcp_server_config_id_from_chats()
|
||||
RETURNS TRIGGER AS
|
||||
$$
|
||||
BEGIN
|
||||
UPDATE chats
|
||||
SET mcp_server_ids = array_remove(mcp_server_ids, OLD.id)
|
||||
WHERE OLD.id = ANY(mcp_server_ids);
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER remove_chat_mcp_server_config_id
|
||||
BEFORE DELETE ON mcp_server_configs FOR EACH ROW
|
||||
EXECUTE PROCEDURE remove_mcp_server_config_id_from_chats();
|
||||
|
||||
COMMENT ON TRIGGER
|
||||
remove_chat_mcp_server_config_id
|
||||
ON mcp_server_configs IS
|
||||
'When an MCP server config is deleted, this trigger removes its ID from all chats.';
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op for boundary_log scopes: keep enum values to avoid dependency churn.
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add boundary_log scopes for RBAC.
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:*';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:create';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:delete';
|
||||
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:read';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE boundary_sessions DROP CONSTRAINT IF EXISTS boundary_sessions_owner_id_fkey;
|
||||
ALTER TABLE boundary_sessions DROP COLUMN IF EXISTS owner_id;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Add owner_id to boundary_sessions to avoid expensive JOINs when
|
||||
-- deriving the workspace owner for RBAC checks during log insertion.
|
||||
ALTER TABLE boundary_sessions ADD COLUMN owner_id uuid;
|
||||
|
||||
COMMENT ON COLUMN boundary_sessions.owner_id IS 'The ID of the user who owns the workspace. NULL if the user has been deleted.';
|
||||
|
||||
-- Backfill owner_id from the workspace agent -> workspace -> owner chain.
|
||||
-- Soft-deleted agents and workspaces are included so that their audit
|
||||
-- data is preserved.
|
||||
UPDATE boundary_sessions bs
|
||||
SET owner_id = w.owner_id
|
||||
FROM workspace_agents wa
|
||||
JOIN workspace_resources wr ON wa.resource_id = wr.id
|
||||
JOIN provisioner_jobs pj ON wr.job_id = pj.id
|
||||
JOIN workspace_builds wb ON pj.id = wb.job_id
|
||||
JOIN workspaces w ON wb.workspace_id = w.id
|
||||
WHERE wa.id = bs.workspace_agent_id
|
||||
AND pj.type = 'workspace_build';
|
||||
|
||||
-- Delete any sessions that could not be backfilled (orphaned data
|
||||
-- with no resolvable workspace agent or workspace build chain).
|
||||
DELETE FROM boundary_sessions WHERE owner_id IS NULL;
|
||||
|
||||
-- Add FK constraint. SET NULL preserves audit data when a user is
|
||||
-- hard-deleted; the session and its logs survive with a NULL owner.
|
||||
ALTER TABLE boundary_sessions
|
||||
ADD CONSTRAINT boundary_sessions_owner_id_fkey
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP TRIGGER IF EXISTS trigger_delete_user_ai_budget_overrides_on_org_member_delete ON organization_members;
|
||||
DROP FUNCTION IF EXISTS delete_user_ai_budget_overrides_on_org_member_delete;
|
||||
DROP TRIGGER IF EXISTS trigger_delete_user_ai_budget_overrides_on_group_member_delete ON group_members;
|
||||
DROP FUNCTION IF EXISTS delete_user_ai_budget_overrides_on_group_member_delete;
|
||||
DROP TRIGGER IF EXISTS trigger_enforce_user_ai_budget_override_membership ON user_ai_budget_overrides;
|
||||
DROP FUNCTION IF EXISTS enforce_user_ai_budget_override_membership;
|
||||
DROP TABLE IF EXISTS user_ai_budget_overrides CASCADE;
|
||||
@@ -0,0 +1,76 @@
|
||||
CREATE TABLE user_ai_budget_overrides (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
-- Spend limit applied to the user, in micro-units (1 unit = 1,000,000).
|
||||
spend_limit_micros BIGINT NOT NULL CHECK (spend_limit_micros >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
-- The membership invariant (user must be a member of the attributed
|
||||
-- group, including when that group is "Everyone") would naturally be
|
||||
-- a composite FK to group_members_expanded, but PostgreSQL does not
|
||||
-- allow FKs to views. It's enforced instead by a write-time trigger
|
||||
-- on this table and removal-time triggers on the underlying
|
||||
-- membership tables.
|
||||
);
|
||||
|
||||
COMMENT ON TABLE user_ai_budget_overrides IS 'Per-user AI spend override that supersedes group budget resolution.';
|
||||
|
||||
-- Write-time membership check. Reads from group_members_expanded so
|
||||
-- the "Everyone" group (whose membership lives in organization_members)
|
||||
-- is correctly handled. Raises check_violation with a constraint name
|
||||
-- so callers can match it via database.IsCheckViolation in Go.
|
||||
CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM group_members_expanded
|
||||
WHERE user_id = NEW.user_id AND group_id = NEW.group_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'user % is not a member of group %', NEW.user_id, NEW.group_id
|
||||
USING ERRCODE = 'check_violation',
|
||||
CONSTRAINT = 'user_ai_budget_overrides_must_be_group_member';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_enforce_user_ai_budget_override_membership
|
||||
BEFORE INSERT OR UPDATE ON user_ai_budget_overrides
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE enforce_user_ai_budget_override_membership();
|
||||
|
||||
-- When a user is removed from a regular group (any group except
|
||||
-- "Everyone"), delete any override attributed to that group.
|
||||
CREATE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM user_ai_budget_overrides
|
||||
WHERE user_id = OLD.user_id AND group_id = OLD.group_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_group_member_delete
|
||||
BEFORE DELETE ON group_members
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE delete_user_ai_budget_overrides_on_group_member_delete();
|
||||
|
||||
-- When a user is removed from an organization, delete any override
|
||||
-- attributed to that organization's "Everyone" group (which has
|
||||
-- id == organization_id).
|
||||
CREATE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM user_ai_budget_overrides
|
||||
WHERE user_id = OLD.user_id AND group_id = OLD.organization_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_org_member_delete
|
||||
BEFORE DELETE ON organization_members
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE delete_user_ai_budget_overrides_on_org_member_delete();
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
-- Re-insert boundary session and log fixture data after migration 000511
|
||||
-- deletes orphaned rows (the original fixture's workspace_agent links to a
|
||||
-- template_version_import job, not a workspace_build, so the backfill
|
||||
-- cannot resolve the owner).
|
||||
|
||||
INSERT INTO boundary_sessions (
|
||||
id,
|
||||
workspace_agent_id,
|
||||
confined_process_name,
|
||||
started_at,
|
||||
updated_at,
|
||||
owner_id
|
||||
) VALUES (
|
||||
'a1b2c3d4-e5f6-4890-abcd-ef1234567890',
|
||||
'45e89705-e09d-4850-bcec-f9a937f5d78d',
|
||||
'claude-code',
|
||||
'2026-04-01 10:00:00+00',
|
||||
'2026-04-01 10:00:00+00',
|
||||
'30095c71-380b-457a-8995-97b8ee6e5307'
|
||||
);
|
||||
|
||||
INSERT INTO boundary_logs (
|
||||
id,
|
||||
session_id,
|
||||
sequence_number,
|
||||
captured_at,
|
||||
created_at,
|
||||
proto,
|
||||
method,
|
||||
detail,
|
||||
matched_rule
|
||||
) VALUES (
|
||||
'b2c3d4e5-f6a7-4901-bcde-f12345678901',
|
||||
'a1b2c3d4-e5f6-4890-abcd-ef1234567890',
|
||||
0,
|
||||
'2026-04-01 10:00:01+00',
|
||||
'2026-04-01 10:00:00+00',
|
||||
'http',
|
||||
'GET',
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
'domain=api.anthropic.com'
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user