Merge branch 'main' into workspace-bdje

This commit is contained in:
Jaayden Halko
2026-06-01 19:17:28 +08:00
committed by GitHub
328 changed files with 17640 additions and 5506 deletions
+15
View File
@@ -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 }}
+1 -1
View File
@@ -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
+27
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 }}
+93 -8
View File
@@ -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
+82
View File
@@ -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 }}
+6 -1
View File
@@ -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"
+9 -1
View File
@@ -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
View File
@@ -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
+48 -13
View File
@@ -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
+8
View File
@@ -57,6 +57,14 @@ func NewCopilotProvider(cfg config.Copilot) provider.Provider {
return provider.NewCopilot(cfg)
}
// NewDisabledProviderStub returns a Provider that reports Enabled() ==
// false and has no-op implementations for all other methods. Use this
// instead of constructing a concrete provider for disabled rows so that
// adding a new provider type does not require updating a switch here.
func NewDisabledProviderStub(name, providerType string) provider.Provider {
return provider.NewDisabledStub(name, providerType)
}
func NewMetrics(reg prometheus.Registerer) *metrics.Metrics {
return metrics.NewMetrics(reg)
}
+52 -9
View File
@@ -20,6 +20,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/aibridge/circuitbreaker"
aibcontext "github.com/coder/coder/v2/aibridge/context"
"github.com/coder/coder/v2/aibridge/intercept"
"github.com/coder/coder/v2/aibridge/mcp"
"github.com/coder/coder/v2/aibridge/metrics"
"github.com/coder/coder/v2/aibridge/provider"
@@ -30,6 +31,11 @@ import (
const (
// The duration after which an async recording will be aborted.
recordingTimeout = time.Second * 5
// ErrorCodeProviderDisabled is the code written in the response
// body when a request targets a configured-but-disabled provider.
// Paired with HTTP 503.
ErrorCodeProviderDisabled = "provider_disabled"
)
// RequestBridge is an [http.Handler] which is capable of masquerading as AI providers' APIs;
@@ -96,6 +102,14 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re
mux := http.NewServeMux()
for _, prov := range providers {
// Disabled providers serve a 503 sentinel on every path under
// "/<name>/". Bound to the bare name (not RoutePrefix) so paths
// outside the provider's normal "/v1" subtree are also caught.
if !prov.Enabled() {
prefix := fmt.Sprintf("/%s/", prov.Name())
mux.HandleFunc(prefix, disabledProviderHandler(prov.Name(), logger))
continue
}
// Create per-provider circuit breaker if configured
cfg := prov.CircuitBreakerConfig()
providerName := prov.Name()
@@ -170,6 +184,20 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re
}, nil
}
// disabledProviderHandler returns 503 with a body containing
// [ErrorCodeProviderDisabled] and the provider name for every request
// targeting name.
func disabledProviderHandler(name string, logger slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger.Debug(r.Context(), "refusing request for disabled ai provider",
slog.F("provider", name),
slog.F("path", r.URL.Path),
slog.F("method", r.Method),
)
http.Error(w, fmt.Sprintf("%s: AI provider %q is disabled", ErrorCodeProviderDisabled, name), http.StatusServiceUnavailable)
}
}
// newInterceptionProcessor returns an [http.HandlerFunc] which is capable of creating a new interceptor and processing a given request
// using [Provider] p, recording all usage events using [Recorder] rec.
// If cbs is non-nil, circuit breaker protection is applied per endpoint/model tuple.
@@ -248,11 +276,18 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC
slog.F("user_agent", r.UserAgent()),
slog.F("streaming", interceptor.Streaming()),
slog.F("credential_kind", string(cred.Kind)),
slog.F("credential_hint", cred.Hint),
slog.F("credential_length", cred.Length),
)
log.Debug(ctx, "interception started")
// Log BYOK credentials. Centralized credentials are set by
// the key failover loop.
credLogFields := []slog.Field{}
if cred.Kind == intercept.CredentialKindBYOK {
credLogFields = append(credLogFields,
slog.F("credential_hint", cred.Hint),
slog.F("credential_length", cred.Length),
)
}
log.Debug(ctx, "interception started", credLogFields...)
if m != nil {
m.InterceptionsInflight.WithLabelValues(p.Name(), interceptor.Model(), route).Add(1)
defer func() {
@@ -261,22 +296,30 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC
}
// Process request with circuit breaker protection if configured
if err := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error {
execErr := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error {
return interceptor.ProcessRequest(rw, r)
}); err != nil {
})
// For centralized, the hint now reflects the last attempted
// key from the failover loop.
credHint := interceptor.Credential().Hint
credLen := interceptor.Credential().Length
if execErr != nil {
if m != nil {
m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusFailed, route, r.Method, actor.ID, string(client)).Add(1)
}
span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", err))
log.Warn(ctx, "interception failed", slog.Error(err))
span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", execErr))
log.Warn(ctx, "interception failed", slog.Error(execErr), slog.F("credential_hint", credHint), slog.F("credential_length", credLen))
} else {
if m != nil {
m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusCompleted, route, r.Method, actor.ID, string(client)).Add(1)
}
log.Debug(ctx, "interception ended")
log.Debug(ctx, "interception ended", slog.F("credential_hint", credHint), slog.F("credential_length", credLen))
}
_ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{ID: interceptor.ID().String()})
_ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{
ID: interceptor.ID().String(),
CredentialHint: credHint,
})
// Ensure all recording have completed before completing request.
asyncRecorder.Wait()
+55
View File
@@ -205,3 +205,58 @@ func TestPassthroughRoutesForProviders(t *testing.T) {
})
}
}
// TestDisabledProviderHandler asserts that requests to a disabled
// provider return a 503 with an ErrorCodeProviderDisabled body and
// that a sibling enabled provider keeps routing normally.
func TestDisabledProviderHandler(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("upstream-reached"))
}))
t.Cleanup(upstream.Close)
enabled := aibridge.NewOpenAIProvider(config.OpenAI{Name: "enabled-openai", BaseURL: upstream.URL})
disabled := aibridge.NewDisabledProviderStub("disabled-openai", "openai")
bridge, err := aibridge.NewRequestBridge(
t.Context(),
[]provider.Provider{enabled, disabled},
nil, nil, logger, nil, bridgeTestTracer,
)
require.NoError(t, err)
for _, tc := range []struct {
name string
path string
}{
{name: "Bridged", path: "/disabled-openai/v1/chat/completions"},
{name: "Passthrough", path: "/disabled-openai/v1/models"},
{name: "Unknown", path: "/disabled-openai/anything/else"},
} {
t.Run("DisabledProviderReturnsSentinel/"+tc.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodPost, tc.path, nil)
resp := httptest.NewRecorder()
bridge.ServeHTTP(resp, req)
assert.Equal(t, http.StatusServiceUnavailable, resp.Code)
assert.Contains(t, resp.Body.String(), aibridge.ErrorCodeProviderDisabled)
assert.Contains(t, resp.Body.String(), "disabled-openai")
})
}
t.Run("EnabledProviderUnaffected", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/enabled-openai/v1/models", nil)
resp := httptest.NewRecorder()
bridge.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, "upstream-reached", resp.Body.String())
})
}
@@ -291,15 +291,16 @@ func (i *BlockingInterception) newChatCompletionWithKey(ctx context.Context, svc
// 401/403. Errors that aren't key-specific don't trigger
// failover and are returned to the caller.
func (i *BlockingInterception) newChatCompletionWithKeyFailover(ctx context.Context, svc openai.ChatCompletionService, opts []option.RequestOption) (*openai.ChatCompletion, error) {
// TODO(ssncferreira): update the interception's credential
// hint with the actually-used key (the successful key on
// success, the last tried key on failure) in the upstack PR.
walker := i.cfg.KeyPool.Walker()
for {
key, keyPoolErr := walker.Next()
if keyPoolErr != nil {
return nil, keyPoolErr
}
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
i.logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
requestOpts := append([]option.RequestOption{}, opts...)
requestOpts = append(requestOpts,
@@ -72,31 +72,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning 200.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {statusCode: http.StatusOK, body: successBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -104,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -120,15 +125,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -136,25 +142,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s.
// Then: 3 requests, 429 response with smallest Retry-After,
// all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -168,15 +175,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -184,14 +192,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -199,6 +208,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -219,9 +229,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -252,6 +263,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
var pool *keypool.Pool
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
if len(tc.keys) > 0 {
var err error
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
@@ -259,6 +271,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
interceptor := NewBlockingInterceptor(
@@ -269,7 +282,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
http.Header{},
"Authorization",
otel.Tracer("blocking_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -288,6 +301,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
@@ -309,6 +323,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
expectedSeenKeys []string
expectedStatusCode int
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -319,12 +336,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: textCompleteBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -342,12 +360,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: textCompleteBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -369,12 +388,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusTooManyRequests,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -409,7 +429,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.OpenAI{
@@ -459,6 +479,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
defer seenKeysMu.Unlock()
assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys")
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
@@ -164,6 +164,11 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re
break
}
currentKey = key
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
opts = append(opts,
option.WithAPIKey(key.Value()),
// Disable SDK retries because the failover
@@ -144,36 +144,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning a successful stream.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -185,16 +189,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -206,15 +211,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -226,6 +232,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 pre-stream with
@@ -233,19 +240,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
// Then: 3 requests, 429 response with smallest
// Retry-After, all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -259,15 +266,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401 pre-stream.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -275,14 +283,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500 pre-stream.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -290,6 +299,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -310,9 +320,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -342,6 +353,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
var pool *keypool.Pool
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
if len(tc.keys) > 0 {
var err error
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
@@ -349,6 +361,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
interceptor := NewStreamingInterceptor(
@@ -359,7 +372,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
http.Header{},
"Authorization",
otel.Tracer("streaming_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -378,6 +391,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
@@ -435,6 +449,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
// error (e.g. all keys exhausted).
expectedErr bool
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -445,13 +462,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedBodyContains: "done",
expectErrorAsSSEEvent: false,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -469,13 +487,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "done",
expectErrorAsSSEEvent: false,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -497,7 +516,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "all configured keys are rate-limited",
expectErrorAsSSEEvent: true,
expectedErr: true,
@@ -505,6 +524,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -538,7 +558,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.OpenAI{
@@ -596,6 +616,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
defer seenKeysMu.Unlock()
assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys")
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
+6
View File
@@ -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.
+4 -3
View File
@@ -367,15 +367,16 @@ func (i *BlockingInterception) newMessageWithKey(ctx context.Context, svc anthro
// Errors that aren't key-specific don't trigger failover and
// are returned to the caller.
func (i *BlockingInterception) newMessageWithKeyFailover(ctx context.Context, svc anthropic.MessageService) (*anthropic.Message, error) {
// TODO(ssncferreira): update the interception's credential
// hint with the actually-used key (the successful key on
// success, the last tried key on failure) in the upstack PR.
walker := i.cfg.KeyPool.Walker()
for {
key, keyPoolErr := walker.Next()
if keyPoolErr != nil {
return nil, keyPoolErr
}
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
i.logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
msg, err := i.newMessageWithKey(ctx, svc,
option.WithAPIKey(key.Value()),
@@ -19,6 +19,7 @@ import (
"github.com/coder/coder/v2/aibridge/internal/testutil"
"github.com/coder/coder/v2/aibridge/keypool"
"github.com/coder/coder/v2/aibridge/mcp"
"github.com/coder/coder/v2/aibridge/utils"
"github.com/coder/quartz"
)
@@ -54,31 +55,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning 200.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {statusCode: http.StatusOK, body: successBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -86,15 +91,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -102,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -118,25 +125,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s.
// Then: 3 requests, 429 response with smallest Retry-After,
// all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -150,15 +158,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -166,14 +175,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -181,6 +191,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -201,9 +212,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -234,6 +246,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
cfg := config.Anthropic{BaseURL: upstream.URL + "/"}
var pool *keypool.Pool
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
if len(tc.keys) > 0 {
var err error
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
@@ -241,6 +254,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
payload, err := NewRequestPayload([]byte(requestBody))
@@ -255,7 +269,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
http.Header{},
"X-Api-Key",
otel.Tracer("blocking_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -271,6 +285,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
@@ -296,6 +311,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
expectedStatusCode int
expectedRetryAfter string
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -306,12 +324,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -329,12 +348,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -356,13 +376,14 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "3",
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -397,7 +418,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.Anthropic{
@@ -447,6 +468,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
seenKeysMu.Lock()
defer seenKeysMu.Unlock()
+5
View File
@@ -195,6 +195,11 @@ newStream:
break
}
currentKey = key
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
streamOpts = append(streamOpts,
option.WithAPIKey(key.Value()),
// Disable SDK retries because the failover
@@ -21,6 +21,7 @@ import (
"github.com/coder/coder/v2/aibridge/internal/testutil"
"github.com/coder/coder/v2/aibridge/keypool"
"github.com/coder/coder/v2/aibridge/mcp"
"github.com/coder/coder/v2/aibridge/utils"
"github.com/coder/quartz"
)
@@ -60,36 +61,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning a successful stream.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -101,16 +106,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -122,15 +128,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -142,6 +149,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 pre-stream with
@@ -149,19 +157,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
// Then: 3 requests, 429 response with smallest
// Retry-After, all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -175,15 +183,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401 pre-stream.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -191,14 +200,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500 pre-stream.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -206,6 +216,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -226,9 +237,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRetryAfter: "5",
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -258,6 +270,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
cfg := config.Anthropic{BaseURL: upstream.URL + "/"}
var pool *keypool.Pool
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
if len(tc.keys) > 0 {
var err error
pool, err = keypool.New(tc.keys, quartz.NewMock(t))
@@ -265,6 +278,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
payload, err := NewRequestPayload([]byte(requestBody))
@@ -279,7 +293,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
http.Header{},
"X-Api-Key",
otel.Tracer("streaming_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -301,6 +315,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) {
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
@@ -387,6 +402,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
// error (e.g. all keys exhausted).
expectedErr bool
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -397,13 +415,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedBodyContains: "done",
expectErrorAsSSEEvent: false,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -421,13 +440,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "done",
expectErrorAsSSEEvent: false,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -453,7 +473,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "all configured keys are rate-limited",
expectErrorAsSSEEvent: true,
expectedErr: true,
@@ -461,6 +481,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -494,7 +515,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.Anthropic{
@@ -553,6 +574,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) {
defer seenKeysMu.Unlock()
assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys")
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
})
}
}
+4 -3
View File
@@ -171,15 +171,16 @@ func (i *BlockingResponsesInterceptor) newResponseWithKey(ctx context.Context, s
// Errors that aren't key-specific don't trigger failover and
// are returned to the caller.
func (i *BlockingResponsesInterceptor) newResponseWithKeyFailover(ctx context.Context, srv responses.ResponseService, opts []option.RequestOption) (*responses.Response, error) {
// TODO(ssncferreira): update the interception's credential
// hint with the actually-used key (the successful key on
// success, the last tried key on failure) in the upstack PR.
walker := i.cfg.KeyPool.Walker()
for {
key, keyPoolErr := walker.Next()
if keyPoolErr != nil {
return nil, keyPoolErr
}
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
i.logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
requestOpts := append([]option.RequestOption{}, opts...)
requestOpts = append(requestOpts,
@@ -58,31 +58,35 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning 200.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {statusCode: http.StatusOK, body: successBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -90,15 +94,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -106,15 +111,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403, key-1 returns 200.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {statusCode: http.StatusOK, body: successBody},
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusOK, body: successBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusOK,
@@ -122,25 +128,26 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s.
// Then: 3 requests, 429 response with smallest Retry-After,
// all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -154,15 +161,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -170,14 +178,15 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -185,6 +194,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -204,8 +214,9 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -235,6 +246,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
t.Cleanup(upstream.Close)
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
var pool *keypool.Pool
if len(tc.keys) > 0 {
var err error
@@ -243,6 +255,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
payload, err := NewRequestPayload([]byte(requestBody))
@@ -256,7 +269,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
http.Header{},
"Authorization",
otel.Tracer("blocking_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -272,6 +285,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
@@ -296,6 +310,9 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
expectedSeenKeys []string
expectedStatusCode int
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -306,12 +323,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: textCompleteBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -329,12 +347,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, body: textCompleteBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -356,12 +375,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedStatusCode: http.StatusTooManyRequests,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -396,7 +416,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.OpenAI{
@@ -444,6 +464,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
seenKeysMu.Lock()
defer seenKeysMu.Unlock()
@@ -144,6 +144,11 @@ func (i *StreamingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r
return xerrors.Errorf("key pool exhausted: %w", keyPoolErr)
}
currentKey = key
// Record the key in use so the hint reflects the last attempted key.
i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value())
i.logger.Debug(ctx, "using centralized api key",
slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length))
opts = append(opts,
option.WithAPIKey(key.Value()),
// Disable SDK retries because the failover
@@ -51,36 +51,40 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
expectedRetryAfter string
// Expected key states after the request, by index in keys.
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: last
// attempted key for centralized, user key from initial request for BYOK.
expectedCredentialHint string
}{
{
// Given: 1 valid key returning a successful stream.
// Then: 1 request, 200 response, key remains valid.
name: "single_valid_key",
keys: []string{"k0"},
keys: []string{"k0-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedRequestCount: 1,
expectedStatusCode: http.StatusOK,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 returns 429 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 temporary, key-1 valid.
name: "failover_after_429",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -92,16 +96,17 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 401 pre-stream, key-1
// streams successfully.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_401",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -113,15 +118,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams.
// Then: 2 requests, 200 response, key-0 permanent, key-1 valid.
name: "failover_after_403",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1": {
"k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody},
"k1-long-key": {
statusCode: http.StatusOK,
headers: map[string]string{"Content-Type": "text/event-stream"},
body: streamingSuccessBody,
@@ -133,6 +139,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 3 keys; all return 429 pre-stream with
@@ -140,19 +147,19 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
// Then: 3 requests, 429 response with smallest
// Retry-After, all keys temporary.
name: "all_keys_rate_limited",
keys: []string{"k0", "k1", "k2"},
keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"},
responses: map[string]upstreamResponse{
"k0": {
"k0-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
body: rateLimitBody,
},
"k1": {
"k1-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "3"},
body: rateLimitBody,
},
"k2": {
"k2-long-key": {
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "10"},
body: rateLimitBody,
@@ -166,15 +173,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k2-long-key"),
},
{
// Given: 2 keys; both return 401 pre-stream.
// Then: 2 requests, 502 api_error response, both keys permanent.
name: "all_keys_unauthorized",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
"k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody},
},
expectedRequestCount: 2,
expectedStatusCode: http.StatusBadGateway,
@@ -182,14 +190,15 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStatePermanent,
keypool.KeyStatePermanent,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 returns 500 pre-stream.
// Then: 1 request, 500 response, both keys remain valid.
name: "server_error_no_failover",
keys: []string{"k0", "k1"},
keys: []string{"k0-long-key", "k1-long-key"},
responses: map[string]upstreamResponse{
"k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
"k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusInternalServerError,
@@ -197,6 +206,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: BYOK with a single key returning 429.
@@ -216,8 +226,9 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
body: rateLimitBody,
},
},
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedRequestCount: 1,
expectedStatusCode: http.StatusTooManyRequests,
expectedCredentialHint: utils.MaskSecret("user-byok"),
},
}
@@ -246,6 +257,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
t.Cleanup(upstream.Close)
cfg := config.OpenAI{BaseURL: upstream.URL + "/"}
credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "")
var pool *keypool.Pool
if len(tc.keys) > 0 {
var err error
@@ -254,6 +266,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
cfg.KeyPool = pool
} else if tc.byokKey != "" {
cfg.Key = tc.byokKey
credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey)
}
payload, err := NewRequestPayload([]byte(streamingRequestBody))
@@ -267,7 +280,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
http.Header{},
"Authorization",
otel.Tracer("streaming_test"),
intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""),
credInfo,
)
interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil)
@@ -283,6 +296,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code")
assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
if pool != nil {
assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states")
}
@@ -339,6 +353,9 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
// error (e.g. all keys exhausted).
expectedErr bool
expectedKeyStates []keypool.KeyState
// Expected credential hint after ProcessRequest: hint of the
// last attempted key across all agentic-loop iterations.
expectedCredentialHint string
}{
{
// Given: 2 keys; both upstream calls succeed on key-0.
@@ -349,12 +366,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 2,
expectedSeenKeys: []string{"k0", "k0"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key"},
expectedBodyContains: "done",
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateValid,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k0-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then 429s
@@ -372,12 +390,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
{statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "done",
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateValid,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
{
// Given: 2 keys; key-0 succeeds initially, then both
@@ -399,13 +418,14 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
},
},
expectedRequestCount: 3,
expectedSeenKeys: []string{"k0", "k0", "k1"},
expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"},
expectedBodyContains: "all configured keys are rate-limited",
expectedErr: true,
expectedKeyStates: []keypool.KeyState{
keypool.KeyStateTemporary,
keypool.KeyStateTemporary,
},
expectedCredentialHint: utils.MaskSecret("k1-long-key"),
},
}
@@ -439,7 +459,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
}))
t.Cleanup(upstream.Close)
pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t))
pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t))
require.NoError(t, err)
cfg := config.OpenAI{
@@ -489,6 +509,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) {
assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count")
body := w.Body.String()
assert.Contains(t, body, tc.expectedBodyContains, "response body")
assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint")
seenKeysMu.Lock()
defer seenKeysMu.Unlock()
@@ -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 }
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"net/http"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/aibridge/utils"
)
// MarkKeyOnStatus marks key based on a key-specific HTTP
@@ -32,7 +31,7 @@ func MarkKeyOnStatus(
if key.MarkTemporary(cooldown) {
logger.Info(ctx, "key marked temporary",
slog.F("provider", providerName),
slog.F("api_key_hint", utils.MaskSecret(key.Value())),
slog.F("api_key_hint", key.Hint()),
slog.F("status", statusCode),
slog.F("cooldown", cooldown))
}
@@ -41,7 +40,7 @@ func MarkKeyOnStatus(
if key.MarkPermanent() {
logger.Warn(ctx, "key marked permanent",
slog.F("provider", providerName),
slog.F("api_key_hint", utils.MaskSecret(key.Value())),
slog.F("api_key_hint", key.Hint()),
slog.F("status", statusCode))
}
return true
+7
View File
@@ -7,6 +7,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/aibridge/utils"
"github.com/coder/quartz"
)
@@ -116,6 +117,12 @@ func (k *Key) Value() string {
return k.value
}
// Hint returns a masked, identifiable fragment of the key, suitable
// for logs and persisted records.
func (k *Key) Hint() string {
return utils.MaskSecret(k.value)
}
// State returns the current state of the key, derived from its
// permanent flag and cooldown deadline.
func (k *Key) State() KeyState {
+5 -8
View File
@@ -95,6 +95,8 @@ func (p *Anthropic) Name() string {
return p.cfg.Name
}
func (*Anthropic) Enabled() bool { return true }
func (p *Anthropic) RoutePrefix() string {
return fmt.Sprintf("/%s", p.Name())
}
@@ -168,15 +170,10 @@ func (p *Anthropic) CreateInterceptor(_ http.ResponseWriter, r *http.Request, tr
authHeaderName = "Authorization"
credKind = intercept.CredentialKindBYOK
credSecret = token
} else if cfg.KeyPool != nil {
// Centralized: use the first key as a placeholder hint.
// TODO(ssncferreira): record the actually-used key in
// the interception record to reflect failover.
if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil {
credSecret = key.Value()
}
}
// Centralized leaves credSecret empty: the hint is set by the
// failover loop on each key attempt and persisted at
// end-of-interception.
cred := intercept.NewCredentialInfo(credKind, credSecret)
var interceptor intercept.Interceptor
+3 -1
View File
@@ -257,7 +257,9 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) {
setHeaders: map[string]string{},
wantXApiKey: "test-key",
wantCredentialKind: intercept.CredentialKindCentralized,
wantCredentialHint: "t...y",
// Centralized hint is empty at CreateInterceptor; set
// by the key failover loop during ProcessRequest.
wantCredentialHint: "",
},
{
name: "Messages_BYOK_BearerToken_And_APIKey",
+2
View File
@@ -78,6 +78,8 @@ func (p *Copilot) Name() string {
return p.cfg.Name
}
func (*Copilot) Enabled() bool { return true }
func (p *Copilot) BaseURL() string {
return p.cfg.BaseURL
}
+47
View File
@@ -0,0 +1,47 @@
package provider
import (
"fmt"
"net/http"
"go.opentelemetry.io/otel/trace"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/aibridge/config"
"github.com/coder/coder/v2/aibridge/intercept"
"github.com/coder/coder/v2/aibridge/keypool"
)
// DisabledStub is a Provider placeholder for a configured-but-disabled
// provider. Only Name and Enabled return meaningful values; all other
// methods return empty/nil so the stub never influences routing.
type DisabledStub struct {
name string
providerType string
}
// NewDisabledStub returns a Provider stub that reports Enabled() == false.
// The type string is preserved so callers can distinguish provider families.
func NewDisabledStub(name, providerType string) *DisabledStub {
return &DisabledStub{name: name, providerType: providerType}
}
func (d *DisabledStub) Type() string { return d.providerType }
func (d *DisabledStub) Name() string { return d.name }
func (*DisabledStub) Enabled() bool { return false }
func (*DisabledStub) BaseURL() string { return "" }
func (d *DisabledStub) RoutePrefix() string {
return fmt.Sprintf("/%s", d.name)
}
func (*DisabledStub) BridgedRoutes() []string { return nil }
func (*DisabledStub) PassthroughRoutes() []string { return nil }
func (*DisabledStub) AuthHeader() string { return "" }
func (*DisabledStub) KeyFailoverConfig(_ slog.Logger) keypool.KeyFailoverConfig {
return keypool.KeyFailoverConfig{}
}
func (*DisabledStub) CircuitBreakerConfig() *config.CircuitBreaker { return nil }
func (*DisabledStub) APIDumpDir() string { return "" }
func (*DisabledStub) CreateInterceptor(_ http.ResponseWriter, _ *http.Request, _ trace.Tracer) (intercept.Interceptor, error) {
//nolint:nilnil // disabled providers never reach the interceptor.
return nil, nil
}
+5 -7
View File
@@ -84,6 +84,8 @@ func (p *OpenAI) Name() string {
return p.cfg.Name
}
func (*OpenAI) Enabled() bool { return true }
func (p *OpenAI) RoutePrefix() string {
// Route prefix includes version to match default OpenAI base URL.
// More detailed explanation: https://github.com/coder/aibridge/pull/174#discussion_r2782320152
@@ -141,14 +143,10 @@ func (p *OpenAI) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trace
cfg.KeyPool = nil
credKind = intercept.CredentialKindBYOK
credSecret = token
} else if cfg.KeyPool != nil {
// Centralized: use the first key as a placeholder hint.
// TODO(ssncferreira): record the actually-used key in
// the interception record to reflect failover.
if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil {
credSecret = key.Value()
}
}
// Centralized leaves credSecret empty: the hint is set by the
// failover loop on each key attempt and persisted at
// end-of-interception.
cred := intercept.NewCredentialInfo(credKind, credSecret)
path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix())
+6 -2
View File
@@ -229,7 +229,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) {
setHeaders: map[string]string{},
wantAuthorization: "Bearer centralized-key",
wantCredentialKind: intercept.CredentialKindCentralized,
wantCredentialHint: "ce...ey",
// Centralized hint is empty at CreateInterceptor; set
// by the key failover loop during ProcessRequest.
wantCredentialHint: "",
},
{
name: "Responses_BYOK",
@@ -249,7 +251,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) {
setHeaders: map[string]string{},
wantAuthorization: "Bearer centralized-key",
wantCredentialKind: intercept.CredentialKindCentralized,
wantCredentialHint: "ce...ey",
// Centralized hint is empty at CreateInterceptor; set
// by the key failover loop during ProcessRequest.
wantCredentialHint: "",
},
// X-Api-Key should not appear in production since clients use Authorization,
// but ensure it is stripped if it does arrive.
+2
View File
@@ -53,6 +53,8 @@ type Provider interface {
// Name returns the provider instance name.
// Defaults to Type() when not explicitly configured.
Name() string
// Enabled reports whether the provider should serve requests.
Enabled() bool
// BaseURL defines the base URL endpoint for this provider's API.
BaseURL() string
+9 -2
View File
@@ -39,13 +39,20 @@ type InterceptionRecord struct {
Client string
UserAgent string
CorrelatingToolCallID *string
CredentialKind string
CredentialHint string
// CredentialKind is always set: either BYOK or centralized.
CredentialKind string
// CredentialHint is only set for BYOK, where the key is known
// from the request. Centralized uses key failover, so the hint
// can only be determined at end-of-interception.
CredentialHint string
}
type InterceptionRecordEnded struct {
ID string
EndedAt time.Time
// CredentialHint is the hint observed at end-of-interception.
// Only applied to the DB row for centralized; ignored for BYOK.
CredentialHint string
}
type TokenUsageRecord struct {
+94 -28
View File
@@ -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) {
+81 -7
View File
@@ -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)
})
}
})
}
+4 -3
View File
@@ -7,8 +7,8 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestMain(m *testing.M) {
@@ -17,11 +17,12 @@ func TestMain(m *testing.M) {
func TestCli(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
clitest.CreateTemplateVersionSource(t, nil)
client := coderdtest.New(t, nil)
i, config := clitest.New(t)
clitest.SetupConfig(t, client, config)
pty := ptytest.New(t).Attach(i)
stdout := expecter.NewAttachedToInvocation(t, i)
clitest.Start(t, i)
pty.ExpectMatch("coder")
stdout.ExpectMatchContext(ctx, "coder")
}
+5 -6
View File
@@ -10,8 +10,8 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/serpent"
)
@@ -21,7 +21,6 @@ func TestExternalAuth(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
ptty := ptytest.New(t)
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
var fetched atomic.Bool
@@ -42,16 +41,16 @@ func TestExternalAuth(t *testing.T) {
}
inv := cmd.Invoke().WithContext(ctx)
stdout := expecter.NewAttachedToInvocation(t, inv)
ptty.Attach(inv)
done := make(chan struct{})
go func() {
defer close(done)
err := inv.Run()
assert.NoError(t, err)
}()
ptty.ExpectMatchContext(ctx, "You must authenticate with")
ptty.ExpectMatchContext(ctx, "https://example.com/gitauth/github")
ptty.ExpectMatchContext(ctx, "Successfully authenticated with GitHub")
stdout.ExpectMatchContext(ctx, "You must authenticate with")
stdout.ExpectMatchContext(ctx, "https://example.com/gitauth/github")
stdout.ExpectMatchContext(ctx, "Successfully authenticated with GitHub")
<-done
}
+19 -20
View File
@@ -16,8 +16,8 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/serpent"
)
@@ -48,12 +48,12 @@ func TestProvisionerJob(t *testing.T) {
test.JobMutex.Unlock()
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateRunning)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateRunning)
return true
}, testutil.IntervalFast)
})
@@ -85,12 +85,12 @@ func TestProvisionerJob(t *testing.T) {
test.JobMutex.Unlock()
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.PTY.ExpectMatch("Something")
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, "Something")
test.Next <- struct{}{}
test.PTY.ExpectMatch("Something")
test.Stdout.ExpectMatchContext(ctx, "Something")
return true
}, testutil.IntervalFast)
})
@@ -151,12 +151,12 @@ func TestProvisionerJob(t *testing.T) {
test.JobMutex.Unlock()
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectRegexMatch(tc.expected)
test.Stdout.ExpectRegexMatchContext(ctx, tc.expected)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued) // step completed
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued) // step completed
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateRunning)
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateRunning)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateRunning)
return true
}, testutil.IntervalFast)
})
@@ -193,11 +193,11 @@ func TestProvisionerJob(t *testing.T) {
test.JobMutex.Unlock()
})
testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
test.Next <- struct{}{}
test.PTY.ExpectMatch("Gracefully canceling")
test.Stdout.ExpectMatchContext(ctx, "Gracefully canceling")
test.Next <- struct{}{}
test.PTY.ExpectMatch(cliui.ProvisioningStateQueued)
test.Stdout.ExpectMatchContext(ctx, cliui.ProvisioningStateQueued)
return true
}, testutil.IntervalFast)
})
@@ -208,7 +208,7 @@ type provisionerJobTest struct {
Job *codersdk.ProvisionerJob
JobMutex *sync.Mutex
Logs chan codersdk.ProvisionerJobLog
PTY *ptytest.PTY
Stdout *expecter.Expecter
}
func newProvisionerJob(t *testing.T) provisionerJobTest {
@@ -240,8 +240,7 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
}
inv := cmd.Invoke()
ptty := ptytest.New(t)
ptty.Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
done := make(chan struct{})
go func() {
defer close(done)
@@ -258,7 +257,7 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
Job: job,
JobMutex: &jobLock,
Logs: logs,
PTY: ptty,
Stdout: stdout,
}
}
+7 -13
View File
@@ -8,7 +8,6 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/serpent"
)
@@ -16,10 +15,9 @@ func TestSelect(t *testing.T) {
t.Parallel()
t.Run("Select", func(t *testing.T) {
t.Parallel()
ptty := ptytest.New(t)
msgChan := make(chan string)
go func() {
resp, err := newSelect(ptty, cliui.SelectOptions{
resp, err := newSelect(cliui.SelectOptions{
Options: []string{"First", "Second"},
})
assert.NoError(t, err)
@@ -29,7 +27,7 @@ func TestSelect(t *testing.T) {
})
}
func newSelect(ptty *ptytest.PTY, opts cliui.SelectOptions) (string, error) {
func newSelect(opts cliui.SelectOptions) (string, error) {
value := ""
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
@@ -39,7 +37,6 @@ func newSelect(ptty *ptytest.PTY, opts cliui.SelectOptions) (string, error) {
},
}
inv := cmd.Invoke()
ptty.Attach(inv)
return value, inv.Run()
}
@@ -47,10 +44,10 @@ func TestRichSelect(t *testing.T) {
t.Parallel()
t.Run("RichSelect", func(t *testing.T) {
t.Parallel()
ptty := ptytest.New(t)
msgChan := make(chan string)
go func() {
resp, err := newRichSelect(ptty, cliui.RichSelectOptions{
resp, err := newRichSelect(cliui.RichSelectOptions{
Options: []codersdk.TemplateVersionParameterOption{
{Name: "A-Name", Value: "A-Value", Description: "A-Description."},
{Name: "B-Name", Value: "B-Value", Description: "B-Description."},
@@ -63,7 +60,7 @@ func TestRichSelect(t *testing.T) {
})
}
func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, error) {
func newRichSelect(opts cliui.RichSelectOptions) (string, error) {
value := ""
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
@@ -75,7 +72,6 @@ func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, err
},
}
inv := cmd.Invoke()
ptty.Attach(inv)
return value, inv.Run()
}
@@ -181,11 +177,10 @@ func TestMultiSelect(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ptty := ptytest.New(t)
msgChan := make(chan []string)
go func() {
resp, err := newMultiSelect(ptty, tt.items, tt.allowCustom)
resp, err := newMultiSelect(tt.items, tt.allowCustom)
assert.NoError(t, err)
msgChan <- resp
}()
@@ -195,7 +190,7 @@ func TestMultiSelect(t *testing.T) {
}
}
func newMultiSelect(pty *ptytest.PTY, items []string, custom bool) ([]string, error) {
func newMultiSelect(items []string, custom bool) ([]string, error) {
var values []string
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
@@ -211,6 +206,5 @@ func newMultiSelect(pty *ptytest.PTY, items []string, custom bool) ([]string, er
},
}
inv := cmd.Invoke()
pty.Attach(inv)
return values, inv.Run()
}
+13 -12
View File
@@ -24,8 +24,8 @@ import (
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func sshConfigFileName(t *testing.T) (sshConfig string) {
@@ -64,6 +64,8 @@ func TestConfigSSH(t *testing.T) {
t.Skip("See coder/internal#117")
}
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
const hostname = "test-coder."
const expectedKey = "ConnectionAttempts"
const removeKey = "ConnectTimeout"
@@ -131,9 +133,8 @@ func TestConfigSSH(t *testing.T) {
"--ssh-config-file", sshConfigFile,
"--skip-proxy-command")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t)
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
waiter := clitest.StartWithWaiter(t, inv)
@@ -143,8 +144,8 @@ func TestConfigSSH(t *testing.T) {
{match: "Continue?", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
pty.WriteLine(m.write)
stdout.ExpectMatchContext(ctx, m.match)
stdin.WriteLine(m.write)
}
waiter.RequireSuccess()
@@ -157,10 +158,8 @@ func TestConfigSSH(t *testing.T) {
home := filepath.Dir(filepath.Dir(sshConfigFile))
// #nosec
sshCmd := exec.Command("ssh", "-F", sshConfigFile, hostname+r.Workspace.Name, "echo", "test")
pty = ptytest.New(t)
// Set HOME because coder config is included from ~/.ssh/coder.
sshCmd.Env = append(sshCmd.Env, fmt.Sprintf("HOME=%s", home))
inv.Stderr = pty.Output()
data, err := sshCmd.Output()
require.NoError(t, err)
require.Equal(t, "test", strings.TrimSpace(string(data)))
@@ -693,6 +692,8 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client, db := coderdtest.NewWithDatabase(t, nil)
user := coderdtest.CreateFirstUser(t, client)
@@ -718,8 +719,8 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
//nolint:gocritic // This has always ran with the admin user.
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
pty.Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
done := tGo(t, func() {
err := inv.Run()
if !tt.wantErr {
@@ -730,8 +731,8 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
})
for _, m := range tt.matches {
pty.ExpectMatch(m.match)
pty.WriteLine(m.write)
stdout.ExpectMatchContext(ctx, m.match)
stdin.WriteLine(m.write)
}
<-done
+184 -175
View File
@@ -20,8 +20,8 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestCreateDynamic(t *testing.T) {
@@ -74,14 +74,14 @@ func TestCreateDynamic(t *testing.T) {
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
doneChan := make(chan error)
go func() {
doneChan <- inv.Run()
}()
pty.ExpectMatchContext(ctx, "has been created")
stdout.ExpectMatchContext(ctx, "has been created")
err := testutil.RequireReceive(ctx, t, doneChan)
require.NoError(t, err)
@@ -103,14 +103,14 @@ func TestCreateDynamic(t *testing.T) {
}
inv, root = clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
pty = ptytest.New(t).Attach(inv)
stdout = expecter.NewAttachedToInvocation(t, inv)
doneChan = make(chan error)
go func() {
doneChan <- inv.Run()
}()
pty.ExpectMatchContext(ctx, "has been created")
stdout.ExpectMatchContext(ctx, "has been created")
err = testutil.RequireReceive(ctx, t, doneChan)
require.NoError(t, err)
@@ -129,7 +129,8 @@ func TestCreateDynamic(t *testing.T) {
// When enable_region=true, the region parameter becomes required and CLI should prompt.
t.Run("PromptForConditionalParam", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
ctx := testutil.Context(t, time.Hour)
logger := testutil.Logger(t)
template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
MainTF: conditionalParamTF,
@@ -143,7 +144,8 @@ func TestCreateDynamic(t *testing.T) {
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
doneChan := make(chan error)
go func() {
@@ -151,14 +153,14 @@ func TestCreateDynamic(t *testing.T) {
}()
// CLI should prompt for the region parameter since enable_region=true
pty.ExpectMatchContext(ctx, "region")
pty.WriteLine("eu-west")
stdout.ExpectMatchContext(ctx, "region")
stdin.WriteLine("eu-west")
// Confirm creation
pty.ExpectMatchContext(ctx, "Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
pty.ExpectMatchContext(ctx, "has been created")
stdout.ExpectMatchContext(ctx, "has been created")
err := <-doneChan
require.NoError(t, err)
@@ -305,14 +307,14 @@ func TestCreateDynamic(t *testing.T) {
"-y",
)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
doneChan := make(chan error)
go func() {
doneChan <- inv.Run()
}()
pty.ExpectMatchContext(ctx, "has been created")
stdout.ExpectMatchContext(ctx, "has been created")
err = <-doneChan
require.NoError(t, err, "slider=8 should succeed when max_slider=10")
@@ -331,6 +333,8 @@ func TestCreate(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -348,7 +352,8 @@ func TestCreate(t *testing.T) {
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -363,9 +368,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
stdin.WriteLine(m.write)
}
}
<-doneChan
@@ -385,6 +390,8 @@ func TestCreate(t *testing.T) {
t.Run("CreateForOtherUser", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent())
@@ -403,7 +410,8 @@ func TestCreate(t *testing.T) {
//nolint:gocritic // Creating a workspace for another user requires owner permissions.
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -418,9 +426,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
stdin.WriteLine(m.write)
}
}
<-doneChan
@@ -439,6 +447,8 @@ func TestCreate(t *testing.T) {
t.Run("CreateWithSpecificTemplateVersion", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -467,7 +477,8 @@ func TestCreate(t *testing.T) {
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -482,9 +493,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
stdin.WriteLine(m.write)
}
}
<-doneChan
@@ -506,6 +517,8 @@ func TestCreate(t *testing.T) {
t.Run("InheritStopAfterFromTemplate", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -522,7 +535,8 @@ func TestCreate(t *testing.T) {
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
waiter := clitest.StartWithWaiter(t, inv)
matches := []struct {
match string
@@ -533,9 +547,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
stdin.WriteLine(m.write)
}
}
waiter.RequireSuccess()
@@ -570,6 +584,8 @@ func TestCreate(t *testing.T) {
t.Run("FromNothing", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -579,7 +595,8 @@ func TestCreate(t *testing.T) {
inv, root := clitest.New(t, "create", "")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -592,8 +609,8 @@ func TestCreate(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
<-doneChan
@@ -621,14 +638,14 @@ func TestCreate(t *testing.T) {
)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatchContext(ctx, "building in the background")
stdout.ExpectMatchContext(ctx, "building in the background")
_ = testutil.TryReceive(ctx, t, doneChan)
// Verify workspace was actually created.
@@ -658,14 +675,14 @@ func TestCreate(t *testing.T) {
)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatchContext(ctx, "building in the background")
stdout.ExpectMatchContext(ctx, "building in the background")
_ = testutil.TryReceive(ctx, t, doneChan)
// Verify workspace was created and parameters were applied.
@@ -706,14 +723,14 @@ func TestCreate(t *testing.T) {
)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatchContext(ctx, "building in the background")
stdout.ExpectMatchContext(ctx, "building in the background")
_ = testutil.TryReceive(ctx, t, doneChan)
ws, err := member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
@@ -801,7 +818,7 @@ func TestCreateWithRichParameters(t *testing.T) {
setup func() []string
// handlePty optionally runs after the command is started. It should handle
// all expected prompts from the pty.
handlePty func(pty *ptytest.PTY)
handlePty func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer)
// postRun runs after the command has finished but before the workspace is
// verified. It must return the workspace name to check (used for the copy
// workspace tests).
@@ -818,15 +835,15 @@ func TestCreateWithRichParameters(t *testing.T) {
}{
{
name: "ValuesFromPrompt",
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Enter the value for each parameter as prompted.
for _, param := range params {
pty.ExpectMatch(param.name)
pty.WriteLine(param.value)
stdout.ExpectMatchContext(ctx, param.name)
stdin.WriteLine(param.value)
}
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
},
{
@@ -839,16 +856,16 @@ func TestCreateWithRichParameters(t *testing.T) {
}
return args
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Simply accept the defaults.
for _, param := range params {
pty.ExpectMatch(param.name)
pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
pty.WriteLine("")
stdout.ExpectMatchContext(ctx, param.name)
stdout.ExpectMatchContext(ctx, `Enter a value (default: "`+param.value+`")`)
stdin.WriteLine("")
}
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
},
{
@@ -865,10 +882,10 @@ func TestCreateWithRichParameters(t *testing.T) {
return []string{"--rich-parameter-file", parameterFile.Name()}
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
},
{
@@ -881,10 +898,10 @@ func TestCreateWithRichParameters(t *testing.T) {
}
return args
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
},
{
@@ -920,9 +937,6 @@ func TestCreateWithRichParameters(t *testing.T) {
postRun: func(t *testing.T, tctx testContext) string {
inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y")
clitest.SetupConfig(t, tctx.member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err := inv.Run()
require.NoError(t, err, "failed to create a workspace based on the source workspace")
return "other-workspace"
@@ -952,9 +966,6 @@ func TestCreateWithRichParameters(t *testing.T) {
// Then create the copy. It should use the old template version.
inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y")
clitest.SetupConfig(t, tctx.member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err := inv.Run()
require.NoError(t, err, "failed to create a workspace based on the source workspace")
return "other-workspace"
@@ -962,16 +973,16 @@ func TestCreateWithRichParameters(t *testing.T) {
},
{
name: "ValuesFromTemplateDefaults",
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Simply accept the defaults.
for _, param := range params {
pty.ExpectMatch(param.name)
pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
pty.WriteLine("")
stdout.ExpectMatchContext(ctx, param.name)
stdout.ExpectMatchContext(ctx, `Enter a value (default: "`+param.value+`")`)
stdin.WriteLine("")
}
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
withDefaults: true,
},
@@ -980,14 +991,14 @@ func TestCreateWithRichParameters(t *testing.T) {
setup: func() []string {
return []string{"--use-parameter-defaults"}
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Default values should get printed.
for _, param := range params {
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", param.name, param.value))
}
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
withDefaults: true,
},
@@ -1001,14 +1012,14 @@ func TestCreateWithRichParameters(t *testing.T) {
}
return args
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Default values should get printed.
for _, param := range params {
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", param.name, param.value))
}
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
},
{
@@ -1031,14 +1042,14 @@ cli_param: from file`)
"--parameter", "cli_param=from cli",
}
},
handlePty: func(pty *ptytest.PTY) {
handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Should get prompted for the input param since it has no default.
pty.ExpectMatch("input_param")
pty.WriteLine("from input")
stdout.ExpectMatchContext(ctx, "input_param")
stdin.WriteLine("from input")
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
},
withDefaults: true,
inputParameters: []param{
@@ -1082,6 +1093,8 @@ cli_param: from file`)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
parameters := params
if len(tt.inputParameters) > 0 {
@@ -1122,14 +1135,15 @@ cli_param: from file`)
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan error)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
doneChan <- inv.Run()
}()
// The test may do something with the pty.
if tt.handlePty != nil {
tt.handlePty(pty)
tt.handlePty(ctx, stdout, stdin)
}
// Wait for the command to exit.
@@ -1235,6 +1249,7 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI uses the specified preset instead of the default
t.Run("PresetFlag", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1263,17 +1278,15 @@ func TestCreateWithPreset(t *testing.T) {
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", preset.Name)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the selected preset as well as its parameters
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
pty.ExpectMatch(presetName)
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
stdout.ExpectMatchContext(ctx, presetName)
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
@@ -1312,6 +1325,7 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI automatically uses the default preset to create the workspace
t.Run("DefaultPreset", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1340,22 +1354,17 @@ func TestCreateWithPreset(t *testing.T) {
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the default preset as well as its parameters
presetName := fmt.Sprintf("Preset '%s' (default) applied:", defaultPreset.Name)
pty.ExpectMatch(presetName)
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
stdout.ExpectMatchContext(ctx, presetName)
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 2)
@@ -1389,12 +1398,14 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI prompts the user to select a preset.
t.Run("NoDefaultPresetPromptUser", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Given: a template and a template version with two presets
// Given: a template and a template version with a single, non-default preset.
preset := proto.Preset{
Name: "preset-test",
Description: "Preset Test.",
@@ -1414,7 +1425,8 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1422,18 +1434,16 @@ func TestCreateWithPreset(t *testing.T) {
}()
// Should: prompt the user for the preset
pty.ExpectMatch("Select a preset below:")
pty.WriteLine("\n")
pty.ExpectMatch("Preset 'preset-test' applied")
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Select a preset below:")
// We don't actually have to respond to the selector, since we hardcode the cliui.Select to return the
// first option in test scenarios (c.f. cliui/select.go)
stdout.ExpectMatchContext(ctx, "Preset 'preset-test' applied")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
<-doneChan
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1460,6 +1470,7 @@ func TestCreateWithPreset(t *testing.T) {
// with workspace creation without applying any preset.
t.Run("TemplateVersionWithoutPresets", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1476,17 +1487,12 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
pty.ExpectMatch("No preset applied.")
stdout.ExpectMatchContext(ctx, "No preset applied.")
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Name: workspaceName,
})
@@ -1509,6 +1515,7 @@ func TestCreateWithPreset(t *testing.T) {
// The workspace should be created without using any preset-defined parameters.
t.Run("PresetFlagNone", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1533,17 +1540,12 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
pty.ExpectMatch("No preset applied.")
stdout.ExpectMatchContext(ctx, "No preset applied.")
// Verify that the new workspace doesn't use the preset parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1591,9 +1593,6 @@ func TestCreateWithPreset(t *testing.T) {
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", "invalid-preset")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err := inv.Run()
// Should: fail with an error indicating the preset was not found
@@ -1610,6 +1609,7 @@ func TestCreateWithPreset(t *testing.T) {
// - and the value of parameter B from the parameter flag.
t.Run("PresetOverridesParameterFlagValues", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1633,21 +1633,16 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the selected preset as well as its parameter
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
pty.ExpectMatch(presetName)
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
stdout.ExpectMatchContext(ctx, presetName)
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1679,6 +1674,7 @@ func TestCreateWithPreset(t *testing.T) {
// - and the value of parameter B from the file.
t.Run("PresetOverridesParameterFileValues", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1707,21 +1703,16 @@ func TestCreateWithPreset(t *testing.T) {
"--preset", preset.Name,
"--rich-parameter-file", parameterFile.Name())
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the selected preset as well as its parameter
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
pty.ExpectMatch(presetName)
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
stdout.ExpectMatchContext(ctx, presetName)
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1748,7 +1739,8 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI prompts the user for input to fill in the missing parameters.
t.Run("PromptsForMissingParametersWhenPresetIsIncomplete", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -1769,7 +1761,8 @@ func TestCreateWithPreset(t *testing.T) {
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "--preset", preset.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1778,21 +1771,18 @@ func TestCreateWithPreset(t *testing.T) {
// Should: display the selected preset as well as its parameters
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
pty.ExpectMatch(presetName)
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
stdout.ExpectMatchContext(ctx, presetName)
stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
// Should: prompt for the missing parameter
pty.ExpectMatch(thirdParameterDescription)
pty.WriteLine(thirdParameterValue)
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, thirdParameterDescription)
stdin.WriteLine(thirdParameterValue)
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
<-doneChan
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1857,7 +1847,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateString", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -1869,7 +1860,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1885,9 +1877,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
@@ -1895,6 +1887,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateNumber", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1907,7 +1901,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1923,9 +1918,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
@@ -1933,6 +1928,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateNumber_CustomError", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1945,7 +1942,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1961,9 +1959,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
@@ -1971,6 +1969,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateBool", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1983,7 +1983,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1999,9 +2000,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
@@ -2018,15 +2019,18 @@ func TestCreateValidateRichParameters(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
t.Run("Prompt", func(t *testing.T) {
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "create", "my-workspace-1", "--template", template.Name)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
pty.ExpectMatch(listOfStringsParameterName)
pty.ExpectMatch("aaa, bbb, ccc")
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, listOfStringsParameterName)
stdout.ExpectMatchContext(ctx, "aaa, bbb, ccc")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
})
t.Run("Default", func(t *testing.T) {
@@ -2049,6 +2053,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateListOfStrings_YAMLFile", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -2066,8 +2072,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
- fff`)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
matches := []string{
@@ -2076,9 +2082,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
})
@@ -2086,6 +2092,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
func TestCreateWithGitAuth(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
echoResponses := &echo.Responses{
Parse: echo.ParseComplete,
ProvisionInit: echo.InitComplete,
@@ -2120,13 +2128,14 @@ func TestCreateWithGitAuth(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
pty.ExpectMatch("You must authenticate with GitHub to create a workspace")
stdout.ExpectMatchContext(ctx, "You must authenticate with GitHub to create a workspace")
resp := coderdtest.RequestExternalAuthCallback(t, "github", member)
_ = resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Confirm create?")
stdin.WriteLine("yes")
}
+15 -15
View File
@@ -22,8 +22,8 @@ import (
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/quartz"
)
@@ -31,6 +31,7 @@ func TestDelete(t *testing.T) {
t.Parallel()
t.Run("WithParameter", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -42,7 +43,7 @@ func TestDelete(t *testing.T) {
inv, root := clitest.New(t, "delete", workspace.Name, "-y")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -51,7 +52,7 @@ func TestDelete(t *testing.T) {
assert.ErrorIs(t, err, io.EOF)
}
}()
pty.ExpectMatch("has been deleted")
stdout.ExpectMatchContext(ctx, "has been deleted")
<-doneChan
})
@@ -71,8 +72,7 @@ func TestDelete(t *testing.T) {
clitest.SetupConfig(t, templateAdmin, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.WithContext(ctx).Run()
@@ -81,7 +81,7 @@ func TestDelete(t *testing.T) {
assert.ErrorIs(t, err, io.EOF)
}
}()
pty.ExpectMatch("has been deleted")
stdout.ExpectMatchContext(ctx, "has been deleted")
testutil.TryReceive(ctx, t, doneChan)
_, err := client.Workspace(ctx, workspace.ID)
@@ -117,8 +117,7 @@ func TestDelete(t *testing.T) {
//nolint:gocritic // Deleting orphaned workspaces requires an admin.
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -127,7 +126,7 @@ func TestDelete(t *testing.T) {
assert.ErrorIs(t, err, io.EOF)
}
}()
pty.ExpectMatch("has been deleted")
stdout.ExpectMatchContext(ctx, "has been deleted")
<-doneChan
})
@@ -146,11 +145,12 @@ func TestDelete(t *testing.T) {
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "delete", user.Username+"/"+workspace.Name, "-y")
//nolint:gocritic // This requires an admin.
clitest.SetupConfig(t, adminClient, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -160,7 +160,7 @@ func TestDelete(t *testing.T) {
}
}()
pty.ExpectMatch("has been deleted")
stdout.ExpectMatchContext(ctx, "has been deleted")
<-doneChan
workspace, err = client.Workspace(context.Background(), workspace.ID)
@@ -207,7 +207,7 @@ func TestDelete(t *testing.T) {
// Then: the workspace deletion should warn about no provisioners
inv, root := clitest.New(t, "delete", workspace.Name, "-y")
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.SetupConfig(t, templateAdmin, root)
doneChan := make(chan struct{})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
@@ -216,7 +216,7 @@ func TestDelete(t *testing.T) {
defer close(doneChan)
_ = inv.WithContext(ctx).Run()
}()
pty.ExpectMatch("there are no provisioners that accept the required tags")
stdout.ExpectMatchContext(ctx, "there are no provisioners that accept the required tags")
cancel()
<-doneChan
})
@@ -311,7 +311,7 @@ func TestDelete(t *testing.T) {
inv, root := clitest.New(t, "delete", workspaceOwner+"/"+workspace.Name, "-y")
clitest.SetupConfig(t, runClient, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
var runErr error
go func() {
defer close(doneChan)
@@ -324,7 +324,7 @@ func TestDelete(t *testing.T) {
require.Error(t, runErr)
require.Contains(t, runErr.Error(), expectedErr)
} else {
pty.ExpectMatch("has been deleted")
stdout.ExpectMatchContext(ctx, "has been deleted")
<-doneChan
// When running with the race detector on, we sometimes get an EOF.
+12 -10
View File
@@ -15,8 +15,8 @@ import (
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestExpRpty(t *testing.T) {
@@ -28,7 +28,7 @@ func TestExpRpty(t *testing.T) {
client, workspace, agentToken := setupWorkspaceForAgent(t)
inv, root := clitest.New(t, "exp", "rpty", workspace.Name)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdin := testutil.NewWriterAttachedToInvocation(t, testutil.Logger(t), inv)
ctx := testutil.Context(t, testutil.WaitLong)
@@ -40,7 +40,7 @@ func TestExpRpty(t *testing.T) {
assert.NoError(t, err)
})
pty.WriteLine("exit")
stdin.WriteLine("exit")
<-cmdDone
})
@@ -51,7 +51,7 @@ func TestExpRpty(t *testing.T) {
randStr := uuid.NewString()
inv, root := clitest.New(t, "exp", "rpty", workspace.Name, "echo", randStr)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitLong)
@@ -63,7 +63,7 @@ func TestExpRpty(t *testing.T) {
assert.NoError(t, err)
})
pty.ExpectMatch(randStr)
stdout.ExpectMatchContext(ctx, randStr)
<-cmdDone
})
@@ -86,6 +86,7 @@ func TestExpRpty(t *testing.T) {
t.Skip("Skipping test on non-Linux platform")
}
logger := testutil.Logger(t)
wantLabel := "coder.devcontainers.TestExpRpty.Container"
client, workspace, agentToken := setupWorkspaceForAgent(t)
@@ -124,7 +125,8 @@ func TestExpRpty(t *testing.T) {
inv, root := clitest.New(t, "exp", "rpty", workspace.Name, "-c", ct.Container.ID)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
ctx := testutil.Context(t, testutil.WaitLong)
cmdDone := tGo(t, func() {
@@ -132,10 +134,10 @@ func TestExpRpty(t *testing.T) {
assert.NoError(t, err)
})
pty.ExpectMatchContext(ctx, " #")
pty.WriteLine("hostname")
pty.ExpectMatchContext(ctx, ct.Container.Config.Hostname)
pty.WriteLine("exit")
stdout.ExpectMatchContext(ctx, " #")
stdin.WriteLine("hostname")
stdout.ExpectMatchContext(ctx, ct.Container.Config.Hostname)
stdin.WriteLine("exit")
<-cmdDone
})
}
+4 -4
View File
@@ -15,8 +15,8 @@ import (
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestList(t *testing.T) {
@@ -34,7 +34,7 @@ func TestList(t *testing.T) {
inv, root := clitest.New(t, "ls")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancelFunc()
@@ -44,8 +44,8 @@ func TestList(t *testing.T) {
assert.NoError(t, errC)
close(done)
}()
pty.ExpectMatch(r.Workspace.Name)
pty.ExpectMatch("Started")
stdout.ExpectMatchContext(ctx, r.Workspace.Name)
stdout.ExpectMatchContext(ctx, "Started")
cancelFunc()
<-done
})
+113 -97
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
@@ -15,8 +14,8 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/pretty"
)
@@ -74,13 +73,16 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserTTY", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
// The --force-tty flag is required on Windows, because the `isatty` library does not
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
ctx := testutil.Context(t, testutil.WaitMedium)
go func() {
defer close(doneChan)
err := root.Run()
@@ -105,12 +107,11 @@ func TestLogin(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
<-doneChan
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -126,13 +127,16 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserTTYWithNoTrial", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
// The --force-tty flag is required on Windows, because the `isatty` library does not
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
ctx := testutil.Context(t, testutil.WaitMedium)
go func() {
defer close(doneChan)
err := root.Run()
@@ -151,12 +155,11 @@ func TestLogin(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
<-doneChan
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -172,13 +175,16 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserTTYNameOptional", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
// The --force-tty flag is required on Windows, because the `isatty` library does not
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
ctx := testutil.Context(t, testutil.WaitMedium)
go func() {
defer close(doneChan)
err := root.Run()
@@ -203,12 +209,11 @@ func TestLogin(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
<-doneChan
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -224,16 +229,19 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserTTYFlag", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
// The --force-tty flag is required on Windows, because the `isatty` library does not
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
inv, _ := clitest.New(t, "--url", client.URL.String(), "login", "--force-tty")
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
ctx := testutil.Context(t, testutil.WaitMedium)
clitest.Start(t, inv)
pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with flag URL: '%s'", client.URL.String()))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Attempting to authenticate with flag URL: '%s'", client.URL.String()))
matches := []string{
"first user?", "yes",
"username", coderdtest.FirstUserParams.Username,
@@ -252,11 +260,10 @@ func TestLogin(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
pty.ExpectMatch("Welcome to Coder")
ctx := testutil.Context(t, testutil.WaitShort)
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -272,6 +279,7 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserFlags", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
inv, _ := clitest.New(
t, "login", client.URL.String(),
@@ -281,22 +289,23 @@ func TestLogin(t *testing.T) {
"--first-user-password", coderdtest.FirstUserParams.Password,
"--first-user-trial",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
ctx := testutil.Context(t, testutil.WaitMedium)
w := clitest.StartWithWaiter(t, inv)
pty.ExpectMatch("firstName")
pty.WriteLine(coderdtest.TrialUserParams.FirstName)
pty.ExpectMatch("lastName")
pty.WriteLine(coderdtest.TrialUserParams.LastName)
pty.ExpectMatch("phoneNumber")
pty.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
pty.ExpectMatch("jobTitle")
pty.WriteLine(coderdtest.TrialUserParams.JobTitle)
pty.ExpectMatch("companyName")
pty.WriteLine(coderdtest.TrialUserParams.CompanyName)
stdout.ExpectMatchContext(ctx, "firstName")
stdin.WriteLine(coderdtest.TrialUserParams.FirstName)
stdout.ExpectMatchContext(ctx, "lastName")
stdin.WriteLine(coderdtest.TrialUserParams.LastName)
stdout.ExpectMatchContext(ctx, "phoneNumber")
stdin.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
stdout.ExpectMatchContext(ctx, "jobTitle")
stdin.WriteLine(coderdtest.TrialUserParams.JobTitle)
stdout.ExpectMatchContext(ctx, "companyName")
stdin.WriteLine(coderdtest.TrialUserParams.CompanyName)
// `developers` and `country` `cliui.Select` automatically selects the first option during tests.
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
w.RequireSuccess()
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -312,6 +321,7 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserFlagsNameOptional", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
inv, _ := clitest.New(
t, "login", client.URL.String(),
@@ -320,22 +330,23 @@ func TestLogin(t *testing.T) {
"--first-user-password", coderdtest.FirstUserParams.Password,
"--first-user-trial",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
ctx := testutil.Context(t, testutil.WaitMedium)
w := clitest.StartWithWaiter(t, inv)
pty.ExpectMatch("firstName")
pty.WriteLine(coderdtest.TrialUserParams.FirstName)
pty.ExpectMatch("lastName")
pty.WriteLine(coderdtest.TrialUserParams.LastName)
pty.ExpectMatch("phoneNumber")
pty.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
pty.ExpectMatch("jobTitle")
pty.WriteLine(coderdtest.TrialUserParams.JobTitle)
pty.ExpectMatch("companyName")
pty.WriteLine(coderdtest.TrialUserParams.CompanyName)
stdout.ExpectMatchContext(ctx, "firstName")
stdin.WriteLine(coderdtest.TrialUserParams.FirstName)
stdout.ExpectMatchContext(ctx, "lastName")
stdin.WriteLine(coderdtest.TrialUserParams.LastName)
stdout.ExpectMatchContext(ctx, "phoneNumber")
stdin.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
stdout.ExpectMatchContext(ctx, "jobTitle")
stdin.WriteLine(coderdtest.TrialUserParams.JobTitle)
stdout.ExpectMatchContext(ctx, "companyName")
stdin.WriteLine(coderdtest.TrialUserParams.CompanyName)
// `developers` and `country` `cliui.Select` automatically selects the first option during tests.
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
w.RequireSuccess()
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
@@ -351,6 +362,7 @@ func TestLogin(t *testing.T) {
t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
client := coderdtest.New(t, nil)
@@ -359,7 +371,8 @@ func TestLogin(t *testing.T) {
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
go func() {
defer close(doneChan)
err := root.WithContext(ctx).Run()
@@ -377,59 +390,60 @@ func TestLogin(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
stdout.ExpectMatchContext(ctx, match)
stdin.WriteLine(value)
}
// Validate that we reprompt for matching passwords.
pty.ExpectMatch("Passwords do not match")
pty.ExpectMatch("Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password"))
pty.WriteLine(coderdtest.FirstUserParams.Password)
pty.ExpectMatch("Confirm")
pty.WriteLine(coderdtest.FirstUserParams.Password)
pty.ExpectMatch("trial")
pty.WriteLine("yes")
pty.ExpectMatch("firstName")
pty.WriteLine(coderdtest.TrialUserParams.FirstName)
pty.ExpectMatch("lastName")
pty.WriteLine(coderdtest.TrialUserParams.LastName)
pty.ExpectMatch("phoneNumber")
pty.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
pty.ExpectMatch("jobTitle")
pty.WriteLine(coderdtest.TrialUserParams.JobTitle)
pty.ExpectMatch("companyName")
pty.WriteLine(coderdtest.TrialUserParams.CompanyName)
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, "Passwords do not match")
stdout.ExpectMatchContext(ctx, "Enter a "+pretty.Sprint(cliui.DefaultStyles.Field, "password"))
stdin.WriteLine(coderdtest.FirstUserParams.Password)
stdout.ExpectMatchContext(ctx, "Confirm")
stdin.WriteLine(coderdtest.FirstUserParams.Password)
stdout.ExpectMatchContext(ctx, "trial")
stdin.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "firstName")
stdin.WriteLine(coderdtest.TrialUserParams.FirstName)
stdout.ExpectMatchContext(ctx, "lastName")
stdin.WriteLine(coderdtest.TrialUserParams.LastName)
stdout.ExpectMatchContext(ctx, "phoneNumber")
stdin.WriteLine(coderdtest.TrialUserParams.PhoneNumber)
stdout.ExpectMatchContext(ctx, "jobTitle")
stdin.WriteLine(coderdtest.TrialUserParams.JobTitle)
stdout.ExpectMatchContext(ctx, "companyName")
stdin.WriteLine(coderdtest.TrialUserParams.CompanyName)
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
<-doneChan
})
t.Run("ExistingUserValidTokenTTY", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitMedium)
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String(), "--no-open")
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
go func() {
defer close(doneChan)
err := root.Run()
assert.NoError(t, err)
}()
pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with argument URL: '%s'", client.URL.String()))
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
if runtime.GOOS != "windows" {
// For some reason, the match does not show up on Windows.
pty.ExpectMatch(client.SessionToken())
}
pty.ExpectMatch("Welcome to Coder")
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Attempting to authenticate with argument URL: '%s'", client.URL.String()))
stdout.ExpectMatchContext(ctx, "Paste your token here:")
stdin.WriteLine(client.SessionToken())
stdout.ExpectMatchContext(ctx, "Welcome to Coder")
<-doneChan
})
t.Run("ExistingUserURLSavedInConfig", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, nil)
url := client.URL.String()
coderdtest.CreateFirstUser(t, client)
@@ -438,21 +452,24 @@ func TestLogin(t *testing.T) {
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with config URL: '%s'", url))
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Attempting to authenticate with config URL: '%s'", url))
stdout.ExpectMatchContext(ctx, "Paste your token here:")
stdin.WriteLine(client.SessionToken())
<-doneChan
})
t.Run("ExistingUserURLSavedInEnv", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, nil)
url := client.URL.String()
coderdtest.CreateFirstUser(t, client)
@@ -461,21 +478,23 @@ func TestLogin(t *testing.T) {
inv.Environ.Set("CODER_URL", url)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with environment URL: '%s'", url))
pty.ExpectMatch("Paste your token here:")
pty.WriteLine(client.SessionToken())
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Attempting to authenticate with environment URL: '%s'", url))
stdout.ExpectMatchContext(ctx, "Paste your token here:")
stdin.WriteLine(client.SessionToken())
<-doneChan
})
t.Run("ExistingUserInvalidTokenTTY", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
@@ -483,7 +502,8 @@ func TestLogin(t *testing.T) {
defer cancelFunc()
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", client.URL.String(), "--no-open")
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), root)
go func() {
defer close(doneChan)
err := root.WithContext(ctx).Run()
@@ -491,13 +511,9 @@ func TestLogin(t *testing.T) {
assert.Error(t, err)
}()
pty.ExpectMatch("Paste your token here:")
pty.WriteLine("an-invalid-token")
if runtime.GOOS != "windows" {
// For some reason, the match does not show up on Windows.
pty.ExpectMatch("an-invalid-token")
}
pty.ExpectMatch("That's not a valid token!")
stdout.ExpectMatchContext(ctx, "Paste your token here:")
stdin.WriteLine("an-invalid-token")
stdout.ExpectMatchContext(ctx, "That's not a valid token!")
cancelFunc()
<-doneChan
})
@@ -582,12 +598,12 @@ func TestLoginToken(t *testing.T) {
inv, root := clitest.New(t, "login", "token", "--url", client.URL.String())
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
pty.ExpectMatch(client.SessionToken())
stdout.ExpectMatchContext(ctx, client.SessionToken())
})
t.Run("NoTokenStored", func(t *testing.T) {
+11 -6
View File
@@ -17,7 +17,8 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/pretty"
)
@@ -29,6 +30,7 @@ func TestCurrentOrganization(t *testing.T) {
// 2. The user is connecting to an older Coder instance.
t.Run("no-default", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
orgID := uuid.New()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -49,13 +51,13 @@ func TestCurrentOrganization(t *testing.T) {
client := codersdk.New(must(url.Parse(srv.URL)))
inv, root := clitest.New(t, "organizations", "show", "selected")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
errC := make(chan error)
go func() {
errC <- inv.Run()
}()
require.NoError(t, <-errC)
pty.ExpectMatch(orgID.String())
stdout.ExpectMatchContext(ctx, orgID.String())
})
}
@@ -140,6 +142,8 @@ func TestOrganizationDelete(t *testing.T) {
t.Run("Prompted", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitMedium)
orgID := uuid.New()
var deleteCalled atomic.Bool
@@ -167,15 +171,16 @@ func TestOrganizationDelete(t *testing.T) {
client := codersdk.New(must(url.Parse(server.URL)))
inv, root := clitest.New(t, "organizations", "delete", "my-org")
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
execDone := make(chan error)
go func() {
execDone <- inv.Run()
}()
pty.ExpectMatch(fmt.Sprintf("Delete organization %s?", pretty.Sprint(cliui.DefaultStyles.Code, "my-org")))
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Delete organization %s?", pretty.Sprint(cliui.DefaultStyles.Code, "my-org")))
stdin.WriteLine("yes")
require.NoError(t, <-execDone)
require.True(t, deleteCalled.Load(), "expected delete request")
+9 -19
View File
@@ -25,8 +25,8 @@ import (
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestPortForward_None(t *testing.T) {
@@ -160,10 +160,7 @@ func TestPortForward(t *testing.T) {
// the "local" listener.
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t)
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
iNet := testutil.NewInProcNet()
inv.Net = iNet
@@ -175,7 +172,7 @@ func TestPortForward(t *testing.T) {
t.Logf("command complete; err=%s", err.Error())
errC <- err
}()
pty.ExpectMatchContext(ctx, "Ready!")
stdout.ExpectMatchContext(ctx, "Ready!")
// Open two connections simultaneously and test them out of
// sync.
@@ -216,10 +213,7 @@ func TestPortForward(t *testing.T) {
// the "local" listeners.
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag1, flag2)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t)
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
iNet := testutil.NewInProcNet()
inv.Net = iNet
@@ -229,7 +223,7 @@ func TestPortForward(t *testing.T) {
go func() {
errC <- inv.WithContext(ctx).Run()
}()
pty.ExpectMatchContext(ctx, "Ready!")
stdout.ExpectMatchContext(ctx, "Ready!")
// Open a connection to both listener 1 and 2 simultaneously and
// then test them out of order.
@@ -277,8 +271,7 @@ func TestPortForward(t *testing.T) {
// the "local" listeners.
inv, root := clitest.New(t, append([]string{"-v", "port-forward", workspace.Name}, flags...)...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
iNet := testutil.NewInProcNet()
inv.Net = iNet
@@ -288,7 +281,7 @@ func TestPortForward(t *testing.T) {
go func() {
errC <- inv.WithContext(ctx).Run()
}()
pty.ExpectMatchContext(ctx, "Ready!")
stdout.ExpectMatchContext(ctx, "Ready!")
// Open connections to all items in the "dial" array.
var (
@@ -338,10 +331,7 @@ func TestPortForward(t *testing.T) {
// the "local" listener.
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t)
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
iNet := testutil.NewInProcNet()
inv.Net = iNet
@@ -359,7 +349,7 @@ func TestPortForward(t *testing.T) {
t.Logf("command complete; err=%s", err.Error())
errC <- err
}()
pty.ExpectMatchContext(ctx, "Ready!")
stdout.ExpectMatchContext(ctx, "Ready!")
// Test IPv4 still works
dialCtx, dialCtxCancel := context.WithTimeout(ctx, testutil.WaitShort)
+7 -6
View File
@@ -8,12 +8,13 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestRename(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, AllowWorkspaceRenames: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -30,13 +31,13 @@ func TestRename(t *testing.T) {
want := coderdtest.RandomUsername(t)
inv, root := clitest.New(t, "rename", workspace.Name, want, "--yes")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t)
pty.Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
pty.ExpectMatch("confirm rename:")
pty.WriteLine(workspace.Name)
pty.ExpectMatch("renamed to")
stdout.ExpectMatchContext(ctx, "confirm rename:")
stdin.WriteLine(workspace.Name)
stdout.ExpectMatchContext(ctx, "renamed to")
ws, err := client.Workspace(ctx, workspace.ID)
assert.NoError(t, err)
+39 -42
View File
@@ -1,7 +1,6 @@
package cli_test
import (
"context"
"fmt"
"testing"
@@ -14,8 +13,8 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestRestart(t *testing.T) {
@@ -49,15 +48,15 @@ func TestRestart(t *testing.T) {
inv, root := clitest.New(t, "restart", workspace.Name, "--yes")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
done := make(chan error, 1)
go func() {
done <- inv.WithContext(ctx).Run()
}()
pty.ExpectMatch("Stopping workspace")
pty.ExpectMatch("Starting workspace")
pty.ExpectMatch("workspace has been restarted")
stdout.ExpectMatchContext(ctx, "Stopping workspace")
stdout.ExpectMatchContext(ctx, "Starting workspace")
stdout.ExpectMatchContext(ctx, "workspace has been restarted")
err := <-done
require.NoError(t, err, "execute failed")
@@ -66,6 +65,7 @@ func TestRestart(t *testing.T) {
t.Run("PromptEphemeralParameters", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -84,13 +84,15 @@ func TestRestart(t *testing.T) {
inv, root := clitest.New(t, "restart", workspace.Name, "--prompt-ephemeral-parameters")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
matches := []string{
ephemeralParameterDescription, ephemeralParameterValue,
"Restart workspace?", "yes",
@@ -101,18 +103,15 @@ func TestRestart(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
// Verify if build option is set
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
@@ -126,6 +125,7 @@ func TestRestart(t *testing.T) {
t.Run("EphemeralParameterFlags", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -143,13 +143,15 @@ func TestRestart(t *testing.T) {
"--ephemeral-parameter", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue))
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
matches := []string{
"Restart workspace?", "yes",
"Stopping workspace", "",
@@ -159,18 +161,15 @@ func TestRestart(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
// Verify if build option is set
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
@@ -184,6 +183,7 @@ func TestRestart(t *testing.T) {
t.Run("with deprecated build-options flag", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -202,13 +202,15 @@ func TestRestart(t *testing.T) {
inv, root := clitest.New(t, "restart", workspace.Name, "--build-options")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
matches := []string{
ephemeralParameterDescription, ephemeralParameterValue,
"Restart workspace?", "yes",
@@ -219,18 +221,15 @@ func TestRestart(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
// Verify if build option is set
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
@@ -244,6 +243,7 @@ func TestRestart(t *testing.T) {
t.Run("with deprecated build-option flag", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -261,13 +261,15 @@ func TestRestart(t *testing.T) {
"--build-option", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue))
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
matches := []string{
"Restart workspace?", "yes",
"Stopping workspace", "",
@@ -277,18 +279,15 @@ func TestRestart(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
stdout.ExpectMatchContext(ctx, match)
if value != "" {
pty.WriteLine(value)
stdin.WriteLine(value)
}
}
<-doneChan
// Verify if build option is set
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, memberUser.ID.String(), workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
@@ -349,20 +348,18 @@ func TestRestartWithParameters(t *testing.T) {
inv, root := clitest.New(t, "restart", workspace.Name, "-y")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
pty.ExpectMatch("workspace has been restarted")
stdout.ExpectMatchContext(ctx, "workspace has been restarted")
<-doneChan
// Verify if immutable parameter is set
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
@@ -376,6 +373,7 @@ func TestRestartWithParameters(t *testing.T) {
t.Run("AlwaysPrompt", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
// Create the workspace
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -396,24 +394,23 @@ func TestRestartWithParameters(t *testing.T) {
inv, root := clitest.New(t, "restart", workspace.Name, "-y", "--always-prompt")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
ctx := testutil.Context(t, testutil.WaitShort)
// We should be prompted for the parameters again.
newValue := "xyz"
pty.ExpectMatch(mutableParameterName)
pty.WriteLine(newValue)
pty.ExpectMatch("workspace has been restarted")
stdout.ExpectMatchContext(ctx, mutableParameterName)
stdin.WriteLine(newValue)
stdout.ExpectMatchContext(ctx, "workspace has been restarted")
<-doneChan
// Verify that the updated values are persisted.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
+73 -62
View File
@@ -19,8 +19,8 @@ import (
"github.com/coder/coder/v2/coderd/schedule/cron"
"github.com/coder/coder/v2/coderd/util/tz"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
// setupTestSchedule creates 4 workspaces:
@@ -97,20 +97,21 @@ func TestScheduleShow(t *testing.T) {
inv, root := clitest.New(t, "schedule", "show")
//nolint:gocritic // Testing that owner user sees all
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: they should see their own workspaces.
// 1st workspace: a-owner-ws1 has both autostart and autostop enabled.
pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[0].OwnerName+"/"+ws[0].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
// 2nd workspace: b-owner-ws2 has only autostart enabled.
pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[1].OwnerName+"/"+ws[1].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
})
t.Run("OwnerAll", func(t *testing.T) {
@@ -118,26 +119,27 @@ func TestScheduleShow(t *testing.T) {
inv, root := clitest.New(t, "schedule", "show", "--all")
//nolint:gocritic // Testing that owner user sees all
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: they should see all workspaces
// 1st workspace: a-owner-ws1 has both autostart and autostop enabled.
pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[0].OwnerName+"/"+ws[0].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
// 2nd workspace: b-owner-ws2 has only autostart enabled.
pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[1].OwnerName+"/"+ws[1].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
// 3rd workspace: c-member-ws3 has only autostop enabled.
pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[2].OwnerName+"/"+ws[2].Name)
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
// 4th workspace: d-member-ws4 has neither autostart nor autostop enabled.
pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name)
stdout.ExpectMatchContext(ctx, ws[3].OwnerName+"/"+ws[3].Name)
})
t.Run("OwnerSearchByName", func(t *testing.T) {
@@ -145,14 +147,15 @@ func TestScheduleShow(t *testing.T) {
inv, root := clitest.New(t, "schedule", "show", "--search", "name:"+ws[1].Name)
//nolint:gocritic // Testing that owner user sees all
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: they should see workspaces matching that query
// 2nd workspace: b-owner-ws2 has only autostart enabled.
pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[1].OwnerName+"/"+ws[1].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
})
t.Run("OwnerOneArg", func(t *testing.T) {
@@ -160,37 +163,39 @@ func TestScheduleShow(t *testing.T) {
inv, root := clitest.New(t, "schedule", "show", ws[2].OwnerName+"/"+ws[2].Name)
//nolint:gocritic // Testing that owner user sees all
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: they should see that workspace
// 3rd workspace: c-member-ws3 has only autostop enabled.
pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[2].OwnerName+"/"+ws[2].Name)
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
})
t.Run("MemberNoArgs", func(t *testing.T) {
// When: a member specifies no args
inv, root := clitest.New(t, "schedule", "show")
clitest.SetupConfig(t, memberClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: they should see their own workspaces
// 1st workspace: c-member-ws3 has only autostop enabled.
pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[2].OwnerName+"/"+ws[2].Name)
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
// 2nd workspace: d-member-ws4 has neither autostart nor autostop enabled.
pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name)
stdout.ExpectMatchContext(ctx, ws[3].OwnerName+"/"+ws[3].Name)
})
t.Run("MemberAll", func(t *testing.T) {
// When: a member lists all workspaces
inv, root := clitest.New(t, "schedule", "show", "--all")
clitest.SetupConfig(t, memberClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
errC := make(chan error)
go func() {
@@ -200,11 +205,11 @@ func TestScheduleShow(t *testing.T) {
// Then: they should only see their own
// 1st workspace: c-member-ws3 has only autostop enabled.
pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
pty.ExpectMatch("8h")
pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[2].OwnerName+"/"+ws[2].Name)
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
// 2nd workspace: d-member-ws4 has neither autostart nor autostop enabled.
pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name)
stdout.ExpectMatchContext(ctx, ws[3].OwnerName+"/"+ws[3].Name)
})
t.Run("JSON", func(t *testing.T) {
@@ -276,13 +281,14 @@ func TestScheduleModify(t *testing.T) {
)
//nolint:gocritic // this workspace is not owned by the same user
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[3].OwnerName+"/"+ws[3].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
})
t.Run("SetStop", func(t *testing.T) {
@@ -292,13 +298,14 @@ func TestScheduleModify(t *testing.T) {
)
//nolint:gocritic // this workspace is not owned by the same user
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name)
pty.ExpectMatch("8h30m")
pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, ws[2].OwnerName+"/"+ws[2].Name)
stdout.ExpectMatchContext(ctx, "8h30m")
stdout.ExpectMatchContext(ctx, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339))
})
t.Run("UnsetStart", func(t *testing.T) {
@@ -308,11 +315,12 @@ func TestScheduleModify(t *testing.T) {
)
//nolint:gocritic // this workspace is owned by owner
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name)
stdout.ExpectMatchContext(ctx, ws[1].OwnerName+"/"+ws[1].Name)
})
t.Run("UnsetStop", func(t *testing.T) {
@@ -322,11 +330,12 @@ func TestScheduleModify(t *testing.T) {
)
//nolint:gocritic // this workspace is owned by owner
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
stdout.ExpectMatchContext(ctx, ws[0].OwnerName+"/"+ws[0].Name)
})
}
@@ -359,7 +368,8 @@ func TestScheduleOverride(t *testing.T) {
)
clitest.SetupConfig(t, ownerClient, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Fetch the workspace to get the actual deadline set by the
@@ -376,11 +386,11 @@ func TestScheduleOverride(t *testing.T) {
expectedDeadline := updated.LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)
// Then: the updated schedule should be shown
pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name)
pty.ExpectMatch(sched.Humanize())
pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339))
pty.ExpectMatch("8h")
pty.ExpectMatch(expectedDeadline)
stdout.ExpectMatchContext(ctx, ws[0].OwnerName+"/"+ws[0].Name)
stdout.ExpectMatchContext(ctx, sched.Humanize())
stdout.ExpectMatchContext(ctx, sched.Next(now).In(loc).Format(time.RFC3339))
stdout.ExpectMatchContext(ctx, "8h")
stdout.ExpectMatchContext(ctx, expectedDeadline)
})
}
}
@@ -422,13 +432,14 @@ func TestScheduleStart_TemplateAutostartRequirement(t *testing.T) {
"schedule", "start", workspace.Name, "9:30AM", "Mon-Fri",
)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
ctx := testutil.Context(t, testutil.WaitShort)
require.NoError(t, inv.Run())
// Then: warning should be shown
// In AGPL, this will show all days (enterprise feature defaults to all days allowed)
pty.ExpectMatch("Warning")
pty.ExpectMatch("may only autostart")
stdout.ExpectMatchContext(ctx, "Warning")
stdout.ExpectMatchContext(ctx, "may only autostart")
})
t.Run("NoWarningWhenManual", func(t *testing.T) {
+14 -10
View File
@@ -14,8 +14,8 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
func TestSecretCreate(t *testing.T) {
@@ -501,6 +501,7 @@ func TestSecretDelete(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
@@ -516,12 +517,13 @@ func TestSecretDelete(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
inv = inv.WithContext(ctx)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
waiter := clitest.StartWithWaiter(t, inv)
pty.ExpectMatchContext(ctx, "Delete secret")
pty.ExpectMatchContext(ctx, "service-token")
pty.WriteLine("yes")
pty.ExpectMatchContext(ctx, "Deleted secret")
stdout.ExpectMatchContext(ctx, "Delete secret")
stdout.ExpectMatchContext(ctx, "service-token")
stdin.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Deleted secret")
require.NoError(t, waiter.Wait())
@@ -566,6 +568,7 @@ func TestSecretDelete(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
@@ -574,11 +577,12 @@ func TestSecretDelete(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
inv = inv.WithContext(ctx)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
waiter := clitest.StartWithWaiter(t, inv)
pty.ExpectMatchContext(ctx, "Delete secret")
pty.ExpectMatchContext(ctx, "missing-secret")
pty.WriteLine("yes")
stdout.ExpectMatchContext(ctx, "Delete secret")
stdout.ExpectMatchContext(ctx, "missing-secret")
stdin.WriteLine("yes")
err := waiter.Wait()
require.ErrorContains(t, err, `delete secret "missing-secret"`)
+26 -13
View File
@@ -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)
}
+137 -2
View File
@@ -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}
}
+25 -24
View File
@@ -21,6 +21,7 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
)
//nolint:paralleltest, tparallel
@@ -128,19 +129,17 @@ func TestServerCreateAdminUser(t *testing.T) {
"--email", email,
"--password", password,
)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv)
pty.ExpectMatchContext(ctx, "Creating user...")
pty.ExpectMatchContext(ctx, "Generating user SSH key...")
pty.ExpectMatchContext(ctx, fmt.Sprintf("Adding user to organization %q (%s) as admin...", org1Name, org1ID.String()))
pty.ExpectMatchContext(ctx, fmt.Sprintf("Adding user to organization %q (%s) as admin...", org2Name, org2ID.String()))
pty.ExpectMatchContext(ctx, "User created successfully.")
pty.ExpectMatchContext(ctx, username)
pty.ExpectMatchContext(ctx, email)
pty.ExpectMatchContext(ctx, "****")
stdout.ExpectMatchContext(ctx, "Creating user...")
stdout.ExpectMatchContext(ctx, "Generating user SSH key...")
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Adding user to organization %q (%s) as admin...", org1Name, org1ID.String()))
stdout.ExpectMatchContext(ctx, fmt.Sprintf("Adding user to organization %q (%s) as admin...", org2Name, org2ID.String()))
stdout.ExpectMatchContext(ctx, "User created successfully.")
stdout.ExpectMatchContext(ctx, username)
stdout.ExpectMatchContext(ctx, email)
stdout.ExpectMatchContext(ctx, "****")
verifyUser(t, connectionURL, username, email, password)
})
@@ -184,6 +183,7 @@ func TestServerCreateAdminUser(t *testing.T) {
// Skip on non-Linux because it spawns a PostgreSQL instance.
t.SkipNow()
}
logger := testutil.Logger(t)
connectionURL, err := dbtestutil.Open(t)
require.NoError(t, err)
@@ -195,23 +195,24 @@ func TestServerCreateAdminUser(t *testing.T) {
"--postgres-url", connectionURL,
"--ssh-keygen-algorithm", "ed25519",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
pty.ExpectMatchContext(ctx, "Username")
pty.WriteLine(username)
pty.ExpectMatchContext(ctx, "Email")
pty.WriteLine(email)
pty.ExpectMatchContext(ctx, "Password")
pty.WriteLine(password)
pty.ExpectMatchContext(ctx, "Confirm password")
pty.WriteLine(password)
stdout.ExpectMatchContext(ctx, "Username")
stdin.WriteLine(username)
stdout.ExpectMatchContext(ctx, "Email")
stdin.WriteLine(email)
stdout.ExpectMatchContext(ctx, "Password")
stdin.WriteLine(password)
stdout.ExpectMatchContext(ctx, "Confirm password")
stdin.WriteLine(password)
pty.ExpectMatchContext(ctx, "User created successfully.")
pty.ExpectMatchContext(ctx, username)
pty.ExpectMatchContext(ctx, email)
pty.ExpectMatchContext(ctx, "****")
stdout.ExpectMatchContext(ctx, "User created successfully.")
stdout.ExpectMatchContext(ctx, username)
stdout.ExpectMatchContext(ctx, email)
stdout.ExpectMatchContext(ctx, "****")
verifyUser(t, connectionURL, username, email, password)
})
+62 -69
View File
@@ -59,6 +59,7 @@ import (
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/tailnet/tailnettest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/serpent"
)
@@ -229,7 +230,7 @@ func TestServer(t *testing.T) {
"--access-url", "http://example.com",
"--ephemeral",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Embedded postgres takes a while to fire up.
const superDuperLong = testutil.WaitSuperLong * 3
@@ -240,7 +241,7 @@ func TestServer(t *testing.T) {
}()
matchCh1 := make(chan string, 1)
go func() {
matchCh1 <- pty.ExpectMatchContext(ctx, "Using an ephemeral deployment directory")
matchCh1 <- stdout.ExpectMatchContext(ctx, "Using an ephemeral deployment directory")
}()
select {
case err := <-errCh:
@@ -248,7 +249,7 @@ func TestServer(t *testing.T) {
case <-matchCh1:
// OK!
}
rootDirLine := pty.ReadLine(ctx)
rootDirLine := stdout.ReadLine(ctx)
rootDir := strings.TrimPrefix(rootDirLine, "Using an ephemeral deployment directory")
rootDir = strings.TrimSpace(rootDir)
rootDir = strings.TrimPrefix(rootDir, "(")
@@ -259,7 +260,7 @@ func TestServer(t *testing.T) {
matchCh2 := make(chan string, 1)
go func() {
// The "View the Web UI" log is a decent indicator that the server was successfully started.
matchCh2 <- pty.ExpectMatchContext(ctx, "View the Web UI")
matchCh2 <- stdout.ExpectMatchContext(ctx, "View the Web UI")
}()
select {
case err := <-errCh:
@@ -276,24 +277,23 @@ func TestServer(t *testing.T) {
t.Run("BuiltinPostgresURL", func(t *testing.T) {
t.Parallel()
root, _ := clitest.New(t, "server", "postgres-builtin-url")
pty := ptytest.New(t)
root.Stdout = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, root)
ctx := testutil.Context(t, testutil.WaitShort)
err := root.Run()
require.NoError(t, err)
pty.ExpectMatch("psql")
stdout.ExpectMatchContext(ctx, "psql")
})
t.Run("BuiltinPostgresURLRaw", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
root, _ := clitest.New(t, "server", "postgres-builtin-url", "--raw-url")
pty := ptytest.New(t)
root.Stdout = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, root)
err := root.WithContext(ctx).Run()
require.NoError(t, err)
got := pty.ReadLine(ctx)
got := stdout.ReadLine(ctx)
if !strings.HasPrefix(got, "postgres://") {
t.Fatalf("expected postgres URL to start with \"postgres://\", got %q", got)
}
@@ -506,6 +506,7 @@ func TestServer(t *testing.T) {
// reachable.
t.Run("LocalAccessURL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
inv, cfg := clitest.New(t,
"server",
dbArg(t),
@@ -513,7 +514,7 @@ func TestServer(t *testing.T) {
"--access-url", "http://localhost:3000/",
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Since we end the test after seeing the log lines about the access url, we could cancel the test before
// our initial interactions with PostgreSQL are complete. So, ignore errors of that type for this test.
startIgnoringPostgresQueryCancel(t, inv)
@@ -521,9 +522,9 @@ func TestServer(t *testing.T) {
// Just wait for startup
_ = waitAccessURL(t, cfg)
pty.ExpectMatch("this may cause unexpected problems when creating workspaces")
pty.ExpectMatch("View the Web UI:")
pty.ExpectMatch("http://localhost:3000/")
stdout.ExpectMatchContext(ctx, "this may cause unexpected problems when creating workspaces")
stdout.ExpectMatchContext(ctx, "View the Web UI:")
stdout.ExpectMatchContext(ctx, "http://localhost:3000/")
})
// Validate that an https scheme is prepended to a remote access URL
@@ -531,6 +532,7 @@ func TestServer(t *testing.T) {
t.Run("RemoteAccessURL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
inv, cfg := clitest.New(t,
"server",
dbArg(t),
@@ -538,7 +540,7 @@ func TestServer(t *testing.T) {
"--access-url", "https://foobarbaz.mydomain",
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Since we end the test after seeing the log lines about the access url, we could cancel the test before
// our initial interactions with PostgreSQL are complete. So, ignore errors of that type for this test.
@@ -547,13 +549,14 @@ func TestServer(t *testing.T) {
// Just wait for startup
_ = waitAccessURL(t, cfg)
pty.ExpectMatch("this may cause unexpected problems when creating workspaces")
pty.ExpectMatch("View the Web UI:")
pty.ExpectMatch("https://foobarbaz.mydomain")
stdout.ExpectMatchContext(ctx, "this may cause unexpected problems when creating workspaces")
stdout.ExpectMatchContext(ctx, "View the Web UI:")
stdout.ExpectMatchContext(ctx, "https://foobarbaz.mydomain")
})
t.Run("NoWarningWithRemoteAccessURL", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
inv, cfg := clitest.New(t,
"server",
dbArg(t),
@@ -561,7 +564,7 @@ func TestServer(t *testing.T) {
"--access-url", "https://google.com",
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Since we end the test after seeing the log lines about the access url, we could cancel the test before
// our initial interactions with PostgreSQL are complete. So, ignore errors of that type for this test.
startIgnoringPostgresQueryCancel(t, inv)
@@ -569,8 +572,8 @@ func TestServer(t *testing.T) {
// Just wait for startup
_ = waitAccessURL(t, cfg)
pty.ExpectMatch("View the Web UI:")
pty.ExpectMatch("https://google.com")
stdout.ExpectMatchContext(ctx, "View the Web UI:")
stdout.ExpectMatchContext(ctx, "https://google.com")
})
t.Run("NoSchemeAccessURL", func(t *testing.T) {
@@ -735,8 +738,6 @@ func TestServer(t *testing.T) {
"--tls-key-file", key2Path,
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t)
root.Stdout = pty.Output()
clitest.Start(t, root.WithContext(ctx))
accessURL := waitAccessURL(t, cfg)
@@ -814,18 +815,18 @@ func TestServer(t *testing.T) {
"--tls-key-file", keyPath,
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv)
// We can't use waitAccessURL as it will only return the HTTP URL.
const httpLinePrefix = "Started HTTP listener at"
pty.ExpectMatch(httpLinePrefix)
httpLine := pty.ReadLine(ctx)
stdout.ExpectMatchContext(ctx, httpLinePrefix)
httpLine := stdout.ReadLine(ctx)
httpAddr := strings.TrimSpace(strings.TrimPrefix(httpLine, httpLinePrefix))
require.NotEmpty(t, httpAddr)
const tlsLinePrefix = "Started TLS/HTTPS listener at "
pty.ExpectMatch(tlsLinePrefix)
tlsLine := pty.ReadLine(ctx)
stdout.ExpectMatchContext(ctx, tlsLinePrefix)
tlsLine := stdout.ReadLine(ctx)
tlsAddr := strings.TrimSpace(strings.TrimPrefix(tlsLine, tlsLinePrefix))
require.NotEmpty(t, tlsAddr)
@@ -951,8 +952,7 @@ func TestServer(t *testing.T) {
}
inv, _ := clitest.New(t, flags...)
pty := ptytest.New(t)
pty.Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv)
@@ -963,15 +963,15 @@ func TestServer(t *testing.T) {
// We can't use waitAccessURL as it will only return the HTTP URL.
if c.httpListener {
const httpLinePrefix = "Started HTTP listener at"
pty.ExpectMatch(httpLinePrefix)
httpLine := pty.ReadLine(ctx)
stdout.ExpectMatchContext(ctx, httpLinePrefix)
httpLine := stdout.ReadLine(ctx)
httpAddr = strings.TrimSpace(strings.TrimPrefix(httpLine, httpLinePrefix))
require.NotEmpty(t, httpAddr)
}
if c.tlsListener {
const tlsLinePrefix = "Started TLS/HTTPS listener at"
pty.ExpectMatch(tlsLinePrefix)
tlsLine := pty.ReadLine(ctx)
stdout.ExpectMatchContext(ctx, tlsLinePrefix)
tlsLine := stdout.ReadLine(ctx)
tlsAddr = strings.TrimSpace(strings.TrimPrefix(tlsLine, tlsLinePrefix))
require.NotEmpty(t, tlsAddr)
}
@@ -1041,6 +1041,7 @@ func TestServer(t *testing.T) {
t.Run("CanListenUnspecifiedv4", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
inv, _ := clitest.New(t,
"server",
dbArg(t),
@@ -1048,18 +1049,19 @@ func TestServer(t *testing.T) {
"--access-url", "http://example.com",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Since we end the test after seeing the log lines about the HTTP listener, we could cancel the test before
// our initial interactions with PostgreSQL are complete. So, ignore errors of that type for this test.
startIgnoringPostgresQueryCancel(t, inv)
pty.ExpectMatch("Started HTTP listener")
pty.ExpectMatch("http://0.0.0.0:")
stdout.ExpectMatchContext(ctx, "Started HTTP listener")
stdout.ExpectMatchContext(ctx, "http://0.0.0.0:")
})
t.Run("CanListenUnspecifiedv6", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
inv, _ := clitest.New(t,
"server",
dbArg(t),
@@ -1067,13 +1069,13 @@ func TestServer(t *testing.T) {
"--access-url", "http://example.com",
)
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
// Since we end the test after seeing the log lines about the HTTP listener, we could cancel the test before
// our initial interactions with PostgreSQL are complete. So, ignore errors of that type for this test.
startIgnoringPostgresQueryCancel(t, inv)
pty.ExpectMatch("Started HTTP listener at")
pty.ExpectMatch("http://[::]:")
stdout.ExpectMatchContext(ctx, "Started HTTP listener at")
stdout.ExpectMatchContext(ctx, "http://[::]:")
})
t.Run("NoAddress", func(t *testing.T) {
@@ -1128,12 +1130,10 @@ func TestServer(t *testing.T) {
"--access-url", "http://example.com",
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv.WithContext(ctx))
pty.ExpectMatch("is deprecated")
stdout.ExpectMatchContext(ctx, "is deprecated")
accessURL := waitAccessURL(t, cfg)
require.Equal(t, "http", accessURL.Scheme)
@@ -1158,12 +1158,10 @@ func TestServer(t *testing.T) {
"--tls-key-file", keyPath,
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t)
root.Stdout = pty.Output()
root.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, root)
clitest.Start(t, root.WithContext(ctx))
pty.ExpectMatch("is deprecated")
stdout.ExpectMatchContext(ctx, "is deprecated")
accessURL := waitAccessURL(t, cfg)
require.Equal(t, "https", accessURL.Scheme)
@@ -1259,15 +1257,13 @@ func TestServer(t *testing.T) {
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv)
// Wait until we see the prometheus address in the logs.
addrMatchExpr := `http server listening\s+addr=(\S+)\s+name=prometheus`
lineMatch := pty.ExpectRegexMatchContext(ctx, addrMatchExpr)
lineMatch := stdout.ExpectRegexMatchContext(ctx, addrMatchExpr)
promAddr := regexp.MustCompile(addrMatchExpr).FindStringSubmatch(lineMatch)[1]
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
@@ -1322,15 +1318,13 @@ func TestServer(t *testing.T) {
"--cache-dir", t.TempDir(),
)
pty := ptytest.New(t)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
stdout := expecter.NewAttachedToInvocation(t, inv)
clitest.Start(t, inv)
// Wait until we see the prometheus address in the logs.
addrMatchExpr := `http server listening\s+addr=(\S+)\s+name=prometheus`
lineMatch := pty.ExpectRegexMatchContext(ctx, addrMatchExpr)
lineMatch := stdout.ExpectRegexMatchContext(ctx, addrMatchExpr)
promAddr := regexp.MustCompile(addrMatchExpr).FindStringSubmatch(lineMatch)[1]
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
@@ -1751,7 +1745,6 @@ func TestServer(t *testing.T) {
inv, cfg := clitest.New(t,
args...,
)
ptytest.New(t).Attach(inv)
inv = inv.WithContext(ctx)
w := clitest.StartWithWaiter(t, inv)
gotURL := waitAccessURL(t, cfg)
@@ -2019,15 +2012,15 @@ func TestServer_Logging_NoParallel(t *testing.T) {
"--provisioner-types=echo",
"--log-stackdriver", fi,
)
// Attach pty so we get debug output from the command if this test
// Attach expecter so we get debug output from the command if this test
// fails.
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
startIgnoringPostgresQueryCancel(t, inv.WithContext(ctx))
// Wait for server to listen on HTTP, this is a good
// starting point for expecting logs.
_ = pty.ExpectMatchContext(ctx, "Started HTTP listener at")
_ = stdout.ExpectMatchContext(ctx, "Started HTTP listener at")
loggingWaitFile(t, fi, testutil.WaitSuperLong)
})
@@ -2056,15 +2049,15 @@ func TestServer_Logging_NoParallel(t *testing.T) {
"--log-json", fi2,
"--log-stackdriver", fi3,
)
// Attach pty so we get debug output from the command if this test
// Attach expecter so we get debug output from the command if this test
// fails.
pty := ptytest.New(t).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
startIgnoringPostgresQueryCancel(t, inv)
// Wait for server to listen on HTTP, this is a good
// starting point for expecting logs.
_ = pty.ExpectMatchContext(ctx, "Started HTTP listener at")
_ = stdout.ExpectMatchContext(ctx, "Started HTTP listener at")
loggingWaitFile(t, fi1, testutil.WaitSuperLong)
loggingWaitFile(t, fi2, testutil.WaitSuperLong)
@@ -2258,7 +2251,7 @@ func TestServer_GracefulShutdown(t *testing.T) {
return ctx, stopFunc
})
serverErr := make(chan error, 1)
pty := ptytest.New(t).Attach(root)
stdout := expecter.NewAttachedToInvocation(t, root)
go func() {
serverErr <- root.WithContext(ctx).Run()
}()
@@ -2266,7 +2259,7 @@ func TestServer_GracefulShutdown(t *testing.T) {
// It's fair to assume `stopFunc` isn't nil here, because the server
// has started and access URL is propagated.
stopFunc()
pty.ExpectMatch("waiting for provisioner jobs to complete")
stdout.ExpectMatchContext(ctx, "waiting for provisioner jobs to complete")
err := <-serverErr
require.NoError(t, err)
}
@@ -2501,19 +2494,19 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
inv.Logger = inv.Logger.Named(opts.name)
errChan := make(chan error, 1)
pty := ptytest.New(t).Named(opts.name).Attach(inv)
stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
errChan <- inv.WithContext(ctx).Run()
// close the pty here so that we can start tearing down resources. This test creates multiple servers with
// associated ptys. There is a `t.Cleanup()` that does this, but it waits until the whole test is complete.
_ = pty.Close()
stdout.Close("invocation complete")
}()
if opts.waitForSnapshot {
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "submitted snapshot")
stdout.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "submitted snapshot")
}
if opts.waitForTelemetryDisabledCheck {
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "finished telemetry status check")
stdout.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "finished telemetry status check")
}
return errChan, cancelFunc
}
+51 -12
View File
@@ -237,7 +237,10 @@ func Test_TaskSend(t *testing.T) {
t.Parallel()
// Given: An initializing task (workspace running, no agent
// connected).
// connected). Close the agent, pause, then resume so the
// workspace is started but no agent is connected. The
// command enters waitForTaskIdle directly (initializing
// path), where we verify it handles an external pause.
setupCtx := testutil.Context(t, testutil.WaitLong)
setup := setupCLITaskTest(setupCtx, t, nil)
@@ -245,8 +248,13 @@ func Test_TaskSend(t *testing.T) {
pauseTask(setupCtx, t, setup.userClient, setup.task)
resumeTask(setupCtx, t, setup.userClient, setup.task)
// Set up mock clock and traps before starting the command.
mClock := quartz.NewMock(t)
tickTrap := mClock.Trap().NewTicker("task_send", "poll")
resetTrap := mClock.Trap().TickerReset("task_send", "poll")
// When: We attempt to send input to the initializing task.
inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input")
inv, root := clitest.NewWithClock(t, mClock, "task", "send", setup.task.Name, "some task input")
clitest.SetupConfig(t, setup.userClient, root)
ctx := testutil.Context(t, testutil.WaitLong)
@@ -259,11 +267,34 @@ func Test_TaskSend(t *testing.T) {
// of waitForTaskIdle.
pty.ExpectMatchContext(ctx, "Waiting for task to become idle")
// Pause the task while waitForTaskIdle is polling. Since
// no agent is connected, the task stays initializing until
// we pause it, at which point the status becomes paused.
// Wait for ticker creation and release it.
tickCall := tickTrap.MustWait(ctx)
tickCall.MustRelease(ctx)
tickTrap.Close()
// Fire the first poll. The goroutine calls ticker.Reset
// which the trap catches, freezing the goroutine BEFORE
// client.TaskByID runs. Release it so the first poll
// sees 'initializing' and continues.
mClock.Advance(time.Nanosecond).MustWait(ctx)
resetCall := resetTrap.MustWait(ctx)
resetCall.MustRelease(ctx)
// Fire the second poll. The goroutine is again frozen at
// ticker.Reset by the trap.
mClock.Advance(5 * time.Second).MustWait(ctx)
resetCall = resetTrap.MustWait(ctx)
// While the goroutine is frozen (before client.TaskByID),
// pause the task. The stop build completes, so the DB has
// (stop, succeeded) = 'paused'.
pauseTask(ctx, t, setup.userClient, setup.task)
// Release the trap. The goroutine unfreezes and
// client.TaskByID deterministically sees 'paused'.
resetCall.MustRelease(ctx)
resetTrap.Close()
// Then: The command should fail because the task was paused.
err := w.Wait()
require.Error(t, err)
@@ -303,23 +334,31 @@ func Test_TaskSend(t *testing.T) {
tickCall.MustRelease(ctx)
tickTrap.Close()
// Fire the immediate first poll (time.Nanosecond initial interval).
// Fire the first poll. The goroutine calls ticker.Reset
// which the trap catches, freezing the goroutine BEFORE
// client.TaskByID runs. Release it so the first poll
// sees "working" and continues.
mClock.Advance(time.Nanosecond).MustWait(ctx)
// Wait for Reset (confirms first poll completed and saw "working").
resetCall := resetTrap.MustWait(ctx)
resetCall.MustRelease(ctx)
resetTrap.Close()
// Transition the app back to idle so waitForTaskIdle proceeds.
// Fire the second poll. The goroutine is again frozen
// at ticker.Reset by the trap.
mClock.Advance(5 * time.Second).MustWait(ctx)
resetCall = resetTrap.MustWait(ctx)
// While the goroutine is frozen (before client.TaskByID),
// transition the app to idle.
require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
AppSlug: "task-sidebar",
State: codersdk.WorkspaceAppStatusStateIdle,
Message: "ready",
}))
// Fire second poll at the regular 5s interval.
mClock.Advance(5 * time.Second).MustWait(ctx)
// Release the trap. The goroutine unfreezes and
// client.TaskByID deterministically sees "idle".
resetCall.MustRelease(ctx)
resetTrap.Close()
// Then: The command should complete successfully.
require.NoError(t, w.Wait())
+11 -8
View File
@@ -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.
+52 -19
View File
@@ -116,10 +116,21 @@ func SeedAIProvidersFromEnv(
if err != nil {
return xerrors.Errorf("decode existing settings for %q: %w", dp.Name, err)
}
// Load existing bearer keys so the canonical hash
// includes credentials for comparison.
existingKeyRows, err := tx.GetAIProviderKeysByProviderID(sysCtx, existing.ID)
if err != nil {
return xerrors.Errorf("load existing keys for %q: %w", dp.Name, err)
}
existingKeys := make([]string, 0, len(existingKeyRows))
for _, k := range existingKeyRows {
existingKeys = append(existingKeys, k.APIKey)
}
existingDP := desiredAIProvider{
Type: existing.Type,
BaseURL: existing.BaseUrl,
Bedrock: existingSettings.Bedrock,
Keys: existingKeys,
}
existingHash := computeProviderHash(existingDP.canonical())
if existingHash == dp.Hash {
@@ -196,18 +207,15 @@ func SeedAIProvidersFromEnv(
// canonicalAIProvider is the shape we hash to detect drift between the
// configured environment and the row stored in the database. The fields
// we hash are exactly the operator-controllable inputs that affect
// runtime behavior. Credentials are intentionally NOT part of the hash
// so operators can rotate them via the API without forcing a server
// restart. This applies to both bearer API keys (stored in
// ai_provider_keys) and to Bedrock access key/secret pairs (stored in
// the settings blob because Bedrock authenticates via settings rather
// than a bearer token).
// runtime behavior, including credentials.
//
// Model and SmallFastModel are excluded: they're tunables, and their
// serpent defaults shift across releases.
type canonicalAIProvider struct {
Type string `json:"type"`
BaseURL string `json:"base_url"`
BedrockRegion string `json:"bedrock_region"`
KeysHash string `json:"keys_hash"`
}
// desiredAIProvider is a normalized provider description sourced from
@@ -235,9 +243,39 @@ func (d desiredAIProvider) canonical() canonicalAIProvider {
if d.Bedrock != nil {
c.BedrockRegion = d.Bedrock.Region
}
c.KeysHash = computeKeysHash(d.Keys, d.Bedrock)
return c
}
// computeKeysHash produces a deterministic hash over the bearer API
// keys and, for Bedrock providers, the access key and secret.
func computeKeysHash(bearerKeys []string, bedrock *codersdk.AIProviderBedrockSettings) string {
// Collect all credential material in a deterministic order.
// Bearer keys are sorted so reordering in env vars does not
// trigger a false-positive drift.
sorted := make([]string, len(bearerKeys))
copy(sorted, bearerKeys)
slices.Sort(sorted)
h := sha256.New()
for _, k := range sorted {
_, _ = h.Write([]byte(k))
// Separator so "ab"+"c" != "a"+"bc".
_, _ = h.Write([]byte{0})
}
if bedrock != nil {
if bedrock.AccessKey != nil {
_, _ = h.Write([]byte(*bedrock.AccessKey))
}
_, _ = h.Write([]byte{0})
if bedrock.AccessKeySecret != nil {
_, _ = h.Write([]byte(*bedrock.AccessKeySecret))
}
_, _ = h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
func computeProviderHash(c canonicalAIProvider) string {
// json.Marshal is deterministic for structs because field order is
// fixed by the struct definition.
@@ -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]
+119 -26
View File
@@ -91,21 +91,23 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
}
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
// Changing the API key alone does NOT count as drift: keys
// live in a separate table and operators rotate them via the
// API. Only changes to non-credential provider-level fields
// (base_url, type, Bedrock region/model) trip the drift check.
// Changing the API key counts as drift: keys are included
// in the canonical hash so operators notice when env-var
// credential changes are ignored by an existing provider.
cfg.LegacyOpenAI.Key = serpent.String("sk-rotated")
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
// Changing the base URL is real drift.
cfg.LegacyOpenAI.BaseURL = serpent.String("https://api.openai.com/v2")
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
require.Error(t, err)
require.Contains(t, err.Error(), "differs from the current environment configuration")
// Changing the base URL is also real drift.
cfg.LegacyOpenAI.Key = serpent.String("sk-original")
cfg.LegacyOpenAI.BaseURL = serpent.String("https://api.openai.com/v2")
err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
require.Error(t, err)
require.Contains(t, err.Error(), "differs from the current environment configuration")
})
t.Run("BedrockCredentialRotationIsNotDrift", func(t *testing.T) {
t.Run("BedrockCredentialChangeIsDrift", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
@@ -120,17 +122,20 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
}
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
// Rotating the Bedrock access key and secret in env must NOT
// trip the drift check: they're credentials, equivalent to
// bearer API keys, and operators rotate them via the API.
// Rotating the Bedrock access key in env trips the drift
// check so operators know the change did not take effect.
cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-rotated")
cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-rotated")
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
require.Error(t, err)
require.Contains(t, err.Error(), "differs from the current environment configuration")
// Changing the Bedrock region (a non-credential field) is
// real drift.
// also real drift.
cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-original")
cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-original")
cfg.LegacyBedrock.Region = serpent.String("us-west-2")
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
require.Error(t, err)
require.Contains(t, err.Error(), "differs from the current environment configuration")
})
@@ -293,6 +298,57 @@ func TestSeedAIProvidersFromEnv(t *testing.T) {
require.Equal(t, "sk-ant-1", anKeys[0].APIKey)
})
t.Run("IndexedProvidersKeyDriftWithMultipleKeysAndProviders", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
cfg := codersdk.AIBridgeConfig{
Providers: []codersdk.AIProviderConfig{
{
Type: "openai",
Name: "primary-openai",
BaseURL: "https://api.openai.com/v1",
Keys: []string{"sk-openai-1", "sk-openai-2"},
},
{
Type: "anthropic",
Name: "primary-anthropic",
BaseURL: "https://api.anthropic.com/",
Keys: []string{"sk-ant-1", "sk-ant-2"},
},
},
}
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
// Reordering keys must not count as drift. The canonical hash
// sorts keys before hashing, so equivalent key sets remain
// stable across restarts.
cfg.Providers[0].Keys = []string{"sk-openai-2", "sk-openai-1"}
cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-1"}
require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)))
// Changing one key on one provider must block startup even
// when multiple providers are configured.
cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-rotated"}
err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))
require.Error(t, err)
require.Contains(t, err.Error(), "differs from the current environment configuration")
require.Contains(t, err.Error(), `"primary-anthropic"`)
oa, err := db.GetAIProviderByName(ctx, "primary-openai")
require.NoError(t, err)
oaKeys, err := db.GetAIProviderKeysByProviderID(ctx, oa.ID)
require.NoError(t, err)
require.ElementsMatch(t, []string{"sk-openai-1", "sk-openai-2"}, []string{oaKeys[0].APIKey, oaKeys[1].APIKey})
an, err := db.GetAIProviderByName(ctx, "primary-anthropic")
require.NoError(t, err)
anKeys, err := db.GetAIProviderKeysByProviderID(ctx, an.ID)
require.NoError(t, err)
require.ElementsMatch(t, []string{"sk-ant-1", "sk-ant-2"}, []string{anKeys[0].APIKey, anKeys[1].APIKey})
})
t.Run("BedrockIndexedProviderHasNoKeys", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
@@ -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)
+94
View File
@@ -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)
}
}
+84
View File
@@ -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()
})
}
+5 -2
View File
@@ -30,7 +30,9 @@ const (
type Pooler interface {
Acquire(ctx context.Context, req Request, clientFn ClientFunc, mcpBootstrapper MCPProxyBuilder) (http.Handler, error)
// ReplaceProviders swaps the providers used to construct future
// RequestBridge instances and clears the cache.
// RequestBridge instances and clears the cache. Disabled providers
// must be included; the bridge serves a 503 sentinel on their
// routes.
ReplaceProviders(providers []aibridge.Provider)
Shutdown(ctx context.Context) error
}
@@ -53,7 +55,8 @@ var _ Pooler = &CachedBridgePool{}
type CachedBridgePool struct {
cache *ristretto.Cache[string, *aibridge.RequestBridge]
// providers is the live provider set used by new RequestBridge instances.
// providers is the live provider set used by new RequestBridge
// instances. Includes disabled providers.
providers atomic.Pointer[[]aibridge.Provider]
providerVersion atomic.Int64
logger slog.Logger
+239 -228
View File
@@ -216,8 +216,9 @@ type RecordInterceptionEndedRequest struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID.
EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID.
EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"`
CredentialHint string `protobuf:"bytes,3,opt,name=credential_hint,json=credentialHint,proto3" json:"credential_hint,omitempty"`
}
func (x *RecordInterceptionEndedRequest) Reset() {
@@ -266,6 +267,13 @@ func (x *RecordInterceptionEndedRequest) GetEndedAt() *timestamppb.Timestamp {
return nil
}
func (x *RecordInterceptionEndedRequest) GetCredentialHint() string {
if x != nil {
return x.CredentialHint
}
return ""
}
type RecordInterceptionEndedResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -1295,249 +1303,252 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{
0x42, 0x14, 0x0a, 0x12, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x73, 0x73,
0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x22, 0x67, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e,
0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x5f,
0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73,
0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x22, 0x21, 0x0a,
0x1f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f,
0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c,
0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12,
0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73,
0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f,
0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52,
0x6f, 0x6e, 0x73, 0x65, 0x22, 0x90, 0x01, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49,
0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64,
0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65,
0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x12, 0x27,
0x0a, 0x0f, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x68, 0x69, 0x6e,
0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74,
0x69, 0x61, 0x6c, 0x48, 0x69, 0x6e, 0x74, 0x22, 0x21, 0x0a, 0x1f, 0x52, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64,
0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52,
0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45,
0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39,
0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09,
0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63,
0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f,
0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68,
0x65, 0x52, 0x65, 0x61, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73,
0x12, 0x37, 0x0a, 0x18, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f,
0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01,
0x28, 0x03, 0x52, 0x15, 0x63, 0x61, 0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e,
0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74,
0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05,
0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e,
0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63,
0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e,
0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15,
0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18,
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a,
0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72,
0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63,
0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12,
0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f,
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e,
0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74,
0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48,
0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54,
0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08,
0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61,
0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54,
0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65,
0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45,
0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61,
0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07,
0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68, 0x65, 0x52, 0x65, 0x61, 0x64, 0x49,
0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x63, 0x61,
0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f,
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x63, 0x61,
0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b,
0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45,
0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c,
0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x22, 0x8f, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f,
0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27,
0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69,
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22,
0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01,
0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88,
0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18,
0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08,
0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08,
0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f,
0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01,
0x28, 0x09, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x45, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61,
0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61,
0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18,
0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c,
0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51,
0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,
0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38,
0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c,
0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54,
0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x22, 0xb8, 0x02, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c,
0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27,
0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65,
0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e,
0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f,
0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e,
0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a,
0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63,
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61,
0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f,
0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63,
0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f,
0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12,
0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64,
0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73,
0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64,
0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61,
0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74,
0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a,
0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01,
0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74,
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x8f, 0x04,
0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67,
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65,
0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49,
0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76,
0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09,
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04,
0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c,
0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74,
0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74,
0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0f,
0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x88,
0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08,
0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63,
0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72,
0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63,
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65,
0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63,
0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f,
0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61,
0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76,
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f,
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79,
0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52,
0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f,
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69,
0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22,
0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61,
0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x02, 0x0a, 0x19, 0x52,
0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68,
0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47, 0x65, 0x74,
0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64,
0x22, 0xb2, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x12, 0x51, 0x0a, 0x19, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61,
0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18,
0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43,
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65,
0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, 0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72,
0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74,
0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18,
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77,
0x52, 0x65, 0x67, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65,
0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d,
0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a,
0x24, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63,
0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31,
0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d,
0x63, 0x70, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64,
0x73, 0x22, 0xda, 0x02, 0x0a, 0x25, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76,
0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61,
0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61,
0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x3e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43,
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b,
0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74,
0x72, 0x79, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73,
0x12, 0x50, 0x0a, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x38, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45,
0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f,
0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65,
0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74,
0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3e,
0x0a, 0x13, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69,
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x6b,
0x0a, 0x14, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49,
0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12,
0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f,
0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20,
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74,
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49,
0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74,
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25,
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74,
0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65,
0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e,
0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a,
0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67,
0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d,
0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67,
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61,
0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65,
0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c,
0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55,
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67,
0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63,
0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47,
0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65,
0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49,
0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d,
0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65,
0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e,
0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d,
0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74,
0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69,
0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e,
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d,
0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72,
0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xb2, 0x01, 0x0a, 0x1b, 0x47,
0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43,
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65,
0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74,
0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54,
0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41,
0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50,
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x51, 0x0a, 0x19,
0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63,
0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61,
0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22,
0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c,
0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x67, 0x65, 0x78, 0x12,
0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67,
0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65,
0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a, 0x24, 0x47, 0x65, 0x74, 0x4d, 0x43,
0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b,
0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31, 0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f,
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x69, 0x64,
0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d, 0x63, 0x70, 0x53, 0x65, 0x72, 0x76,
0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64, 0x73, 0x22, 0xda, 0x02, 0x0a, 0x25,
0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65,
0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75,
0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72,
0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30,
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64,
0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f,
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3e, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74,
0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73,
0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x61, 0x63,
0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x50, 0x0a, 0x06, 0x65, 0x72,
0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41,
0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45,
0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11,
0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72,
0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a,
0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03,
0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14,
0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76,
0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3e, 0x0a, 0x13, 0x49, 0x73, 0x41, 0x75,
0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x6b, 0x0a, 0x14, 0x49, 0x73, 0x41, 0x75,
0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61,
0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65,
0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65,
0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65,
0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a,
0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74,
0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e,
0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55,
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55,
0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67,
0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f,
0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52,
0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c,
0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21,
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64,
0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75,
0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65,
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65,
0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72,
0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42,
0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74,
0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54,
0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50,
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65,
0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32,
0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a,
0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a,
0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62,
0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69,
0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x33,
}
var (
+1
View File
@@ -58,6 +58,7 @@ message RecordInterceptionResponse {}
message RecordInterceptionEndedRequest {
string id = 1; // UUID.
google.protobuf.Timestamp ended_at = 2;
string credential_hint = 3;
}
message RecordInterceptionEndedResponse {}
+28
View File
@@ -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
}
+3 -2
View File
@@ -45,8 +45,9 @@ func (t *recorderTranslation) RecordInterception(ctx context.Context, req *aibri
func (t *recorderTranslation) RecordInterceptionEnded(ctx context.Context, req *aibridge.InterceptionRecordEnded) error {
_, err := t.client.RecordInterceptionEnded(ctx, &proto.RecordInterceptionEndedRequest{
Id: req.ID,
EndedAt: timestamppb.New(req.EndedAt),
Id: req.ID,
EndedAt: timestamppb.New(req.EndedAt),
CredentialHint: req.CredentialHint,
})
return err
}
+3 -2
View File
@@ -222,8 +222,9 @@ func (s *Server) RecordInterceptionEnded(ctx context.Context, in *proto.RecordIn
}
_, err = s.store.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{
ID: intcID,
EndedAt: in.EndedAt.AsTime(),
ID: intcID,
EndedAt: in.EndedAt.AsTime(),
CredentialHint: in.CredentialHint,
})
if err != nil {
return nil, xerrors.Errorf("end interception: %w", err)
+13 -10
View File
@@ -944,23 +944,26 @@ func TestRecordInterceptionEnded(t *testing.T) {
{
name: "ok",
request: &proto.RecordInterceptionEndedRequest{
Id: uuid.UUID{1}.String(),
EndedAt: timestamppb.Now(),
Id: uuid.UUID{1}.String(),
EndedAt: timestamppb.Now(),
CredentialHint: "sk-a...efgh",
},
setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionEndedRequest) {
interceptionID, err := uuid.Parse(req.GetId())
assert.NoError(t, err, "parse interception UUID")
db.EXPECT().UpdateAIBridgeInterceptionEnded(gomock.Any(), database.UpdateAIBridgeInterceptionEndedParams{
ID: interceptionID,
EndedAt: req.EndedAt.AsTime(),
ID: interceptionID,
EndedAt: req.EndedAt.AsTime(),
CredentialHint: req.CredentialHint,
}).Return(database.AIBridgeInterception{
ID: interceptionID,
InitiatorID: uuid.UUID{2},
Provider: "prov",
Model: "mod",
StartedAt: time.Now(),
EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true},
ID: interceptionID,
InitiatorID: uuid.UUID{2},
Provider: "prov",
Model: "mod",
StartedAt: time.Now(),
EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true},
CredentialHint: req.CredentialHint,
}, nil)
},
},
+234 -72
View File
@@ -9171,6 +9171,110 @@ const docTemplate = `{
]
}
},
"/api/v2/users/{user}/ai/budget": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get user AI budget override",
"operationId": "get-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Upsert user AI budget override",
"operationId": "upsert-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"description": "Upsert user AI budget override request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"delete": {
"tags": [
"Enterprise"
],
"summary": "Delete user AI budget override",
"operationId": "delete-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/users/{user}/appearance": {
"get": {
"produces": [
@@ -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": {
+220 -72
View File
@@ -8132,6 +8132,98 @@
]
}
},
"/api/v2/users/{user}/ai/budget": {
"get": {
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get user AI budget override",
"operationId": "get-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"put": {
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Upsert user AI budget override",
"operationId": "upsert-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"description": "Upsert user AI budget override request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserAIBudgetOverride"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"delete": {
"tags": ["Enterprise"],
"summary": "Delete user AI budget override",
"operationId": "delete-user-ai-budget-override",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/users/{user}/appearance": {
"get": {
"produces": ["application/json"],
@@ -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": {
+56
View File
@@ -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:
+103
View File
@@ -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,
},
}
}
+17
View File
@@ -422,6 +422,23 @@ func (e *Executor) runOnce(t time.Time) Stats {
Isolation: sql.LevelRepeatableRead,
TxIdentifier: "lifecycle",
})
// A concurrent build (e.g. from the API or another lifecycle
// executor) may have already inserted a build with the same
// number. This is a benign race; the other actor's build
// will take effect. Clear the error so downstream checks
// (audit, notification, stats) treat this as a no-op.
if database.IsUniqueViolation(err, database.UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey) {
log.Info(e.ctx, "skipping workspace: concurrent build already inserted", slog.Error(err))
err = nil
// Reset notification flags set before builder.Build.
// The build was rolled back, so this executor did not
// perform the transition. The concurrent actor handles
// both the build and any notifications. Without these
// resets, downstream code would send duplicate or
// incorrect notifications.
didAutoUpdate = false
shouldNotifyTaskPause = false
}
if auditLog != nil {
// If the transition didn't succeed then updating the workspace
// to indicate dormant didn't either.
@@ -4,10 +4,12 @@ import (
"context"
"database/sql"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
@@ -160,6 +162,92 @@ func TestMultipleLifecycleExecutors(t *testing.T) {
assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID])
}
// uniqueViolationStore wraps a database.Store and injects a unique violation
// error from InsertWorkspaceBuild after a configurable number of successful
// calls. This simulates a concurrent build race (e.g. an API-driven start
// racing with the lifecycle executor autostart).
type uniqueViolationStore struct {
database.Store
insertCount *atomic.Int32 // pointer: shared across InTx copies
failAfterN int32
}
func newUniqueViolationStore(db database.Store, failAfterN int32) *uniqueViolationStore {
return &uniqueViolationStore{
Store: db,
insertCount: &atomic.Int32{},
failAfterN: failAfterN,
}
}
func (s *uniqueViolationStore) InTx(fn func(database.Store) error, opts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return fn(&uniqueViolationStore{
Store: tx,
insertCount: s.insertCount, // shared pointer
failAfterN: s.failAfterN,
})
}, opts)
}
func (s *uniqueViolationStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
n := s.insertCount.Add(1)
if n > s.failAfterN {
return &pq.Error{
Code: pq.ErrorCode("23505"),
Constraint: string(database.UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey),
Message: `duplicate key value violates unique constraint "workspace_builds_workspace_id_build_number_key"`,
}
}
return s.Store.InsertWorkspaceBuild(ctx, arg)
}
func TestExecutorBuildNumberRaceIsHandled(t *testing.T) {
t.Parallel()
// The lifecycle executor must handle a unique-violation from
// InsertWorkspaceBuild gracefully. This error occurs when a concurrent
// actor (API handler, another executor, prebuilds reconciler) inserts a
// build with the same number before the executor's INSERT lands.
//
// We inject the error via a store wrapper. The first two
// InsertWorkspaceBuild calls succeed (setup builds), then the third
// (the lifecycle executor's autostart build) gets a unique violation.
realDB, ps := dbtestutil.NewDB(t)
wrappedDB := newUniqueViolationStore(realDB, 2) // Allow builds 1 (start) and 2 (stop); fail build 3 (autostart)
var (
sched, _ = cron.Weekly("CRON_TZ=UTC 0 * * * *")
tickCh = make(chan time.Time)
statsCh = make(chan autobuild.Stats)
client = coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
AutobuildTicker: tickCh,
AutobuildStats: statsCh,
Database: wrappedDB,
Pubsub: ps,
})
workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutostartSchedule = ptr.Ref(sched.String())
})
)
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
p, err := coderdtest.GetProvisionerForTags(realDB, time.Now(), workspace.OrganizationID, nil)
require.NoError(t, err)
next := sched.Next(workspace.LatestBuild.CreatedAt)
coderdtest.UpdateProvisionerLastSeenAt(t, realDB, p.ID, next)
tickCh <- next
stats := <-statsCh
// The lifecycle executor should treat the unique violation as a benign
// race, not as a hard error.
assert.Empty(t, stats.Errors, "lifecycle executor should not report unique-violation as error")
}
func TestExecutorAutostartTemplateUpdated(t *testing.T) {
t.Parallel()
+1
View File
@@ -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
+10
View File
@@ -1509,6 +1509,16 @@ func GroupAIBudget(b database.GroupAiBudget) codersdk.GroupAIBudget {
}
}
func UserAIBudgetOverride(o database.UserAiBudgetOverride) codersdk.UserAIBudgetOverride {
return codersdk.UserAIBudgetOverride{
UserID: o.UserID,
GroupID: o.GroupID,
SpendLimitMicros: o.SpendLimitMicros,
CreatedAt: o.CreatedAt,
UpdatedAt: o.UpdatedAt,
}
}
func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidatedAtRow) []codersdk.InvalidatedPreset {
var presets []codersdk.InvalidatedPreset
for _, p := range invalidatedPresets {
+110 -14
View File
@@ -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 {
+65 -15
View File
@@ -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()
+49 -15
View File
@@ -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
View File
@@ -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)
+52 -7
View File
@@ -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()
+89 -2
View File
@@ -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
View File
@@ -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();
@@ -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