Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2970c54140 | |||
| 26e3da1f17 | |||
| b49c4b3257 | |||
| 55da992aeb | |||
| 613029cb21 | |||
| 7e0cf53dd1 | |||
| fa050ee0ab | |||
| bfb6583ecc | |||
| 40b3970388 | |||
| fa284dc149 | |||
| b89dc439b7 | |||
| d4ce9620d6 | |||
| 16408b157b | |||
| ef29702014 | |||
| 43e67d12e2 | |||
| 94cf95a3e8 | |||
| 5e2f845272 | |||
| 3d5dc93060 | |||
| 6e1fe14d6c | |||
| c0b939f7e4 | |||
| 1fd77bc459 | |||
| 37c3476ca7 | |||
| 26a3f82a39 | |||
| ea6b11472c | |||
| a92dc3d5b3 | |||
| a69aea2c83 | |||
| c2db391019 | |||
| 895cc07395 | |||
| 0377c985e4 | |||
| 7090a1e205 | |||
| f358a6db11 | |||
| 2204731ddb | |||
| d7037280da | |||
| 799b190dee | |||
| 3eeeabfd68 | |||
| 7dfa33b410 | |||
| e008f720b6 | |||
| d4cd982608 | |||
| 3ee4f6d0ec | |||
| c352a51b22 | |||
| 2ee3386cc5 | |||
| 8f3bb0b0d1 | |||
| b1267c458c | |||
| a5c06a3751 | |||
| 7b44976618 | |||
| c3f41ce08c | |||
| 6f15b178a4 | |||
| 1375fd9ead | |||
| 7546e94534 | |||
| 59b2afaa80 | |||
| 303389e75a | |||
| 25d7f27cdb | |||
| f2e998848e | |||
| d2e54819bf | |||
| 806d7e4c11 | |||
| 7123518baa | |||
| bb186b8699 | |||
| bbca7f546c | |||
| 4bff2f7296 | |||
| c3cd3614e4 | |||
| 612aae2523 | |||
| 49f135bcd4 | |||
| f47f89d997 | |||
| 78bc5861e0 | |||
| 0d21365825 | |||
| 409360c62d | |||
| 6c8209bdf1 | |||
| ece531ab4e | |||
| 15c61906e2 | |||
| 8d6822b23a | |||
| 98834a7837 | |||
| 338b952d71 | |||
| 9b14fd3adc | |||
| b82693d4cc | |||
| f5858c8a18 | |||
| 9843adb8c6 | |||
| fa7baebdd8 | |||
| 3398833919 | |||
| 365ab0e609 | |||
| 7c948a7ad8 | |||
| e195856c43 | |||
| 6a81474ff0 | |||
| 6c49938fca | |||
| e1282b6904 | |||
| 57cc50c703 | |||
| d29a168785 | |||
| 859099f1f2 | |||
| 6d8e6d4830 | |||
| 4c7844ad3d | |||
| 5dcc9dd8ab | |||
| fdd928e01c | |||
| f0152e291a | |||
| 26ce070393 | |||
| e78d89620b | |||
| 1dd0519a38 | |||
| 47b3846bca | |||
| 3e29eec560 | |||
| 1b03202e90 | |||
| f799cba395 | |||
| 408a35a961 | |||
| 97e8a5b093 | |||
| a14a22eb54 | |||
| 6ef9670384 | |||
| 2132c53f28 | |||
| 59b71f296f | |||
| 0ac05b4144 | |||
| 6346eb7af8 | |||
| 09f50046cb | |||
| ed679bb3da | |||
| bfae5b03dc | |||
| ca2e728fcb | |||
| 12a6a9b5f0 | |||
| b163b4c950 | |||
| 9776dc16bd | |||
| e79f1d0406 | |||
| 2bfd54dfdb | |||
| 8db1e0481a | |||
| 31654deb87 | |||
| 25ac3dbab8 | |||
| a002fbbae6 | |||
| 08343a7a9f | |||
| d176714f90 | |||
| 34c7fe2eaf | |||
| a406ed7cc5 | |||
| 1813605012 | |||
| a4e14448c2 | |||
| 4d414a0df7 | |||
| ff9ed91811 | |||
| ea465d4ea3 | |||
| fe68ec9095 | |||
| ab126e0f0a | |||
| ad23ea3561 | |||
| 3b07f7b9c4 | |||
| 11b35a5f94 | |||
| 170fbcdb14 | |||
| 3db5558603 | |||
| 61961db41d | |||
| d2d7c0ee40 | |||
| d25d95231f | |||
| 3a62a8e70e | |||
| 7fc84ecf0b | |||
| 0ebe8e57ad | |||
| 3894edbcc3 | |||
| d5296a4855 | |||
| 5073493850 | |||
| 32354261d3 | |||
| 6683d807ac | |||
| 7c2479ce92 | |||
| e1156b050f | |||
| 0712faef4f | |||
| 7d5cd06f83 |
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: doc-check
|
||||
description: Checks if code changes require documentation updates
|
||||
---
|
||||
|
||||
# Documentation Check Skill
|
||||
|
||||
Review code changes and determine if documentation updates or new documentation
|
||||
is needed.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Get the code changes** - Use the method provided in the prompt, or if none
|
||||
specified:
|
||||
- For a PR: `gh pr diff <PR_NUMBER> --repo coder/coder`
|
||||
- For local changes: `git diff main` or `git diff --staged`
|
||||
- For a branch: `git diff main...<branch>`
|
||||
|
||||
2. **Understand the scope** - Consider what changed:
|
||||
- Is this user-facing or internal?
|
||||
- Does it change behavior, APIs, CLI flags, or configuration?
|
||||
- Even for "internal" or "chore" changes, always verify the actual diff
|
||||
|
||||
3. **Search the docs** for related content in `docs/`
|
||||
|
||||
4. **Decide what's needed**:
|
||||
- Do existing docs need updates to match the code?
|
||||
- Is new documentation needed for undocumented features?
|
||||
- Or is everything already covered?
|
||||
|
||||
5. **Report findings** - Use the method provided in the prompt, or if none
|
||||
specified, summarize findings directly
|
||||
|
||||
## What to Check
|
||||
|
||||
- **Accuracy**: Does documentation match current code behavior?
|
||||
- **Completeness**: Are new features/options documented?
|
||||
- **Examples**: Do code examples still work?
|
||||
- **CLI/API changes**: Are new flags, endpoints, or options documented?
|
||||
- **Configuration**: Are new environment variables or settings documented?
|
||||
- **Breaking changes**: Are migration steps documented if needed?
|
||||
- **Premium features**: Should docs indicate `(Premium)` in the title?
|
||||
|
||||
## Key Documentation Info
|
||||
|
||||
- **`docs/manifest.json`** - Navigation structure; new pages MUST be added here
|
||||
- **`docs/reference/cli/*.md`** - Auto-generated from Go code, don't edit directly
|
||||
- **Premium features** - H1 title should include `(Premium)` suffix
|
||||
|
||||
## Coder-Specific Patterns
|
||||
|
||||
### Callouts
|
||||
|
||||
Use GitHub-Flavored Markdown alerts:
|
||||
|
||||
```markdown
|
||||
> [!NOTE]
|
||||
> Additional helpful information.
|
||||
|
||||
> [!WARNING]
|
||||
> Important warning about potential issues.
|
||||
|
||||
> [!TIP]
|
||||
> Helpful tip for users.
|
||||
```
|
||||
|
||||
### CLI Documentation
|
||||
|
||||
CLI docs in `docs/reference/cli/` are auto-generated. Don't suggest editing them
|
||||
directly. Instead, changes should be made in the Go code that defines the CLI
|
||||
commands (typically in `cli/` directory).
|
||||
|
||||
### Code Examples
|
||||
|
||||
Use `sh` for shell commands:
|
||||
|
||||
```sh
|
||||
coder server --flag-name value
|
||||
```
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Start Docker service if not already running.
|
||||
sudo service docker start
|
||||
sudo service docker status >/dev/null 2>&1 || sudo service docker start
|
||||
|
||||
@@ -4,7 +4,7 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.24.10"
|
||||
default: "1.25.7"
|
||||
use-preinstalled-go:
|
||||
description: "Whether to use preinstalled Go."
|
||||
default: "false"
|
||||
|
||||
@@ -7,5 +7,5 @@ runs:
|
||||
- name: Install Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
with:
|
||||
terraform_version: 1.14.1
|
||||
terraform_version: 1.14.5
|
||||
terraform_wrapper: false
|
||||
|
||||
+36
-36
@@ -35,12 +35,12 @@ jobs:
|
||||
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
# runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# fetch-depth: 1
|
||||
# # See: https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#commits-made-by-this-action-do-not-trigger-new-workflow-runs
|
||||
@@ -157,12 +157,12 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
echo "LINT_CACHE_DIR=$dir" >> "$GITHUB_ENV"
|
||||
|
||||
- name: golangci-lint cache
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: |
|
||||
${{ env.LINT_CACHE_DIR }}
|
||||
@@ -251,12 +251,12 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -308,12 +308,12 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -360,7 +360,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -386,7 +386,7 @@ jobs:
|
||||
uses: coder/setup-ramdisk-action@e1100847ab2d7bcd9d14bcda8f2d1b0f07b36f1b # v0.1.0
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -554,12 +554,12 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -616,12 +616,12 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -688,12 +688,12 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -715,12 +715,12 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -748,12 +748,12 @@ jobs:
|
||||
name: ${{ matrix.variant.name }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -828,12 +828,12 @@ jobs:
|
||||
if: needs.changes.outputs.site == 'true' || needs.changes.outputs.ci == 'true'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# 👇 Ensures Chromatic can read your full git history
|
||||
fetch-depth: 0
|
||||
@@ -849,7 +849,7 @@ jobs:
|
||||
# the check to pass. This is desired in PRs, but not in mainline.
|
||||
- name: Publish to Chromatic (non-mainline)
|
||||
if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -881,7 +881,7 @@ jobs:
|
||||
# infinitely "in progress" in mainline unless we re-review each build.
|
||||
- name: Publish to Chromatic (mainline)
|
||||
if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -909,12 +909,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# 0 is required here for version.sh to work.
|
||||
fetch-depth: 0
|
||||
@@ -980,7 +980,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1018,7 +1018,7 @@ jobs:
|
||||
steps:
|
||||
# Harden Runner doesn't work on macOS
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -1100,12 +1100,12 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -1155,12 +1155,12 @@ jobs:
|
||||
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -1552,12 +1552,12 @@ jobs:
|
||||
if: needs.changes.outputs.db == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Checkout create-task-action
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
path: ./.github/actions/create-task-action
|
||||
|
||||
@@ -249,7 +249,7 @@ jobs:
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Checkout create-task-action
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
path: ./.github/actions/create-task-action
|
||||
|
||||
@@ -36,12 +36,12 @@ jobs:
|
||||
verdict: ${{ steps.check.outputs.verdict }} # DEPLOY or NOOP
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -65,12 +65,12 @@ jobs:
|
||||
packages: write # to retag image as dogfood
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -146,12 +146,12 @@ jobs:
|
||||
needs: deploy
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -2,14 +2,26 @@
|
||||
# It creates a Coder Task that uses AI to analyze the PR changes,
|
||||
# search existing docs, and comment with recommendations.
|
||||
#
|
||||
# Triggered by: Adding the "doc-check" label to a PR, or manual dispatch.
|
||||
# Triggers:
|
||||
# - New PR opened: Initial documentation review
|
||||
# - PR updated (synchronize): Re-review after changes
|
||||
# - Label "doc-check" added: Manual trigger for review
|
||||
# - PR marked ready for review: Review when draft is promoted
|
||||
# - Workflow dispatch: Manual run with PR URL
|
||||
#
|
||||
# Note: This workflow requires access to secrets and will be skipped for:
|
||||
# - Any PR where secrets are not available
|
||||
# For these PRs, maintainers can manually trigger via workflow_dispatch.
|
||||
|
||||
name: AI Documentation Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- labeled
|
||||
- ready_for_review
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_url:
|
||||
@@ -26,8 +38,16 @@ jobs:
|
||||
doc-check:
|
||||
name: Analyze PR for Documentation Updates Needed
|
||||
runs-on: ubuntu-latest
|
||||
# Run on: opened, synchronize, labeled (with doc-check label), ready_for_review, or workflow_dispatch
|
||||
# Skip draft PRs unless manually triggered
|
||||
if: |
|
||||
(github.event.label.name == 'doc-check' || github.event_name == 'workflow_dispatch') &&
|
||||
(
|
||||
github.event.action == 'opened' ||
|
||||
github.event.action == 'synchronize' ||
|
||||
github.event.label.name == 'doc-check' ||
|
||||
github.event.action == 'ready_for_review' ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
) &&
|
||||
(github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch')
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
@@ -39,120 +59,155 @@ jobs:
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Check if secrets are available
|
||||
id: check-secrets
|
||||
env:
|
||||
CODER_URL: ${{ secrets.DOC_CHECK_CODER_URL }}
|
||||
CODER_TOKEN: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }}
|
||||
run: |
|
||||
if [[ -z "${CODER_URL}" || -z "${CODER_TOKEN}" ]]; then
|
||||
echo "skip=true" >> "${GITHUB_OUTPUT}"
|
||||
echo "Secrets not available - skipping doc-check."
|
||||
echo "This is expected for PRs where secrets are not available."
|
||||
echo "Maintainers can manually trigger via workflow_dispatch if needed."
|
||||
{
|
||||
echo "⚠️ Workflow skipped: Secrets not available"
|
||||
echo ""
|
||||
echo "This workflow requires secrets that are unavailable for this run."
|
||||
echo "Maintainers can manually trigger via workflow_dispatch if needed."
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
else
|
||||
echo "skip=false" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
- name: Setup Coder CLI
|
||||
if: steps.check-secrets.outputs.skip != 'true'
|
||||
uses: coder/setup-action@4a607a8113d4e676e2d7c34caa20a814bc88bfda # v1
|
||||
with:
|
||||
access_url: ${{ secrets.DOC_CHECK_CODER_URL }}
|
||||
coder_session_token: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }}
|
||||
|
||||
- name: Determine PR Context
|
||||
if: steps.check-secrets.outputs.skip != 'true'
|
||||
id: determine-context
|
||||
env:
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_EVENT_ACTION: ${{ github.event.action }}
|
||||
GITHUB_EVENT_PR_HTML_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GITHUB_EVENT_SENDER_ID: ${{ github.event.sender.id }}
|
||||
GITHUB_EVENT_SENDER_LOGIN: ${{ github.event.sender.login }}
|
||||
INPUTS_PR_URL: ${{ inputs.pr_url }}
|
||||
INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || '' }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Using template preset: ${INPUTS_TEMPLATE_PRESET}"
|
||||
echo "template_preset=${INPUTS_TEMPLATE_PRESET}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# For workflow_dispatch, use the provided PR URL
|
||||
# Determine trigger type for task context
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
if ! GITHUB_USER_ID=$(gh api "users/${GITHUB_ACTOR}" --jq '.id'); then
|
||||
echo "::error::Failed to get GitHub user ID for actor ${GITHUB_ACTOR}"
|
||||
echo "trigger_type=manual" >> "${GITHUB_OUTPUT}"
|
||||
echo "Using PR URL: ${INPUTS_PR_URL}"
|
||||
|
||||
# Validate PR URL format
|
||||
if [[ ! "${INPUTS_PR_URL}" =~ ^https://github\.com/[^/]+/[^/]+/pull/[0-9]+$ ]]; then
|
||||
echo "::error::Invalid PR URL format: ${INPUTS_PR_URL}"
|
||||
echo "::error::Expected format: https://github.com/owner/repo/pull/NUMBER"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using workflow_dispatch actor: ${GITHUB_ACTOR} (ID: ${GITHUB_USER_ID})"
|
||||
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
|
||||
echo "github_username=${GITHUB_ACTOR}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using PR URL: ${INPUTS_PR_URL}"
|
||||
# Convert /pull/ to /issues/ for create-task-action compatibility
|
||||
ISSUE_URL="${INPUTS_PR_URL/\/pull\//\/issues\/}"
|
||||
echo "pr_url=${ISSUE_URL}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Extract PR number from URL for later use
|
||||
PR_NUMBER=$(echo "${INPUTS_PR_URL}" | grep -oP '(?<=pull/)\d+')
|
||||
echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
|
||||
GITHUB_USER_ID=${GITHUB_EVENT_SENDER_ID}
|
||||
echo "Using label adder: ${GITHUB_EVENT_SENDER_LOGIN} (ID: ${GITHUB_USER_ID})"
|
||||
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
|
||||
echo "github_username=${GITHUB_EVENT_SENDER_LOGIN}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using PR URL: ${GITHUB_EVENT_PR_HTML_URL}"
|
||||
# Convert /pull/ to /issues/ for create-task-action compatibility
|
||||
ISSUE_URL="${GITHUB_EVENT_PR_HTML_URL/\/pull\//\/issues\/}"
|
||||
echo "pr_url=${ISSUE_URL}" >> "${GITHUB_OUTPUT}"
|
||||
echo "pr_number=${GITHUB_EVENT_PR_NUMBER}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Set trigger type based on action
|
||||
case "${GITHUB_EVENT_ACTION}" in
|
||||
opened)
|
||||
echo "trigger_type=new_pr" >> "${GITHUB_OUTPUT}"
|
||||
;;
|
||||
synchronize)
|
||||
echo "trigger_type=pr_updated" >> "${GITHUB_OUTPUT}"
|
||||
;;
|
||||
labeled)
|
||||
echo "trigger_type=label_requested" >> "${GITHUB_OUTPUT}"
|
||||
;;
|
||||
ready_for_review)
|
||||
echo "trigger_type=ready_for_review" >> "${GITHUB_OUTPUT}"
|
||||
;;
|
||||
*)
|
||||
echo "trigger_type=unknown" >> "${GITHUB_OUTPUT}"
|
||||
;;
|
||||
esac
|
||||
|
||||
else
|
||||
echo "::error::Unsupported event type: ${GITHUB_EVENT_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Extract changed files and build prompt
|
||||
- name: Build task prompt
|
||||
if: steps.check-secrets.outputs.skip != 'true'
|
||||
id: extract-context
|
||||
env:
|
||||
PR_URL: ${{ steps.determine-context.outputs.pr_url }}
|
||||
PR_NUMBER: ${{ steps.determine-context.outputs.pr_number }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TRIGGER_TYPE: ${{ steps.determine-context.outputs.trigger_type }}
|
||||
run: |
|
||||
echo "Analyzing PR #${PR_NUMBER}"
|
||||
echo "Analyzing PR #${PR_NUMBER} (trigger: ${TRIGGER_TYPE})"
|
||||
|
||||
# Build task prompt - using unquoted heredoc so variables expand
|
||||
TASK_PROMPT=$(cat <<EOF
|
||||
Review PR #${PR_NUMBER} and determine if documentation needs updating or creating.
|
||||
# Build context based on trigger type
|
||||
case "${TRIGGER_TYPE}" in
|
||||
new_pr)
|
||||
CONTEXT="This is a NEW PR. Perform a thorough documentation review."
|
||||
;;
|
||||
pr_updated)
|
||||
CONTEXT="This PR was UPDATED with new commits. Only comment if the changes affect documentation needs or address previous feedback."
|
||||
;;
|
||||
label_requested)
|
||||
CONTEXT="A documentation review was REQUESTED via label. Perform a thorough documentation review."
|
||||
;;
|
||||
ready_for_review)
|
||||
CONTEXT="This PR was marked READY FOR REVIEW (converted from draft). Perform a thorough documentation review."
|
||||
;;
|
||||
manual)
|
||||
CONTEXT="This is a MANUAL review request. Perform a thorough documentation review."
|
||||
;;
|
||||
*)
|
||||
CONTEXT="Perform a thorough documentation review."
|
||||
;;
|
||||
esac
|
||||
|
||||
PR URL: ${PR_URL}
|
||||
# Build task prompt with PR-specific context
|
||||
TASK_PROMPT="Use the doc-check skill to review PR #${PR_NUMBER} in coder/coder.
|
||||
|
||||
WORKFLOW:
|
||||
1. Setup (repo is pre-cloned at ~/coder)
|
||||
cd ~/coder
|
||||
git fetch origin pull/${PR_NUMBER}/head:pr-${PR_NUMBER}
|
||||
git checkout pr-${PR_NUMBER}
|
||||
${CONTEXT}
|
||||
|
||||
2. Get PR info
|
||||
Use GitHub MCP tools to get PR title, body, and diff
|
||||
Or use: git diff main...pr-${PR_NUMBER}
|
||||
Use \`gh\` to get PR details, diff, and all comments. Check for previous doc-check comments (from coder-doc-check) and only post a new comment if it adds value.
|
||||
|
||||
3. Understand Changes
|
||||
Read the diff and identify what changed
|
||||
Ask: Is this user-facing? Does it change behavior? Is it a new feature?
|
||||
## Comment format
|
||||
|
||||
4. Search for Related Docs
|
||||
cat ~/coder/docs/manifest.json | jq '.routes[] | {title, path}' | head -50
|
||||
grep -ri "relevant_term" ~/coder/docs/ --include="*.md"
|
||||
Use this structure (only include relevant sections):
|
||||
|
||||
5. Decide
|
||||
NEEDS DOCS if: New feature, API change, CLI change, behavior change, user-visible
|
||||
NO DOCS if: Internal refactor, test-only, already documented, non-user-facing, dependency updates
|
||||
FIRST check: Did this PR already update docs? If yes and complete, say "No Changes Needed"
|
||||
\`\`\`
|
||||
## Documentation Check
|
||||
|
||||
6. Comment on the PR using this format
|
||||
### Previous Feedback
|
||||
[For re-reviews only: Addressed | Partially addressed | Not yet addressed]
|
||||
|
||||
COMMENT FORMAT:
|
||||
## 📚 Documentation Check
|
||||
### Updates Needed
|
||||
- [ ] \`docs/path/file.md\` - [what needs to change]
|
||||
|
||||
### ✅ Updates Needed
|
||||
- **[docs/path/file.md](github_link)** - Brief what needs changing
|
||||
### New Documentation Needed
|
||||
- [ ] \`docs/suggested/path.md\` - [what should be documented]
|
||||
|
||||
### 📝 New Docs Needed
|
||||
- **docs/suggested/location.md** - What should be documented
|
||||
|
||||
### ✨ No Changes Needed
|
||||
[Reason: Documents already updated in PR | Internal changes only | Test-only | No user-facing impact]
|
||||
### No Changes Needed
|
||||
[brief explanation - use this OR the above sections, not both]
|
||||
|
||||
---
|
||||
*This comment was generated by an AI Agent through [Coder Tasks](https://coder.com/docs/ai-coder/tasks)*
|
||||
|
||||
DOCS STRUCTURE:
|
||||
Read ~/coder/docs/manifest.json for the complete documentation structure.
|
||||
Common areas include: reference/, admin/, user-guides/, ai-coder/, install/, tutorials/
|
||||
But check manifest.json - it has everything.
|
||||
|
||||
EOF
|
||||
)
|
||||
*Automated review via [Coder Tasks](https://coder.com/docs/ai-coder/tasks)*
|
||||
\`\`\`"
|
||||
|
||||
# Output the prompt
|
||||
{
|
||||
@@ -162,7 +217,8 @@ jobs:
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Checkout create-task-action
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
if: steps.check-secrets.outputs.skip != 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
path: ./.github/actions/create-task-action
|
||||
@@ -171,22 +227,24 @@ jobs:
|
||||
repository: coder/create-task-action
|
||||
|
||||
- name: Create Coder Task for Documentation Check
|
||||
if: steps.check-secrets.outputs.skip != 'true'
|
||||
id: create_task
|
||||
uses: ./.github/actions/create-task-action
|
||||
with:
|
||||
coder-url: ${{ secrets.DOC_CHECK_CODER_URL }}
|
||||
coder-token: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }}
|
||||
coder-organization: "default"
|
||||
coder-template-name: coder
|
||||
coder-template-name: coder-workflow-bot
|
||||
coder-template-preset: ${{ steps.determine-context.outputs.template_preset }}
|
||||
coder-task-name-prefix: doc-check
|
||||
coder-task-prompt: ${{ steps.extract-context.outputs.task_prompt }}
|
||||
github-user-id: ${{ steps.determine-context.outputs.github_user_id }}
|
||||
coder-username: doc-check-bot
|
||||
github-token: ${{ github.token }}
|
||||
github-issue-url: ${{ steps.determine-context.outputs.pr_url }}
|
||||
comment-on-issue: true
|
||||
comment-on-issue: false
|
||||
|
||||
- name: Write outputs
|
||||
- name: Write Task Info
|
||||
if: steps.check-secrets.outputs.skip != 'true'
|
||||
env:
|
||||
TASK_CREATED: ${{ steps.create_task.outputs.task-created }}
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
@@ -201,5 +259,140 @@ jobs:
|
||||
echo "**Task name:** ${TASK_NAME}"
|
||||
echo "**Task URL:** ${TASK_URL}"
|
||||
echo ""
|
||||
echo "The Coder task is analyzing the PR changes and will comment with documentation recommendations."
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Wait for Task Completion
|
||||
if: steps.check-secrets.outputs.skip != 'true'
|
||||
id: wait_task
|
||||
env:
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
run: |
|
||||
echo "Waiting for task to complete..."
|
||||
echo "Task name: ${TASK_NAME}"
|
||||
|
||||
if [[ -z "${TASK_NAME}" ]]; then
|
||||
echo "::error::TASK_NAME is empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAX_WAIT=600 # 10 minutes
|
||||
WAITED=0
|
||||
POLL_INTERVAL=3
|
||||
LAST_STATUS=""
|
||||
|
||||
is_workspace_message() {
|
||||
local msg="$1"
|
||||
[[ -z "$msg" ]] && return 0 # Empty = treat as workspace/startup
|
||||
[[ "$msg" =~ ^Workspace ]] && return 0
|
||||
[[ "$msg" =~ ^Agent ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $WAITED -lt $MAX_WAIT ]]; do
|
||||
# Get task status (|| true prevents set -e from exiting on non-zero)
|
||||
RAW_OUTPUT=$(coder task status "${TASK_NAME}" -o json 2>&1) || true
|
||||
STATUS_JSON=$(echo "$RAW_OUTPUT" | grep -v "^version mismatch\|^download v" || true)
|
||||
|
||||
# Debug: show first poll's raw output
|
||||
if [[ $WAITED -eq 0 ]]; then
|
||||
echo "Raw status output: ${RAW_OUTPUT:0:500}"
|
||||
fi
|
||||
|
||||
if [[ -z "$STATUS_JSON" ]] || ! echo "$STATUS_JSON" | jq -e . >/dev/null 2>&1; then
|
||||
if [[ "$LAST_STATUS" != "waiting" ]]; then
|
||||
echo "[${WAITED}s] Waiting for task status..."
|
||||
LAST_STATUS="waiting"
|
||||
fi
|
||||
sleep $POLL_INTERVAL
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
continue
|
||||
fi
|
||||
|
||||
TASK_STATE=$(echo "$STATUS_JSON" | jq -r '.current_state.state // "unknown"')
|
||||
TASK_MESSAGE=$(echo "$STATUS_JSON" | jq -r '.current_state.message // ""')
|
||||
WORKSPACE_STATUS=$(echo "$STATUS_JSON" | jq -r '.workspace_status // "unknown"')
|
||||
|
||||
# Build current status string for comparison
|
||||
CURRENT_STATUS="${TASK_STATE}|${WORKSPACE_STATUS}|${TASK_MESSAGE}"
|
||||
|
||||
# Only log if status changed
|
||||
if [[ "$CURRENT_STATUS" != "$LAST_STATUS" ]]; then
|
||||
if [[ "$TASK_STATE" == "idle" ]] && is_workspace_message "$TASK_MESSAGE"; then
|
||||
echo "[${WAITED}s] Workspace ready, waiting for Agent..."
|
||||
else
|
||||
echo "[${WAITED}s] State: ${TASK_STATE} | Workspace: ${WORKSPACE_STATUS} | ${TASK_MESSAGE}"
|
||||
fi
|
||||
LAST_STATUS="$CURRENT_STATUS"
|
||||
fi
|
||||
|
||||
if [[ "$WORKSPACE_STATUS" == "failed" || "$WORKSPACE_STATUS" == "canceled" ]]; then
|
||||
echo "::error::Workspace failed: ${WORKSPACE_STATUS}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TASK_STATE" == "idle" ]]; then
|
||||
if ! is_workspace_message "$TASK_MESSAGE"; then
|
||||
# Real completion message from Claude!
|
||||
echo ""
|
||||
echo "Task completed: ${TASK_MESSAGE}"
|
||||
RESULT_URI=$(echo "$STATUS_JSON" | jq -r '.current_state.uri // ""')
|
||||
echo "result_uri=${RESULT_URI}" >> "${GITHUB_OUTPUT}"
|
||||
echo "task_message=${TASK_MESSAGE}" >> "${GITHUB_OUTPUT}"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep $POLL_INTERVAL
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
if [[ $WAITED -ge $MAX_WAIT ]]; then
|
||||
echo "::error::Task monitoring timed out after ${MAX_WAIT}s"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Fetch Task Logs
|
||||
if: always() && steps.check-secrets.outputs.skip != 'true'
|
||||
env:
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
run: |
|
||||
echo "::group::Task Conversation Log"
|
||||
if [[ -n "${TASK_NAME}" ]]; then
|
||||
coder task logs "${TASK_NAME}" 2>&1 || echo "Failed to fetch logs"
|
||||
else
|
||||
echo "No task name, skipping log fetch"
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Cleanup Task
|
||||
if: always() && steps.check-secrets.outputs.skip != 'true'
|
||||
env:
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
run: |
|
||||
if [[ -n "${TASK_NAME}" ]]; then
|
||||
echo "Deleting task: ${TASK_NAME}"
|
||||
coder task delete "${TASK_NAME}" -y 2>&1 || echo "Task deletion failed or already deleted"
|
||||
else
|
||||
echo "No task name, skipping cleanup"
|
||||
fi
|
||||
|
||||
- name: Write Final Summary
|
||||
if: always() && steps.check-secrets.outputs.skip != 'true'
|
||||
env:
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
TASK_MESSAGE: ${{ steps.wait_task.outputs.task_message }}
|
||||
RESULT_URI: ${{ steps.wait_task.outputs.result_uri }}
|
||||
PR_NUMBER: ${{ steps.determine-context.outputs.pr_number }}
|
||||
run: |
|
||||
{
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "### Result"
|
||||
echo ""
|
||||
echo "**Status:** ${TASK_MESSAGE:-Task completed}"
|
||||
if [[ -n "${RESULT_URI}" ]]; then
|
||||
echo "**Comment:** ${RESULT_URI}"
|
||||
fi
|
||||
echo ""
|
||||
echo "Task \`${TASK_NAME}\` has been cleaned up."
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
@@ -38,12 +38,12 @@ jobs:
|
||||
if: github.repository_owner == 'coder'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -26,12 +26,12 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
# on version 2.29 and above.
|
||||
nix_version: "2.28.5"
|
||||
|
||||
- uses: nix-community/cache-nix-action@b426b118b6dc86d6952988d396aa7c6b09776d08 # v7.0.0
|
||||
- uses: nix-community/cache-nix-action@106bba72ed8e29c8357661199511ef07790175e9 # v7.0.1
|
||||
with:
|
||||
# restore and save a cache using this key
|
||||
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
@@ -125,12 +125,12 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
uses: coder/setup-ramdisk-action@e1100847ab2d7bcd9d14bcda8f2d1b0f07b36f1b # v0.1.0
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -39,12 +39,12 @@ jobs:
|
||||
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -76,12 +76,12 @@ jobs:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
pull-requests: write # needed for commenting on PRs
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -228,12 +228,12 @@ jobs:
|
||||
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
kubectl create namespace "pr${PR_NUMBER}"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
steps:
|
||||
# Harden Runner doesn't work on macOS.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -164,12 +164,12 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -802,7 +802,7 @@ jobs:
|
||||
# TODO: skip this if it's not a new release (i.e. a backport). This is
|
||||
# fine right now because it just makes a PR that we can close.
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -878,7 +878,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -888,7 +888,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -971,12 +971,12 @@ jobs:
|
||||
if: ${{ !inputs.dry_run }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -69,12 +69,12 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
echo "image=$(cat "$image_job")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8
|
||||
uses: aquasecurity/trivy-action@c1824fd6edce30d7ab345a9989de00bbd46ef284 # v0.34.0
|
||||
with:
|
||||
image-ref: ${{ steps.build.outputs.image }}
|
||||
format: sarif
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -96,12 +96,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run delete-old-branches-action
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
path: ./.github/actions/create-task-action
|
||||
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
pull-requests: write # required to post PR review comments by the action
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -211,14 +211,6 @@ issues:
|
||||
- path: scripts/rules.go
|
||||
linters:
|
||||
- ALL
|
||||
# Boundary code is imported from github.com/coder/boundary and has different
|
||||
# lint standards. Suppress lint issues in this imported code.
|
||||
- path: enterprise/cli/boundary/
|
||||
linters:
|
||||
- revive
|
||||
- gocritic
|
||||
- gosec
|
||||
- errorlint
|
||||
|
||||
fix: true
|
||||
max-issues-per-linter: 0
|
||||
|
||||
@@ -69,6 +69,9 @@ MOST_GO_SRC_FILES := $(shell \
|
||||
# All the shell files in the repo, excluding ignored files.
|
||||
SHELL_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
|
||||
|
||||
MIGRATION_FILES := $(shell find ./coderd/database/migrations/ -maxdepth 1 $(FIND_EXCLUSIONS) -type f -name '*.sql')
|
||||
FIXTURE_FILES := $(shell find ./coderd/database/migrations/testdata/fixtures/ $(FIND_EXCLUSIONS) -type f -name '*.sql')
|
||||
|
||||
# Ensure we don't use the user's git configs which might cause side-effects
|
||||
GIT_FLAGS = GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
|
||||
|
||||
@@ -561,7 +564,7 @@ endif
|
||||
|
||||
# Note: we don't run zizmor in the lint target because it takes a while. CI
|
||||
# runs it explicitly.
|
||||
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint lint/check-scopes
|
||||
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint lint/check-scopes lint/migrations
|
||||
.PHONY: lint
|
||||
|
||||
lint/site-icons:
|
||||
@@ -619,6 +622,12 @@ lint/check-scopes: coderd/database/dump.sql
|
||||
go run ./scripts/check-scopes
|
||||
.PHONY: lint/check-scopes
|
||||
|
||||
# Verify migrations do not hardcode the public schema.
|
||||
lint/migrations:
|
||||
./scripts/check_pg_schema.sh "Migrations" $(MIGRATION_FILES)
|
||||
./scripts/check_pg_schema.sh "Fixtures" $(FIXTURE_FILES)
|
||||
.PHONY: lint/migrations
|
||||
|
||||
# All files generated by the database should be added here, and this can be used
|
||||
# as a target for jobs that need to run after the database is generated.
|
||||
DB_GEN_FILES := \
|
||||
|
||||
+15
-6
@@ -40,6 +40,7 @@ import (
|
||||
"github.com/coder/clistat"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentfiles"
|
||||
"github.com/coder/coder/v2/agent/agentscripts"
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
@@ -295,6 +296,8 @@ type agent struct {
|
||||
containerAPIOptions []agentcontainers.Option
|
||||
containerAPI *agentcontainers.API
|
||||
|
||||
filesAPI *agentfiles.API
|
||||
|
||||
socketServerEnabled bool
|
||||
socketPath string
|
||||
socketServer *agentsocket.Server
|
||||
@@ -365,6 +368,8 @@ func (a *agent) init() {
|
||||
|
||||
a.containerAPI = agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...)
|
||||
|
||||
a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem)
|
||||
|
||||
a.reconnectingPTYServer = reconnectingpty.NewServer(
|
||||
a.logger.Named("reconnecting-pty"),
|
||||
a.sshServer,
|
||||
@@ -877,12 +882,16 @@ const (
|
||||
)
|
||||
|
||||
func (a *agent) reportConnection(id uuid.UUID, connectionType proto.Connection_Type, ip string) (disconnected func(code int, reason string)) {
|
||||
// Remove the port from the IP because ports are not supported in coderd.
|
||||
if host, _, err := net.SplitHostPort(ip); err != nil {
|
||||
a.logger.Error(a.hardCtx, "split host and port for connection report failed", slog.F("ip", ip), slog.Error(err))
|
||||
} else {
|
||||
// Best effort.
|
||||
ip = host
|
||||
// A blank IP can unfortunately happen if the connection is broken in a data race before we get to introspect it. We
|
||||
// still report it, and the recipient can handle a blank IP.
|
||||
if ip != "" {
|
||||
// Remove the port from the IP because ports are not supported in coderd.
|
||||
if host, _, err := net.SplitHostPort(ip); err != nil {
|
||||
a.logger.Error(a.hardCtx, "split host and port for connection report failed", slog.F("ip", ip), slog.Error(err))
|
||||
} else {
|
||||
// Best effort.
|
||||
ip = host
|
||||
}
|
||||
}
|
||||
|
||||
// If the IP is "localhost" (which it can be in some cases), set it to
|
||||
|
||||
+55
-9
@@ -121,7 +121,8 @@ func TestAgent_ImmediateClose(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// NOTE: These tests only work when your default shell is bash for some reason.
|
||||
// NOTE(Cian): I noticed that these tests would fail when my default shell was zsh.
|
||||
// Writing "exit 0" to stdin before closing fixed the issue for me.
|
||||
|
||||
func TestAgent_Stats_SSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -148,16 +149,37 @@ func TestAgent_Stats_SSH(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
var s *proto.Stats
|
||||
// We are looking for four different stats to be reported. They might not all
|
||||
// arrive at the same time, so we loop until we've seen them all.
|
||||
var connectionCountSeen, rxBytesSeen, txBytesSeen, sessionCountSSHSeen bool
|
||||
require.Eventuallyf(t, func() bool {
|
||||
var ok bool
|
||||
s, ok = <-stats
|
||||
return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && s.SessionCountSsh == 1
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if s.ConnectionCount > 0 {
|
||||
connectionCountSeen = true
|
||||
}
|
||||
if s.RxBytes > 0 {
|
||||
rxBytesSeen = true
|
||||
}
|
||||
if s.TxBytes > 0 {
|
||||
txBytesSeen = true
|
||||
}
|
||||
if s.SessionCountSsh == 1 {
|
||||
sessionCountSSHSeen = true
|
||||
}
|
||||
return connectionCountSeen && rxBytesSeen && txBytesSeen && sessionCountSSHSeen
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats: %+v", s,
|
||||
"never saw all stats: %+v, saw connectionCount: %t, rxBytes: %t, txBytes: %t, sessionCountSsh: %t",
|
||||
s, connectionCountSeen, rxBytesSeen, txBytesSeen, sessionCountSSHSeen,
|
||||
)
|
||||
_, err = stdin.Write([]byte("exit 0\n"))
|
||||
require.NoError(t, err, "writing exit to stdin")
|
||||
_ = stdin.Close()
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, err, "waiting for session to exit")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -183,12 +205,31 @@ func TestAgent_Stats_ReconnectingPTY(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
var s *proto.Stats
|
||||
// We are looking for four different stats to be reported. They might not all
|
||||
// arrive at the same time, so we loop until we've seen them all.
|
||||
var connectionCountSeen, rxBytesSeen, txBytesSeen, sessionCountReconnectingPTYSeen bool
|
||||
require.Eventuallyf(t, func() bool {
|
||||
var ok bool
|
||||
s, ok = <-stats
|
||||
return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && s.SessionCountReconnectingPty == 1
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if s.ConnectionCount > 0 {
|
||||
connectionCountSeen = true
|
||||
}
|
||||
if s.RxBytes > 0 {
|
||||
rxBytesSeen = true
|
||||
}
|
||||
if s.TxBytes > 0 {
|
||||
txBytesSeen = true
|
||||
}
|
||||
if s.SessionCountReconnectingPty == 1 {
|
||||
sessionCountReconnectingPTYSeen = true
|
||||
}
|
||||
return connectionCountSeen && rxBytesSeen && txBytesSeen && sessionCountReconnectingPTYSeen
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats: %+v", s,
|
||||
"never saw all stats: %+v, saw connectionCount: %t, rxBytes: %t, txBytes: %t, sessionCountReconnectingPTY: %t",
|
||||
s, connectionCountSeen, rxBytesSeen, txBytesSeen, sessionCountReconnectingPTYSeen,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -218,9 +259,10 @@ func TestAgent_Stats_Magic(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, strings.TrimSpace(string(output)))
|
||||
})
|
||||
|
||||
t.Run("TracksVSCode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "window" {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Sleeping for infinity doesn't work on Windows")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
@@ -252,7 +294,9 @@ func TestAgent_Stats_Magic(t *testing.T) {
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats",
|
||||
)
|
||||
// The shell will automatically exit if there is no stdin!
|
||||
|
||||
_, err = stdin.Write([]byte("exit 0\n"))
|
||||
require.NoError(t, err, "writing exit to stdin")
|
||||
_ = stdin.Close()
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
@@ -3633,9 +3677,11 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
_, err = stdin.Write([]byte("exit 0\n"))
|
||||
require.NoError(t, err, "writing exit to stdin")
|
||||
_ = stdin.Close()
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, err, "waiting for session to exit")
|
||||
}
|
||||
|
||||
// echoOnce accepts a single connection, reads 4 bytes and echos them back
|
||||
|
||||
@@ -779,10 +779,13 @@ func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) {
|
||||
// close frames.
|
||||
_ = conn.CloseRead(context.Background())
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText)
|
||||
defer wsNetConn.Close()
|
||||
|
||||
go httpapi.Heartbeat(ctx, conn)
|
||||
go httpapi.HeartbeatClose(ctx, api.logger, cancel, conn)
|
||||
|
||||
updateCh := make(chan struct{}, 1)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package agentfiles
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
)
|
||||
|
||||
// API exposes file-related operations performed through the agent.
|
||||
type API struct {
|
||||
logger slog.Logger
|
||||
filesystem afero.Fs
|
||||
}
|
||||
|
||||
func NewAPI(logger slog.Logger, filesystem afero.Fs) *API {
|
||||
api := &API{
|
||||
logger: logger,
|
||||
filesystem: filesystem,
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
// Routes returns the HTTP handler for file-related routes.
|
||||
func (api *API) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Post("/list-directory", api.HandleLS)
|
||||
r.Get("/read-file", api.HandleReadFile)
|
||||
r.Post("/write-file", api.HandleWriteFile)
|
||||
r.Post("/edit-files", api.HandleEditFiles)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package agentfiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
|
||||
type HTTPResponseCode = int
|
||||
|
||||
func (a *agent) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *API) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
query := r.URL.Query()
|
||||
@@ -42,7 +42,7 @@ func (a *agent) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
status, err := a.streamFile(ctx, rw, path, offset, limit)
|
||||
status, err := api.streamFile(ctx, rw, path, offset, limit)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, status, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
@@ -51,12 +51,12 @@ func (a *agent) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) streamFile(ctx context.Context, rw http.ResponseWriter, path string, offset, limit int64) (HTTPResponseCode, error) {
|
||||
func (api *API) streamFile(ctx context.Context, rw http.ResponseWriter, path string, offset, limit int64) (HTTPResponseCode, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path)
|
||||
}
|
||||
|
||||
f, err := a.filesystem.Open(path)
|
||||
f, err := api.filesystem.Open(path)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
@@ -97,13 +97,13 @@ func (a *agent) streamFile(ctx context.Context, rw http.ResponseWriter, path str
|
||||
reader := io.NewSectionReader(f, offset, bytesToRead)
|
||||
_, err = io.Copy(rw, reader)
|
||||
if err != nil && !errors.Is(err, io.EOF) && ctx.Err() == nil {
|
||||
a.logger.Error(ctx, "workspace agent read file", slog.Error(err))
|
||||
api.logger.Error(ctx, "workspace agent read file", slog.Error(err))
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (a *agent) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *API) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
query := r.URL.Query()
|
||||
@@ -118,7 +118,7 @@ func (a *agent) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
status, err := a.writeFile(ctx, r, path)
|
||||
status, err := api.writeFile(ctx, r, path)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, status, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
@@ -131,13 +131,13 @@ func (a *agent) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HTTPResponseCode, error) {
|
||||
func (api *API) writeFile(ctx context.Context, r *http.Request, path string) (HTTPResponseCode, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
err := a.filesystem.MkdirAll(dir, 0o755)
|
||||
err := api.filesystem.MkdirAll(dir, 0o755)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
@@ -149,7 +149,7 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT
|
||||
return status, err
|
||||
}
|
||||
|
||||
f, err := a.filesystem.Create(path)
|
||||
f, err := api.filesystem.Create(path)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
@@ -164,13 +164,13 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT
|
||||
|
||||
_, err = io.Copy(f, r.Body)
|
||||
if err != nil && !errors.Is(err, io.EOF) && ctx.Err() == nil {
|
||||
a.logger.Error(ctx, "workspace agent write file", slog.Error(err))
|
||||
api.logger.Error(ctx, "workspace agent write file", slog.Error(err))
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *API) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req workspacesdk.FileEditRequest
|
||||
@@ -188,7 +188,7 @@ func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
|
||||
var combinedErr error
|
||||
status := http.StatusOK
|
||||
for _, edit := range req.Files {
|
||||
s, err := a.editFile(r.Context(), edit.Path, edit.Edits)
|
||||
s, err := api.editFile(r.Context(), edit.Path, edit.Edits)
|
||||
// Keep the highest response status, so 500 will be preferred over 400, etc.
|
||||
if s > status {
|
||||
status = s
|
||||
@@ -210,7 +210,7 @@ func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.FileEdit) (int, error) {
|
||||
func (api *API) editFile(ctx context.Context, path string, edits []workspacesdk.FileEdit) (int, error) {
|
||||
if path == "" {
|
||||
return http.StatusBadRequest, xerrors.New("\"path\" is required")
|
||||
}
|
||||
@@ -223,7 +223,7 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
return http.StatusBadRequest, xerrors.New("must specify at least one edit")
|
||||
}
|
||||
|
||||
f, err := a.filesystem.Open(path)
|
||||
f, err := api.filesystem.Open(path)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
@@ -252,7 +252,7 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
|
||||
// Create an adjacent file to ensure it will be on the same device and can be
|
||||
// moved atomically.
|
||||
tmpfile, err := afero.TempFile(a.filesystem, filepath.Dir(path), filepath.Base(path))
|
||||
tmpfile, err := afero.TempFile(api.filesystem, filepath.Dir(path), filepath.Base(path))
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -260,13 +260,13 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
|
||||
_, err = io.Copy(tmpfile, replace.Chain(f, transforms...))
|
||||
if err != nil {
|
||||
if rerr := a.filesystem.Remove(tmpfile.Name()); rerr != nil {
|
||||
a.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
|
||||
if rerr := api.filesystem.Remove(tmpfile.Name()); rerr != nil {
|
||||
api.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
|
||||
}
|
||||
return http.StatusInternalServerError, xerrors.Errorf("edit %s: %w", path, err)
|
||||
}
|
||||
|
||||
err = a.filesystem.Rename(tmpfile.Name(), path)
|
||||
err = api.filesystem.Rename(tmpfile.Name(), path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package agent_test
|
||||
package agentfiles_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -16,10 +18,10 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentfiles"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -106,15 +108,15 @@ func TestReadFile(t *testing.T) {
|
||||
|
||||
tmpdir := os.TempDir()
|
||||
noPermsFilePath := filepath.Join(tmpdir, "no-perms")
|
||||
//nolint:dogsled
|
||||
conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) {
|
||||
opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error {
|
||||
if file == noPermsFilePath {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
||||
if file == noPermsFilePath {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
})
|
||||
api := agentfiles.NewAPI(logger, fs)
|
||||
|
||||
dirPath := filepath.Join(tmpdir, "a-directory")
|
||||
err := fs.MkdirAll(dirPath, 0o755)
|
||||
@@ -260,19 +262,22 @@ func TestReadFile(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
reader, mimeType, err := conn.ReadFile(ctx, tt.path, tt.offset, tt.limit)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("/read-file?path=%s&offset=%d&limit=%d", tt.path, tt.offset, tt.limit), nil)
|
||||
api.Routes().ServeHTTP(w, r)
|
||||
|
||||
if tt.errCode != 0 {
|
||||
require.Error(t, err)
|
||||
cerr := coderdtest.SDKError(t, err)
|
||||
require.Contains(t, cerr.Error(), tt.error)
|
||||
require.Equal(t, tt.errCode, cerr.StatusCode())
|
||||
} else {
|
||||
got := &codersdk.Error{}
|
||||
err := json.NewDecoder(w.Body).Decode(got)
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
bytes, err := io.ReadAll(reader)
|
||||
require.ErrorContains(t, got, tt.error)
|
||||
require.Equal(t, tt.errCode, w.Code)
|
||||
} else {
|
||||
bytes, err := io.ReadAll(w.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.bytes, bytes)
|
||||
require.Equal(t, tt.mimeType, mimeType)
|
||||
require.Equal(t, tt.mimeType, w.Header().Get("Content-Type"))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -284,15 +289,14 @@ func TestWriteFile(t *testing.T) {
|
||||
tmpdir := os.TempDir()
|
||||
noPermsFilePath := filepath.Join(tmpdir, "no-perms-file")
|
||||
noPermsDirPath := filepath.Join(tmpdir, "no-perms-dir")
|
||||
//nolint:dogsled
|
||||
conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) {
|
||||
opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error {
|
||||
if file == noPermsFilePath || file == noPermsDirPath {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
})
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
||||
if file == noPermsFilePath || file == noPermsDirPath {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
})
|
||||
api := agentfiles.NewAPI(logger, fs)
|
||||
|
||||
dirPath := filepath.Join(tmpdir, "directory")
|
||||
err := fs.MkdirAll(dirPath, 0o755)
|
||||
@@ -371,17 +375,21 @@ func TestWriteFile(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
reader := bytes.NewReader(tt.bytes)
|
||||
err := conn.WriteFile(ctx, tt.path, reader)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("/write-file?path=%s", tt.path), reader)
|
||||
api.Routes().ServeHTTP(w, r)
|
||||
|
||||
if tt.errCode != 0 {
|
||||
require.Error(t, err)
|
||||
cerr := coderdtest.SDKError(t, err)
|
||||
require.Contains(t, cerr.Error(), tt.error)
|
||||
require.Equal(t, tt.errCode, cerr.StatusCode())
|
||||
got := &codersdk.Error{}
|
||||
err := json.NewDecoder(w.Body).Decode(got)
|
||||
require.NoError(t, err)
|
||||
require.ErrorContains(t, got, tt.error)
|
||||
require.Equal(t, tt.errCode, w.Code)
|
||||
} else {
|
||||
bytes, err := afero.ReadFile(fs, tt.path)
|
||||
require.NoError(t, err)
|
||||
b, err := afero.ReadFile(fs, tt.path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.bytes, b)
|
||||
require.Equal(t, tt.bytes, bytes)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -393,21 +401,20 @@ func TestEditFiles(t *testing.T) {
|
||||
tmpdir := os.TempDir()
|
||||
noPermsFilePath := filepath.Join(tmpdir, "no-perms-file")
|
||||
failRenameFilePath := filepath.Join(tmpdir, "fail-rename")
|
||||
//nolint:dogsled
|
||||
conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) {
|
||||
opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error {
|
||||
if file == noPermsFilePath {
|
||||
return &os.PathError{
|
||||
Op: call,
|
||||
Path: file,
|
||||
Err: os.ErrPermission,
|
||||
}
|
||||
} else if file == failRenameFilePath && call == "rename" {
|
||||
return xerrors.New("rename failed")
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
||||
if file == noPermsFilePath {
|
||||
return &os.PathError{
|
||||
Op: call,
|
||||
Path: file,
|
||||
Err: os.ErrPermission,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else if file == failRenameFilePath && call == "rename" {
|
||||
return xerrors.New("rename failed")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
api := agentfiles.NewAPI(logger, fs)
|
||||
|
||||
dirPath := filepath.Join(tmpdir, "directory")
|
||||
err := fs.MkdirAll(dirPath, 0o755)
|
||||
@@ -701,16 +708,26 @@ func TestEditFiles(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: tt.edits})
|
||||
buf := bytes.NewBuffer(nil)
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
err := enc.Encode(workspacesdk.FileEditRequest{Files: tt.edits})
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
||||
api.Routes().ServeHTTP(w, r)
|
||||
|
||||
if tt.errCode != 0 {
|
||||
require.Error(t, err)
|
||||
cerr := coderdtest.SDKError(t, err)
|
||||
for _, error := range tt.errors {
|
||||
require.Contains(t, cerr.Error(), error)
|
||||
}
|
||||
require.Equal(t, tt.errCode, cerr.StatusCode())
|
||||
} else {
|
||||
got := &codersdk.Error{}
|
||||
err := json.NewDecoder(w.Body).Decode(got)
|
||||
require.NoError(t, err)
|
||||
for _, error := range tt.errors {
|
||||
require.ErrorContains(t, got, error)
|
||||
}
|
||||
require.Equal(t, tt.errCode, w.Code)
|
||||
} else {
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
for path, expect := range tt.expected {
|
||||
b, err := afero.ReadFile(fs, path)
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package agentfiles
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
var WindowsDriveRegex = regexp.MustCompile(`^[a-zA-Z]:\\$`)
|
||||
|
||||
func (a *agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *API) HandleLS(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// An absolute path may be optionally provided, otherwise a path split into an
|
||||
@@ -43,7 +43,7 @@ func (a *agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := listFiles(a.filesystem, path, req)
|
||||
resp, err := listFiles(api.filesystem, path, req)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
@@ -1,4 +1,4 @@
|
||||
package agent
|
||||
package agentfiles
|
||||
|
||||
import (
|
||||
"os"
|
||||
+2
-4
@@ -27,6 +27,8 @@ func (a *agent) apiHandler() http.Handler {
|
||||
})
|
||||
})
|
||||
|
||||
r.Mount("/api/v0", a.filesAPI.Routes())
|
||||
|
||||
if a.devcontainers {
|
||||
r.Mount("/api/v0/containers", a.containerAPI.Routes())
|
||||
} else if manifest := a.manifest.Load(); manifest != nil && manifest.ParentID != uuid.Nil {
|
||||
@@ -49,10 +51,6 @@ func (a *agent) apiHandler() http.Handler {
|
||||
|
||||
r.Get("/api/v0/listening-ports", a.listeningPortsHandler.handler)
|
||||
r.Get("/api/v0/netcheck", a.HandleNetcheck)
|
||||
r.Post("/api/v0/list-directory", a.HandleLS)
|
||||
r.Get("/api/v0/read-file", a.HandleReadFile)
|
||||
r.Post("/api/v0/write-file", a.HandleWriteFile)
|
||||
r.Post("/api/v0/edit-files", a.HandleEditFiles)
|
||||
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
|
||||
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
|
||||
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)
|
||||
|
||||
@@ -78,9 +78,13 @@ func TestBoundaryLogs_EndToEnd(t *testing.T) {
|
||||
sink := &logSink{}
|
||||
logger := slog.Make(sink)
|
||||
workspaceID := uuid.New()
|
||||
templateID := uuid.New()
|
||||
templateVersionID := uuid.New()
|
||||
reporter := &agentapi.BoundaryLogsAPI{
|
||||
Log: logger,
|
||||
WorkspaceID: workspaceID,
|
||||
Log: logger,
|
||||
WorkspaceID: workspaceID,
|
||||
TemplateID: templateID,
|
||||
TemplateVersionID: templateVersionID,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -123,6 +127,8 @@ func TestBoundaryLogs_EndToEnd(t *testing.T) {
|
||||
require.Equal(t, "boundary_request", entry.Message)
|
||||
require.Equal(t, "allow", getField(entry.Fields, "decision"))
|
||||
require.Equal(t, workspaceID.String(), getField(entry.Fields, "workspace_id"))
|
||||
require.Equal(t, templateID.String(), getField(entry.Fields, "template_id"))
|
||||
require.Equal(t, templateVersionID.String(), getField(entry.Fields, "template_version_id"))
|
||||
require.Equal(t, "GET", getField(entry.Fields, "http_method"))
|
||||
require.Equal(t, "https://example.com/allowed", getField(entry.Fields, "http_url"))
|
||||
require.Equal(t, "*.example.com", getField(entry.Fields, "matched_rule"))
|
||||
@@ -155,6 +161,8 @@ func TestBoundaryLogs_EndToEnd(t *testing.T) {
|
||||
require.Equal(t, "boundary_request", entry.Message)
|
||||
require.Equal(t, "deny", getField(entry.Fields, "decision"))
|
||||
require.Equal(t, workspaceID.String(), getField(entry.Fields, "workspace_id"))
|
||||
require.Equal(t, templateID.String(), getField(entry.Fields, "template_id"))
|
||||
require.Equal(t, templateVersionID.String(), getField(entry.Fields, "template_version_id"))
|
||||
require.Equal(t, "POST", getField(entry.Fields, "http_method"))
|
||||
require.Equal(t, "https://blocked.com/denied", getField(entry.Fields, "http_url"))
|
||||
require.Equal(t, nil, getField(entry.Fields, "matched_rule"))
|
||||
|
||||
@@ -81,6 +81,10 @@ type BackedPipe struct {
|
||||
// Unified error handling with generation filtering
|
||||
errChan chan ErrorEvent
|
||||
|
||||
// forceReconnectHook is a test hook invoked after ForceReconnect registers
|
||||
// with the singleflight group.
|
||||
forceReconnectHook func()
|
||||
|
||||
// singleflight group to dedupe concurrent ForceReconnect calls
|
||||
sf singleflight.Group
|
||||
|
||||
@@ -324,6 +328,13 @@ func (bp *BackedPipe) handleConnectionError(errorEvt ErrorEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetForceReconnectHookForTests sets a hook invoked after ForceReconnect
|
||||
// registers with the singleflight group. It must be set before any
|
||||
// concurrent ForceReconnect calls.
|
||||
func (bp *BackedPipe) SetForceReconnectHookForTests(hook func()) {
|
||||
bp.forceReconnectHook = hook
|
||||
}
|
||||
|
||||
// ForceReconnect forces a reconnection attempt immediately.
|
||||
// This can be used to force a reconnection if a new connection is established.
|
||||
// It prevents duplicate reconnections when called concurrently.
|
||||
@@ -331,7 +342,7 @@ func (bp *BackedPipe) ForceReconnect() error {
|
||||
// Deduplicate concurrent ForceReconnect calls so only one reconnection
|
||||
// attempt runs at a time from this API. Use the pipe's internal context
|
||||
// to ensure Close() cancels any in-flight attempt.
|
||||
_, err, _ := bp.sf.Do("force-reconnect", func() (interface{}, error) {
|
||||
resultChan := bp.sf.DoChan("force-reconnect", func() (interface{}, error) {
|
||||
bp.mu.Lock()
|
||||
defer bp.mu.Unlock()
|
||||
|
||||
@@ -346,5 +357,11 @@ func (bp *BackedPipe) ForceReconnect() error {
|
||||
|
||||
return nil, bp.reconnectLocked()
|
||||
})
|
||||
return err
|
||||
|
||||
if hook := bp.forceReconnectHook; hook != nil {
|
||||
hook()
|
||||
}
|
||||
|
||||
result := <-resultChan
|
||||
return result.Err
|
||||
}
|
||||
|
||||
@@ -742,12 +742,15 @@ func TestBackedPipe_DuplicateReconnectionPrevention(t *testing.T) {
|
||||
|
||||
const numConcurrent = 3
|
||||
startSignals := make([]chan struct{}, numConcurrent)
|
||||
startedSignals := make([]chan struct{}, numConcurrent)
|
||||
for i := range startSignals {
|
||||
startSignals[i] = make(chan struct{})
|
||||
startedSignals[i] = make(chan struct{})
|
||||
}
|
||||
|
||||
enteredSignals := make(chan struct{}, numConcurrent)
|
||||
bp.SetForceReconnectHookForTests(func() {
|
||||
enteredSignals <- struct{}{}
|
||||
})
|
||||
|
||||
errors := make([]error, numConcurrent)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -758,15 +761,12 @@ func TestBackedPipe_DuplicateReconnectionPrevention(t *testing.T) {
|
||||
defer wg.Done()
|
||||
// Wait for the signal to start
|
||||
<-startSignals[idx]
|
||||
// Signal that we're about to call ForceReconnect
|
||||
close(startedSignals[idx])
|
||||
errors[idx] = bp.ForceReconnect()
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Start the first ForceReconnect and wait for it to block
|
||||
close(startSignals[0])
|
||||
<-startedSignals[0]
|
||||
|
||||
// Wait for the first reconnect to actually start and block
|
||||
testutil.RequireReceive(testCtx, t, blockedChan)
|
||||
@@ -777,9 +777,9 @@ func TestBackedPipe_DuplicateReconnectionPrevention(t *testing.T) {
|
||||
close(startSignals[i])
|
||||
}
|
||||
|
||||
// Wait for all additional goroutines to have started their calls
|
||||
for i := 1; i < numConcurrent; i++ {
|
||||
<-startedSignals[i]
|
||||
// Wait for all ForceReconnect calls to join the singleflight operation.
|
||||
for i := 0; i < numConcurrent; i++ {
|
||||
testutil.RequireReceive(testCtx, t, enteredSignals)
|
||||
}
|
||||
|
||||
// At this point, one reconnect has started and is blocked,
|
||||
|
||||
@@ -7,6 +7,6 @@ func IsInitProcess() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func ForkReap(_ ...Option) error {
|
||||
return nil
|
||||
func ForkReap(_ ...Option) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -32,12 +32,13 @@ func TestReap(t *testing.T) {
|
||||
}
|
||||
|
||||
pids := make(reap.PidCh, 1)
|
||||
err := reaper.ForkReap(
|
||||
exitCode, err := reaper.ForkReap(
|
||||
reaper.WithPIDCallback(pids),
|
||||
// Provide some argument that immediately exits.
|
||||
reaper.WithExecArgs("/bin/sh", "-c", "exit 0"),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, exitCode)
|
||||
|
||||
cmd := exec.Command("tail", "-f", "/dev/null")
|
||||
err = cmd.Start()
|
||||
@@ -65,6 +66,36 @@ func TestReap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest
|
||||
func TestForkReapExitCodes(t *testing.T) {
|
||||
if testutil.InCI() {
|
||||
t.Skip("Detected CI, skipping reaper tests")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
expectedCode int
|
||||
}{
|
||||
{"exit 0", "exit 0", 0},
|
||||
{"exit 1", "exit 1", 1},
|
||||
{"exit 42", "exit 42", 42},
|
||||
{"exit 255", "exit 255", 255},
|
||||
{"SIGKILL", "kill -9 $$", 128 + 9},
|
||||
{"SIGTERM", "kill -15 $$", 128 + 15},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
exitCode, err := reaper.ForkReap(
|
||||
reaper.WithExecArgs("/bin/sh", "-c", tt.command),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expectedCode, exitCode, "exit code mismatch for %q", tt.command)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // Signal handling.
|
||||
func TestReapInterrupt(t *testing.T) {
|
||||
// Don't run the reaper test in CI. It does weird
|
||||
@@ -84,13 +115,17 @@ func TestReapInterrupt(t *testing.T) {
|
||||
defer signal.Stop(usrSig)
|
||||
|
||||
go func() {
|
||||
errC <- reaper.ForkReap(
|
||||
exitCode, err := reaper.ForkReap(
|
||||
reaper.WithPIDCallback(pids),
|
||||
reaper.WithCatchSignals(os.Interrupt),
|
||||
// Signal propagation does not extend to children of children, so
|
||||
// we create a little bash script to ensure sleep is interrupted.
|
||||
reaper.WithExecArgs("/bin/sh", "-c", fmt.Sprintf("pid=0; trap 'kill -USR2 %d; kill -TERM $pid' INT; sleep 10 &\npid=$!; kill -USR1 %d; wait", os.Getpid(), os.Getpid())),
|
||||
)
|
||||
// The child exits with 128 + SIGTERM (15) = 143, but the trap catches
|
||||
// SIGINT and sends SIGTERM to the sleep process, so exit code varies.
|
||||
_ = exitCode
|
||||
errC <- err
|
||||
}()
|
||||
|
||||
require.Equal(t, <-usrSig, syscall.SIGUSR1)
|
||||
|
||||
@@ -40,7 +40,10 @@ func catchSignals(pid int, sigs []os.Signal) {
|
||||
// the reaper and an exec.Command waiting for its process to complete.
|
||||
// The provided 'pids' channel may be nil if the caller does not care about the
|
||||
// reaped children PIDs.
|
||||
func ForkReap(opt ...Option) error {
|
||||
//
|
||||
// Returns the child's exit code (using 128+signal for signal termination)
|
||||
// and any error from Wait4.
|
||||
func ForkReap(opt ...Option) (int, error) {
|
||||
opts := &options{
|
||||
ExecArgs: os.Args,
|
||||
}
|
||||
@@ -53,7 +56,7 @@ func ForkReap(opt ...Option) error {
|
||||
|
||||
pwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get wd: %w", err)
|
||||
return 1, xerrors.Errorf("get wd: %w", err)
|
||||
}
|
||||
|
||||
pattrs := &syscall.ProcAttr{
|
||||
@@ -72,7 +75,7 @@ func ForkReap(opt ...Option) error {
|
||||
//#nosec G204
|
||||
pid, err := syscall.ForkExec(opts.ExecArgs[0], opts.ExecArgs, pattrs)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fork exec: %w", err)
|
||||
return 1, xerrors.Errorf("fork exec: %w", err)
|
||||
}
|
||||
|
||||
go catchSignals(pid, opts.CatchSignals)
|
||||
@@ -82,5 +85,18 @@ func ForkReap(opt ...Option) error {
|
||||
for xerrors.Is(err, syscall.EINTR) {
|
||||
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
|
||||
}
|
||||
return err
|
||||
|
||||
// Convert wait status to exit code using standard Unix conventions:
|
||||
// - Normal exit: use the exit code
|
||||
// - Signal termination: use 128 + signal number
|
||||
var exitCode int
|
||||
switch {
|
||||
case wstatus.Exited():
|
||||
exitCode = wstatus.ExitStatus()
|
||||
case wstatus.Signaled():
|
||||
exitCode = 128 + int(wstatus.Signal())
|
||||
default:
|
||||
exitCode = 1
|
||||
}
|
||||
return exitCode, err
|
||||
}
|
||||
|
||||
+3
-3
@@ -136,7 +136,7 @@ func workspaceAgent() *serpent.Command {
|
||||
// to do this else we fork bomb ourselves.
|
||||
//nolint:gocritic
|
||||
args := append(os.Args, "--no-reap")
|
||||
err := reaper.ForkReap(
|
||||
exitCode, err := reaper.ForkReap(
|
||||
reaper.WithExecArgs(args...),
|
||||
reaper.WithCatchSignals(StopSignals...),
|
||||
)
|
||||
@@ -145,8 +145,8 @@ func workspaceAgent() *serpent.Command {
|
||||
return xerrors.Errorf("fork reap: %w", err)
|
||||
}
|
||||
|
||||
logger.Info(ctx, "reaper process exiting")
|
||||
return nil
|
||||
logger.Info(ctx, "reaper child process exited", slog.F("exit_code", exitCode))
|
||||
return ExitError(exitCode, nil)
|
||||
}
|
||||
|
||||
// Handle interrupt signals to allow for graceful shutdown,
|
||||
|
||||
+2
-11
@@ -10,12 +10,8 @@ import (
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.TemplateVersionParameter, defaultOverrides map[string]string) (string, error) {
|
||||
label := templateVersionParameter.Name
|
||||
if templateVersionParameter.DisplayName != "" {
|
||||
label = templateVersionParameter.DisplayName
|
||||
}
|
||||
|
||||
func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.TemplateVersionParameter, name, defaultValue string) (string, error) {
|
||||
label := name
|
||||
if templateVersionParameter.Ephemeral {
|
||||
label += pretty.Sprint(DefaultStyles.Warn, " (build option)")
|
||||
}
|
||||
@@ -26,11 +22,6 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.DescriptionPlaintext, "\n"), "\n "))+"\n")
|
||||
}
|
||||
|
||||
defaultValue := templateVersionParameter.DefaultValue
|
||||
if v, ok := defaultOverrides[templateVersionParameter.Name]; ok {
|
||||
defaultValue = v
|
||||
}
|
||||
|
||||
var err error
|
||||
var value string
|
||||
switch {
|
||||
|
||||
+2
-2
@@ -32,12 +32,12 @@ type PromptOptions struct {
|
||||
const skipPromptFlag = "yes"
|
||||
|
||||
// SkipPromptOption adds a "--yes/-y" flag to the cmd that can be used to skip
|
||||
// prompts.
|
||||
// confirmation prompts.
|
||||
func SkipPromptOption() serpent.Option {
|
||||
return serpent.Option{
|
||||
Flag: skipPromptFlag,
|
||||
FlagShorthand: "y",
|
||||
Description: "Bypass prompts.",
|
||||
Description: "Bypass confirmation prompts.",
|
||||
// Discard
|
||||
Value: serpent.BoolOf(new(bool)),
|
||||
}
|
||||
|
||||
+17
-5
@@ -42,9 +42,10 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
stopAfter time.Duration
|
||||
workspaceName string
|
||||
|
||||
parameterFlags workspaceParameterFlags
|
||||
autoUpdates string
|
||||
copyParametersFrom string
|
||||
parameterFlags workspaceParameterFlags
|
||||
autoUpdates string
|
||||
copyParametersFrom string
|
||||
useParameterDefaults bool
|
||||
// Organization context is only required if more than 1 template
|
||||
// shares the same name across multiple organizations.
|
||||
orgContext = NewOrganizationContext()
|
||||
@@ -308,7 +309,7 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
displayAppliedPreset(inv, preset, presetParameters)
|
||||
} else {
|
||||
// Inform the user that no preset was applied
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied."))
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", cliui.Bold("No preset applied."))
|
||||
}
|
||||
|
||||
if opts.BeforeCreate != nil {
|
||||
@@ -329,6 +330,8 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
RichParameterDefaults: cliBuildParameterDefaults,
|
||||
|
||||
SourceWorkspaceParameters: sourceWorkspaceParameters,
|
||||
|
||||
UseParameterDefaults: useParameterDefaults,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("prepare build: %w", err)
|
||||
@@ -435,6 +438,12 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
Description: "Specify the source workspace name to copy parameters from.",
|
||||
Value: serpent.StringOf(©ParametersFrom),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "use-parameter-defaults",
|
||||
Env: "CODER_WORKSPACE_USE_PARAMETER_DEFAULTS",
|
||||
Description: "Automatically accept parameter defaults when no value is provided.",
|
||||
Value: serpent.BoolOf(&useParameterDefaults),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
)
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
|
||||
@@ -459,6 +468,8 @@ type prepWorkspaceBuildArgs struct {
|
||||
RichParameters []codersdk.WorkspaceBuildParameter
|
||||
RichParameterFile string
|
||||
RichParameterDefaults []codersdk.WorkspaceBuildParameter
|
||||
|
||||
UseParameterDefaults bool
|
||||
}
|
||||
|
||||
// resolvePreset returns the preset matching the given presetName (if specified),
|
||||
@@ -561,7 +572,8 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
|
||||
WithPromptRichParameters(args.PromptRichParameters).
|
||||
WithRichParameters(args.RichParameters).
|
||||
WithRichParametersFile(parameterFile).
|
||||
WithRichParametersDefaults(args.RichParameterDefaults)
|
||||
WithRichParametersDefaults(args.RichParameterDefaults).
|
||||
WithUseParameterDefaults(args.UseParameterDefaults)
|
||||
buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+411
-327
@@ -318,353 +318,437 @@ func prepareEchoResponses(parameters []*proto.RichParameter, presets ...*proto.P
|
||||
}
|
||||
}
|
||||
|
||||
type param struct {
|
||||
name string
|
||||
ptype string
|
||||
value string
|
||||
mutable bool
|
||||
}
|
||||
|
||||
func TestCreateWithRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
firstParameterName = "first_parameter"
|
||||
firstParameterDescription = "This is first parameter"
|
||||
firstParameterValue = "1"
|
||||
|
||||
secondParameterName = "second_parameter"
|
||||
secondParameterDisplayName = "Second Parameter"
|
||||
secondParameterDescription = "This is second parameter"
|
||||
secondParameterValue = "2"
|
||||
|
||||
immutableParameterName = "third_parameter"
|
||||
immutableParameterDescription = "This is not mutable parameter"
|
||||
immutableParameterValue = "4"
|
||||
)
|
||||
|
||||
echoResponses := func() *echo.Responses {
|
||||
return prepareEchoResponses([]*proto.RichParameter{
|
||||
{Name: firstParameterName, Description: firstParameterDescription, Mutable: true},
|
||||
{Name: secondParameterName, DisplayName: secondParameterDisplayName, Description: secondParameterDescription, Mutable: true},
|
||||
{Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false},
|
||||
})
|
||||
// Default parameters and their expected values.
|
||||
params := []param{
|
||||
{
|
||||
name: "number_param",
|
||||
ptype: "number",
|
||||
value: "777",
|
||||
mutable: true,
|
||||
},
|
||||
{
|
||||
name: "string_param",
|
||||
ptype: "string",
|
||||
value: "qux",
|
||||
mutable: true,
|
||||
},
|
||||
{
|
||||
name: "bool_param",
|
||||
// TODO: Setting the type breaks booleans. It claims the default is false
|
||||
// but when you then accept this default it errors saying that the value
|
||||
// must be true or false. For now, use a string.
|
||||
ptype: "string",
|
||||
value: "false",
|
||||
mutable: true,
|
||||
},
|
||||
{
|
||||
name: "immutable_string_param",
|
||||
ptype: "string",
|
||||
value: "i am eternal",
|
||||
mutable: false,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("InputParameters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
type testContext struct {
|
||||
client *codersdk.Client
|
||||
member *codersdk.Client
|
||||
owner codersdk.CreateFirstUserResponse
|
||||
template codersdk.Template
|
||||
workspaceName string
|
||||
}
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
tests := []struct {
|
||||
name string
|
||||
// setup runs before the command is started and return arguments that will
|
||||
// be appended to the create command.
|
||||
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)
|
||||
// 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).
|
||||
postRun func(t *testing.T, args testContext) string
|
||||
// errors contains expected errors. The workspace will not be verified if
|
||||
// errors are expected.
|
||||
errors []string
|
||||
// inputParameters overrides the default parameters.
|
||||
inputParameters []param
|
||||
// expectedParameters defaults to inputParameters.
|
||||
expectedParameters []param
|
||||
// withDefaults sets DefaultValue to each parameter's value.
|
||||
withDefaults bool
|
||||
}{
|
||||
{
|
||||
name: "ValuesFromPrompt",
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Enter the value for each parameter as prompted.
|
||||
for _, param := range params {
|
||||
pty.ExpectMatch(param.name)
|
||||
pty.WriteLine(param.value)
|
||||
}
|
||||
// Confirm the creation.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromDefaultFlags",
|
||||
setup: func() []string {
|
||||
// Provide the defaults on the command line.
|
||||
args := []string{}
|
||||
for _, param := range params {
|
||||
args = append(args, "--parameter-default", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
return args
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Simply accept the defaults.
|
||||
for _, param := range params {
|
||||
pty.ExpectMatch(param.name)
|
||||
pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
|
||||
pty.WriteLine("")
|
||||
}
|
||||
// Confirm the creation.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromFile",
|
||||
setup: func() []string {
|
||||
// Create a file with the values.
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
for _, param := range params {
|
||||
_, err := parameterFile.WriteString(fmt.Sprintf("%s: %s\n", param.name, param.value))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
return []string{"--rich-parameter-file", parameterFile.Name()}
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// No prompts, we only need to confirm.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromFlags",
|
||||
setup: func() []string {
|
||||
// Provide the values on the command line.
|
||||
var args []string
|
||||
for _, param := range params {
|
||||
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
return args
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// No prompts, we only need to confirm.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MisspelledParameter",
|
||||
setup: func() []string {
|
||||
// Provide the values on the command line.
|
||||
args := []string{}
|
||||
for i, param := range params {
|
||||
if i == 0 {
|
||||
// Slightly misspell the first parameter with an extra character.
|
||||
args = append(args, "--parameter", fmt.Sprintf("n%s=%s", param.name, param.value))
|
||||
} else {
|
||||
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
}
|
||||
return args
|
||||
},
|
||||
errors: []string{
|
||||
"parameter \"n" + params[0].name + "\" is not present in the template",
|
||||
"Did you mean: " + params[0].name,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromWorkspace",
|
||||
setup: func() []string {
|
||||
// Provide the values on the command line.
|
||||
args := []string{"-y"}
|
||||
for _, param := range params {
|
||||
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
return args
|
||||
},
|
||||
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"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromOutdatedWorkspace",
|
||||
setup: func() []string {
|
||||
// Provide the values on the command line.
|
||||
args := []string{"-y"}
|
||||
for _, param := range params {
|
||||
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
return args
|
||||
},
|
||||
postRun: func(t *testing.T, tctx testContext) string {
|
||||
// Update the template to a new version.
|
||||
version2 := coderdtest.CreateTemplateVersion(t, tctx.client, tctx.owner.OrganizationID, prepareEchoResponses([]*proto.RichParameter{
|
||||
{Name: "another_parameter", Type: "string", DefaultValue: "not-relevant"},
|
||||
}), func(ctvr *codersdk.CreateTemplateVersionRequest) {
|
||||
ctvr.TemplateID = tctx.template.ID
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, tctx.client, version2.ID)
|
||||
coderdtest.UpdateActiveTemplateVersion(t, tctx.client, tctx.template.ID, version2.ID)
|
||||
|
||||
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)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
// 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"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ValuesFromTemplateDefaults",
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Simply accept the defaults.
|
||||
for _, param := range params {
|
||||
pty.ExpectMatch(param.name)
|
||||
pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
|
||||
pty.WriteLine("")
|
||||
}
|
||||
// Confirm the creation.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
withDefaults: true,
|
||||
},
|
||||
{
|
||||
name: "ValuesFromTemplateDefaultsNoPrompt",
|
||||
setup: func() []string {
|
||||
return []string{"--use-parameter-defaults"}
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Default values should get printed.
|
||||
for _, param := range params {
|
||||
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
|
||||
}
|
||||
// No prompts, we only need to confirm.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
withDefaults: true,
|
||||
},
|
||||
{
|
||||
name: "ValuesFromDefaultFlagsNoPrompt",
|
||||
setup: func() []string {
|
||||
// Provide the defaults on the command line.
|
||||
args := []string{"--use-parameter-defaults"}
|
||||
for _, param := range params {
|
||||
args = append(args, "--parameter-default", fmt.Sprintf("%s=%s", param.name, param.value))
|
||||
}
|
||||
return args
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Default values should get printed.
|
||||
for _, param := range params {
|
||||
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
|
||||
}
|
||||
// No prompts, we only need to confirm.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
},
|
||||
{
|
||||
// File and flags should override template defaults. Additionally, if a
|
||||
// value has no default value we should still get a prompt for it.
|
||||
name: "ValuesFromMultipleSources",
|
||||
setup: func() []string {
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, err := parameterFile.WriteString(`
|
||||
file_param: from file
|
||||
cli_param: from file`)
|
||||
require.NoError(t, err)
|
||||
return []string{
|
||||
"--use-parameter-defaults",
|
||||
"--rich-parameter-file", parameterFile.Name(),
|
||||
"--parameter-default", "file_param=from cli default",
|
||||
"--parameter-default", "cli_param=from cli default",
|
||||
"--parameter", "cli_param=from cli",
|
||||
}
|
||||
},
|
||||
handlePty: func(pty *ptytest.PTY) {
|
||||
// Should get prompted for the input param since it has no default.
|
||||
pty.ExpectMatch("input_param")
|
||||
pty.WriteLine("from input")
|
||||
|
||||
matches := []string{
|
||||
firstParameterDescription, firstParameterValue,
|
||||
secondParameterDisplayName, "",
|
||||
secondParameterDescription, secondParameterValue,
|
||||
immutableParameterDescription, immutableParameterValue,
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
// Confirm the creation.
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
},
|
||||
withDefaults: true,
|
||||
inputParameters: []param{
|
||||
{
|
||||
name: "template_param",
|
||||
value: "from template default",
|
||||
},
|
||||
{
|
||||
name: "file_param",
|
||||
value: "from template default",
|
||||
},
|
||||
{
|
||||
name: "cli_param",
|
||||
value: "from template default",
|
||||
},
|
||||
{
|
||||
name: "input_param",
|
||||
},
|
||||
},
|
||||
expectedParameters: []param{
|
||||
{
|
||||
name: "template_param",
|
||||
value: "from template default",
|
||||
},
|
||||
{
|
||||
name: "file_param",
|
||||
value: "from file",
|
||||
},
|
||||
{
|
||||
name: "cli_param",
|
||||
value: "from cli",
|
||||
},
|
||||
{
|
||||
name: "input_param",
|
||||
value: "from input",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if value != "" {
|
||||
pty.WriteLine(value)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parameters := params
|
||||
if len(tt.inputParameters) > 0 {
|
||||
parameters = tt.inputParameters
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("ParametersDefaults", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Convert parameters for the echo provisioner response.
|
||||
var rparams []*proto.RichParameter
|
||||
for i, param := range parameters {
|
||||
defaultValue := ""
|
||||
if tt.withDefaults {
|
||||
defaultValue = param.value
|
||||
}
|
||||
rparams = append(rparams, &proto.RichParameter{
|
||||
Name: param.name,
|
||||
Type: param.ptype,
|
||||
Mutable: param.mutable,
|
||||
DefaultValue: defaultValue,
|
||||
Order: int32(i), //nolint:gosec
|
||||
})
|
||||
}
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
// Set up the template.
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(rparams))
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name,
|
||||
"--parameter-default", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
|
||||
"--parameter-default", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
|
||||
"--parameter-default", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
// Run the command, possibly setting up values.
|
||||
workspaceName := "my-workspace"
|
||||
args := []string{"create", workspaceName, "--template", template.Name}
|
||||
if tt.setup != nil {
|
||||
args = append(args, tt.setup()...)
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan error)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
doneChan <- inv.Run()
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
firstParameterDescription, firstParameterValue,
|
||||
secondParameterDescription, secondParameterValue,
|
||||
immutableParameterDescription, immutableParameterValue,
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
defaultValue := matches[i+1]
|
||||
// The test may do something with the pty.
|
||||
if tt.handlePty != nil {
|
||||
tt.handlePty(pty)
|
||||
}
|
||||
|
||||
pty.ExpectMatch(match)
|
||||
pty.ExpectMatch(`Enter a value (default: "` + defaultValue + `")`)
|
||||
pty.WriteLine("")
|
||||
}
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
pty.WriteLine("yes")
|
||||
<-doneChan
|
||||
// Wait for the command to exit.
|
||||
err := <-doneChan
|
||||
|
||||
// Verify that the expected default values were used.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
// The test may want to run additional setup like copying the workspace.
|
||||
if tt.postRun != nil {
|
||||
workspaceName = tt.postRun(t, testContext{
|
||||
client: client,
|
||||
member: member,
|
||||
owner: owner,
|
||||
template: template,
|
||||
workspaceName: workspaceName,
|
||||
})
|
||||
}
|
||||
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Name: "my-workspace",
|
||||
if len(tt.errors) > 0 {
|
||||
require.Error(t, err)
|
||||
for _, errstr := range tt.errors {
|
||||
assert.ErrorContains(t, err, errstr)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the workspace was created and has the right template and values.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{Name: workspaceName})
|
||||
require.NoError(t, err, "expected to find created workspace")
|
||||
require.Len(t, workspaces.Workspaces, 1)
|
||||
|
||||
workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
|
||||
require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
|
||||
|
||||
buildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceLatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
if len(tt.expectedParameters) > 0 {
|
||||
parameters = tt.expectedParameters
|
||||
}
|
||||
require.Len(t, buildParameters, len(parameters))
|
||||
for _, param := range parameters {
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: param.name, Value: param.value})
|
||||
}
|
||||
}
|
||||
})
|
||||
require.NoError(t, err, "can't list available workspaces")
|
||||
require.Len(t, workspaces.Workspaces, 1)
|
||||
|
||||
workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
|
||||
require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
|
||||
|
||||
buildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceLatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buildParameters, 3)
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue})
|
||||
})
|
||||
|
||||
t.Run("RichParametersFile", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString(
|
||||
firstParameterName + ": " + firstParameterValue + "\n" +
|
||||
secondParameterName + ": " + secondParameterValue + "\n" +
|
||||
immutableParameterName + ": " + immutableParameterValue)
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, member, root)
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("ParameterFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name,
|
||||
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("WrongParameterName/DidYouMean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
wrongFirstParameterName := "frst-prameter"
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name,
|
||||
"--parameter", fmt.Sprintf("%s=%s", wrongFirstParameterName, firstParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err := inv.Run()
|
||||
assert.ErrorContains(t, err, "parameter \""+wrongFirstParameterName+"\" is not present in the template")
|
||||
assert.ErrorContains(t, err, "Did you mean: "+firstParameterName)
|
||||
})
|
||||
|
||||
t.Run("CopyParameters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
// Firstly, create a regular workspace using template with parameters.
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "-y",
|
||||
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err := inv.Run()
|
||||
require.NoError(t, err, "can't create first workspace")
|
||||
|
||||
// Secondly, create a new workspace using parameters from the previous workspace.
|
||||
const otherWorkspace = "other-workspace"
|
||||
|
||||
inv, root = clitest.New(t, "create", "--copy-parameters-from", "my-workspace", otherWorkspace, "-y")
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty = ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err = inv.Run()
|
||||
require.NoError(t, err, "can't create a workspace based on the source workspace")
|
||||
|
||||
// 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: otherWorkspace,
|
||||
})
|
||||
require.NoError(t, err, "can't list available workspaces")
|
||||
require.Len(t, workspaces.Workspaces, 1)
|
||||
|
||||
otherWorkspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
|
||||
|
||||
buildParameters, err := client.WorkspaceBuildParameters(ctx, otherWorkspaceLatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buildParameters, 3)
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue})
|
||||
})
|
||||
|
||||
t.Run("CopyParametersFromNotUpdatedWorkspace", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
// Firstly, create a regular workspace using template with parameters.
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "-y",
|
||||
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err := inv.Run()
|
||||
require.NoError(t, err, "can't create first workspace")
|
||||
|
||||
// Secondly, update the template to the newer version.
|
||||
version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses([]*proto.RichParameter{
|
||||
{Name: "third_parameter", Type: "string", DefaultValue: "not-relevant"},
|
||||
}), func(ctvr *codersdk.CreateTemplateVersionRequest) {
|
||||
ctvr.TemplateID = template.ID
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID)
|
||||
coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, version2.ID)
|
||||
|
||||
// Thirdly, create a new workspace using parameters from the previous workspace.
|
||||
const otherWorkspace = "other-workspace"
|
||||
|
||||
inv, root = clitest.New(t, "create", "--copy-parameters-from", "my-workspace", otherWorkspace, "-y")
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty = ptytest.New(t).Attach(inv)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err = inv.Run()
|
||||
require.NoError(t, err, "can't create a workspace based on the source workspace")
|
||||
|
||||
// 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: otherWorkspace,
|
||||
})
|
||||
require.NoError(t, err, "can't list available workspaces")
|
||||
require.Len(t, workspaces.Workspaces, 1)
|
||||
|
||||
otherWorkspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
|
||||
require.Equal(t, version.ID, otherWorkspaceLatestBuild.TemplateVersionID)
|
||||
|
||||
buildParameters, err := client.WorkspaceBuildParameters(ctx, otherWorkspaceLatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buildParameters, 3)
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue})
|
||||
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWithPreset(t *testing.T) {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (*RootCmd) boundary() *serpent.Command {
|
||||
return &serpent.Command{
|
||||
Use: "boundary",
|
||||
Short: "Network isolation tool for monitoring and restricting HTTP/HTTPS requests (enterprise)",
|
||||
Long: `boundary creates an isolated network environment for target processes. This is an enterprise feature.`,
|
||||
Handler: func(_ *serpent.Invocation) error {
|
||||
return xerrors.New("boundary is an enterprise feature; upgrade to use this command")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// Here we want to test that integrating boundary as a subcommand doesn't break anything.
|
||||
// The full boundary functionality is tested in enterprise/cli.
|
||||
func TestBoundarySubcommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
inv, _ := clitest.New(t, "exp", "boundary", "--help")
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
go func() {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
// Expect the --help output to include the short description.
|
||||
pty.ExpectMatch("Network isolation tool")
|
||||
}
|
||||
@@ -68,6 +68,8 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
|
||||
r.scaletestTaskStatus(),
|
||||
r.scaletestSMTP(),
|
||||
r.scaletestPrebuilds(),
|
||||
r.scaletestBridge(),
|
||||
r.scaletestLLMMock(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scaletest/bridge"
|
||||
"github.com/coder/coder/v2/scaletest/createusers"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestBridge() *serpent.Command {
|
||||
var (
|
||||
concurrentUsers int64
|
||||
noCleanup bool
|
||||
mode string
|
||||
upstreamURL string
|
||||
provider string
|
||||
requestsPerUser int64
|
||||
useStreamingAPI bool
|
||||
requestPayloadSize int64
|
||||
numMessages int64
|
||||
httpTimeout time.Duration
|
||||
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "bridge",
|
||||
Short: "Generate load on the AI Bridge service.",
|
||||
Long: `Generate load for AI Bridge testing. Supports two modes: 'bridge' mode routes requests through the Coder AI Bridge, 'direct' mode makes requests directly to an upstream URL (useful for baseline comparisons).
|
||||
|
||||
Examples:
|
||||
# Test OpenAI API through bridge
|
||||
coder scaletest bridge --mode bridge --provider openai --concurrent-users 10 --request-count 5 --num-messages 10
|
||||
|
||||
# Test OpenAI Responses API through bridge
|
||||
coder scaletest bridge --mode bridge --provider responses --concurrent-users 10 --request-count 5 --num-messages 10
|
||||
|
||||
# Test Anthropic API through bridge
|
||||
coder scaletest bridge --mode bridge --provider anthropic --concurrent-users 10 --request-count 5 --num-messages 10
|
||||
|
||||
# Test directly against mock server
|
||||
coder scaletest bridge --mode direct --provider openai --upstream-url http://localhost:8080/v1/chat/completions
|
||||
`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := bridge.NewMetrics(reg)
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
var userConfig createusers.Config
|
||||
if bridge.RequestMode(mode) == bridge.RequestModeBridge {
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(me.OrganizationIDs) == 0 {
|
||||
return xerrors.Errorf("admin user must have at least one organization")
|
||||
}
|
||||
userConfig = createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
}
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Bridge mode: creating users and making requests through AI Bridge...")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Direct mode: making requests directly to %s\n", upstreamURL)
|
||||
}
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse output flags: %w", err)
|
||||
}
|
||||
|
||||
config := bridge.Config{
|
||||
Mode: bridge.RequestMode(mode),
|
||||
Metrics: metrics,
|
||||
Provider: provider,
|
||||
RequestCount: int(requestsPerUser),
|
||||
Stream: useStreamingAPI,
|
||||
RequestPayloadSize: int(requestPayloadSize),
|
||||
NumMessages: int(numMessages),
|
||||
HTTPTimeout: httpTimeout,
|
||||
UpstreamURL: upstreamURL,
|
||||
User: userConfig,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
if err := config.PrepareRequestBody(); err != nil {
|
||||
return xerrors.Errorf("prepare request body: %w", err)
|
||||
}
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
|
||||
for i := range concurrentUsers {
|
||||
id := strconv.Itoa(int(i))
|
||||
name := fmt.Sprintf("bridge-%s", id)
|
||||
var runner harness.Runnable = bridge.NewRunner(client, config)
|
||||
th.AddRun(name, id, runner)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Bridge scaletest configuration:")
|
||||
tw := tabwriter.NewWriter(inv.Stderr, 0, 0, 2, ' ', 0)
|
||||
for _, opt := range inv.Command.Options {
|
||||
if opt.Hidden || opt.ValueSource == serpent.ValueSourceNone {
|
||||
continue
|
||||
}
|
||||
_, _ = fmt.Fprintf(tw, " %s:\t%s", opt.Name, opt.Value.String())
|
||||
if opt.ValueSource != serpent.ValueSourceDefault {
|
||||
_, _ = fmt.Fprintf(tw, "\t(from %s)", opt.ValueSource)
|
||||
}
|
||||
_, _ = fmt.Fprintln(tw)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nRunning bridge scaletest...")
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip stats.
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !noCleanup {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "concurrent-users",
|
||||
FlagShorthand: "c",
|
||||
Env: "CODER_SCALETEST_BRIDGE_CONCURRENT_USERS",
|
||||
Description: "Required: Number of concurrent users.",
|
||||
Value: serpent.Validate(serpent.Int64Of(&concurrentUsers), func(value *serpent.Int64) error {
|
||||
if value == nil || value.Value() <= 0 {
|
||||
return xerrors.Errorf("--concurrent-users must be greater than 0")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Flag: "mode",
|
||||
Env: "CODER_SCALETEST_BRIDGE_MODE",
|
||||
Default: "direct",
|
||||
Description: "Request mode: 'bridge' (create users and use AI Bridge) or 'direct' (make requests directly to upstream-url).",
|
||||
Value: serpent.EnumOf(&mode, string(bridge.RequestModeBridge), string(bridge.RequestModeDirect)),
|
||||
},
|
||||
{
|
||||
Flag: "upstream-url",
|
||||
Env: "CODER_SCALETEST_BRIDGE_UPSTREAM_URL",
|
||||
Description: "URL to make requests to directly (required in direct mode, e.g., http://localhost:8080/v1/chat/completions).",
|
||||
Value: serpent.StringOf(&upstreamURL),
|
||||
},
|
||||
{
|
||||
Flag: "provider",
|
||||
Env: "CODER_SCALETEST_BRIDGE_PROVIDER",
|
||||
Required: true,
|
||||
Description: "API provider to use.",
|
||||
Value: serpent.EnumOf(&provider, "completions", "messages", "responses"),
|
||||
},
|
||||
{
|
||||
Flag: "request-count",
|
||||
Env: "CODER_SCALETEST_BRIDGE_REQUEST_COUNT",
|
||||
Default: "1",
|
||||
Description: "Number of sequential requests to make per runner.",
|
||||
Value: serpent.Validate(serpent.Int64Of(&requestsPerUser), func(value *serpent.Int64) error {
|
||||
if value == nil || value.Value() <= 0 {
|
||||
return xerrors.Errorf("--request-count must be greater than 0")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
},
|
||||
{
|
||||
Flag: "stream",
|
||||
Env: "CODER_SCALETEST_BRIDGE_STREAM",
|
||||
Description: "Enable streaming requests.",
|
||||
Value: serpent.BoolOf(&useStreamingAPI),
|
||||
},
|
||||
{
|
||||
Flag: "request-payload-size",
|
||||
Env: "CODER_SCALETEST_BRIDGE_REQUEST_PAYLOAD_SIZE",
|
||||
Default: "1024",
|
||||
Description: "Size in bytes of the request payload (user message content). If 0, uses default message content.",
|
||||
Value: serpent.Int64Of(&requestPayloadSize),
|
||||
},
|
||||
{
|
||||
Flag: "num-messages",
|
||||
Env: "CODER_SCALETEST_BRIDGE_NUM_MESSAGES",
|
||||
Default: "1",
|
||||
Description: "Number of messages to include in the conversation.",
|
||||
Value: serpent.Int64Of(&numMessages),
|
||||
},
|
||||
{
|
||||
Flag: "no-cleanup",
|
||||
Env: "CODER_SCALETEST_NO_CLEANUP",
|
||||
Description: "Do not clean up resources after the test completes.",
|
||||
Value: serpent.BoolOf(&noCleanup),
|
||||
},
|
||||
{
|
||||
Flag: "http-timeout",
|
||||
Env: "CODER_SCALETEST_BRIDGE_HTTP_TIMEOUT",
|
||||
Default: "30s",
|
||||
Description: "Timeout for individual HTTP requests to the upstream provider.",
|
||||
Value: serpent.DurationOf(&httpTimeout),
|
||||
},
|
||||
}
|
||||
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/scaletest/llmmock"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (*RootCmd) scaletestLLMMock() *serpent.Command {
|
||||
var (
|
||||
address string
|
||||
artificialLatency time.Duration
|
||||
responsePayloadSize int64
|
||||
|
||||
pprofEnable bool
|
||||
pprofAddress string
|
||||
|
||||
traceEnable bool
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "llm-mock",
|
||||
Short: "Start a mock LLM API server for testing",
|
||||
Long: `Start a mock LLM API server that simulates OpenAI and Anthropic APIs`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx, stop := signal.NotifyContext(inv.Context(), StopSignals...)
|
||||
defer stop()
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelInfo)
|
||||
|
||||
if pprofEnable {
|
||||
closePprof := ServeHandler(ctx, logger, nil, pprofAddress, "pprof")
|
||||
defer closePprof()
|
||||
logger.Info(ctx, "pprof server started", slog.F("address", pprofAddress))
|
||||
}
|
||||
|
||||
config := llmmock.Config{
|
||||
Address: address,
|
||||
Logger: logger,
|
||||
ArtificialLatency: artificialLatency,
|
||||
ResponsePayloadSize: int(responsePayloadSize),
|
||||
PprofEnable: pprofEnable,
|
||||
PprofAddress: pprofAddress,
|
||||
TraceEnable: traceEnable,
|
||||
}
|
||||
srv := new(llmmock.Server)
|
||||
|
||||
if err := srv.Start(ctx, config); err != nil {
|
||||
return xerrors.Errorf("start mock LLM server: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = srv.Stop()
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Mock LLM API server started on %s\n", srv.APIAddress())
|
||||
_, _ = fmt.Fprintf(inv.Stdout, " OpenAI endpoint: %s/v1/chat/completions\n", srv.APIAddress())
|
||||
_, _ = fmt.Fprintf(inv.Stdout, " OpenAI responses endpoint: %s/v1/responses\n", srv.APIAddress())
|
||||
_, _ = fmt.Fprintf(inv.Stdout, " Anthropic endpoint: %s/v1/messages\n", srv.APIAddress())
|
||||
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = []serpent.Option{
|
||||
{
|
||||
Flag: "address",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_ADDRESS",
|
||||
Default: "localhost",
|
||||
Description: "Address to bind the mock LLM API server. Can include a port (e.g., 'localhost:8080' or ':8080'). Uses a random port if no port is specified.",
|
||||
Value: serpent.StringOf(&address),
|
||||
},
|
||||
{
|
||||
Flag: "artificial-latency",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_ARTIFICIAL_LATENCY",
|
||||
Default: "0s",
|
||||
Description: "Artificial latency to add to each response (e.g., 100ms, 1s). Simulates slow upstream processing.",
|
||||
Value: serpent.DurationOf(&artificialLatency),
|
||||
},
|
||||
{
|
||||
Flag: "response-payload-size",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_RESPONSE_PAYLOAD_SIZE",
|
||||
Default: "0",
|
||||
Description: "Size in bytes of the response payload. If 0, uses default context-aware responses.",
|
||||
Value: serpent.Int64Of(&responsePayloadSize),
|
||||
},
|
||||
{
|
||||
Flag: "pprof-enable",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_PPROF_ENABLE",
|
||||
Default: "false",
|
||||
Description: "Serve pprof metrics on the address defined by pprof-address.",
|
||||
Value: serpent.BoolOf(&pprofEnable),
|
||||
},
|
||||
{
|
||||
Flag: "pprof-address",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_PPROF_ADDRESS",
|
||||
Default: "127.0.0.1:6060",
|
||||
Description: "The bind address to serve pprof.",
|
||||
Value: serpent.StringOf(&pprofAddress),
|
||||
},
|
||||
{
|
||||
Flag: "trace-enable",
|
||||
Env: "CODER_SCALETEST_LLM_MOCK_TRACE_ENABLE",
|
||||
Default: "false",
|
||||
Description: "Whether application tracing data is collected. It exports to a backend configured by environment variables. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md.",
|
||||
Value: serpent.BoolOf(&traceEnable),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -65,6 +65,22 @@ func (r *RootCmd) organizationSettings(orgContext *OrganizationContext) *serpent
|
||||
return cli.OrganizationIDPSyncSettings(ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "workspace-sharing",
|
||||
Aliases: []string{"workspacesharing"},
|
||||
Short: "Workspace sharing settings for the organization.",
|
||||
Patch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID, input json.RawMessage) (any, error) {
|
||||
var req codersdk.WorkspaceSharingSettings
|
||||
err := json.Unmarshal(input, &req)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("unmarshalling workspace sharing settings: %w", err)
|
||||
}
|
||||
return cli.PatchWorkspaceSharingSettings(ctx, org.String(), req)
|
||||
},
|
||||
Fetch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID) (any, error) {
|
||||
return cli.WorkspaceSharingSettings(ctx, org.String())
|
||||
},
|
||||
},
|
||||
}
|
||||
cmd := &serpent.Command{
|
||||
Use: "settings",
|
||||
|
||||
@@ -34,6 +34,7 @@ type ParameterResolver struct {
|
||||
|
||||
promptRichParameters bool
|
||||
promptEphemeralParameters bool
|
||||
useParameterDefaults bool
|
||||
}
|
||||
|
||||
func (pr *ParameterResolver) WithLastBuildParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
|
||||
@@ -86,8 +87,21 @@ func (pr *ParameterResolver) WithPromptEphemeralParameters(promptEphemeralParame
|
||||
return pr
|
||||
}
|
||||
|
||||
// Resolve gathers workspace build parameters in a layered fashion, applying values from various sources
|
||||
// in order of precedence: parameter file < CLI/ENV < source build < last build < preset < user input.
|
||||
func (pr *ParameterResolver) WithUseParameterDefaults(useParameterDefaults bool) *ParameterResolver {
|
||||
pr.useParameterDefaults = useParameterDefaults
|
||||
return pr
|
||||
}
|
||||
|
||||
// Resolve gathers workspace build parameters in a layered fashion, applying
|
||||
// values from various sources in order of precedence:
|
||||
// 1. template defaults (if auto-accepting defaults)
|
||||
// 2. cli parameter defaults (if auto-accepting defaults)
|
||||
// 3. parameter file
|
||||
// 4. CLI/ENV
|
||||
// 5. source build
|
||||
// 6. last build
|
||||
// 7. preset
|
||||
// 8. user input (unless auto-accepting defaults)
|
||||
func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) {
|
||||
var staged []codersdk.WorkspaceBuildParameter
|
||||
var err error
|
||||
@@ -262,9 +276,25 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild
|
||||
(action == WorkspaceUpdate && tvp.Mutable && tvp.Required) ||
|
||||
(action == WorkspaceUpdate && !tvp.Mutable && firstTimeUse) ||
|
||||
(tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) {
|
||||
parameterValue, err := cliui.RichParameter(inv, tvp, pr.richParametersDefaults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
name := tvp.Name
|
||||
if tvp.DisplayName != "" {
|
||||
name = tvp.DisplayName
|
||||
}
|
||||
|
||||
parameterValue := tvp.DefaultValue
|
||||
if v, ok := pr.richParametersDefaults[tvp.Name]; ok {
|
||||
parameterValue = v
|
||||
}
|
||||
|
||||
// Auto-accept the default if there is one.
|
||||
if pr.useParameterDefaults && parameterValue != "" {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Using default value for %s: '%s'\n", name, parameterValue)
|
||||
} else {
|
||||
var err error
|
||||
parameterValue, err = cliui.RichParameter(inv, tvp, name, parameterValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
resolved = append(resolved, codersdk.WorkspaceBuildParameter{
|
||||
|
||||
+10
-1
@@ -24,6 +24,7 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/mitchellh/go-wordwrap"
|
||||
"golang.org/x/mod/semver"
|
||||
@@ -150,7 +151,6 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
|
||||
r.promptExample(),
|
||||
r.rptyCommand(),
|
||||
r.syncCommand(),
|
||||
r.boundary(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +332,12 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
|
||||
// support links.
|
||||
return
|
||||
}
|
||||
if cmd.Name() == "boundary" {
|
||||
// The boundary command is integrated from the boundary package
|
||||
// and has YAML-only options (e.g., allowlist from config file)
|
||||
// that don't have flags or env vars.
|
||||
return
|
||||
}
|
||||
merr = errors.Join(
|
||||
merr,
|
||||
xerrors.Errorf("option %q in %q should have a flag or env", opt.Name, cmd.FullName()),
|
||||
@@ -923,6 +929,9 @@ func splitNamedWorkspace(identifier string) (owner string, workspaceName string,
|
||||
// a bare name (for a workspace owned by the current user) or a "user/workspace" combination,
|
||||
// where user is either a username or UUID.
|
||||
func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) {
|
||||
if uid, err := uuid.Parse(identifier); err == nil {
|
||||
return client.Workspace(ctx, uid)
|
||||
}
|
||||
owner, name, err := splitNamedWorkspace(identifier)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, err
|
||||
|
||||
+3
-3
@@ -197,7 +197,7 @@ func TestSharingStatus(t *testing.T) {
|
||||
ctx = testutil.Context(t, testutil.WaitMedium)
|
||||
)
|
||||
|
||||
err := workspaceOwnerClient.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{
|
||||
err := client.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{
|
||||
UserRoles: map[string]codersdk.WorkspaceRole{
|
||||
toShareWithUser.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
},
|
||||
@@ -248,7 +248,7 @@ func TestSharingRemove(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
// Share the workspace with a user to later remove
|
||||
err := workspaceOwnerClient.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{
|
||||
err := client.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{
|
||||
UserRoles: map[string]codersdk.WorkspaceRole{
|
||||
toShareWithUser.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
toRemoveUser.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
@@ -309,7 +309,7 @@ func TestSharingRemove(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
// Share the workspace with a user to later remove
|
||||
err := workspaceOwnerClient.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{
|
||||
err := client.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{
|
||||
UserRoles: map[string]codersdk.WorkspaceRole{
|
||||
toRemoveUser2.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
toRemoveUser1.ID.String(): codersdk.WorkspaceRoleUse,
|
||||
|
||||
+3
-2
@@ -1,8 +1,10 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -43,11 +45,11 @@ func (r *RootCmd) show() *serpent.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace: %w", err)
|
||||
}
|
||||
|
||||
options := cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: workspace.Name,
|
||||
ServerVersion: buildInfo.Version,
|
||||
ShowDetails: details,
|
||||
Title: fmt.Sprintf("%s/%s (%s since %s) %s:%s", workspace.OwnerName, workspace.Name, workspace.LatestBuild.Status, time.Since(workspace.LatestBuild.CreatedAt).Round(time.Second).String(), workspace.TemplateName, workspace.LatestBuild.TemplateVersionName),
|
||||
}
|
||||
if workspace.LatestBuild.Status == codersdk.WorkspaceStatusRunning {
|
||||
// Get listening ports for each agent.
|
||||
@@ -55,7 +57,6 @@ func (r *RootCmd) show() *serpent.Command {
|
||||
options.ListeningPorts = ports
|
||||
options.Devcontainers = devcontainers
|
||||
}
|
||||
|
||||
return cliui.WorkspaceResources(inv.Stdout, workspace.LatestBuild.Resources, options)
|
||||
},
|
||||
}
|
||||
|
||||
+10
-4
@@ -2,6 +2,7 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestShow(t *testing.T) {
|
||||
@@ -28,7 +30,7 @@ func TestShow(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
args := []string{
|
||||
"show",
|
||||
@@ -38,26 +40,30 @@ func TestShow(t *testing.T) {
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: fmt.Sprintf("%s/%s", workspace.OwnerName, workspace.Name)},
|
||||
{match: fmt.Sprintf("(%s since ", build.Status)},
|
||||
{match: fmt.Sprintf("%s:%s", workspace.TemplateName, workspace.LatestBuild.TemplateVersionName)},
|
||||
{match: "compute.main"},
|
||||
{match: "smith (linux, i386)"},
|
||||
{match: "coder ssh " + workspace.Name},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.ExpectMatchContext(ctx, m.match)
|
||||
if len(m.write) > 0 {
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+255
-6
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -44,13 +45,18 @@ var supportBundleBlurb = cliui.Bold("This will collect the following information
|
||||
` - Coder deployment version
|
||||
- Coder deployment Configuration (sanitized), including enabled experiments
|
||||
- Coder deployment health snapshot
|
||||
- Coder deployment stats (aggregated workspace/session metrics)
|
||||
- Entitlements (if available)
|
||||
- Health settings (dismissed healthchecks)
|
||||
- Coder deployment Network troubleshooting information
|
||||
- Workspace list accessible to the user (sanitized)
|
||||
- Workspace configuration, parameters, and build logs
|
||||
- Template version and source code for the given workspace
|
||||
- Agent details (with environment variable sanitized)
|
||||
- Agent network diagnostics
|
||||
- Agent logs
|
||||
- License status
|
||||
- pprof profiling data (if --pprof is enabled)
|
||||
` + cliui.Bold("Note: ") +
|
||||
cliui.Wrap("While we try to sanitize sensitive data from support bundles, we cannot guarantee that they do not contain information that you or your organization may consider sensitive.\n") +
|
||||
cliui.Bold("Please confirm that you will:\n") +
|
||||
@@ -61,6 +67,9 @@ var supportBundleBlurb = cliui.Bold("This will collect the following information
|
||||
func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
var outputPath string
|
||||
var coderURLOverride string
|
||||
var workspacesTotalCap64 int64 = 10
|
||||
var templateName string
|
||||
var pprof bool
|
||||
cmd := &serpent.Command{
|
||||
Use: "bundle <workspace> [<agent>]",
|
||||
Short: "Generate a support bundle to troubleshoot issues connecting to a workspace.",
|
||||
@@ -121,8 +130,9 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
}
|
||||
|
||||
var (
|
||||
wsID uuid.UUID
|
||||
agtID uuid.UUID
|
||||
wsID uuid.UUID
|
||||
agtID uuid.UUID
|
||||
templateID uuid.UUID
|
||||
)
|
||||
|
||||
if len(inv.Args) == 0 {
|
||||
@@ -155,6 +165,16 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve template by name if provided (captures active version)
|
||||
// Fallback: if canonical name lookup fails, match DisplayName (case-insensitive).
|
||||
if templateName != "" {
|
||||
id, err := resolveTemplateID(inv.Context(), client, templateName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
templateID = id
|
||||
}
|
||||
|
||||
if outputPath == "" {
|
||||
cwd, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
@@ -176,12 +196,25 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
if r.verbose {
|
||||
clientLog.AppendSinks(sloghuman.Sink(inv.Stderr))
|
||||
}
|
||||
if pprof {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "pprof data collection will take approximately 30 seconds...")
|
||||
}
|
||||
|
||||
// Bypass rate limiting for support bundle collection since it makes many API calls.
|
||||
client.HTTPClient.Transport = &codersdk.HeaderTransport{
|
||||
Transport: client.HTTPClient.Transport,
|
||||
Header: http.Header{codersdk.BypassRatelimitHeader: {"true"}},
|
||||
}
|
||||
|
||||
deps := support.Deps{
|
||||
Client: client,
|
||||
// Support adds a sink so we don't need to supply one ourselves.
|
||||
Log: clientLog,
|
||||
WorkspaceID: wsID,
|
||||
AgentID: agtID,
|
||||
Log: clientLog,
|
||||
WorkspaceID: wsID,
|
||||
AgentID: agtID,
|
||||
WorkspacesTotalCap: int(workspacesTotalCap64),
|
||||
TemplateID: templateID,
|
||||
CollectPprof: pprof,
|
||||
}
|
||||
|
||||
bun, err := support.Run(inv.Context(), &deps)
|
||||
@@ -217,11 +250,102 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
Description: "Override the URL to your Coder deployment. This may be useful, for example, if you need to troubleshoot a specific Coder replica.",
|
||||
Value: serpent.StringOf(&coderURLOverride),
|
||||
},
|
||||
{
|
||||
Flag: "workspaces-total-cap",
|
||||
Env: "CODER_SUPPORT_BUNDLE_WORKSPACES_TOTAL_CAP",
|
||||
Description: "Maximum number of workspaces to include in the support bundle. Set to 0 or negative value to disable the cap. Defaults to 10.",
|
||||
Value: serpent.Int64Of(&workspacesTotalCap64),
|
||||
},
|
||||
{
|
||||
Flag: "template",
|
||||
Env: "CODER_SUPPORT_BUNDLE_TEMPLATE",
|
||||
Description: "Template name to include in the support bundle. Use org_name/template_name if template name is reused across multiple organizations.",
|
||||
Value: serpent.StringOf(&templateName),
|
||||
},
|
||||
{
|
||||
Flag: "pprof",
|
||||
Env: "CODER_SUPPORT_BUNDLE_PPROF",
|
||||
Description: "Collect pprof profiling data from the Coder server and agent. Requires Coder server version 2.28.0 or newer.",
|
||||
Value: serpent.BoolOf(&pprof),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Resolve a template to its ID, supporting:
|
||||
// - org/name form
|
||||
// - slug or display name match (case-insensitive) across all memberships
|
||||
func resolveTemplateID(ctx context.Context, client *codersdk.Client, templateArg string) (uuid.UUID, error) {
|
||||
orgPart := ""
|
||||
namePart := templateArg
|
||||
if slash := strings.IndexByte(templateArg, '/'); slash > 0 && slash < len(templateArg)-1 {
|
||||
orgPart = templateArg[:slash]
|
||||
namePart = templateArg[slash+1:]
|
||||
}
|
||||
|
||||
resolveInOrg := func(orgID uuid.UUID) (codersdk.Template, bool, error) {
|
||||
if t, err := client.TemplateByName(ctx, orgID, namePart); err == nil {
|
||||
return t, true, nil
|
||||
}
|
||||
tpls, err := client.TemplatesByOrganization(ctx, orgID)
|
||||
if err != nil {
|
||||
return codersdk.Template{}, false, nil
|
||||
}
|
||||
for _, t := range tpls {
|
||||
if strings.EqualFold(t.Name, namePart) || strings.EqualFold(t.DisplayName, namePart) {
|
||||
return t, true, nil
|
||||
}
|
||||
}
|
||||
return codersdk.Template{}, false, nil
|
||||
}
|
||||
|
||||
if orgPart != "" {
|
||||
org, err := client.OrganizationByName(ctx, orgPart)
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("get organization %q: %w", orgPart, err)
|
||||
}
|
||||
t, found, err := resolveInOrg(org.ID)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if !found {
|
||||
return uuid.Nil, xerrors.Errorf("template %q not found in organization %q", namePart, orgPart)
|
||||
}
|
||||
return t.ID, nil
|
||||
}
|
||||
|
||||
orgs, err := client.OrganizationsByUser(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("get organizations: %w", err)
|
||||
}
|
||||
var (
|
||||
foundTpl codersdk.Template
|
||||
foundOrgs []string
|
||||
)
|
||||
for _, org := range orgs {
|
||||
if t, found, err := resolveInOrg(org.ID); err == nil && found {
|
||||
if len(foundOrgs) == 0 {
|
||||
foundTpl = t
|
||||
}
|
||||
foundOrgs = append(foundOrgs, org.Name)
|
||||
}
|
||||
}
|
||||
switch len(foundOrgs) {
|
||||
case 0:
|
||||
return uuid.Nil, xerrors.Errorf("template %q not found in your organizations", namePart)
|
||||
case 1:
|
||||
return foundTpl.ID, nil
|
||||
default:
|
||||
return uuid.Nil, xerrors.Errorf(
|
||||
"template %q found in multiple organizations (%s); use --template \"<org_name/%s>\" to target desired template.",
|
||||
namePart,
|
||||
strings.Join(foundOrgs, ", "),
|
||||
namePart,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeBundle makes a best-effort attempt to write a short summary
|
||||
// of the support bundle to the user's terminal.
|
||||
func summarizeBundle(inv *serpent.Invocation, bun *support.Bundle) {
|
||||
@@ -283,6 +407,10 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
|
||||
"deployment/config.json": src.Deployment.Config,
|
||||
"deployment/experiments.json": src.Deployment.Experiments,
|
||||
"deployment/health.json": src.Deployment.HealthReport,
|
||||
"deployment/stats.json": src.Deployment.Stats,
|
||||
"deployment/entitlements.json": src.Deployment.Entitlements,
|
||||
"deployment/health_settings.json": src.Deployment.HealthSettings,
|
||||
"deployment/workspaces.json": src.Deployment.Workspaces,
|
||||
"network/connection_info.json": src.Network.ConnectionInfo,
|
||||
"network/netcheck.json": src.Network.Netcheck,
|
||||
"network/interfaces.json": src.Network.Interfaces,
|
||||
@@ -302,6 +430,49 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Include named template artifacts (if requested)
|
||||
if src.NamedTemplate.Template.ID != uuid.Nil {
|
||||
name := src.NamedTemplate.Template.Name
|
||||
// JSON files
|
||||
for k, v := range map[string]any{
|
||||
"templates/" + name + "/template.json": src.NamedTemplate.Template,
|
||||
"templates/" + name + "/template_version.json": src.NamedTemplate.TemplateVersion,
|
||||
} {
|
||||
f, err := dest.Create(k)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create file %q in archive: %w", k, err)
|
||||
}
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return xerrors.Errorf("write json to %q: %w", k, err)
|
||||
}
|
||||
}
|
||||
// Binary template file (zip)
|
||||
if namedZipBytes, err := base64.StdEncoding.DecodeString(src.NamedTemplate.TemplateFileBase64); err == nil {
|
||||
k := "templates/" + name + "/template_file.zip"
|
||||
f, err := dest.Create(k)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create file %q in archive: %w", k, err)
|
||||
}
|
||||
if _, err := f.Write(namedZipBytes); err != nil {
|
||||
return xerrors.Errorf("write file %q in archive: %w", k, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buildInfoRef string
|
||||
if src.Deployment.BuildInfo != nil {
|
||||
if raw, err := json.Marshal(src.Deployment.BuildInfo); err == nil {
|
||||
buildInfoRef = base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
}
|
||||
|
||||
tailnetHTML := src.Network.TailnetDebug
|
||||
if buildInfoRef != "" {
|
||||
tailnetHTML += "\n<!-- trace " + buildInfoRef + " -->"
|
||||
}
|
||||
|
||||
templateVersionBytes, err := base64.StdEncoding.DecodeString(src.Workspace.TemplateFileBase64)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decode template zip from base64")
|
||||
@@ -319,10 +490,11 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
|
||||
"agent/client_magicsock.html": string(src.Agent.ClientMagicsockHTML),
|
||||
"agent/startup_logs.txt": humanizeAgentLogs(src.Agent.StartupLogs),
|
||||
"agent/prometheus.txt": string(src.Agent.Prometheus),
|
||||
"deployment/prometheus.txt": string(src.Deployment.Prometheus),
|
||||
"cli_logs.txt": string(src.CLILogs),
|
||||
"logs.txt": strings.Join(src.Logs, "\n"),
|
||||
"network/coordinator_debug.html": src.Network.CoordinatorDebug,
|
||||
"network/tailnet_debug.html": src.Network.TailnetDebug,
|
||||
"network/tailnet_debug.html": tailnetHTML,
|
||||
"workspace/build_logs.txt": humanizeBuildLogs(src.Workspace.BuildLogs),
|
||||
"workspace/template_file.zip": string(templateVersionBytes),
|
||||
"license-status.txt": licenseStatus,
|
||||
@@ -335,12 +507,89 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
|
||||
return xerrors.Errorf("write file %q in archive: %w", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write pprof binary data
|
||||
if err := writePprofData(src.Pprof, dest); err != nil {
|
||||
return xerrors.Errorf("write pprof data: %w", err)
|
||||
}
|
||||
|
||||
if err := dest.Close(); err != nil {
|
||||
return xerrors.Errorf("close zip file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePprofData(pprof support.Pprof, dest *zip.Writer) error {
|
||||
// Write server pprof data directly to pprof directory
|
||||
if pprof.Server != nil {
|
||||
if err := writePprofCollection("pprof", pprof.Server, dest); err != nil {
|
||||
return xerrors.Errorf("write server pprof data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write agent pprof data
|
||||
if pprof.Agent != nil {
|
||||
if err := writePprofCollection("pprof/agent", pprof.Agent, dest); err != nil {
|
||||
return xerrors.Errorf("write agent pprof data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePprofCollection(basePath string, collection *support.PprofCollection, dest *zip.Writer) error {
|
||||
// Define the pprof files to write with their extensions
|
||||
files := map[string][]byte{
|
||||
"allocs.prof.gz": collection.Allocs,
|
||||
"heap.prof.gz": collection.Heap,
|
||||
"profile.prof.gz": collection.Profile,
|
||||
"block.prof.gz": collection.Block,
|
||||
"mutex.prof.gz": collection.Mutex,
|
||||
"goroutine.prof.gz": collection.Goroutine,
|
||||
"threadcreate.prof.gz": collection.Threadcreate,
|
||||
"trace.gz": collection.Trace,
|
||||
}
|
||||
|
||||
// Write binary pprof files
|
||||
for filename, data := range files {
|
||||
if len(data) > 0 {
|
||||
filePath := basePath + "/" + filename
|
||||
f, err := dest.Create(filePath)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create pprof file %q: %w", filePath, err)
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return xerrors.Errorf("write pprof file %q: %w", filePath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write cmdline as text file
|
||||
if collection.Cmdline != "" {
|
||||
filePath := basePath + "/cmdline.txt"
|
||||
f, err := dest.Create(filePath)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create cmdline file %q: %w", filePath, err)
|
||||
}
|
||||
if _, err := f.Write([]byte(collection.Cmdline)); err != nil {
|
||||
return xerrors.Errorf("write cmdline file %q: %w", filePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
if collection.Symbol != "" {
|
||||
filePath := basePath + "/symbol.txt"
|
||||
f, err := dest.Create(filePath)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create symbol file %q: %w", filePath, err)
|
||||
}
|
||||
if _, err := f.Write([]byte(collection.Symbol)); err != nil {
|
||||
return xerrors.Errorf("write symbol file %q: %w", filePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func humanizeAgentLogs(ls []codersdk.WorkspaceAgentLog) string {
|
||||
var buf bytes.Buffer
|
||||
tw := tabwriter.NewWriter(&buf, 0, 2, 1, ' ', 0)
|
||||
|
||||
@@ -46,6 +46,8 @@ func TestSupportBundle(t *testing.T) {
|
||||
|
||||
// Support bundle tests can share a single coderdtest instance.
|
||||
var dc codersdk.DeploymentConfig
|
||||
dc.Values = coderdtest.DeploymentValues(t)
|
||||
dc.Values.Prometheus.Enable = true
|
||||
secretValue := uuid.NewString()
|
||||
seedSecretDeploymentOptions(t, &dc, secretValue)
|
||||
client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
@@ -203,6 +205,10 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge
|
||||
var v codersdk.DeploymentConfig
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotEmpty(t, v, "deployment config should not be empty")
|
||||
case "deployment/entitlements.json":
|
||||
var v codersdk.Entitlements
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotNil(t, v, "entitlements should not be nil")
|
||||
case "deployment/experiments.json":
|
||||
var v codersdk.Experiments
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
@@ -211,6 +217,22 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge
|
||||
var v healthsdk.HealthcheckReport
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotEmpty(t, v, "health report should not be empty")
|
||||
case "deployment/health_settings.json":
|
||||
var v healthsdk.HealthSettings
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotEmpty(t, v, "health settings should not be empty")
|
||||
case "deployment/stats.json":
|
||||
var v codersdk.DeploymentStats
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotNil(t, v, "deployment stats should not be nil")
|
||||
case "deployment/workspaces.json":
|
||||
var v codersdk.Workspace
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotNil(t, v, "deployment workspaces should not be nil")
|
||||
case "deployment/prometheus.txt":
|
||||
bs := readBytesFromZip(t, f)
|
||||
require.NotEmpty(t, bs, "prometheus metrics should not be empty")
|
||||
require.Contains(t, string(bs), "go_goroutines", "prometheus metrics should contain go runtime metrics")
|
||||
case "network/connection_info.json":
|
||||
var v workspacesdk.AgentConnectionInfo
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ OPTIONS:
|
||||
configured in the workspace template is used.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+4
-1
@@ -49,8 +49,11 @@ OPTIONS:
|
||||
--template-version string, $CODER_TEMPLATE_VERSION
|
||||
Specify a template version name.
|
||||
|
||||
--use-parameter-defaults bool, $CODER_WORKSPACE_USE_PARAMETER_DEFAULTS
|
||||
Automatically accept parameter defaults when no value is provided.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ OPTIONS:
|
||||
resources.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ OPTIONS:
|
||||
empty, will use $HOME.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -18,7 +18,7 @@ OPTIONS:
|
||||
Reads stdin for the json role definition to upload.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -23,7 +23,7 @@ OPTIONS:
|
||||
Reads stdin for the json role definition to upload.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -15,6 +15,7 @@ SUBCOMMANDS:
|
||||
memberships from an IdP.
|
||||
role-sync Role sync settings to sync organization roles from an
|
||||
IdP.
|
||||
workspace-sharing Workspace sharing settings for the organization.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -15,6 +15,7 @@ SUBCOMMANDS:
|
||||
memberships from an IdP.
|
||||
role-sync Role sync settings to sync organization roles from an
|
||||
IdP.
|
||||
workspace-sharing Workspace sharing settings for the organization.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -15,6 +15,7 @@ SUBCOMMANDS:
|
||||
memberships from an IdP.
|
||||
role-sync Role sync settings to sync organization roles from an
|
||||
IdP.
|
||||
workspace-sharing Workspace sharing settings for the organization.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -15,6 +15,7 @@ SUBCOMMANDS:
|
||||
memberships from an IdP.
|
||||
role-sync Role sync settings to sync organization roles from an
|
||||
IdP.
|
||||
workspace-sharing Workspace sharing settings for the organization.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ OPTIONS:
|
||||
services it's registered with.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ OPTIONS:
|
||||
pairs for the parameters.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+40
-4
@@ -15,9 +15,11 @@ SUBCOMMANDS:
|
||||
|
||||
OPTIONS:
|
||||
--allow-workspace-renames bool, $CODER_ALLOW_WORKSPACE_RENAMES (default: false)
|
||||
DEPRECATED: Allow users to rename their workspaces. Use only for
|
||||
temporary compatibility reasons, this will be removed in a future
|
||||
release.
|
||||
Allow users to rename their workspaces. WARNING: Renaming a workspace
|
||||
can cause Terraform resources that depend on the workspace name to be
|
||||
destroyed and recreated, potentially causing data loss. Only enable
|
||||
this if your templates do not use workspace names in resource
|
||||
identifiers, or if you understand the risks.
|
||||
|
||||
--cache-dir string, $CODER_CACHE_DIRECTORY (default: [cache dir])
|
||||
The directory to cache temporary files. If unspecified and
|
||||
@@ -109,11 +111,18 @@ AI BRIDGE OPTIONS:
|
||||
The access key secret to use with the access key to authenticate
|
||||
against the AWS Bedrock API.
|
||||
|
||||
--aibridge-bedrock-base-url string, $CODER_AIBRIDGE_BEDROCK_BASE_URL
|
||||
The base URL to use for the AWS Bedrock API. Use this setting to
|
||||
specify an exact URL to use. Takes precedence over
|
||||
CODER_AIBRIDGE_BEDROCK_REGION.
|
||||
|
||||
--aibridge-bedrock-model string, $CODER_AIBRIDGE_BEDROCK_MODEL (default: global.anthropic.claude-sonnet-4-5-20250929-v1:0)
|
||||
The model to use when making requests to the AWS Bedrock API.
|
||||
|
||||
--aibridge-bedrock-region string, $CODER_AIBRIDGE_BEDROCK_REGION
|
||||
The AWS Bedrock API region.
|
||||
The AWS Bedrock API region to use. Constructs a base URL to use for
|
||||
the AWS Bedrock API in the form of
|
||||
'https://bedrock-runtime.<region>.amazonaws.com'.
|
||||
|
||||
--aibridge-bedrock-small-fastmodel string, $CODER_AIBRIDGE_BEDROCK_SMALL_FAST_MODEL (default: global.anthropic.claude-haiku-4-5-20251001-v1:0)
|
||||
The small fast model to use when making requests to the AWS Bedrock
|
||||
@@ -121,6 +130,10 @@ AI BRIDGE OPTIONS:
|
||||
See
|
||||
https://docs.claude.com/en/docs/claude-code/settings#environment-variables.
|
||||
|
||||
--aibridge-circuit-breaker-enabled bool, $CODER_AIBRIDGE_CIRCUIT_BREAKER_ENABLED (default: false)
|
||||
Enable the circuit breaker to protect against cascading failures from
|
||||
upstream AI provider rate limits (429, 503, 529 overloaded).
|
||||
|
||||
--aibridge-retention duration, $CODER_AIBRIDGE_RETENTION (default: 60d)
|
||||
Length of time to retain data such as interceptions and all related
|
||||
records (token, prompt, tool use).
|
||||
@@ -147,6 +160,18 @@ AI BRIDGE OPTIONS:
|
||||
Maximum number of AI Bridge requests per second per replica. Set to 0
|
||||
to disable (unlimited).
|
||||
|
||||
--aibridge-send-actor-headers bool, $CODER_AIBRIDGE_SEND_ACTOR_HEADERS (default: false)
|
||||
Once enabled, extra headers will be added to upstream requests to
|
||||
identify the user (actor) making requests to AI Bridge. This is only
|
||||
needed if you are using a proxy between AI Bridge and an upstream AI
|
||||
provider. This will send X-Ai-Bridge-Actor-Id (the ID of the user
|
||||
making the request) and X-Ai-Bridge-Actor-Metadata-Username (their
|
||||
username).
|
||||
|
||||
--aibridge-structured-logging bool, $CODER_AIBRIDGE_STRUCTURED_LOGGING (default: false)
|
||||
Emit structured logs for AI Bridge interception records. Use this for
|
||||
exporting these records to external SIEM or observability systems.
|
||||
|
||||
AI BRIDGE PROXY OPTIONS:
|
||||
--aibridge-proxy-cert-file string, $CODER_AIBRIDGE_PROXY_CERT_FILE
|
||||
Path to the CA certificate file for AI Bridge Proxy.
|
||||
@@ -161,6 +186,17 @@ AI BRIDGE PROXY OPTIONS:
|
||||
--aibridge-proxy-listen-addr string, $CODER_AIBRIDGE_PROXY_LISTEN_ADDR (default: :8888)
|
||||
The address the AI Bridge Proxy will listen on.
|
||||
|
||||
--aibridge-proxy-upstream string, $CODER_AIBRIDGE_PROXY_UPSTREAM
|
||||
URL of an upstream HTTP proxy to chain tunneled (non-allowlisted)
|
||||
requests through. Format: http://[user:pass@]host:port or
|
||||
https://[user:pass@]host:port.
|
||||
|
||||
--aibridge-proxy-upstream-ca string, $CODER_AIBRIDGE_PROXY_UPSTREAM_CA
|
||||
Path to a PEM-encoded CA certificate to trust for the upstream proxy's
|
||||
TLS connection. Only needed for HTTPS upstream proxies with
|
||||
certificates not trusted by the system. If not provided, the system
|
||||
certificate pool is used.
|
||||
|
||||
CLIENT OPTIONS:
|
||||
These options change the behavior of how clients interact with the Coder.
|
||||
Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ OPTIONS:
|
||||
pairs for the parameters.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+14
-1
@@ -14,12 +14,25 @@ OPTIONS:
|
||||
File path for writing the generated support bundle. Defaults to
|
||||
coder-support-$(date +%s).zip.
|
||||
|
||||
--pprof bool, $CODER_SUPPORT_BUNDLE_PPROF
|
||||
Collect pprof profiling data from the Coder server and agent. Requires
|
||||
Coder server version 2.28.0 or newer.
|
||||
|
||||
--template string, $CODER_SUPPORT_BUNDLE_TEMPLATE
|
||||
Template name to include in the support bundle. Use
|
||||
org_name/template_name if template name is reused across multiple
|
||||
organizations.
|
||||
|
||||
--url-override string, $CODER_SUPPORT_BUNDLE_URL_OVERRIDE
|
||||
Override the URL to your Coder deployment. This may be useful, for
|
||||
example, if you need to troubleshoot a specific Coder replica.
|
||||
|
||||
--workspaces-total-cap int, $CODER_SUPPORT_BUNDLE_WORKSPACES_TOTAL_CAP
|
||||
Maximum number of workspaces to include in the support bundle. Set to
|
||||
0 or negative value to disable the cap. Defaults to 10.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ USAGE:
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ OPTIONS:
|
||||
versions are archived.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ OPTIONS:
|
||||
Specify a file path with values for Terraform-managed variables.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ OPTIONS:
|
||||
Select which organization (uuid or name) to use.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ OPTIONS:
|
||||
for more details.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ OPTIONS:
|
||||
the template version to pull.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
--zip bool
|
||||
Output the template as a zip archive to stdout.
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ OPTIONS:
|
||||
Specify a file path with values for Terraform-managed variables.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -11,7 +11,7 @@ OPTIONS:
|
||||
Select which organization (uuid or name) to use.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -11,7 +11,7 @@ OPTIONS:
|
||||
Select which organization (uuid or name) to use.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ OPTIONS:
|
||||
the user may have.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+56
-9
@@ -575,8 +575,10 @@ userQuietHoursSchedule:
|
||||
# change their quiet hours schedule and the site default is always used.
|
||||
# (default: true, type: bool)
|
||||
allowCustomQuietHours: true
|
||||
# DEPRECATED: Allow users to rename their workspaces. Use only for temporary
|
||||
# compatibility reasons, this will be removed in a future release.
|
||||
# Allow users to rename their workspaces. WARNING: Renaming a workspace can cause
|
||||
# Terraform resources that depend on the workspace name to be destroyed and
|
||||
# recreated, potentially causing data loss. Only enable this if your templates do
|
||||
# not use workspace names in resource identifiers, or if you understand the risks.
|
||||
# (default: false, type: bool)
|
||||
allowWorkspaceRenames: false
|
||||
# Configure how emails are sent.
|
||||
@@ -746,7 +748,12 @@ aibridge:
|
||||
# The base URL of the Anthropic API.
|
||||
# (default: https://api.anthropic.com/, type: string)
|
||||
anthropic_base_url: https://api.anthropic.com/
|
||||
# The AWS Bedrock API region.
|
||||
# The base URL to use for the AWS Bedrock API. Use this setting to specify an
|
||||
# exact URL to use. Takes precedence over CODER_AIBRIDGE_BEDROCK_REGION.
|
||||
# (default: <unset>, type: string)
|
||||
bedrock_base_url: ""
|
||||
# The AWS Bedrock API region to use. Constructs a base URL to use for the AWS
|
||||
# Bedrock API in the form of 'https://bedrock-runtime.<region>.amazonaws.com'.
|
||||
# (default: <unset>, type: string)
|
||||
bedrock_region: ""
|
||||
# The model to use when making requests to the AWS Bedrock API.
|
||||
@@ -768,11 +775,39 @@ aibridge:
|
||||
# Maximum number of concurrent AI Bridge requests per replica. Set to 0 to disable
|
||||
# (unlimited).
|
||||
# (default: 0, type: int)
|
||||
maxConcurrency: 0
|
||||
max_concurrency: 0
|
||||
# Maximum number of AI Bridge requests per second per replica. Set to 0 to disable
|
||||
# (unlimited).
|
||||
# (default: 0, type: int)
|
||||
rateLimit: 0
|
||||
rate_limit: 0
|
||||
# Emit structured logs for AI Bridge interception records. Use this for exporting
|
||||
# these records to external SIEM or observability systems.
|
||||
# (default: false, type: bool)
|
||||
structured_logging: false
|
||||
# Once enabled, extra headers will be added to upstream requests to identify the
|
||||
# user (actor) making requests to AI Bridge. This is only needed if you are using
|
||||
# a proxy between AI Bridge and an upstream AI provider. This will send
|
||||
# X-Ai-Bridge-Actor-Id (the ID of the user making the request) and
|
||||
# X-Ai-Bridge-Actor-Metadata-Username (their username).
|
||||
# (default: false, type: bool)
|
||||
send_actor_headers: false
|
||||
# Enable the circuit breaker to protect against cascading failures from upstream
|
||||
# AI provider rate limits (429, 503, 529 overloaded).
|
||||
# (default: false, type: bool)
|
||||
circuit_breaker_enabled: false
|
||||
# Number of consecutive failures that triggers the circuit breaker to open.
|
||||
# (default: 5, type: int)
|
||||
circuit_breaker_failure_threshold: 5
|
||||
# Cyclic period of the closed state for clearing internal failure counts.
|
||||
# (default: 10s, type: duration)
|
||||
circuit_breaker_interval: 10s
|
||||
# How long the circuit breaker stays open before transitioning to half-open state.
|
||||
# (default: 30s, type: duration)
|
||||
circuit_breaker_timeout: 30s
|
||||
# Maximum number of requests allowed in half-open state before deciding to close
|
||||
# or re-open the circuit.
|
||||
# (default: 3, type: int)
|
||||
circuit_breaker_max_requests: 3
|
||||
aibridgeproxy:
|
||||
# Enable the AI Bridge MITM Proxy for intercepting and decrypting AI provider
|
||||
# requests.
|
||||
@@ -787,13 +822,25 @@ aibridgeproxy:
|
||||
# Path to the CA private key file for AI Bridge Proxy.
|
||||
# (default: <unset>, type: string)
|
||||
key_file: ""
|
||||
# Comma-separated list of domains for which HTTPS traffic will be decrypted and
|
||||
# routed through AI Bridge. Requests to other domains will be tunneled directly
|
||||
# without decryption.
|
||||
# (default: api.anthropic.com,api.openai.com, type: string-array)
|
||||
# Comma-separated list of AI provider domains for which HTTPS traffic will be
|
||||
# decrypted and routed through AI Bridge. Requests to other domains will be
|
||||
# tunneled directly without decryption. Supported domains: api.anthropic.com,
|
||||
# api.openai.com, api.individual.githubcopilot.com.
|
||||
# (default: api.anthropic.com,api.openai.com,api.individual.githubcopilot.com,
|
||||
# type: string-array)
|
||||
domain_allowlist:
|
||||
- api.anthropic.com
|
||||
- api.openai.com
|
||||
- api.individual.githubcopilot.com
|
||||
# URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests
|
||||
# through. Format: http://[user:pass@]host:port or https://[user:pass@]host:port.
|
||||
# (default: <unset>, type: string)
|
||||
upstream_proxy: ""
|
||||
# Path to a PEM-encoded CA certificate to trust for the upstream proxy's TLS
|
||||
# connection. Only needed for HTTPS upstream proxies with certificates not trusted
|
||||
# by the system. If not provided, the system certificate pool is used.
|
||||
# (default: <unset>, type: string)
|
||||
upstream_proxy_ca: ""
|
||||
# Configure data retention policies for various database tables. Retention
|
||||
# policies automatically purge old data to reduce database size and improve
|
||||
# performance. Setting a retention duration to 0 disables automatic purging for
|
||||
|
||||
+16
-7
@@ -17,8 +17,10 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/agentapi/metadatabatcher"
|
||||
"github.com/coder/coder/v2/coderd/agentapi/resourcesmonitor"
|
||||
"github.com/coder/coder/v2/coderd/appearance"
|
||||
"github.com/coder/coder/v2/coderd/boundaryusage"
|
||||
"github.com/coder/coder/v2/coderd/connectionlog"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
@@ -65,10 +67,11 @@ type API struct {
|
||||
var _ agentproto.DRPCAgentServer = &API{}
|
||||
|
||||
type Options struct {
|
||||
AgentID uuid.UUID
|
||||
OwnerID uuid.UUID
|
||||
WorkspaceID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
AgentID uuid.UUID
|
||||
OwnerID uuid.UUID
|
||||
WorkspaceID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
TemplateVersionID uuid.UUID
|
||||
|
||||
AuthenticatedCtx context.Context
|
||||
Log slog.Logger
|
||||
@@ -80,10 +83,12 @@ type Options struct {
|
||||
DerpMapFn func() *tailcfg.DERPMap
|
||||
TailnetCoordinator *atomic.Pointer[tailnet.Coordinator]
|
||||
StatsReporter *workspacestats.Reporter
|
||||
MetadataBatcher *metadatabatcher.Batcher
|
||||
AppearanceFetcher *atomic.Pointer[appearance.Fetcher]
|
||||
PublishWorkspaceUpdateFn func(ctx context.Context, userID uuid.UUID, event wspubsub.WorkspaceEvent)
|
||||
PublishWorkspaceAgentLogsUpdateFn func(ctx context.Context, workspaceAgentID uuid.UUID, msg agentsdk.LogsNotifyMessage)
|
||||
NetworkTelemetryHandler func(batch []*tailnetproto.TelemetryEvent)
|
||||
BoundaryUsageTracker *boundaryusage.Tracker
|
||||
|
||||
AccessURL *url.URL
|
||||
AppHostname string
|
||||
@@ -178,8 +183,8 @@ func New(opts Options, workspace database.Workspace) *API {
|
||||
AgentFn: api.agent,
|
||||
Workspace: api.cachedWorkspaceFields,
|
||||
Database: opts.Database,
|
||||
Pubsub: opts.Pubsub,
|
||||
Log: opts.Log,
|
||||
Batcher: opts.MetadataBatcher,
|
||||
}
|
||||
|
||||
api.LogsAPI = &LogsAPI{
|
||||
@@ -221,8 +226,12 @@ func New(opts Options, workspace database.Workspace) *API {
|
||||
}
|
||||
|
||||
api.BoundaryLogsAPI = &BoundaryLogsAPI{
|
||||
Log: opts.Log,
|
||||
WorkspaceID: opts.WorkspaceID,
|
||||
Log: opts.Log,
|
||||
WorkspaceID: opts.WorkspaceID,
|
||||
OwnerID: opts.OwnerID,
|
||||
TemplateID: workspace.TemplateID,
|
||||
TemplateVersionID: opts.TemplateVersionID,
|
||||
BoundaryUsageTracker: opts.BoundaryUsageTracker,
|
||||
}
|
||||
|
||||
// Start background cache refresh loop to handle workspace changes
|
||||
|
||||
@@ -8,14 +8,21 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/boundaryusage"
|
||||
)
|
||||
|
||||
type BoundaryLogsAPI struct {
|
||||
Log slog.Logger
|
||||
WorkspaceID uuid.UUID
|
||||
Log slog.Logger
|
||||
WorkspaceID uuid.UUID
|
||||
OwnerID uuid.UUID
|
||||
TemplateID uuid.UUID
|
||||
TemplateVersionID uuid.UUID
|
||||
BoundaryUsageTracker *boundaryusage.Tracker
|
||||
}
|
||||
|
||||
func (a *BoundaryLogsAPI) ReportBoundaryLogs(ctx context.Context, req *agentproto.ReportBoundaryLogsRequest) (*agentproto.ReportBoundaryLogsResponse, error) {
|
||||
var allowed, denied int64
|
||||
|
||||
for _, l := range req.Logs {
|
||||
var logTime time.Time
|
||||
if l.Time != nil {
|
||||
@@ -30,9 +37,17 @@ func (a *BoundaryLogsAPI) ReportBoundaryLogs(ctx context.Context, req *agentprot
|
||||
continue
|
||||
}
|
||||
|
||||
if l.Allowed {
|
||||
allowed++
|
||||
} else {
|
||||
denied++
|
||||
}
|
||||
|
||||
fields := []slog.Field{
|
||||
slog.F("decision", allowBoolToString(l.Allowed)),
|
||||
slog.F("workspace_id", a.WorkspaceID.String()),
|
||||
slog.F("template_id", a.TemplateID.String()),
|
||||
slog.F("template_version_id", a.TemplateVersionID.String()),
|
||||
slog.F("http_method", r.HttpRequest.Method),
|
||||
slog.F("http_url", r.HttpRequest.Url),
|
||||
slog.F("event_time", logTime.Format(time.RFC3339Nano)),
|
||||
@@ -48,6 +63,10 @@ func (a *BoundaryLogsAPI) ReportBoundaryLogs(ctx context.Context, req *agentprot
|
||||
}
|
||||
}
|
||||
|
||||
if a.BoundaryUsageTracker != nil && (allowed > 0 || denied > 0) {
|
||||
a.BoundaryUsageTracker.Track(a.WorkspaceID, a.OwnerID, allowed, denied)
|
||||
}
|
||||
|
||||
return &agentproto.ReportBoundaryLogsResponse{}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,27 +2,25 @@ package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/agentapi/metadatabatcher"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
)
|
||||
|
||||
type MetadataAPI struct {
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
Workspace *CachedWorkspaceFields
|
||||
Database database.Store
|
||||
Pubsub pubsub.Pubsub
|
||||
Log slog.Logger
|
||||
Batcher *metadatabatcher.Batcher
|
||||
|
||||
TimeNowFn func() time.Time // defaults to dbtime.Now()
|
||||
}
|
||||
@@ -122,21 +120,10 @@ func (a *MetadataAPI) BatchUpdateMetadata(ctx context.Context, req *agentproto.B
|
||||
)
|
||||
}
|
||||
|
||||
err = a.Database.UpdateWorkspaceAgentMetadata(rbacCtx, dbUpdate)
|
||||
// Use batcher to batch metadata updates.
|
||||
err = a.Batcher.Add(workspaceAgent.ID, dbUpdate.Key, dbUpdate.Value, dbUpdate.Error, dbUpdate.CollectedAt)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("update workspace agent metadata in database: %w", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(WorkspaceAgentMetadataChannelPayload{
|
||||
CollectedAt: collectedAt,
|
||||
Keys: dbUpdate.Key,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("marshal workspace agent metadata channel payload: %w", err)
|
||||
}
|
||||
err = a.Pubsub.Publish(WatchWorkspaceAgentMetadataChannel(workspaceAgent.ID), payload)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("publish workspace agent metadata: %w", err)
|
||||
return nil, xerrors.Errorf("add metadata to batcher: %w", err)
|
||||
}
|
||||
|
||||
// If the metadata keys were too large, we return an error so the agent can
|
||||
@@ -154,12 +141,3 @@ func ellipse(v string, n int) string {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
type WorkspaceAgentMetadataChannelPayload struct {
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
func WatchWorkspaceAgentMetadataChannel(id uuid.UUID) string {
|
||||
return "workspace_agent_metadata:" + id.String()
|
||||
}
|
||||
|
||||
@@ -2,44 +2,26 @@ package agentapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
prom_testutil "github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/agentapi"
|
||||
"github.com/coder/coder/v2/coderd/agentapi/metadatabatcher"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
type fakePublisher struct {
|
||||
// Nil pointer to pass interface check.
|
||||
pubsub.Pubsub
|
||||
publishes [][]byte
|
||||
}
|
||||
|
||||
var _ pubsub.Pubsub = &fakePublisher{}
|
||||
|
||||
func (f *fakePublisher) Publish(_ string, message []byte) error {
|
||||
f.publishes = append(f.publishes, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBatchUpdateMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -50,8 +32,12 @@ func TestBatchUpdateMetadata(t *testing.T) {
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbM := dbmock.NewMockStore(gomock.NewController(t))
|
||||
pub := &fakePublisher{}
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
store := dbmock.NewMockStore(ctrl)
|
||||
ps := pubsub.NewInMemory()
|
||||
reg := prometheus.NewRegistry()
|
||||
|
||||
now := dbtime.Now()
|
||||
req := &agentproto.BatchUpdateMetadataRequest{
|
||||
@@ -76,24 +62,30 @@ func TestBatchUpdateMetadata(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
batchSize := len(req.Metadata)
|
||||
// This test sends 2 metadata entries. With batch size 2, we expect
|
||||
// exactly 1 capacity flush.
|
||||
store.EXPECT().
|
||||
BatchUpdateWorkspaceAgentMetadata(gomock.Any(), gomock.Any()).
|
||||
Return(nil).
|
||||
Times(1)
|
||||
|
||||
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agent.ID,
|
||||
Key: []string{req.Metadata[0].Key, req.Metadata[1].Key},
|
||||
Value: []string{req.Metadata[0].Result.Value, req.Metadata[1].Result.Value},
|
||||
Error: []string{req.Metadata[0].Result.Error, req.Metadata[1].Result.Error},
|
||||
// The value from the agent is ignored.
|
||||
CollectedAt: []time.Time{now, now},
|
||||
}).Return(nil)
|
||||
// Create a real batcher for the test with batch size matching the number
|
||||
// of metadata entries to trigger exactly one capacity flush.
|
||||
batcher, err := metadatabatcher.NewBatcher(ctx, reg, store, ps,
|
||||
metadatabatcher.WithLogger(testutil.Logger(t)),
|
||||
metadatabatcher.WithBatchSize(batchSize),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(batcher.Close)
|
||||
|
||||
api := &agentapi.MetadataAPI{
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbM,
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
Batcher: batcher,
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
@@ -103,27 +95,33 @@ func TestBatchUpdateMetadata(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &agentproto.BatchUpdateMetadataResponse{}, resp)
|
||||
|
||||
require.Equal(t, 1, len(pub.publishes))
|
||||
var gotEvent agentapi.WorkspaceAgentMetadataChannelPayload
|
||||
require.NoError(t, json.Unmarshal(pub.publishes[0], &gotEvent))
|
||||
require.Equal(t, agentapi.WorkspaceAgentMetadataChannelPayload{
|
||||
CollectedAt: now,
|
||||
Keys: []string{req.Metadata[0].Key, req.Metadata[1].Key},
|
||||
}, gotEvent)
|
||||
// Wait for the capacity flush to complete before test ends.
|
||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
||||
return prom_testutil.ToFloat64(batcher.Metrics.MetadataTotal) == 2.0
|
||||
}, testutil.IntervalFast)
|
||||
})
|
||||
|
||||
t.Run("ExceededLength", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbM := dbmock.NewMockStore(gomock.NewController(t))
|
||||
pub := pubsub.NewInMemory()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
store := dbmock.NewMockStore(ctrl)
|
||||
ps := pubsub.NewInMemory()
|
||||
reg := prometheus.NewRegistry()
|
||||
|
||||
// This test sends 4 metadata entries with some exceeding length limits. We set the batchers batch size so that
|
||||
// we can reliably ensure a batch is sent within the WaitShort time period.
|
||||
store.EXPECT().
|
||||
BatchUpdateWorkspaceAgentMetadata(gomock.Any(), gomock.Any()).
|
||||
Return(nil).
|
||||
Times(1)
|
||||
|
||||
now := dbtime.Now()
|
||||
almostLongValue := ""
|
||||
for i := 0; i < 2048; i++ {
|
||||
almostLongValue += "a"
|
||||
}
|
||||
|
||||
now := dbtime.Now()
|
||||
req := &agentproto.BatchUpdateMetadataRequest{
|
||||
Metadata: []*agentproto.Metadata{
|
||||
{
|
||||
@@ -152,34 +150,21 @@ func TestBatchUpdateMetadata(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agent.ID,
|
||||
Key: []string{req.Metadata[0].Key, req.Metadata[1].Key, req.Metadata[2].Key, req.Metadata[3].Key},
|
||||
Value: []string{
|
||||
almostLongValue,
|
||||
almostLongValue, // truncated
|
||||
"",
|
||||
"",
|
||||
},
|
||||
Error: []string{
|
||||
"",
|
||||
"value of 2049 bytes exceeded 2048 bytes",
|
||||
almostLongValue,
|
||||
"error of 2049 bytes exceeded 2048 bytes", // replaced
|
||||
},
|
||||
// The value from the agent is ignored.
|
||||
CollectedAt: []time.Time{now, now, now, now},
|
||||
}).Return(nil)
|
||||
batchSize := len(req.Metadata)
|
||||
batcher, err := metadatabatcher.NewBatcher(ctx, reg, store, ps,
|
||||
metadatabatcher.WithLogger(testutil.Logger(t)),
|
||||
metadatabatcher.WithBatchSize(batchSize),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(batcher.Close)
|
||||
|
||||
api := &agentapi.MetadataAPI{
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbM,
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
Batcher: batcher,
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
@@ -188,13 +173,21 @@ func TestBatchUpdateMetadata(t *testing.T) {
|
||||
resp, err := api.BatchUpdateMetadata(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &agentproto.BatchUpdateMetadataResponse{}, resp)
|
||||
// Wait for the capacity flush to complete before test ends.
|
||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
||||
return prom_testutil.ToFloat64(batcher.Metrics.MetadataTotal) == 4.0
|
||||
}, testutil.IntervalFast)
|
||||
})
|
||||
|
||||
t.Run("KeysTooLong", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbM := dbmock.NewMockStore(gomock.NewController(t))
|
||||
pub := pubsub.NewInMemory()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
store := dbmock.NewMockStore(ctrl)
|
||||
ps := pubsub.NewInMemory()
|
||||
reg := prometheus.NewRegistry()
|
||||
|
||||
now := dbtime.Now()
|
||||
req := &agentproto.BatchUpdateMetadataRequest{
|
||||
@@ -231,595 +224,40 @@ func TestBatchUpdateMetadata(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
batchSize := len(req.Metadata)
|
||||
|
||||
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agent.ID,
|
||||
// No key 4.
|
||||
Key: []string{req.Metadata[0].Key, req.Metadata[1].Key, req.Metadata[2].Key},
|
||||
Value: []string{req.Metadata[0].Result.Value, req.Metadata[1].Result.Value, req.Metadata[2].Result.Value},
|
||||
Error: []string{req.Metadata[0].Result.Error, req.Metadata[1].Result.Error, req.Metadata[2].Result.Error},
|
||||
// The value from the agent is ignored.
|
||||
CollectedAt: []time.Time{now, now, now},
|
||||
}).Return(nil)
|
||||
// This test sends 4 metadata entries but rejects the last one due to excessive key length.
|
||||
// We set the batchers batch size so that we can reliably ensure a batch is sent within the WaitShort time period.
|
||||
store.EXPECT().
|
||||
BatchUpdateWorkspaceAgentMetadata(gomock.Any(), gomock.Any()).
|
||||
Return(nil).
|
||||
Times(1)
|
||||
|
||||
batcher, err := metadatabatcher.NewBatcher(ctx, reg, store, ps,
|
||||
metadatabatcher.WithLogger(testutil.Logger(t)),
|
||||
metadatabatcher.WithBatchSize(batchSize-1), // one of the keys will be rejected
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(batcher.Close)
|
||||
|
||||
api := &agentapi.MetadataAPI{
|
||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbM,
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
Batcher: batcher,
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
}
|
||||
|
||||
// Watch the pubsub for events.
|
||||
var (
|
||||
eventCount int64
|
||||
gotEvent agentapi.WorkspaceAgentMetadataChannelPayload
|
||||
)
|
||||
cancel, err := pub.Subscribe(agentapi.WatchWorkspaceAgentMetadataChannel(agent.ID), func(ctx context.Context, message []byte) {
|
||||
if atomic.AddInt64(&eventCount, 1) > 1 {
|
||||
return
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(message, &gotEvent))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer cancel()
|
||||
|
||||
resp, err := api.BatchUpdateMetadata(context.Background(), req)
|
||||
// Should return error because keys are too long.
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "metadata keys of 6145 bytes exceeded 6144 bytes", err.Error())
|
||||
require.Nil(t, resp)
|
||||
|
||||
require.Equal(t, int64(1), atomic.LoadInt64(&eventCount))
|
||||
require.Equal(t, agentapi.WorkspaceAgentMetadataChannelPayload{
|
||||
CollectedAt: now,
|
||||
// No key 4.
|
||||
Keys: []string{req.Metadata[0].Key, req.Metadata[1].Key, req.Metadata[2].Key},
|
||||
}, gotEvent)
|
||||
})
|
||||
|
||||
// Test RBAC fast path with valid RBAC object - should NOT call GetWorkspaceByAgentID
|
||||
// This test verifies that when a valid RBAC object is present in context, the dbauthz layer
|
||||
// uses the fast path and skips the GetWorkspaceByAgentID database call.
|
||||
t.Run("WorkspaceCached_SkipsDBCall", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
dbM = dbmock.NewMockStore(ctrl)
|
||||
pub = &fakePublisher{}
|
||||
now = dbtime.Now()
|
||||
// Set up consistent IDs that represent a valid workspace->agent relationship
|
||||
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
|
||||
templateID = uuid.MustParse("aaaabbbb-cccc-dddd-eeee-ffffffff0000")
|
||||
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
|
||||
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
agentID = uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
)
|
||||
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: agentID,
|
||||
// In a real scenario, this agent would belong to a resource in the workspace above
|
||||
}
|
||||
|
||||
req := &agentproto.BatchUpdateMetadataRequest{
|
||||
Metadata: []*agentproto.Metadata{
|
||||
{
|
||||
Key: "test_key",
|
||||
Result: &agentproto.WorkspaceAgentMetadata_Result{
|
||||
CollectedAt: timestamppb.New(now.Add(-time.Second)),
|
||||
Age: 1,
|
||||
Value: "test_value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Expect UpdateWorkspaceAgentMetadata to be called
|
||||
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agent.ID,
|
||||
Key: []string{"test_key"},
|
||||
Value: []string{"test_value"},
|
||||
Error: []string{""},
|
||||
CollectedAt: []time.Time{now},
|
||||
}).Return(nil)
|
||||
|
||||
// DO NOT expect GetWorkspaceByAgentID - the fast path should skip this call
|
||||
// If GetWorkspaceByAgentID is called, the test will fail with "unexpected call"
|
||||
|
||||
// dbauthz will call Wrappers() to check for wrapped databases
|
||||
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
|
||||
|
||||
// Set up dbauthz to test the actual authorization layer
|
||||
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
|
||||
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
|
||||
accessControlStore.Store(&acs)
|
||||
|
||||
api := &agentapi.MetadataAPI{
|
||||
AgentFn: func(_ context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
}
|
||||
|
||||
api.Workspace.UpdateValues(database.Workspace{
|
||||
ID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
})
|
||||
|
||||
// Create roles with workspace permissions
|
||||
userRoles := rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleMember(),
|
||||
User: []rbac.Permission{
|
||||
{
|
||||
Negate: false,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.WildcardSymbol,
|
||||
},
|
||||
},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{
|
||||
orgID.String(): {
|
||||
Member: []rbac.Permission{
|
||||
{
|
||||
Negate: false,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.WildcardSymbol,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
agentScope := rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
TemplateID: templateID,
|
||||
VersionID: uuid.New(),
|
||||
})
|
||||
|
||||
ctx := dbauthz.As(context.Background(), rbac.Subject{
|
||||
Type: rbac.SubjectTypeUser,
|
||||
FriendlyName: "testuser",
|
||||
Email: "testuser@example.com",
|
||||
ID: ownerID.String(),
|
||||
Roles: userRoles,
|
||||
Groups: []string{orgID.String()},
|
||||
Scope: agentScope,
|
||||
}.WithCachedASTValue())
|
||||
|
||||
resp, err := api.BatchUpdateMetadata(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
})
|
||||
// Test RBAC slow path - invalid RBAC object should fall back to GetWorkspaceByAgentID
|
||||
// This test verifies that when the RBAC object has invalid IDs (nil UUIDs), the dbauthz layer
|
||||
// falls back to the slow path and calls GetWorkspaceByAgentID.
|
||||
t.Run("InvalidWorkspaceCached_RequiresDBCall", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
dbM = dbmock.NewMockStore(ctrl)
|
||||
pub = &fakePublisher{}
|
||||
now = dbtime.Now()
|
||||
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
|
||||
templateID = uuid.MustParse("aaaabbbb-cccc-dddd-eeee-ffffffff0000")
|
||||
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
|
||||
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
agentID = uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
)
|
||||
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: agentID,
|
||||
}
|
||||
|
||||
req := &agentproto.BatchUpdateMetadataRequest{
|
||||
Metadata: []*agentproto.Metadata{
|
||||
{
|
||||
Key: "test_key",
|
||||
Result: &agentproto.WorkspaceAgentMetadata_Result{
|
||||
CollectedAt: timestamppb.New(now.Add(-time.Second)),
|
||||
Age: 1,
|
||||
Value: "test_value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// EXPECT GetWorkspaceByAgentID to be called because the RBAC fast path validation fails
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agentID).Return(database.Workspace{
|
||||
ID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
}, nil)
|
||||
|
||||
// Expect UpdateWorkspaceAgentMetadata to be called after authorization
|
||||
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agent.ID,
|
||||
Key: []string{"test_key"},
|
||||
Value: []string{"test_value"},
|
||||
Error: []string{""},
|
||||
CollectedAt: []time.Time{now},
|
||||
}).Return(nil)
|
||||
|
||||
// dbauthz will call Wrappers() to check for wrapped databases
|
||||
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
|
||||
|
||||
// Set up dbauthz to test the actual authorization layer
|
||||
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
|
||||
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
|
||||
accessControlStore.Store(&acs)
|
||||
|
||||
api := &agentapi.MetadataAPI{
|
||||
AgentFn: func(_ context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
}
|
||||
|
||||
// Create an invalid RBAC object with nil UUIDs for owner/org
|
||||
// This will fail dbauthz fast path validation and trigger GetWorkspaceByAgentID
|
||||
api.Workspace.UpdateValues(database.Workspace{
|
||||
ID: uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
||||
OwnerID: uuid.Nil, // Invalid: fails dbauthz fast path validation
|
||||
OrganizationID: uuid.Nil, // Invalid: fails dbauthz fast path validation
|
||||
})
|
||||
|
||||
// Create roles with workspace permissions
|
||||
userRoles := rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleMember(),
|
||||
User: []rbac.Permission{
|
||||
{
|
||||
Negate: false,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.WildcardSymbol,
|
||||
},
|
||||
},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{
|
||||
orgID.String(): {
|
||||
Member: []rbac.Permission{
|
||||
{
|
||||
Negate: false,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.WildcardSymbol,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
agentScope := rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
TemplateID: templateID,
|
||||
VersionID: uuid.New(),
|
||||
})
|
||||
|
||||
ctx := dbauthz.As(context.Background(), rbac.Subject{
|
||||
Type: rbac.SubjectTypeUser,
|
||||
FriendlyName: "testuser",
|
||||
Email: "testuser@example.com",
|
||||
ID: ownerID.String(),
|
||||
Roles: userRoles,
|
||||
Groups: []string{orgID.String()},
|
||||
Scope: agentScope,
|
||||
}.WithCachedASTValue())
|
||||
|
||||
resp, err := api.BatchUpdateMetadata(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
})
|
||||
|
||||
// Test RBAC slow path - no RBAC object in context
|
||||
// This test verifies that when no RBAC object is present in context, the dbauthz layer
|
||||
// falls back to the slow path and calls GetWorkspaceByAgentID.
|
||||
t.Run("WorkspaceNotCached_RequiresDBCall", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
dbM = dbmock.NewMockStore(ctrl)
|
||||
pub = &fakePublisher{}
|
||||
now = dbtime.Now()
|
||||
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
|
||||
templateID = uuid.MustParse("aaaabbbb-cccc-dddd-eeee-ffffffff0000")
|
||||
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
|
||||
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
agentID = uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd")
|
||||
)
|
||||
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: agentID,
|
||||
}
|
||||
|
||||
req := &agentproto.BatchUpdateMetadataRequest{
|
||||
Metadata: []*agentproto.Metadata{
|
||||
{
|
||||
Key: "test_key",
|
||||
Result: &agentproto.WorkspaceAgentMetadata_Result{
|
||||
CollectedAt: timestamppb.New(now.Add(-time.Second)),
|
||||
Age: 1,
|
||||
Value: "test_value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// EXPECT GetWorkspaceByAgentID to be called because no RBAC object is in context
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agentID).Return(database.Workspace{
|
||||
ID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
}, nil)
|
||||
|
||||
// Expect UpdateWorkspaceAgentMetadata to be called after authorization
|
||||
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), database.UpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agent.ID,
|
||||
Key: []string{"test_key"},
|
||||
Value: []string{"test_value"},
|
||||
Error: []string{""},
|
||||
CollectedAt: []time.Time{now},
|
||||
}).Return(nil)
|
||||
|
||||
// dbauthz will call Wrappers() to check for wrapped databases
|
||||
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
|
||||
|
||||
// Set up dbauthz to test the actual authorization layer
|
||||
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
|
||||
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
|
||||
accessControlStore.Store(&acs)
|
||||
|
||||
api := &agentapi.MetadataAPI{
|
||||
AgentFn: func(_ context.Context) (database.WorkspaceAgent, error) {
|
||||
return agent, nil
|
||||
},
|
||||
Workspace: &agentapi.CachedWorkspaceFields{},
|
||||
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
|
||||
Pubsub: pub,
|
||||
Log: testutil.Logger(t),
|
||||
TimeNowFn: func() time.Time {
|
||||
return now
|
||||
},
|
||||
}
|
||||
|
||||
// Create roles with workspace permissions
|
||||
userRoles := rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleMember(),
|
||||
User: []rbac.Permission{
|
||||
{
|
||||
Negate: false,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.WildcardSymbol,
|
||||
},
|
||||
},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{
|
||||
orgID.String(): {
|
||||
Member: []rbac.Permission{
|
||||
{
|
||||
Negate: false,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.WildcardSymbol,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
agentScope := rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
TemplateID: templateID,
|
||||
VersionID: uuid.New(),
|
||||
})
|
||||
|
||||
ctx := dbauthz.As(context.Background(), rbac.Subject{
|
||||
Type: rbac.SubjectTypeUser,
|
||||
FriendlyName: "testuser",
|
||||
Email: "testuser@example.com",
|
||||
ID: ownerID.String(),
|
||||
Roles: userRoles,
|
||||
Groups: []string{orgID.String()},
|
||||
Scope: agentScope,
|
||||
}.WithCachedASTValue())
|
||||
|
||||
resp, err := api.BatchUpdateMetadata(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
})
|
||||
|
||||
// Test cache refresh - AutostartSchedule updated
|
||||
// This test verifies that the cache refresh mechanism actually calls GetWorkspaceByID
|
||||
// and updates the cached workspace fields when the workspace is modified (e.g., autostart schedule changes).
|
||||
t.Run("CacheRefreshed_AutostartScheduleUpdated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctrl = gomock.NewController(t)
|
||||
dbM = dbmock.NewMockStore(ctrl)
|
||||
pub = &fakePublisher{}
|
||||
now = dbtime.Now()
|
||||
mClock = quartz.NewMock(t)
|
||||
tickerTrap = mClock.Trap().TickerFunc("cache_refresh")
|
||||
|
||||
workspaceID = uuid.MustParse("12345678-1234-1234-1234-123456789012")
|
||||
ownerID = uuid.MustParse("87654321-4321-4321-4321-210987654321")
|
||||
orgID = uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
templateID = uuid.MustParse("aaaabbbb-cccc-dddd-eeee-ffffffff0000")
|
||||
agentID = uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff")
|
||||
)
|
||||
|
||||
agent := database.WorkspaceAgent{
|
||||
ID: agentID,
|
||||
}
|
||||
|
||||
// Initial workspace - has Monday-Friday 9am autostart
|
||||
initialWorkspace := database.Workspace{
|
||||
ID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
TemplateID: templateID,
|
||||
Name: "my-workspace",
|
||||
OwnerUsername: "testuser",
|
||||
TemplateName: "test-template",
|
||||
AutostartSchedule: sql.NullString{Valid: true, String: "CRON_TZ=UTC 0 9 * * 1-5"},
|
||||
}
|
||||
|
||||
// Updated workspace - user changed autostart to 5pm and renamed workspace
|
||||
updatedWorkspace := database.Workspace{
|
||||
ID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
TemplateID: templateID,
|
||||
Name: "my-workspace-renamed", // Changed!
|
||||
OwnerUsername: "testuser",
|
||||
TemplateName: "test-template",
|
||||
AutostartSchedule: sql.NullString{Valid: true, String: "CRON_TZ=UTC 0 17 * * 1-5"}, // Changed!
|
||||
DormantAt: sql.NullTime{},
|
||||
}
|
||||
|
||||
req := &agentproto.BatchUpdateMetadataRequest{
|
||||
Metadata: []*agentproto.Metadata{
|
||||
{
|
||||
Key: "test_key",
|
||||
Result: &agentproto.WorkspaceAgentMetadata_Result{
|
||||
CollectedAt: timestamppb.New(now.Add(-time.Second)),
|
||||
Age: 1,
|
||||
Value: "test_value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// EXPECT GetWorkspaceByID to be called during cache refresh
|
||||
// This is the key assertion - proves the refresh mechanism is working
|
||||
dbM.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID).Return(updatedWorkspace, nil)
|
||||
|
||||
// API needs to fetch the agent when calling metadata update
|
||||
dbM.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agentID).Return(agent, nil)
|
||||
|
||||
// After refresh, metadata update should work with updated cache
|
||||
dbM.EXPECT().UpdateWorkspaceAgentMetadata(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(ctx context.Context, params database.UpdateWorkspaceAgentMetadataParams) error {
|
||||
require.Equal(t, agent.ID, params.WorkspaceAgentID)
|
||||
require.Equal(t, []string{"test_key"}, params.Key)
|
||||
require.Equal(t, []string{"test_value"}, params.Value)
|
||||
require.Equal(t, []string{""}, params.Error)
|
||||
require.Len(t, params.CollectedAt, 1)
|
||||
return nil
|
||||
},
|
||||
).AnyTimes()
|
||||
|
||||
// May call GetWorkspaceByAgentID if slow path is used before refresh
|
||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agentID).Return(updatedWorkspace, nil).AnyTimes()
|
||||
|
||||
// dbauthz will call Wrappers()
|
||||
dbM.EXPECT().Wrappers().Return([]string{}).AnyTimes()
|
||||
|
||||
// Set up dbauthz
|
||||
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
|
||||
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
|
||||
accessControlStore.Store(&acs)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Create roles with workspace permissions
|
||||
userRoles := rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleMember(),
|
||||
User: []rbac.Permission{
|
||||
{
|
||||
Negate: false,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.WildcardSymbol,
|
||||
},
|
||||
},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{
|
||||
orgID.String(): {
|
||||
Member: []rbac.Permission{
|
||||
{
|
||||
Negate: false,
|
||||
ResourceType: rbac.ResourceWorkspace.Type,
|
||||
Action: policy.WildcardSymbol,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
agentScope := rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
TemplateID: templateID,
|
||||
VersionID: uuid.New(),
|
||||
})
|
||||
|
||||
ctxWithActor := dbauthz.As(ctx, rbac.Subject{
|
||||
Type: rbac.SubjectTypeUser,
|
||||
FriendlyName: "testuser",
|
||||
Email: "testuser@example.com",
|
||||
ID: ownerID.String(),
|
||||
Roles: userRoles,
|
||||
Groups: []string{orgID.String()},
|
||||
Scope: agentScope,
|
||||
}.WithCachedASTValue())
|
||||
|
||||
// Create full API with cached workspace fields (initial state)
|
||||
api := agentapi.New(agentapi.Options{
|
||||
AuthenticatedCtx: ctxWithActor,
|
||||
AgentID: agentID,
|
||||
WorkspaceID: workspaceID,
|
||||
OwnerID: ownerID,
|
||||
OrganizationID: orgID,
|
||||
Database: dbauthz.New(dbM, auth, testutil.Logger(t), accessControlStore),
|
||||
Log: testutil.Logger(t),
|
||||
Clock: mClock,
|
||||
Pubsub: pub,
|
||||
}, initialWorkspace) // Cache is initialized with 9am schedule and "my-workspace" name
|
||||
|
||||
// Wait for ticker to be set up and release it so it can fire
|
||||
tickerTrap.MustWait(ctx).MustRelease(ctx)
|
||||
tickerTrap.Close()
|
||||
|
||||
// Advance clock to trigger cache refresh and wait for it to complete
|
||||
_, aw := mClock.AdvanceNext()
|
||||
aw.MustWait(ctx)
|
||||
|
||||
// At this point, GetWorkspaceByID should have been called and cache updated
|
||||
// The cache now has the 5pm schedule and "my-workspace-renamed" name
|
||||
|
||||
// Now call metadata update to verify the refreshed cache works
|
||||
resp, err := api.MetadataAPI.BatchUpdateMetadata(ctxWithActor, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
||||
return prom_testutil.ToFloat64(batcher.Metrics.MetadataTotal) == 3.0
|
||||
}, testutil.IntervalFast)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package metadatabatcher
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
// uuidBase64Size is the size of a base64-encoded UUID without padding (22 characters).
|
||||
UUIDBase64Size = 22
|
||||
|
||||
// maxAgentIDsPerChunk is the maximum number of agent IDs that can fit in a
|
||||
// single pubsub message. PostgreSQL NOTIFY has an 8KB limit.
|
||||
// With base64 encoding, each UUID is 22 characters, so we can fit
|
||||
// ~363 agent IDs per chunk (8000 / 22 = 363.6).
|
||||
maxAgentIDsPerChunk = maxPubsubPayloadSize / UUIDBase64Size
|
||||
)
|
||||
|
||||
func EncodeAgentID(agentID uuid.UUID, dst []byte) error {
|
||||
// Encode UUID bytes to base64 without padding (RawStdEncoding).
|
||||
// This produces exactly 22 characters per UUID.
|
||||
reqLen := base64.RawStdEncoding.EncodedLen(len(agentID))
|
||||
if len(dst) < reqLen {
|
||||
return xerrors.Errorf("destination byte slice was too small %d, required %d", len(dst), reqLen)
|
||||
}
|
||||
base64.RawStdEncoding.Encode(dst, agentID[:])
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeAgentIDChunks encodes agent IDs into chunks that fit within the
|
||||
// PostgreSQL NOTIFY 8KB payload size limit. Each UUID is base64-encoded
|
||||
// (without padding) and concatenated into a single byte slice per chunk.
|
||||
func EncodeAgentIDChunks(agentIDs []uuid.UUID) ([][]byte, error) {
|
||||
chunks := make([][]byte, 0, (len(agentIDs)+maxAgentIDsPerChunk-1)/maxAgentIDsPerChunk)
|
||||
|
||||
for i := 0; i < len(agentIDs); i += maxAgentIDsPerChunk {
|
||||
end := i + maxAgentIDsPerChunk
|
||||
if end > len(agentIDs) {
|
||||
end = len(agentIDs)
|
||||
}
|
||||
|
||||
chunk := agentIDs[i:end]
|
||||
|
||||
// Build payload by base64-encoding each UUID (without padding) and
|
||||
// concatenating them. This is UTF-8 safe for PostgreSQL NOTIFY.
|
||||
payload := make([]byte, len(chunk)*UUIDBase64Size)
|
||||
for i, agentID := range chunk {
|
||||
err := EncodeAgentID(agentID, payload[i*UUIDBase64Size:(i+1)*UUIDBase64Size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
chunks = append(chunks, payload)
|
||||
}
|
||||
|
||||
return chunks, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package metadatabatcher_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/agentapi/metadatabatcher"
|
||||
)
|
||||
|
||||
func TestEncodeDecodeRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentIDs []uuid.UUID
|
||||
}{
|
||||
{
|
||||
name: "Empty",
|
||||
agentIDs: []uuid.UUID{},
|
||||
},
|
||||
{
|
||||
name: "Single",
|
||||
agentIDs: []uuid.UUID{uuid.New()},
|
||||
},
|
||||
{
|
||||
name: "Multiple",
|
||||
agentIDs: []uuid.UUID{
|
||||
uuid.New(),
|
||||
uuid.New(),
|
||||
uuid.New(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Exactly 363 (one chunk)",
|
||||
agentIDs: func() []uuid.UUID {
|
||||
ids := make([]uuid.UUID, 363)
|
||||
for i := range ids {
|
||||
ids[i] = uuid.New()
|
||||
}
|
||||
return ids
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "364 (two chunks)",
|
||||
agentIDs: func() []uuid.UUID {
|
||||
ids := make([]uuid.UUID, 364)
|
||||
for i := range ids {
|
||||
ids[i] = uuid.New()
|
||||
}
|
||||
return ids
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "600 (multiple chunks)",
|
||||
agentIDs: func() []uuid.UUID {
|
||||
ids := make([]uuid.UUID, 600)
|
||||
for i := range ids {
|
||||
ids[i] = uuid.New()
|
||||
}
|
||||
return ids
|
||||
}(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Encode the agent IDs into chunks.
|
||||
chunks, err := metadatabatcher.EncodeAgentIDChunks(tt.agentIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Decode all chunks and collect the agent IDs.
|
||||
var decoded []uuid.UUID
|
||||
for _, chunk := range chunks {
|
||||
for i := 0; i < len(chunk); i += metadatabatcher.UUIDBase64Size {
|
||||
var u uuid.UUID
|
||||
_, err := base64.RawStdEncoding.Decode(u[:], chunk[i:i+metadatabatcher.UUIDBase64Size])
|
||||
require.NoError(t, err)
|
||||
decoded = append(decoded, u)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we got the same agent IDs back.
|
||||
if len(tt.agentIDs) == 0 {
|
||||
require.Empty(t, decoded)
|
||||
} else {
|
||||
require.Equal(t, tt.agentIDs, decoded)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeAgentIDChunks_PGPubsubSize ensures that each pubsub message generated via EncodeAgentIDChunks fits within
|
||||
// the max allowed 8kb by Postgres.
|
||||
func TestEncodeAgentIDChunks_PGPubsubSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create 600 agents (should split into 2 chunks: 363 + 237).
|
||||
agentIDs := make([]uuid.UUID, 600)
|
||||
for i := range agentIDs {
|
||||
agentIDs[i] = uuid.New()
|
||||
}
|
||||
|
||||
chunks, err := metadatabatcher.EncodeAgentIDChunks(agentIDs)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, chunks, 2)
|
||||
|
||||
// First chunk should have 363 IDs (363 * 22 = 7986 bytes).
|
||||
require.Equal(t, 363*22, len(chunks[0]))
|
||||
|
||||
// Second chunk should have 237 IDs (237 * 22 = 5214 bytes).
|
||||
require.Equal(t, 237*22, len(chunks[1]))
|
||||
|
||||
// Each chunk should be under 8KB.
|
||||
for i, chunk := range chunks {
|
||||
require.LessOrEqual(t, len(chunk), 8000, "chunk %d exceeds 8KB limit", i)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package metadatabatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultMetadataBatchSize is the maximum number of metadata entries
|
||||
// (key-value pairs across all agents) to batch before forcing a flush.
|
||||
// With typical agents having 5-15 metadata keys, this accommodates
|
||||
// 30-100 agents per batch.
|
||||
defaultMetadataBatchSize = 500
|
||||
|
||||
// defaultChannelBufferMultiplier is the multiplier for the channel buffer size
|
||||
// relative to the batch size. A 5x multiplier provides significant headroom
|
||||
// for bursts while the batch is being flushed.
|
||||
defaultChannelBufferMultiplier = 5
|
||||
|
||||
// defaultMetadataFlushInterval is how frequently to flush batched metadata
|
||||
// updates to the database and pubsub. 5 seconds provides a good balance
|
||||
// between reducing database load and maintaining reasonable UI update
|
||||
// latency.
|
||||
defaultMetadataFlushInterval = 5 * time.Second
|
||||
|
||||
// maxPubsubPayloadSize is the maximum size of a single pubsub message.
|
||||
// PostgreSQL NOTIFY has an 8KB limit for the payload.
|
||||
maxPubsubPayloadSize = 8000 // Leave some headroom below 8192 bytes
|
||||
|
||||
// Timeout to use for the context created when flushing the final batch due to the top level context being 'Done'
|
||||
finalFlushTimeout = 15 * time.Second
|
||||
|
||||
// Channel to publish batch metadata updates to, each update contains a list of all Agent IDs that have an update in
|
||||
// the most recent batch
|
||||
MetadataBatchPubsubChannel = "workspace_agent_metadata_batch"
|
||||
|
||||
// flush reasons
|
||||
flushCapacity = "capacity"
|
||||
flushTicker = "scheduled"
|
||||
flushExit = "shutdown"
|
||||
)
|
||||
|
||||
// compositeKey uniquely identifies a metadata entry by agent ID and key name.
|
||||
type compositeKey struct {
|
||||
agentID uuid.UUID
|
||||
key string
|
||||
}
|
||||
|
||||
// value holds a single metadata key-value pair with its error state
|
||||
// and collection timestamp.
|
||||
type value struct {
|
||||
v string
|
||||
error string
|
||||
collectedAt time.Time
|
||||
}
|
||||
|
||||
// update represents a single metadata update to be batched.
|
||||
type update struct {
|
||||
compositeKey
|
||||
value
|
||||
}
|
||||
|
||||
// Batcher holds a buffer of agent metadata updates and periodically
|
||||
// flushes them to the database and pubsub. This reduces database write
|
||||
// frequency and pubsub publish rate.
|
||||
type Batcher struct {
|
||||
store database.Store
|
||||
ps pubsub.Pubsub
|
||||
log slog.Logger
|
||||
|
||||
// updateCh is the buffered channel that receives metadata updates from Add() calls.
|
||||
updateCh chan update
|
||||
|
||||
// batch holds the current batch being accumulated. For updates with the same composite key the most recent value wins.
|
||||
batch map[compositeKey]value
|
||||
currentBatchLen atomic.Int64
|
||||
maxBatchSize int
|
||||
|
||||
clock quartz.Clock
|
||||
timer *quartz.Timer
|
||||
interval time.Duration
|
||||
// Used to only log at warn level for dropped keys infrequently, as it could be noisy in failure scenarios.
|
||||
warnTicker *quartz.Ticker
|
||||
|
||||
// ctx is the context for the batcher. Used to check if shutdown has begun.
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
// Metrics collects Prometheus metrics for the batcher.
|
||||
Metrics Metrics
|
||||
}
|
||||
|
||||
// Option is a functional option for configuring a Batcher.
|
||||
type Option func(b *Batcher)
|
||||
|
||||
func WithBatchSize(size int) Option {
|
||||
return func(b *Batcher) {
|
||||
b.maxBatchSize = size
|
||||
}
|
||||
}
|
||||
|
||||
func WithInterval(d time.Duration) Option {
|
||||
return func(b *Batcher) {
|
||||
b.interval = d
|
||||
}
|
||||
}
|
||||
|
||||
func WithLogger(log slog.Logger) Option {
|
||||
return func(b *Batcher) {
|
||||
b.log = log
|
||||
}
|
||||
}
|
||||
|
||||
func WithClock(clock quartz.Clock) Option {
|
||||
return func(b *Batcher) {
|
||||
b.clock = clock
|
||||
}
|
||||
}
|
||||
|
||||
// NewBatcher creates a new Batcher and starts it. Here ctx controls the lifetime of the batcher, canceling it will
|
||||
// result in the Batcher exiting it's processing routine (run).
|
||||
func NewBatcher(ctx context.Context, reg prometheus.Registerer, store database.Store, ps pubsub.Pubsub, opts ...Option) (*Batcher, error) {
|
||||
b := &Batcher{
|
||||
store: store,
|
||||
ps: ps,
|
||||
Metrics: NewMetrics(),
|
||||
done: make(chan struct{}),
|
||||
log: slog.Logger{},
|
||||
clock: quartz.NewReal(),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(b)
|
||||
}
|
||||
|
||||
b.Metrics.register(reg)
|
||||
|
||||
if b.interval == 0 {
|
||||
b.interval = defaultMetadataFlushInterval
|
||||
}
|
||||
|
||||
if b.maxBatchSize == 0 {
|
||||
b.maxBatchSize = defaultMetadataBatchSize
|
||||
}
|
||||
|
||||
// Create warn ticker after options are applied so it uses the correct clock.
|
||||
b.warnTicker = b.clock.NewTicker(10 * time.Second)
|
||||
|
||||
if b.timer == nil {
|
||||
b.timer = b.clock.NewTimer(b.interval)
|
||||
}
|
||||
|
||||
// Create buffered channel with 5x batch size capacity
|
||||
channelSize := b.maxBatchSize * defaultChannelBufferMultiplier
|
||||
b.updateCh = make(chan update, channelSize)
|
||||
|
||||
// Initialize batch map
|
||||
b.batch = make(map[compositeKey]value)
|
||||
|
||||
b.ctx, b.cancel = context.WithCancel(ctx)
|
||||
go func() {
|
||||
b.run(b.ctx)
|
||||
close(b.done)
|
||||
}()
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (b *Batcher) Close() {
|
||||
b.cancel()
|
||||
if b.timer != nil {
|
||||
b.timer.Stop()
|
||||
}
|
||||
// Wait for the run function to end, it may be sending one last batch.
|
||||
<-b.done
|
||||
}
|
||||
|
||||
// Add adds metadata updates for an agent to the batcher by writing to a
|
||||
// buffered channel. If the channel is full, updates are dropped. Updates
|
||||
// to the same metadata key for the same agent are deduplicated in the batch,
|
||||
// keeping only the value with the most recent collectedAt timestamp.
|
||||
func (b *Batcher) Add(agentID uuid.UUID, keys []string, values []string, errors []string, collectedAt []time.Time) error {
|
||||
if !(len(keys) == len(values) && len(values) == len(errors) && len(errors) == len(collectedAt)) {
|
||||
return xerrors.Errorf("invalid Add call, all inputs must have the same number of items; keys: %d, values: %d, errors: %d, collectedAt: %d", len(keys), len(values), len(errors), len(collectedAt))
|
||||
}
|
||||
|
||||
// Write each update to the channel. If the channel is full, drop the update.
|
||||
var u update
|
||||
droppedCount := 0
|
||||
for i := range keys {
|
||||
u.agentID = agentID
|
||||
u.key = keys[i]
|
||||
u.v = values[i]
|
||||
u.error = errors[i]
|
||||
u.collectedAt = collectedAt[i]
|
||||
|
||||
select {
|
||||
case b.updateCh <- u:
|
||||
// Successfully queued
|
||||
default:
|
||||
// Channel is full, drop this update
|
||||
droppedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Log dropped keys if any were dropped.
|
||||
if droppedCount > 0 {
|
||||
msg := "metadata channel at capacity, dropped updates"
|
||||
fields := []slog.Field{
|
||||
slog.F("agent_id", agentID),
|
||||
slog.F("channel_size", cap(b.updateCh)),
|
||||
slog.F("dropped_count", droppedCount),
|
||||
}
|
||||
select {
|
||||
case <-b.warnTicker.C:
|
||||
b.log.Warn(context.Background(), msg, fields...)
|
||||
default:
|
||||
b.log.Debug(context.Background(), msg, fields...)
|
||||
}
|
||||
|
||||
b.Metrics.DroppedKeysTotal.Add(float64(droppedCount))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processUpdate adds a metadata update to the batch with deduplication based on timestamp.
|
||||
func (b *Batcher) processUpdate(update update) {
|
||||
ck := compositeKey{
|
||||
agentID: update.agentID,
|
||||
key: update.key,
|
||||
}
|
||||
|
||||
// Check if key already exists and only update if new value is newer.
|
||||
existing, exists := b.batch[ck]
|
||||
if exists && update.collectedAt.Before(existing.collectedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
b.batch[ck] = value{
|
||||
v: update.v,
|
||||
error: update.error,
|
||||
collectedAt: update.collectedAt,
|
||||
}
|
||||
if !exists {
|
||||
b.currentBatchLen.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run runs the batcher loop, reading from the update channel and flushing
|
||||
// periodically or when the batch reaches capacity.
|
||||
func (b *Batcher) run(ctx context.Context) {
|
||||
// nolint:gocritic // This is only ever used for one thing - updating agent metadata.
|
||||
authCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
for {
|
||||
select {
|
||||
case update := <-b.updateCh:
|
||||
b.processUpdate(update)
|
||||
|
||||
// Check if batch has reached capacity
|
||||
if int(b.currentBatchLen.Load()) >= b.maxBatchSize {
|
||||
b.flush(authCtx, flushCapacity)
|
||||
// Reset timer so the next scheduled flush is interval duration
|
||||
// from now, not from when it was originally scheduled.
|
||||
b.timer.Reset(b.interval, "metadataBatcher", "capacityFlush")
|
||||
}
|
||||
|
||||
case <-b.timer.C:
|
||||
b.flush(authCtx, flushTicker)
|
||||
// Reset timer to schedule the next flush.
|
||||
b.timer.Reset(b.interval, "metadataBatcher", "scheduledFlush")
|
||||
|
||||
case <-ctx.Done():
|
||||
b.log.Debug(ctx, "context done, flushing before exit")
|
||||
|
||||
// We must create a new context here as the parent context is done.
|
||||
ctxTimeout, cancel := context.WithTimeout(context.Background(), finalFlushTimeout)
|
||||
defer cancel() //nolint:revive // We're returning, defer is fine.
|
||||
|
||||
// nolint:gocritic // This is only ever used for one thing - updating agent metadata.
|
||||
b.flush(dbauthz.AsSystemRestricted(ctxTimeout), flushExit)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flush flushes the current batch to the database and pubsub.
|
||||
func (b *Batcher) flush(ctx context.Context, reason string) {
|
||||
count := len(b.batch)
|
||||
|
||||
if count == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
start := b.clock.Now()
|
||||
b.log.Debug(ctx, "flushing metadata batch",
|
||||
slog.F("reason", reason),
|
||||
slog.F("count", count),
|
||||
)
|
||||
|
||||
// Convert batch map to parallel arrays for the batch query.
|
||||
// Also build map of agent IDs for per-agent metrics and pubsub.
|
||||
var (
|
||||
agentIDs = make([]uuid.UUID, 0, count)
|
||||
keys = make([]string, 0, count)
|
||||
values = make([]string, 0, count)
|
||||
errors = make([]string, 0, count)
|
||||
collectedAt = make([]time.Time, 0, count)
|
||||
agentKeys = make(map[uuid.UUID]int) // Track keys per agent for metrics
|
||||
)
|
||||
|
||||
for ck, mv := range b.batch {
|
||||
agentIDs = append(agentIDs, ck.agentID)
|
||||
keys = append(keys, ck.key)
|
||||
values = append(values, mv.v)
|
||||
errors = append(errors, mv.error)
|
||||
collectedAt = append(collectedAt, mv.collectedAt)
|
||||
agentKeys[ck.agentID]++
|
||||
}
|
||||
|
||||
// Batch has been processed into slices for our DB request, so we can clear it.
|
||||
// It's safe to clear before we know whether the flush is successful as agent metadata is not critical, and therefore
|
||||
// we do not retry failed flushes and losing a batch of metadata is okay.
|
||||
b.batch = make(map[compositeKey]value)
|
||||
b.currentBatchLen.Store(0)
|
||||
|
||||
// Record per-agent utilization metrics.
|
||||
for _, keyCount := range agentKeys {
|
||||
b.Metrics.BatchUtilization.Observe(float64(keyCount))
|
||||
}
|
||||
|
||||
// Update the database with all metadata updates in a single query.
|
||||
err := b.store.BatchUpdateWorkspaceAgentMetadata(ctx, database.BatchUpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agentIDs,
|
||||
Key: keys,
|
||||
Value: values,
|
||||
Error: errors,
|
||||
CollectedAt: collectedAt,
|
||||
})
|
||||
elapsed := b.clock.Since(start)
|
||||
|
||||
if err != nil {
|
||||
if database.IsQueryCanceledError(err) {
|
||||
b.log.Debug(ctx, "query canceled, skipping update of workspace agent metadata", slog.F("elapsed", elapsed))
|
||||
return
|
||||
}
|
||||
b.log.Error(ctx, "error updating workspace agent metadata", slog.Error(err), slog.F("elapsed", elapsed))
|
||||
return
|
||||
}
|
||||
|
||||
// Build list of unique agent IDs for pubsub notification.
|
||||
uniqueAgentIDs := make([]uuid.UUID, 0, len(agentKeys))
|
||||
for agentID := range agentKeys {
|
||||
uniqueAgentIDs = append(uniqueAgentIDs, agentID)
|
||||
}
|
||||
|
||||
// Encode agent IDs into chunks and publish them.
|
||||
chunks, err := EncodeAgentIDChunks(uniqueAgentIDs)
|
||||
if err != nil {
|
||||
b.log.Error(ctx, "Agent ID chunk encoding for pubsub failed",
|
||||
slog.Error(err))
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
if err := b.ps.Publish(MetadataBatchPubsubChannel, chunk); err != nil {
|
||||
b.log.Error(ctx, "failed to publish workspace agent metadata batch",
|
||||
slog.Error(err),
|
||||
slog.F("chunk_size", len(chunk)/UUIDBase64Size),
|
||||
slog.F("payload_size", len(chunk)),
|
||||
)
|
||||
b.Metrics.PublishErrors.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// Record successful batch size and flush duration after successful send/publish.
|
||||
b.Metrics.BatchSize.Observe(float64(count))
|
||||
b.Metrics.MetadataTotal.Add(float64(count))
|
||||
b.Metrics.BatchesTotal.WithLabelValues(reason).Inc()
|
||||
b.Metrics.FlushDuration.WithLabelValues(reason).Observe(time.Since(start).Seconds())
|
||||
|
||||
elapsed = time.Since(start)
|
||||
b.log.Debug(ctx, "flush complete",
|
||||
slog.F("count", count),
|
||||
slog.F("elapsed", elapsed),
|
||||
slog.F("reason", reason),
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
package metadatabatcher
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
type Metrics struct {
|
||||
BatchUtilization prometheus.Histogram
|
||||
FlushDuration *prometheus.HistogramVec
|
||||
BatchSize prometheus.Histogram
|
||||
BatchesTotal *prometheus.CounterVec
|
||||
DroppedKeysTotal prometheus.Counter
|
||||
MetadataTotal prometheus.Counter
|
||||
PublishErrors prometheus.Counter
|
||||
}
|
||||
|
||||
func NewMetrics() Metrics {
|
||||
return Metrics{
|
||||
BatchUtilization: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
Namespace: "coderd",
|
||||
Subsystem: "agentapi",
|
||||
Name: "metadata_batch_utilization",
|
||||
Help: "Number of metadata keys per agent in each batch, updated before flushes.",
|
||||
Buckets: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 40, 80, 160},
|
||||
}),
|
||||
|
||||
BatchSize: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
Namespace: "coderd",
|
||||
Subsystem: "agentapi",
|
||||
Name: "metadata_batch_size",
|
||||
Help: "Total number of metadata entries in each batch, updated before flushes.",
|
||||
Buckets: []float64{10, 25, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500},
|
||||
}),
|
||||
|
||||
FlushDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "coderd",
|
||||
Subsystem: "agentapi",
|
||||
Name: "metadata_flush_duration_seconds",
|
||||
Help: "Time taken to flush metadata batch to database and pubsub.",
|
||||
Buckets: []float64{0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0},
|
||||
}, []string{"reason"}),
|
||||
|
||||
BatchesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "coderd",
|
||||
Subsystem: "agentapi",
|
||||
Name: "metadata_batches_total",
|
||||
Help: "Total number of metadata batches flushed.",
|
||||
}, []string{"reason"}),
|
||||
|
||||
DroppedKeysTotal: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: "coderd",
|
||||
Subsystem: "agentapi",
|
||||
Name: "metadata_dropped_keys_total",
|
||||
Help: "Total number of metadata keys dropped due to capacity limits.",
|
||||
}),
|
||||
|
||||
MetadataTotal: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: "coderd",
|
||||
Subsystem: "agentapi",
|
||||
Name: "metadata_flushed_total",
|
||||
Help: "Total number of unique metadatas flushed.",
|
||||
}),
|
||||
|
||||
PublishErrors: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: "coderd",
|
||||
Subsystem: "agentapi",
|
||||
Name: "metadata_publish_errors_total",
|
||||
Help: "Total number of metadata batch pubsub publish calls that have resulted in an error.",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Metrics) Collectors() []prometheus.Collector {
|
||||
return []prometheus.Collector{
|
||||
m.BatchUtilization,
|
||||
m.BatchSize,
|
||||
m.FlushDuration,
|
||||
m.BatchesTotal,
|
||||
m.DroppedKeysTotal,
|
||||
m.MetadataTotal,
|
||||
m.PublishErrors,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Metrics) register(reg prometheus.Registerer) {
|
||||
if reg != nil {
|
||||
reg.MustRegister(m.BatchUtilization)
|
||||
reg.MustRegister(m.BatchSize)
|
||||
reg.MustRegister(m.FlushDuration)
|
||||
reg.MustRegister(m.DroppedKeysTotal)
|
||||
reg.MustRegister(m.BatchesTotal)
|
||||
reg.MustRegister(m.MetadataTotal)
|
||||
reg.MustRegister(m.PublishErrors)
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
|
||||
Name: agentName,
|
||||
ResourceID: parentAgent.ResourceID,
|
||||
AuthToken: uuid.New(),
|
||||
AuthInstanceID: parentAgent.AuthInstanceID,
|
||||
AuthInstanceID: sql.NullString{},
|
||||
Architecture: req.Architecture,
|
||||
EnvironmentVariables: pqtype.NullRawMessage{},
|
||||
OperatingSystem: req.OperatingSystem,
|
||||
|
||||
@@ -175,6 +175,52 @@ func TestSubAgentAPI(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Context: https://github.com/coder/coder/pull/22196
|
||||
t.Run("CreateSubAgentDoesNotInheritAuthInstanceID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
log = testutil.Logger(t)
|
||||
clock = quartz.NewMock(t)
|
||||
|
||||
db, org = newDatabaseWithOrg(t)
|
||||
user, agent = newUserWithWorkspaceAgent(t, db, org)
|
||||
)
|
||||
|
||||
// Given: The parent agent has an AuthInstanceID set
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
parentAgent, err := db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agent.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parentAgent.AuthInstanceID.Valid, "parent agent should have an AuthInstanceID")
|
||||
require.NotEmpty(t, parentAgent.AuthInstanceID.String)
|
||||
|
||||
api := newAgentAPI(t, log, db, clock, user, org, agent)
|
||||
|
||||
// When: We create a sub agent
|
||||
createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
|
||||
Name: "sub-agent",
|
||||
Directory: "/workspaces/test",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
subAgentID, err := uuid.FromBytes(createResp.Agent.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: The sub-agent must NOT re-use the parent's AuthInstanceID.
|
||||
subAgent, err := db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), subAgentID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, subAgent.AuthInstanceID.Valid, "sub-agent should not have an AuthInstanceID")
|
||||
assert.Empty(t, subAgent.AuthInstanceID.String, "sub-agent AuthInstanceID string should be empty")
|
||||
|
||||
// Double-check: looking up by the parent's instance ID must
|
||||
// still return the parent, not the sub-agent.
|
||||
lookedUp, err := db.GetWorkspaceAgentByInstanceID(dbauthz.AsSystemRestricted(ctx), parentAgent.AuthInstanceID.String)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, parentAgent.ID, lookedUp.ID, "instance ID lookup should still return the parent agent")
|
||||
})
|
||||
|
||||
type expectedAppError struct {
|
||||
index int32
|
||||
field string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user