Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb08b94694 | |||
| ddcc841bdc | |||
| d004710a74 | |||
| c2319e5b4e | |||
| 0cd33d1abb | |||
| 426cc98f7c | |||
| 007f2df079 | |||
| 48b8e22502 | |||
| 18bef5ea2f | |||
| b4cb490c72 | |||
| 7615c2792b | |||
| 753e125758 | |||
| 17edeeaf04 | |||
| 500c17e257 | |||
| 35b9df86b3 | |||
| aff208048e | |||
| a10c5ff381 | |||
| f6556fce9f | |||
| 8e22cd707a | |||
| 8ee6e9457e | |||
| 0bbb7dd0a3 | |||
| 5ea1353d46 | |||
| 52f8143ad3 | |||
| 085370ec6d | |||
| c12bba40ad | |||
| 158243d146 | |||
| eb644732d7 | |||
| a83328c1f0 | |||
| a2728439ff | |||
| 16b8e6072f | |||
| 355150072b | |||
| ad3e8885e4 | |||
| 0b0813e30c | |||
| 430c8c2dd2 | |||
| 1c15534c98 | |||
| 04cf5f8690 | |||
| 897286f335 | |||
| 5ee39e88a3 | |||
| fa314fe7e5 | |||
| 1483fd11ff | |||
| 6067aa3aa1 | |||
| f23836d426 | |||
| da9214e212 | |||
| 79d46769fe | |||
| 86c4948445 | |||
| 6bafbb7bc5 | |||
| b31d09865e | |||
| fe3b825b86 | |||
| edf056babc | |||
| 9ca5b44b56 | |||
| 14f08444a9 | |||
| a3c851c0e6 | |||
| 5bfbb0301f | |||
| f559e51d7f | |||
| a8f2a8a44d | |||
| 04727c06e8 | |||
| ac2c161636 | |||
| 9149c1e9f2 | |||
| 5e85663ce3 | |||
| c47b437c12 | |||
| 7c8deaf0d6 | |||
| c69eb7c157 | |||
| d49545070c | |||
| f59763968f | |||
| 6dbde523ae | |||
| ca94588bd5 | |||
| e61b0fcf42 | |||
| 04f809f2d0 | |||
| f543a87b78 | |||
| e49c917bb0 | |||
| 903c045b9c | |||
| 3bb7975a17 | |||
| dc21699e8c | |||
| 086d6b8719 | |||
| f1fad60f9f | |||
| b6935c3528 | |||
| e96ab0ef59 | |||
| c21b3e49b3 | |||
| 47c703aa4d | |||
| f2a1a7e8c3 | |||
| 616db320d7 | |||
| 02974935b9 | |||
| 476df47d98 | |||
| ea00a993cd | |||
| 51bac75584 | |||
| 219db42984 | |||
| 4cbba17f10 | |||
| 5587dbc622 | |||
| 3819f33693 | |||
| 0e21480794 | |||
| 991831b1dd | |||
| 81c3375670 | |||
| b6d4ef2158 | |||
| 1d1e1f92b0 | |||
| ce04f6cc5d | |||
| 32e504cb2a | |||
| db76541522 | |||
| 7dc75637a1 | |||
| b0f3effe2c | |||
| 2cec1dbbfa | |||
| ab93943493 | |||
| 34f6e72879 | |||
| 46b2f3df8e | |||
| dec2c4c4e2 | |||
| b3f651d62f | |||
| daad93967a | |||
| afd7fc8687 | |||
| 4fa5e6ee3b | |||
| a34897817f | |||
| 9c22ae847e | |||
| 891274838e | |||
| a6b0eae38d | |||
| 1961252918 | |||
| 11f24119c5 | |||
| 3d411ddf4c | |||
| 26d029022d | |||
| 2a5d86e2aa | |||
| eef18424e3 | |||
| 926369b9f2 | |||
| e17b445e55 | |||
| dc5b877f26 | |||
| cb5ddec5c5 | |||
| eb020611a3 | |||
| 697b3a0a06 | |||
| acd6fe7aeb | |||
| 0b214ad7f6 | |||
| 17438d9730 | |||
| 279288affe | |||
| c571995a42 | |||
| bea2f8633a | |||
| dc9166b4cd | |||
| db22227f08 | |||
| 0eb8e904a1 | |||
| 2734123ac2 | |||
| 37432aefa6 | |||
| cf746f3a87 | |||
| ea533aa522 | |||
| 979df63788 | |||
| c4e9749146 | |||
| fb785d3524 | |||
| a899fc57a6 | |||
| 77e2521fa0 | |||
| c627a68e96 | |||
| 7ae3fdc749 | |||
| 7b6e72438b | |||
| 8f78baddb1 | |||
| 0f8f67ec6f | |||
| 9298e7e073 | |||
| 7182c53df7 | |||
| 37222199c3 | |||
| 9c47733e16 | |||
| 139dab7cfe | |||
| d306a2d7e5 | |||
| 30d2fc8bfc | |||
| d80b5fc8ed | |||
| 197b422a31 | |||
| 38017010ce | |||
| 984a834e81 | |||
| 2bcf08457b | |||
| 73dedcc765 | |||
| 94f6e83cfa | |||
| bc0c4ebaa7 | |||
| dc277618ee | |||
| b90c74a94d | |||
| ff532d9bf3 | |||
| 54497f4f6b | |||
| 9629d873fb | |||
| 643fe38b1e | |||
| c827a08c11 | |||
| 1b6556c2f6 | |||
| 859e94d67a | |||
| 50749d131b | |||
| 9986dc0c38 | |||
| 92b63871ca | |||
| 303e9ef7de | |||
| 1ebc217624 | |||
| 06dbadab11 | |||
| 566146af72 | |||
| 7e8fcb4b0f | |||
| dd28eef5b4 | |||
| 2f886ce8d0 | |||
| dcfd6d6f73 | |||
| b20fd6f2c1 | |||
| 2294c55bd9 | |||
| aad1b401c1 | |||
| a8294872a3 | |||
| 95a1ca898f | |||
| c3e3bb58f2 | |||
| 0d765f56f7 |
@@ -27,5 +27,7 @@ ignorePatterns:
|
||||
- pattern: "splunk.com"
|
||||
- pattern: "stackoverflow.com/questions"
|
||||
- pattern: "developer.hashicorp.com/terraform/language"
|
||||
- pattern: "platform.openai.com/docs/api-reference"
|
||||
- pattern: "api.openai.com"
|
||||
aliveStatusCodes:
|
||||
- 200
|
||||
|
||||
@@ -4,7 +4,7 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.24.6"
|
||||
default: "1.24.10"
|
||||
use-preinstalled-go:
|
||||
description: "Whether to use preinstalled Go."
|
||||
default: "false"
|
||||
|
||||
@@ -5,6 +5,13 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup sqlc
|
||||
uses: sqlc-dev/setup-sqlc@c0209b9199cd1cce6a14fc27cabcec491b651761 # v4.0.0
|
||||
with:
|
||||
sqlc-version: "1.27.0"
|
||||
# uses: sqlc-dev/setup-sqlc@c0209b9199cd1cce6a14fc27cabcec491b651761 # v4.0.0
|
||||
# with:
|
||||
# sqlc-version: "1.30.0"
|
||||
|
||||
# Switched to coder/sqlc fork to fix ambiguous column bug, see:
|
||||
# - https://github.com/coder/sqlc/pull/1
|
||||
# - https://github.com/sqlc-dev/sqlc/pull/4159
|
||||
shell: bash
|
||||
run: |
|
||||
CGO_ENABLED=1 go install github.com/coder/sqlc/cmd/sqlc@aab4e865a51df0c43e1839f81a9d349b41d14f05
|
||||
|
||||
@@ -7,5 +7,5 @@ runs:
|
||||
- name: Install Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
with:
|
||||
terraform_version: 1.13.0
|
||||
terraform_version: 1.13.4
|
||||
terraform_wrapper: false
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
app = "sao-paulo-coder"
|
||||
primary_region = "gru"
|
||||
|
||||
[experimental]
|
||||
entrypoint = ["/bin/sh", "-c", "CODER_DERP_SERVER_RELAY_URL=\"http://[${FLY_PRIVATE_IP}]:3000\" /opt/coder wsproxy server"]
|
||||
auto_rollback = true
|
||||
|
||||
[build]
|
||||
image = "ghcr.io/coder/coder-preview:main"
|
||||
|
||||
[env]
|
||||
CODER_ACCESS_URL = "https://sao-paulo.fly.dev.coder.com"
|
||||
CODER_HTTP_ADDRESS = "0.0.0.0:3000"
|
||||
CODER_PRIMARY_ACCESS_URL = "https://dev.coder.com"
|
||||
CODER_WILDCARD_ACCESS_URL = "*--apps.sao-paulo.fly.dev.coder.com"
|
||||
CODER_VERBOSE = "true"
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = true
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
|
||||
# Ref: https://fly.io/docs/reference/configuration/#http_service-concurrency
|
||||
[http_service.concurrency]
|
||||
type = "requests"
|
||||
soft_limit = 50
|
||||
hard_limit = 100
|
||||
|
||||
[[vm]]
|
||||
cpu_kind = "shared"
|
||||
cpus = 2
|
||||
memory_mb = 512
|
||||
+31
-21
@@ -35,7 +35,7 @@ jobs:
|
||||
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
|
||||
# Check for any typos
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@80c8a4945eec0f6d464eaf9e65ed98ef085283d1 # v1.38.1
|
||||
uses: crate-ci/typos@626c4bedb751ce0b7f03262ca97ddda9a076ae1c # v1.39.2
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
|
||||
@@ -230,12 +230,12 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
gen:
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 20
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -271,6 +271,7 @@ jobs:
|
||||
popd
|
||||
|
||||
- name: make gen
|
||||
timeout-minutes: 8
|
||||
run: |
|
||||
# Remove golden files to detect discrepancy in generated files.
|
||||
make clean/golden-files
|
||||
@@ -288,10 +289,10 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.offlinedocs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 7
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -315,6 +316,7 @@ jobs:
|
||||
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0
|
||||
|
||||
- name: make fmt
|
||||
timeout-minutes: 7
|
||||
run: |
|
||||
PATH="${PATH}:$(go env GOPATH)/bin" \
|
||||
make --output-sync -j -B fmt
|
||||
@@ -341,7 +343,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -530,7 +532,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -579,7 +581,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -639,7 +641,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -666,7 +668,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -699,7 +701,7 @@ jobs:
|
||||
name: ${{ matrix.variant.name }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -754,6 +756,14 @@ jobs:
|
||||
path: ./site/test-results/**/*.webm
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload debug log
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: coderd-debug-logs${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/e2e/test-results/debug.log
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload pprof dumps
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
@@ -771,7 +781,7 @@ jobs:
|
||||
if: needs.changes.outputs.site == 'true' || needs.changes.outputs.ci == 'true'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -792,7 +802,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@bc2d84ad2b60813a67d995c5582d696104a19383 # v13.3.2
|
||||
uses: chromaui/action@ac86f2ff0a458ffbce7b40698abd44c0fa34d4b6 # v13.3.3
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -824,7 +834,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@bc2d84ad2b60813a67d995c5582d696104a19383 # v13.3.2
|
||||
uses: chromaui/action@ac86f2ff0a458ffbce7b40698abd44c0fa34d4b6 # v13.3.3
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -852,7 +862,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -923,7 +933,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1043,7 +1053,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1098,7 +1108,7 @@ jobs:
|
||||
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1495,7 +1505,7 @@ 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
verdict: ${{ steps.check.outputs.verdict }} # DEPLOY or NOOP
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
packages: write # to retag image as dogfood
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
|
||||
- name: Set up Flux CLI
|
||||
uses: fluxcd/flux2/action@4a15fa6a023259353ef750acf1c98fe88407d4d0 # v2.7.2
|
||||
uses: fluxcd/flux2/action@b6e76ca2534f76dcb8dd94fb057cdfa923c3b641 # v2.7.3
|
||||
with:
|
||||
# Keep this and the github action up to date with the version of flux installed in dogfood cluster
|
||||
version: "2.7.0"
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
needs: deploy
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -163,12 +163,10 @@ jobs:
|
||||
run: |
|
||||
flyctl deploy --image "$IMAGE" --app paris-coder --config ./.github/fly-wsproxies/paris-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_PARIS" --yes
|
||||
flyctl deploy --image "$IMAGE" --app sydney-coder --config ./.github/fly-wsproxies/sydney-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_SYDNEY" --yes
|
||||
flyctl deploy --image "$IMAGE" --app sao-paulo-coder --config ./.github/fly-wsproxies/sao-paulo-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_SAO_PAULO" --yes
|
||||
flyctl deploy --image "$IMAGE" --app jnb-coder --config ./.github/fly-wsproxies/jnb-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_JNB" --yes
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
IMAGE: ${{ inputs.image }}
|
||||
TOKEN_PARIS: ${{ secrets.FLY_PARIS_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_SYDNEY: ${{ secrets.FLY_SYDNEY_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_SAO_PAULO: ${{ secrets.FLY_SAO_PAULO_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_JNB: ${{ secrets.FLY_JNB_CODER_PROXY_SESSION_TOKEN }}
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
if: github.repository_owner == 'coder'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- uses: tj-actions/changed-files@dbf178ceecb9304128c8e0648591d71208c6e2c9 # v45.0.7
|
||||
- uses: tj-actions/changed-files@70069877f29101175ed2b055d210fe8b1d54d7d7 # v45.0.7
|
||||
id: changed-files
|
||||
with:
|
||||
files: |
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
with:
|
||||
# Pinning to 2.28 here, as Nix gets a "error: [json.exception.type_error.302] type must be array, but is string"
|
||||
# on version 2.29 and above.
|
||||
nix_version: "2.28.4"
|
||||
nix_version: "2.28.5"
|
||||
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
with:
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
pull-requests: write # needed for commenting on PRs
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -228,7 +228,7 @@ jobs:
|
||||
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -785,7 +785,7 @@ jobs:
|
||||
|
||||
- name: Send repository-dispatch event
|
||||
if: ${{ !inputs.dry_run }}
|
||||
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
repository: coder/packages
|
||||
@@ -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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -878,7 +878,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -971,7 +971,7 @@ jobs:
|
||||
if: ${{ !inputs.dry_run }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -47,6 +47,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@014f16e7ab1402f30e7c3329d33797e7948572db # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v3.29.5
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v3.29.5
|
||||
with:
|
||||
languages: go, javascript
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
rm Makefile
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v3.29.5
|
||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v3.29.5
|
||||
|
||||
- name: Send Slack notification on failure
|
||||
if: ${{ failure() }}
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@014f16e7ab1402f30e7c3329d33797e7948572db # v3.29.5
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: "Trivy"
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -120,12 +120,12 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Delete PR Cleanup workflow runs
|
||||
uses: Mattraks/delete-workflow-runs@ab482449ba468316e9a8801e092d0405715c5e6d # v2.1.0
|
||||
uses: Mattraks/delete-workflow-runs@5bf9a1dac5c4d041c029f0a8370ddf0c5cb5aeb7 # v2.1.0
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
delete_workflow_pattern: pr-cleanup.yaml
|
||||
|
||||
- name: Delete PR Deploy workflow skipped runs
|
||||
uses: Mattraks/delete-workflow-runs@ab482449ba468316e9a8801e092d0405715c5e6d # v2.1.0
|
||||
uses: Mattraks/delete-workflow-runs@5bf9a1dac5c4d041c029f0a8370ddf0c5cb5aeb7 # v2.1.0
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
@@ -17,8 +17,8 @@ on:
|
||||
type: string
|
||||
template_preset:
|
||||
description: "Template preset to use"
|
||||
required: true
|
||||
default: "none"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
prefix:
|
||||
description: "Prefix for workspace name"
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
GITHUB_EVENT_USER_LOGIN: ${{ github.event.sender.login }}
|
||||
INPUTS_ISSUE_URL: ${{ inputs.issue_url }}
|
||||
INPUTS_TEMPLATE_NAME: ${{ inputs.template_name || 'coder' }}
|
||||
INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || 'none'}}
|
||||
INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || ''}}
|
||||
INPUTS_PREFIX: ${{ inputs.prefix || 'traiage' }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Extract context key from issue
|
||||
- name: Extract context key and description from issue
|
||||
id: extract-context
|
||||
env:
|
||||
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
@@ -132,86 +132,59 @@ jobs:
|
||||
run: |
|
||||
issue_number="$(gh issue view "${ISSUE_URL}" --json number --jq '.number')"
|
||||
context_key="gh-${issue_number}"
|
||||
echo "context_key=${context_key}" >> "${GITHUB_OUTPUT}"
|
||||
echo "CONTEXT_KEY=${context_key}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Download and install Coder binary
|
||||
shell: bash
|
||||
env:
|
||||
CODER_URL: ${{ secrets.TRAIAGE_CODER_URL }}
|
||||
run: |
|
||||
if [ "${{ runner.arch }}" == "ARM64" ]; then
|
||||
ARCH="arm64"
|
||||
else
|
||||
ARCH="amd64"
|
||||
fi
|
||||
mkdir -p "${HOME}/.local/bin"
|
||||
curl -fsSL --compressed "$CODER_URL/bin/coder-linux-${ARCH}" -o "${HOME}/.local/bin/coder"
|
||||
chmod +x "${HOME}/.local/bin/coder"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
coder version
|
||||
coder whoami
|
||||
echo "$HOME/.local/bin" >> "${GITHUB_PATH}"
|
||||
TASK_PROMPT=$(cat <<EOF
|
||||
Fix ${ISSUE_URL}
|
||||
|
||||
- name: Get Coder username from GitHub actor
|
||||
id: get-coder-username
|
||||
env:
|
||||
CODER_SESSION_TOKEN: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_USER_ID: ${{ steps.determine-inputs.outputs.github_user_id }}
|
||||
run: |
|
||||
user_json=$(
|
||||
coder users list --github-user-id="${GITHUB_USER_ID}" --output=json
|
||||
1. Use the gh CLI to read the issue description and comments.
|
||||
2. Think carefully and try to understand the root cause. If the issue is unclear or not well defined, ask me to clarify and provide more information.
|
||||
3. Write a proposed implementation plan to PLAN.md for me to review before starting implementation. Your plan should use TDD and only make the minimal changes necessary to fix the root cause.
|
||||
4. When I approve your plan, start working on it. If you encounter issues with the plan, ask me for clarification and update the plan as required.
|
||||
5. When you have finished implementation according to the plan, commit and push your changes, and create a PR using the gh CLI for me to review.
|
||||
|
||||
EOF
|
||||
)
|
||||
coder_username=$(jq -r 'first | .username' <<< "$user_json")
|
||||
[[ -z "${coder_username}" || "${coder_username}" == "null" ]] && echo "No Coder user with GitHub user ID ${GITHUB_USER_ID} found" && exit 1
|
||||
echo "coder_username=${coder_username}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "context_key=${context_key}" >> "${GITHUB_OUTPUT}"
|
||||
{
|
||||
echo "TASK_PROMPT<<EOF"
|
||||
echo "${TASK_PROMPT}"
|
||||
echo "EOF"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
path: ./.github/actions/create-task-action
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
repository: coder/create-task-action
|
||||
|
||||
# TODO(Cian): this is a good use-case for 'recipes'
|
||||
- name: Create Coder task
|
||||
id: create-task
|
||||
- name: Create Coder Task
|
||||
id: create_task
|
||||
uses: ./.github/actions/create-task-action
|
||||
with:
|
||||
coder-url: ${{ secrets.TRAIAGE_CODER_URL }}
|
||||
coder-token: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
|
||||
coder-organization: "default"
|
||||
coder-template-name: coder
|
||||
coder-template-preset: ${{ steps.determine-inputs.outputs.template_preset }}
|
||||
coder-task-name-prefix: gh-coder
|
||||
coder-task-prompt: ${{ steps.extract-context.outputs.task_prompt }}
|
||||
github-user-id: ${{ steps.determine-inputs.outputs.github_user_id }}
|
||||
github-token: ${{ github.token }}
|
||||
github-issue-url: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
comment-on-issue: ${{ startsWith(steps.determine-inputs.outputs.issue_url, format('{0}/{1}', github.server_url, github.repository)) }}
|
||||
|
||||
- name: Write outputs
|
||||
env:
|
||||
CODER_USERNAME: ${{ steps.get-coder-username.outputs.coder_username }}
|
||||
CONTEXT_KEY: ${{ steps.extract-context.outputs.context_key }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
PREFIX: ${{ steps.determine-inputs.outputs.prefix }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
TEMPLATE_NAME: ${{ steps.determine-inputs.outputs.template_name }}
|
||||
TEMPLATE_PARAMETERS: ${{ secrets.TRAIAGE_TEMPLATE_PARAMETERS }}
|
||||
TEMPLATE_PRESET: ${{ steps.determine-inputs.outputs.template_preset }}
|
||||
TASK_CREATED: ${{ steps.create_task.outputs.task-created }}
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
TASK_URL: ${{ steps.create_task.outputs.task-url }}
|
||||
run: |
|
||||
# Fetch issue description using `gh` CLI
|
||||
#shellcheck disable=SC2016 # The template string should not be subject to shell expansion
|
||||
issue_description=$(gh issue view "${ISSUE_URL}" \
|
||||
--json 'title,body,comments' \
|
||||
--template '{{printf "%s\n\n%s\n\nComments:\n" .title .body}}{{range $k, $v := .comments}} - {{index $v.author "login"}}: {{printf "%s\n" $v.body}}{{end}}')
|
||||
|
||||
# Write a prompt to PROMPT_FILE
|
||||
PROMPT=$(cat <<EOF
|
||||
Fix ${ISSUE_URL}
|
||||
|
||||
Analyze the below GitHub issue description, understand the root cause, and make appropriate changes to resolve the issue.
|
||||
---
|
||||
${issue_description}
|
||||
EOF
|
||||
)
|
||||
export PROMPT
|
||||
|
||||
export TASK_NAME="${PREFIX}-${CONTEXT_KEY}-${RUN_ID}"
|
||||
echo "Creating task: $TASK_NAME"
|
||||
./scripts/traiage.sh create
|
||||
if [[ "${ISSUE_URL}" == "https://github.com/${GITHUB_REPOSITORY}"* ]]; then
|
||||
gh issue comment "${ISSUE_URL}" --body "Task created: https://dev.coder.com/tasks/${CODER_USERNAME}/${TASK_NAME}" --create-if-none --edit-last
|
||||
else
|
||||
echo "Skipping comment on other repo."
|
||||
fi
|
||||
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_ENV}"
|
||||
{
|
||||
echo "**Task created:** ${TASK_CREATED}"
|
||||
echo "**Task name:** ${TASK_NAME}"
|
||||
echo "**Task URL**: ${TASK_URL}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write # required to post PR review comments by the action
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -91,3 +91,6 @@ __debug_bin*
|
||||
**/.claude/settings.local.json
|
||||
|
||||
/.env
|
||||
|
||||
# Ignore plans written by AI agents.
|
||||
PLAN.md
|
||||
|
||||
@@ -636,8 +636,8 @@ TAILNETTEST_MOCKS := \
|
||||
tailnet/tailnettest/subscriptionmock.go
|
||||
|
||||
AIBRIDGED_MOCKS := \
|
||||
enterprise/x/aibridged/aibridgedmock/clientmock.go \
|
||||
enterprise/x/aibridged/aibridgedmock/poolmock.go
|
||||
enterprise/aibridged/aibridgedmock/clientmock.go \
|
||||
enterprise/aibridged/aibridgedmock/poolmock.go
|
||||
|
||||
GEN_FILES := \
|
||||
tailnet/proto/tailnet.pb.go \
|
||||
@@ -645,7 +645,7 @@ GEN_FILES := \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
vpn/vpn.pb.go \
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go \
|
||||
enterprise/aibridged/proto/aibridged.pb.go \
|
||||
$(DB_GEN_FILES) \
|
||||
$(SITE_GEN_FILES) \
|
||||
coderd/rbac/object_gen.go \
|
||||
@@ -697,7 +697,7 @@ gen/mark-fresh:
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
vpn/vpn.pb.go \
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go \
|
||||
enterprise/aibridged/proto/aibridged.pb.go \
|
||||
coderd/database/dump.sql \
|
||||
$(DB_GEN_FILES) \
|
||||
site/src/api/typesGenerated.ts \
|
||||
@@ -768,8 +768,8 @@ codersdk/workspacesdk/agentconnmock/agentconnmock.go: codersdk/workspacesdk/agen
|
||||
go generate ./codersdk/workspacesdk/agentconnmock/
|
||||
touch "$@"
|
||||
|
||||
$(AIBRIDGED_MOCKS): enterprise/x/aibridged/client.go enterprise/x/aibridged/pool.go
|
||||
go generate ./enterprise/x/aibridged/aibridgedmock/
|
||||
$(AIBRIDGED_MOCKS): enterprise/aibridged/client.go enterprise/aibridged/pool.go
|
||||
go generate ./enterprise/aibridged/aibridgedmock/
|
||||
touch "$@"
|
||||
|
||||
agent/agentcontainers/dcspec/dcspec_gen.go: \
|
||||
@@ -822,13 +822,13 @@ vpn/vpn.pb.go: vpn/vpn.proto
|
||||
--go_opt=paths=source_relative \
|
||||
./vpn/vpn.proto
|
||||
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go: enterprise/x/aibridged/proto/aibridged.proto
|
||||
enterprise/aibridged/proto/aibridged.pb.go: enterprise/aibridged/proto/aibridged.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./enterprise/x/aibridged/proto/aibridged.proto
|
||||
./enterprise/aibridged/proto/aibridged.proto
|
||||
|
||||
site/src/api/typesGenerated.ts: site/node_modules/.installed $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
# -C sets the directory for the go run command
|
||||
|
||||
+3
-1
@@ -250,7 +250,9 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
transforms[i] = replace.String(edit.Search, edit.Replace)
|
||||
}
|
||||
|
||||
tmpfile, err := afero.TempFile(a.filesystem, "", filepath.Base(path))
|
||||
// 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))
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ func (g *Graph[EdgeType, VertexType]) AddEdge(from, to VertexType, edge EdgeType
|
||||
toID := g.getOrCreateVertexID(to)
|
||||
|
||||
if g.canReach(to, from) {
|
||||
return xerrors.Errorf("adding edge (%v -> %v) would create a cycle", from, to)
|
||||
return xerrors.Errorf("adding edge (%v -> %v): %w", from, to, ErrCycleDetected)
|
||||
}
|
||||
|
||||
g.gonumGraph.SetEdge(simple.Edge{F: simple.Node(fromID), T: simple.Node(toID)})
|
||||
|
||||
@@ -148,8 +148,7 @@ func TestGraph(t *testing.T) {
|
||||
graph := &testGraph{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
err := graph.AddEdge(unit1, unit1, testEdgeCompleted)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, fmt.Sprintf("adding edge (%v -> %v) would create a cycle", unit1, unit1))
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
|
||||
return graph
|
||||
},
|
||||
@@ -160,8 +159,7 @@ func TestGraph(t *testing.T) {
|
||||
err := graph.AddEdge(unit1, unit2, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unit2, unit1, testEdgeStarted)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, fmt.Sprintf("adding edge (%v -> %v) would create a cycle", unit2, unit1))
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
|
||||
return graph
|
||||
},
|
||||
@@ -341,7 +339,7 @@ func TestGraphThreadSafety(t *testing.T) {
|
||||
// Verify all attempts correctly returned cycle error
|
||||
for i, err := range cycleErrors {
|
||||
require.Error(t, err, "goroutine %d should have detected cycle", i)
|
||||
require.Contains(t, err.Error(), "would create a cycle")
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
}
|
||||
|
||||
// Verify graph remains valid (original chain intact)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnitNotFound = xerrors.New("unit not found")
|
||||
ErrUnitAlreadyRegistered = xerrors.New("unit already registered")
|
||||
ErrCannotUpdateOtherUnit = xerrors.New("cannot update other unit's status")
|
||||
ErrDependenciesNotSatisfied = xerrors.New("unit dependencies not satisfied")
|
||||
ErrSameStatusAlreadySet = xerrors.New("same status already set")
|
||||
ErrCycleDetected = xerrors.New("cycle detected")
|
||||
ErrFailedToAddDependency = xerrors.New("failed to add dependency")
|
||||
)
|
||||
|
||||
// Status represents the status of a unit.
|
||||
type Status string
|
||||
|
||||
// Status constants for dependency tracking.
|
||||
const (
|
||||
StatusNotRegistered Status = ""
|
||||
StatusPending Status = "pending"
|
||||
StatusStarted Status = "started"
|
||||
StatusComplete Status = "completed"
|
||||
)
|
||||
|
||||
// ID provides a type narrowed representation of the unique identifier of a unit.
|
||||
type ID string
|
||||
|
||||
// Unit represents a point-in-time snapshot of a vertex in the dependency graph.
|
||||
// Units may depend on other units, or be depended on by other units. The unit struct
|
||||
// is not aware of updates made to the dependency graph after it is initialized and should
|
||||
// not be cached.
|
||||
type Unit struct {
|
||||
id ID
|
||||
status Status
|
||||
// ready is true if all dependencies are satisfied.
|
||||
// It does not have an accessor method on Unit, because a unit cannot know whether it is ready.
|
||||
// Only the Manager can calculate whether a unit is ready based on knowledge of the dependency graph.
|
||||
// To discourage use of an outdated readiness value, only the Manager should set and return this field.
|
||||
ready bool
|
||||
}
|
||||
|
||||
func (u Unit) ID() ID {
|
||||
return u.id
|
||||
}
|
||||
|
||||
func (u Unit) Status() Status {
|
||||
return u.status
|
||||
}
|
||||
|
||||
// Dependency represents a dependency relationship between units.
|
||||
type Dependency struct {
|
||||
Unit ID
|
||||
DependsOn ID
|
||||
RequiredStatus Status
|
||||
CurrentStatus Status
|
||||
IsSatisfied bool
|
||||
}
|
||||
|
||||
// Manager provides reactive dependency tracking over a Graph.
|
||||
// It manages Unit registration, dependency relationships, and status updates
|
||||
// with automatic recalculation of readiness when dependencies are satisfied.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// The underlying graph that stores dependency relationships
|
||||
graph *Graph[Status, ID]
|
||||
|
||||
// Store vertex instances for each unit to ensure consistent references
|
||||
units map[ID]Unit
|
||||
}
|
||||
|
||||
// NewManager creates a new Manager instance.
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
graph: &Graph[Status, ID]{},
|
||||
units: make(map[ID]Unit),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a unit to the manager if it is not already registered.
|
||||
// If a Unit is already registered (per the ID field), it is not updated.
|
||||
func (m *Manager) Register(id ID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.registered(id) {
|
||||
return xerrors.Errorf("registering unit %q: %w", id, ErrUnitAlreadyRegistered)
|
||||
}
|
||||
|
||||
m.units[id] = Unit{
|
||||
id: id,
|
||||
status: StatusPending,
|
||||
ready: true,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// registered checks if a unit is registered in the manager.
|
||||
func (m *Manager) registered(id ID) bool {
|
||||
return m.units[id].status != StatusNotRegistered
|
||||
}
|
||||
|
||||
// Unit fetches a unit from the manager. If the unit does not exist,
|
||||
// it returns the Unit zero-value as a placeholder unit, because
|
||||
// units may depend on other units that have not yet been created.
|
||||
func (m *Manager) Unit(id ID) Unit {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return m.units[id]
|
||||
}
|
||||
|
||||
func (m *Manager) IsReady(id ID) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if !m.registered(id) {
|
||||
return false
|
||||
}
|
||||
|
||||
return m.units[id].ready
|
||||
}
|
||||
|
||||
// AddDependency adds a dependency relationship between units.
|
||||
// The unit depends on the dependsOn unit reaching the requiredStatus.
|
||||
func (m *Manager) AddDependency(unit ID, dependsOn ID, requiredStatus Status) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.registered(unit) {
|
||||
return xerrors.Errorf("checking registration for unit %q: %w", unit, ErrUnitNotFound)
|
||||
}
|
||||
|
||||
// Add the dependency edge to the graph
|
||||
// The edge goes from unit to dependsOn, representing the dependency
|
||||
err := m.graph.AddEdge(unit, dependsOn, requiredStatus)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("adding edge for unit %q: %w", unit, errors.Join(ErrFailedToAddDependency, err))
|
||||
}
|
||||
|
||||
// Recalculate readiness for the unit since it now has a new dependency
|
||||
m.recalculateReadinessUnsafe(unit)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStatus updates a unit's status and recalculates readiness for affected dependents.
|
||||
func (m *Manager) UpdateStatus(unit ID, newStatus Status) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.registered(unit) {
|
||||
return xerrors.Errorf("checking registration for unit %q: %w", unit, ErrUnitNotFound)
|
||||
}
|
||||
|
||||
u := m.units[unit]
|
||||
if u.status == newStatus {
|
||||
return xerrors.Errorf("checking status for unit %q: %w", unit, ErrSameStatusAlreadySet)
|
||||
}
|
||||
|
||||
u.status = newStatus
|
||||
m.units[unit] = u
|
||||
|
||||
// Get all units that depend on this one (reverse adjacent vertices)
|
||||
dependents := m.graph.GetReverseAdjacentVertices(unit)
|
||||
|
||||
// Recalculate readiness for all dependents
|
||||
for _, dependent := range dependents {
|
||||
m.recalculateReadinessUnsafe(dependent.From)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// recalculateReadinessUnsafe recalculates the readiness state for a unit.
|
||||
// This method assumes the caller holds the write lock.
|
||||
func (m *Manager) recalculateReadinessUnsafe(unit ID) {
|
||||
u := m.units[unit]
|
||||
dependencies := m.graph.GetForwardAdjacentVertices(unit)
|
||||
|
||||
allSatisfied := true
|
||||
for _, dependency := range dependencies {
|
||||
requiredStatus := dependency.Edge
|
||||
dependsOnUnit := m.units[dependency.To]
|
||||
if dependsOnUnit.status != requiredStatus {
|
||||
allSatisfied = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
u.ready = allSatisfied
|
||||
m.units[unit] = u
|
||||
}
|
||||
|
||||
// GetGraph returns the underlying graph for visualization and debugging.
|
||||
// This should be used carefully as it exposes the internal graph structure.
|
||||
func (m *Manager) GetGraph() *Graph[Status, ID] {
|
||||
return m.graph
|
||||
}
|
||||
|
||||
// GetAllDependencies returns all dependencies for a unit, both satisfied and unsatisfied.
|
||||
func (m *Manager) GetAllDependencies(unit ID) ([]Dependency, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if !m.registered(unit) {
|
||||
return nil, xerrors.Errorf("checking registration for unit %q: %w", unit, ErrUnitNotFound)
|
||||
}
|
||||
|
||||
dependencies := m.graph.GetForwardAdjacentVertices(unit)
|
||||
|
||||
var allDependencies []Dependency
|
||||
|
||||
for _, dependency := range dependencies {
|
||||
dependsOnUnit := m.units[dependency.To]
|
||||
requiredStatus := dependency.Edge
|
||||
allDependencies = append(allDependencies, Dependency{
|
||||
Unit: unit,
|
||||
DependsOn: dependsOnUnit.id,
|
||||
RequiredStatus: requiredStatus,
|
||||
CurrentStatus: dependsOnUnit.status,
|
||||
IsSatisfied: dependsOnUnit.status == requiredStatus,
|
||||
})
|
||||
}
|
||||
|
||||
return allDependencies, nil
|
||||
}
|
||||
|
||||
// GetUnmetDependencies returns a list of unsatisfied dependencies for a unit.
|
||||
func (m *Manager) GetUnmetDependencies(unit ID) ([]Dependency, error) {
|
||||
allDependencies, err := m.GetAllDependencies(unit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var unmetDependencies []Dependency = slice.Filter(allDependencies, func(dependency Dependency) bool {
|
||||
return !dependency.IsSatisfied
|
||||
})
|
||||
|
||||
return unmetDependencies, nil
|
||||
}
|
||||
|
||||
// ExportDOT exports the dependency graph to DOT format for visualization.
|
||||
func (m *Manager) ExportDOT(name string) (string, error) {
|
||||
return m.graph.ToDOT(name)
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
package unit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
)
|
||||
|
||||
const (
|
||||
unitA unit.ID = "serviceA"
|
||||
unitB unit.ID = "serviceB"
|
||||
unitC unit.ID = "serviceC"
|
||||
unitD unit.ID = "serviceD"
|
||||
)
|
||||
|
||||
func TestManager_Register(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RegisterNewUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: a unit is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: the unit should be ready (no dependencies)
|
||||
u := manager.Unit(unitA)
|
||||
assert.Equal(t, unitA, u.ID())
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.True(t, manager.IsReady(unitA))
|
||||
})
|
||||
|
||||
t.Run("RegisterDuplicateUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: a unit is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Newly registered units have StatusPending. We update the unit status to StatusStarted,
|
||||
// so we can later assert that it is not overwritten back to StatusPending by the second
|
||||
// register call
|
||||
manager.UpdateStatus(unitA, unit.StatusStarted)
|
||||
|
||||
// When: the unit is registered again
|
||||
err = manager.Register(unitA)
|
||||
|
||||
// Then: a descriptive error should be returned
|
||||
require.ErrorIs(t, err, unit.ErrUnitAlreadyRegistered)
|
||||
|
||||
// Then: the unit status should not be overwritten
|
||||
u := manager.Unit(unitA)
|
||||
assert.Equal(t, unit.StatusStarted, u.Status())
|
||||
assert.True(t, manager.IsReady(unitA))
|
||||
})
|
||||
|
||||
t.Run("RegisterMultipleUnits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: multiple units are registered
|
||||
unitIDs := []unit.ID{unitA, unitB, unitC}
|
||||
for _, unit := range unitIDs {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Then: all units should be ready initially
|
||||
for _, unitID := range unitIDs {
|
||||
u := manager.Unit(unitID)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.True(t, manager.IsReady(unitID))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_AddDependency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("AddDependencyBetweenRegisteredUnits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: units A and B are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should not be ready (depends on B)
|
||||
u := manager.Unit(unitA)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
// Then: Unit B should still be ready (no dependencies)
|
||||
u = manager.Unit(unitB)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.True(t, manager.IsReady(unitB))
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
assert.True(t, manager.IsReady(unitA))
|
||||
|
||||
// When: Unit B is stopped
|
||||
err = manager.UpdateStatus(unitB, unit.StatusPending)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should no longer be ready, because its dependency is not in the desired state.
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
})
|
||||
|
||||
t.Run("AddDependencyByAnUnregisteredDependentUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given Unit B is registered
|
||||
err := manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given Unit A depends on Unit B being started
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
|
||||
// Then: a descriptive error communicates that the dependency cannot be added
|
||||
// because the dependent unit must be registered first.
|
||||
require.ErrorIs(t, err, unit.ErrUnitNotFound)
|
||||
})
|
||||
|
||||
t.Run("AddDependencyOnAnUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given unit A is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given Unit B is not yet registered
|
||||
// And Unit A depends on Unit B being started
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
u := manager.Unit(unitB)
|
||||
assert.Equal(t, unit.StatusNotRegistered, u.Status())
|
||||
|
||||
// Then: Unit A should not be ready, because it depends on Unit B
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
// When: Unit B is registered
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should still not be ready.
|
||||
// Unit B is not registered, but it has not been started as required by the dependency.
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
assert.True(t, manager.IsReady(unitA))
|
||||
})
|
||||
|
||||
t.Run("AddDependencyCreatesACyclicDependency", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register units
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitC)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitD)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A depends on B
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
// B depends on C
|
||||
err = manager.AddDependency(unitB, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// C depends on D
|
||||
err = manager.AddDependency(unitC, unitD, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to make D depend on A (creates indirect cycle)
|
||||
err = manager.AddDependency(unitD, unitA, unit.StatusStarted)
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_UpdateStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("UpdateStatusTriggersReadinessRecalculation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given units A and B are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should not be ready (depends on B)
|
||||
u := manager.Unit(unitA)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
u = manager.Unit(unitA)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.True(t, manager.IsReady(unitA))
|
||||
})
|
||||
|
||||
t.Run("UpdateStatusWithUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given Unit A is not registered
|
||||
// When: Unit A is updated to unit.StatusStarted
|
||||
err := manager.UpdateStatus(unitA, unit.StatusStarted)
|
||||
|
||||
// Then: a descriptive error communicates that the unit must be registered first.
|
||||
require.ErrorIs(t, err, unit.ErrUnitNotFound)
|
||||
})
|
||||
|
||||
t.Run("LinearChainDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given units A, B, and C are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitC)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create chain: A depends on B being "started", B depends on C being "completed"
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitB, unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: only Unit C should be ready (no dependencies)
|
||||
u := manager.Unit(unitC)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.True(t, manager.IsReady(unitC))
|
||||
|
||||
u = manager.Unit(unitB)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.False(t, manager.IsReady(unitB))
|
||||
|
||||
u = manager.Unit(unitA)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
// When: Unit C is completed
|
||||
err = manager.UpdateStatus(unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit B should be ready, because its dependency is now in the desired state.
|
||||
u = manager.Unit(unitB)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.True(t, manager.IsReady(unitB))
|
||||
|
||||
u = manager.Unit(unitA)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
u = manager.Unit(unitB)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.True(t, manager.IsReady(unitB))
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
u = manager.Unit(unitA)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
assert.True(t, manager.IsReady(unitA))
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_GetUnmetDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnitWithNoDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: Unit A is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A has no dependencies
|
||||
// Then: Unit A should have no unmet dependencies
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, unmet)
|
||||
})
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnitWithUnsatisfiedDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, unmet, 1)
|
||||
|
||||
assert.Equal(t, unitA, unmet[0].Unit)
|
||||
assert.Equal(t, unitB, unmet[0].DependsOn)
|
||||
assert.Equal(t, unit.StatusStarted, unmet[0].RequiredStatus)
|
||||
assert.False(t, unmet[0].IsSatisfied)
|
||||
})
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnitWithSatisfiedDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: Unit A and Unit B are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should have no unmet dependencies
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, unmet)
|
||||
})
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// When: Unit A is requested
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
|
||||
// Then: a descriptive error communicates that the unit must be registered first.
|
||||
require.ErrorIs(t, err, unit.ErrUnitNotFound)
|
||||
assert.Nil(t, unmet)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_MultipleDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("UnitWithMultipleDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register all units
|
||||
units := []unit.ID{unitA, unitB, unitC, unitD}
|
||||
for _, unit := range units {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// A depends on B being unit.StatusStarted AND C being "started"
|
||||
err := manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A should not be ready (depends on both B and C)
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
// Update B to unit.StatusStarted - A should still not be ready (needs C too)
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
// Update C to "started" - A should now be ready
|
||||
err = manager.UpdateStatus(unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, manager.IsReady(unitA))
|
||||
})
|
||||
|
||||
t.Run("ComplexDependencyChain", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register all units
|
||||
units := []unit.ID{unitA, unitB, unitC, unitD}
|
||||
for _, unit := range units {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create complex dependency graph:
|
||||
// A depends on B being unit.StatusStarted AND C being "started"
|
||||
err := manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
// B depends on D being "completed"
|
||||
err = manager.AddDependency(unitB, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
// C depends on D being "completed"
|
||||
err = manager.AddDependency(unitC, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initially only D is ready
|
||||
assert.True(t, manager.IsReady(unitD))
|
||||
assert.False(t, manager.IsReady(unitB))
|
||||
assert.False(t, manager.IsReady(unitC))
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
// Update D to "completed" - B and C should become ready
|
||||
err = manager.UpdateStatus(unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, manager.IsReady(unitB))
|
||||
assert.True(t, manager.IsReady(unitC))
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
// Update B to unit.StatusStarted - A should still not be ready (needs C)
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
// Update C to "started" - A should now be ready
|
||||
err = manager.UpdateStatus(unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, manager.IsReady(unitA))
|
||||
})
|
||||
|
||||
t.Run("DifferentStatusTypes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register units
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitC)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
// Given: Unit A depends on Unit C being "completed"
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should not be ready, because only one of its dependencies is in the desired state.
|
||||
// It still requires Unit C to be completed.
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
|
||||
// When: Unit C is completed
|
||||
err = manager.UpdateStatus(unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because both of its dependencies are in the desired state.
|
||||
assert.True(t, manager.IsReady(unitA))
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_IsReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("IsReadyWithUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: a unit is not registered
|
||||
u := manager.Unit(unitA)
|
||||
assert.Equal(t, unit.StatusNotRegistered, u.Status())
|
||||
// Then: the unit is not ready
|
||||
assert.False(t, manager.IsReady(unitA))
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_ToDOT(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ExportSimpleGraph", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register units
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add dependency
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
dot, err := manager.ExportDOT("test")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, dot)
|
||||
assert.Contains(t, dot, "digraph")
|
||||
})
|
||||
|
||||
t.Run("ExportComplexGraph", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register all units
|
||||
units := []unit.ID{unitA, unitB, unitC, unitD}
|
||||
for _, unit := range units {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create complex dependency graph
|
||||
// A depends on B and C, B depends on D, C depends on D
|
||||
err := manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitB, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitC, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
dot, err := manager.ExportDOT("complex")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, dot)
|
||||
assert.Contains(t, dot, "digraph")
|
||||
})
|
||||
}
|
||||
+45
-17
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -201,18 +202,15 @@ func workspaceAgent() *serpent.Command {
|
||||
// Enable pprof handler
|
||||
// This prevents the pprof import from being accidentally deleted.
|
||||
_ = pprof.Handler
|
||||
pprofSrvClose := ServeHandler(ctx, logger, nil, pprofAddress, "pprof")
|
||||
defer pprofSrvClose()
|
||||
if port, err := extractPort(pprofAddress); err == nil {
|
||||
ignorePorts[port] = "pprof"
|
||||
}
|
||||
if pprofAddress != "" {
|
||||
pprofSrvClose := ServeHandler(ctx, logger, nil, pprofAddress, "pprof")
|
||||
defer pprofSrvClose()
|
||||
|
||||
if port, err := extractPort(prometheusAddress); err == nil {
|
||||
ignorePorts[port] = "prometheus"
|
||||
}
|
||||
|
||||
if port, err := extractPort(debugAddress); err == nil {
|
||||
ignorePorts[port] = "debug"
|
||||
if port, err := extractPort(pprofAddress); err == nil {
|
||||
ignorePorts[port] = "pprof"
|
||||
}
|
||||
} else {
|
||||
logger.Debug(ctx, "pprof address is empty, disabling pprof server")
|
||||
}
|
||||
|
||||
executablePath, err := os.Executable()
|
||||
@@ -276,6 +274,28 @@ func workspaceAgent() *serpent.Command {
|
||||
for {
|
||||
prometheusRegistry := prometheus.NewRegistry()
|
||||
|
||||
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
|
||||
var serverClose []func()
|
||||
if prometheusAddress != "" {
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promHandler, prometheusAddress, "prometheus")
|
||||
serverClose = append(serverClose, prometheusSrvClose)
|
||||
|
||||
if port, err := extractPort(prometheusAddress); err == nil {
|
||||
ignorePorts[port] = "prometheus"
|
||||
}
|
||||
} else {
|
||||
logger.Debug(ctx, "prometheus address is empty, disabling prometheus server")
|
||||
}
|
||||
|
||||
if debugAddress != "" {
|
||||
// ServerHandle depends on `agnt.HTTPDebug()`, but `agnt`
|
||||
// depends on `ignorePorts`. Keep this if statement in sync
|
||||
// with below.
|
||||
if port, err := extractPort(debugAddress); err == nil {
|
||||
ignorePorts[port] = "debug"
|
||||
}
|
||||
}
|
||||
|
||||
agnt := agent.New(agent.Options{
|
||||
Client: client,
|
||||
Logger: logger,
|
||||
@@ -299,10 +319,15 @@ func workspaceAgent() *serpent.Command {
|
||||
},
|
||||
})
|
||||
|
||||
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promHandler, prometheusAddress, "prometheus")
|
||||
|
||||
debugSrvClose := ServeHandler(ctx, logger, agnt.HTTPDebug(), debugAddress, "debug")
|
||||
if debugAddress != "" {
|
||||
// ServerHandle depends on `agnt.HTTPDebug()`, but `agnt`
|
||||
// depends on `ignorePorts`. Keep this if statement in sync
|
||||
// with above.
|
||||
debugSrvClose := ServeHandler(ctx, logger, agnt.HTTPDebug(), debugAddress, "debug")
|
||||
serverClose = append(serverClose, debugSrvClose)
|
||||
} else {
|
||||
logger.Debug(ctx, "debug address is empty, disabling debug server")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -314,8 +339,11 @@ func workspaceAgent() *serpent.Command {
|
||||
}
|
||||
|
||||
lastErr = agnt.Close()
|
||||
debugSrvClose()
|
||||
prometheusSrvClose()
|
||||
|
||||
slices.Reverse(serverClose)
|
||||
for _, closeFunc := range serverClose {
|
||||
closeFunc()
|
||||
}
|
||||
|
||||
if mustExit {
|
||||
break
|
||||
|
||||
@@ -178,6 +178,51 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
require.Greater(t, atomic.LoadInt64(&called), int64(0), "expected coderd to be reached with custom headers")
|
||||
require.Greater(t, atomic.LoadInt64(&derpCalled), int64(0), "expected /derp to be called with custom headers")
|
||||
})
|
||||
|
||||
t.Run("DisabledServers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
logDir := t.TempDir()
|
||||
inv, _ := clitest.New(t,
|
||||
"agent",
|
||||
"--auth", "token",
|
||||
"--agent-token", r.AgentToken,
|
||||
"--agent-url", client.URL.String(),
|
||||
"--log-dir", logDir,
|
||||
"--pprof-address", "",
|
||||
"--prometheus-address", "",
|
||||
"--debug-address", "",
|
||||
)
|
||||
|
||||
clitest.Start(t, inv)
|
||||
|
||||
// Verify the agent is connected and working.
|
||||
resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).
|
||||
MatchResources(matchAgentWithVersion).Wait()
|
||||
require.Len(t, resources, 1)
|
||||
require.Len(t, resources[0].Agents, 1)
|
||||
require.NotEmpty(t, resources[0].Agents[0].Version)
|
||||
|
||||
// Verify the servers are not listening by checking the log for disabled
|
||||
// messages.
|
||||
require.Eventually(t, func() bool {
|
||||
logContent, err := os.ReadFile(filepath.Join(logDir, "coder-agent.log"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
logStr := string(logContent)
|
||||
return strings.Contains(logStr, "pprof address is empty, disabling pprof server") &&
|
||||
strings.Contains(logStr, "prometheus address is empty, disabling prometheus server") &&
|
||||
strings.Contains(logStr, "debug address is empty, disabling debug server")
|
||||
}, testutil.WaitLong, testutil.IntervalMedium)
|
||||
})
|
||||
}
|
||||
|
||||
func matchAgentWithVersion(rs []codersdk.WorkspaceResource) bool {
|
||||
|
||||
@@ -78,6 +78,14 @@ func NewWithCommand(
|
||||
return i, configDir
|
||||
}
|
||||
|
||||
func NewWithSDKOverride(t testing.TB, override any, args ...string) (*serpent.Invocation, config.Root) {
|
||||
var root cli.RootCmd
|
||||
cmd, err := root.Command(root.AGPL())
|
||||
root.TestOverrideSDKClient = override
|
||||
require.NoError(t, err)
|
||||
return NewWithCommand(t, cmd, args...)
|
||||
}
|
||||
|
||||
// SetupConfig applies the URL and SessionToken of the client to the config.
|
||||
func SetupConfig(t *testing.T, client *codersdk.Client, root config.Root) {
|
||||
err := root.Session().Write(client.SessionToken())
|
||||
|
||||
+48
-44
@@ -577,53 +577,57 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
|
||||
return nil, xerrors.Errorf("template version git auth: %w", err)
|
||||
}
|
||||
|
||||
// Run a dry-run with the given parameters to check correctness
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(inv.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
RichParameterValues: buildParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("begin workspace dry-run: %w", err)
|
||||
}
|
||||
// Only perform dry-run for workspace creation and updates
|
||||
// Skip for start and restart to avoid unnecessary delays
|
||||
if args.Action == WorkspaceCreate || args.Action == WorkspaceUpdate {
|
||||
// Run a dry-run with the given parameters to check correctness
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(inv.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
RichParameterValues: buildParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("begin workspace dry-run: %w", err)
|
||||
}
|
||||
|
||||
matchedProvisioners, err := client.TemplateVersionDryRunMatchedProvisioners(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get matched provisioners: %w", err)
|
||||
}
|
||||
cliutil.WarnMatchedProvisioners(inv.Stdout, &matchedProvisioners, dryRun)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
return client.TemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelTemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return client.TemplateVersionDryRunLogsAfter(inv.Context(), templateVersion.ID, dryRun.ID, 0)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
})
|
||||
if err != nil {
|
||||
// TODO (Dean): reprompt for parameter values if we deem it to
|
||||
// be a validation error
|
||||
return nil, xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
matchedProvisioners, err := client.TemplateVersionDryRunMatchedProvisioners(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get matched provisioners: %w", err)
|
||||
}
|
||||
cliutil.WarnMatchedProvisioners(inv.Stdout, &matchedProvisioners, dryRun)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
return client.TemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelTemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return client.TemplateVersionDryRunLogsAfter(inv.Context(), templateVersion.ID, dryRun.ID, 0)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
})
|
||||
if err != nil {
|
||||
// TODO (Dean): reprompt for parameter values if we deem it to
|
||||
// be a validation error
|
||||
return nil, xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
|
||||
resources, err := client.TemplateVersionDryRunResources(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace dry-run resources: %w", err)
|
||||
}
|
||||
resources, err := client.TemplateVersionDryRunResources(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace dry-run resources: %w", err)
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceResources(inv.Stdout, resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
// Since agents haven't connected yet, hiding this makes more sense.
|
||||
HideAgentState: true,
|
||||
Title: "Workspace Preview",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get resources: %w", err)
|
||||
err = cliui.WorkspaceResources(inv.Stdout, resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
// Since agents haven't connected yet, hiding this makes more sense.
|
||||
HideAgentState: true,
|
||||
Title: "Workspace Preview",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get resources: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return buildParameters, nil
|
||||
|
||||
+88
-59
@@ -64,7 +64,9 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
|
||||
r.scaletestWorkspaceTraffic(),
|
||||
r.scaletestAutostart(),
|
||||
r.scaletestNotifications(),
|
||||
r.scaletestTaskStatus(),
|
||||
r.scaletestSMTP(),
|
||||
r.scaletestPrebuilds(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -384,6 +386,88 @@ func (s *scaletestPrometheusFlags) attach(opts *serpent.OptionSet) {
|
||||
)
|
||||
}
|
||||
|
||||
// workspaceTargetFlags holds common flags for targeting specific workspaces in scale tests.
|
||||
type workspaceTargetFlags struct {
|
||||
template string
|
||||
targetWorkspaces string
|
||||
useHostLogin bool
|
||||
}
|
||||
|
||||
// attach adds the workspace target flags to the given options set.
|
||||
func (f *workspaceTargetFlags) attach(opts *serpent.OptionSet) {
|
||||
*opts = append(*opts,
|
||||
serpent.Option{
|
||||
Flag: "template",
|
||||
FlagShorthand: "t",
|
||||
Env: "CODER_SCALETEST_TEMPLATE",
|
||||
Description: "Name or ID of the template. Traffic generation will be limited to workspaces created from this template.",
|
||||
Value: serpent.StringOf(&f.template),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "target-workspaces",
|
||||
Env: "CODER_SCALETEST_TARGET_WORKSPACES",
|
||||
Description: "Target a specific range of workspaces in the format [START]:[END] (exclusive). Example: 0:10 will target the 10 first alphabetically sorted workspaces (0-9).",
|
||||
Value: serpent.StringOf(&f.targetWorkspaces),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "use-host-login",
|
||||
Env: "CODER_SCALETEST_USE_HOST_LOGIN",
|
||||
Default: "false",
|
||||
Description: "Connect as the currently logged in user.",
|
||||
Value: serpent.BoolOf(&f.useHostLogin),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// getTargetedWorkspaces retrieves the workspaces based on the template filter and target range. warnWriter is where to
|
||||
// write a warning message if any workspaces were skipped due to ownership mismatch.
|
||||
func (f *workspaceTargetFlags) getTargetedWorkspaces(ctx context.Context, client *codersdk.Client, organizationIDs []uuid.UUID, warnWriter io.Writer) ([]codersdk.Workspace, error) {
|
||||
// Validate template if provided
|
||||
if f.template != "" {
|
||||
_, err := parseTemplate(ctx, client, organizationIDs, f.template)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse target range
|
||||
targetStart, targetEnd, err := parseTargetRange("workspaces", f.targetWorkspaces)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse target workspaces: %w", err)
|
||||
}
|
||||
|
||||
// Determine owner based on useHostLogin
|
||||
var owner string
|
||||
if f.useHostLogin {
|
||||
owner = codersdk.Me
|
||||
}
|
||||
|
||||
// Get workspaces
|
||||
workspaces, numSkipped, err := getScaletestWorkspaces(ctx, client, owner, f.template)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if numSkipped > 0 {
|
||||
cliui.Warnf(warnWriter, "CODER_DISABLE_OWNER_WORKSPACE_ACCESS is set on the deployment.\n\t%d workspace(s) were skipped due to ownership mismatch.\n\tSet --use-host-login to only target workspaces you own.", numSkipped)
|
||||
}
|
||||
|
||||
// Adjust targetEnd if not specified
|
||||
if targetEnd == 0 {
|
||||
targetEnd = len(workspaces)
|
||||
}
|
||||
|
||||
// Validate range
|
||||
if len(workspaces) == 0 {
|
||||
return nil, xerrors.Errorf("no scaletest workspaces exist")
|
||||
}
|
||||
if targetEnd > len(workspaces) {
|
||||
return nil, xerrors.Errorf("target workspace end %d is greater than the number of workspaces %d", targetEnd, len(workspaces))
|
||||
}
|
||||
|
||||
// Return the sliced workspaces
|
||||
return workspaces[targetStart:targetEnd], nil
|
||||
}
|
||||
|
||||
func requireAdmin(ctx context.Context, client *codersdk.Client) (codersdk.User, error) {
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
@@ -1193,12 +1277,10 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
bytesPerTick int64
|
||||
ssh bool
|
||||
disableDirect bool
|
||||
useHostLogin bool
|
||||
app string
|
||||
template string
|
||||
targetWorkspaces string
|
||||
workspaceProxyURL string
|
||||
|
||||
targetFlags = &workspaceTargetFlags{}
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
strategy = &scaletestStrategyFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
@@ -1243,15 +1325,9 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
},
|
||||
}
|
||||
|
||||
if template != "" {
|
||||
_, err := parseTemplate(ctx, client, me.OrganizationIDs, template)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse template: %w", err)
|
||||
}
|
||||
}
|
||||
targetWorkspaceStart, targetWorkspaceEnd, err := parseTargetRange("workspaces", targetWorkspaces)
|
||||
workspaces, err := targetFlags.getTargetedWorkspaces(ctx, client, me.OrganizationIDs, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse target workspaces: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
appHost, err := client.AppHost(ctx)
|
||||
@@ -1259,30 +1335,6 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
return xerrors.Errorf("get app host: %w", err)
|
||||
}
|
||||
|
||||
var owner string
|
||||
if useHostLogin {
|
||||
owner = codersdk.Me
|
||||
}
|
||||
|
||||
workspaces, numSkipped, err := getScaletestWorkspaces(inv.Context(), client, owner, template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if numSkipped > 0 {
|
||||
cliui.Warnf(inv.Stdout, "CODER_DISABLE_OWNER_WORKSPACE_ACCESS is set on the deployment.\n\t%d workspace(s) were skipped due to ownership mismatch.\n\tSet --use-host-login to only target workspaces you own.", numSkipped)
|
||||
}
|
||||
|
||||
if targetWorkspaceEnd == 0 {
|
||||
targetWorkspaceEnd = len(workspaces)
|
||||
}
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
return xerrors.Errorf("no scaletest workspaces exist")
|
||||
}
|
||||
if targetWorkspaceEnd > len(workspaces) {
|
||||
return xerrors.Errorf("target workspace end %d is greater than the number of workspaces %d", targetWorkspaceEnd, len(workspaces))
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
@@ -1307,10 +1359,6 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
|
||||
th := harness.NewTestHarness(strategy.toStrategy(), cleanupStrategy.toStrategy())
|
||||
for idx, ws := range workspaces {
|
||||
if idx < targetWorkspaceStart || idx >= targetWorkspaceEnd {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
agent codersdk.WorkspaceAgent
|
||||
name = "workspace-traffic"
|
||||
@@ -1415,19 +1463,6 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
}
|
||||
|
||||
cmd.Options = []serpent.Option{
|
||||
{
|
||||
Flag: "template",
|
||||
FlagShorthand: "t",
|
||||
Env: "CODER_SCALETEST_TEMPLATE",
|
||||
Description: "Name or ID of the template. Traffic generation will be limited to workspaces created from this template.",
|
||||
Value: serpent.StringOf(&template),
|
||||
},
|
||||
{
|
||||
Flag: "target-workspaces",
|
||||
Env: "CODER_SCALETEST_TARGET_WORKSPACES",
|
||||
Description: "Target a specific range of workspaces in the format [START]:[END] (exclusive). Example: 0:10 will target the 10 first alphabetically sorted workspaces (0-9).",
|
||||
Value: serpent.StringOf(&targetWorkspaces),
|
||||
},
|
||||
{
|
||||
Flag: "bytes-per-tick",
|
||||
Env: "CODER_SCALETEST_WORKSPACE_TRAFFIC_BYTES_PER_TICK",
|
||||
@@ -1463,13 +1498,6 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
Description: "Send WebSocket traffic to a workspace app (proxied via coderd), cannot be used with --ssh.",
|
||||
Value: serpent.StringOf(&app),
|
||||
},
|
||||
{
|
||||
Flag: "use-host-login",
|
||||
Env: "CODER_SCALETEST_USE_HOST_LOGIN",
|
||||
Default: "false",
|
||||
Description: "Connect as the currently logged in user.",
|
||||
Value: serpent.BoolOf(&useHostLogin),
|
||||
},
|
||||
{
|
||||
Flag: "workspace-proxy-url",
|
||||
Env: "CODER_SCALETEST_WORKSPACE_PROXY_URL",
|
||||
@@ -1479,6 +1507,7 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
},
|
||||
}
|
||||
|
||||
targetFlags.attach(&cmd.Options)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
strategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -29,12 +30,13 @@ import (
|
||||
|
||||
func (r *RootCmd) scaletestNotifications() *serpent.Command {
|
||||
var (
|
||||
userCount int64
|
||||
ownerUserPercentage float64
|
||||
notificationTimeout time.Duration
|
||||
dialTimeout time.Duration
|
||||
noCleanup bool
|
||||
smtpAPIURL string
|
||||
userCount int64
|
||||
templateAdminPercentage float64
|
||||
notificationTimeout time.Duration
|
||||
smtpRequestTimeout time.Duration
|
||||
dialTimeout time.Duration
|
||||
noCleanup bool
|
||||
smtpAPIURL string
|
||||
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
|
||||
@@ -77,24 +79,24 @@ func (r *RootCmd) scaletestNotifications() *serpent.Command {
|
||||
return xerrors.Errorf("--user-count must be greater than 0")
|
||||
}
|
||||
|
||||
if ownerUserPercentage < 0 || ownerUserPercentage > 100 {
|
||||
return xerrors.Errorf("--owner-user-percentage must be between 0 and 100")
|
||||
if templateAdminPercentage < 0 || templateAdminPercentage > 100 {
|
||||
return xerrors.Errorf("--template-admin-percentage must be between 0 and 100")
|
||||
}
|
||||
|
||||
if smtpAPIURL != "" && !strings.HasPrefix(smtpAPIURL, "http://") && !strings.HasPrefix(smtpAPIURL, "https://") {
|
||||
return xerrors.Errorf("--smtp-api-url must start with http:// or https://")
|
||||
}
|
||||
|
||||
ownerUserCount := int64(float64(userCount) * ownerUserPercentage / 100)
|
||||
if ownerUserCount == 0 && ownerUserPercentage > 0 {
|
||||
ownerUserCount = 1
|
||||
templateAdminCount := int64(float64(userCount) * templateAdminPercentage / 100)
|
||||
if templateAdminCount == 0 && templateAdminPercentage > 0 {
|
||||
templateAdminCount = 1
|
||||
}
|
||||
regularUserCount := userCount - ownerUserCount
|
||||
regularUserCount := userCount - templateAdminCount
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Distribution plan:\n")
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Total users: %d\n", userCount)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Owner users: %d (%.1f%%)\n", ownerUserCount, ownerUserPercentage)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Regular users: %d (%.1f%%)\n", regularUserCount, 100.0-ownerUserPercentage)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Template admins: %d (%.1f%%)\n", templateAdminCount, templateAdminPercentage)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Regular users: %d (%.1f%%)\n", regularUserCount, 100.0-templateAdminPercentage)
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
@@ -127,13 +129,12 @@ func (r *RootCmd) scaletestNotifications() *serpent.Command {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Creating users...")
|
||||
|
||||
dialBarrier := &sync.WaitGroup{}
|
||||
ownerWatchBarrier := &sync.WaitGroup{}
|
||||
templateAdminWatchBarrier := &sync.WaitGroup{}
|
||||
dialBarrier.Add(int(userCount))
|
||||
ownerWatchBarrier.Add(int(ownerUserCount))
|
||||
templateAdminWatchBarrier.Add(int(templateAdminCount))
|
||||
|
||||
expectedNotificationIDs := map[uuid.UUID]struct{}{
|
||||
notificationsLib.TemplateUserAccountCreated: {},
|
||||
notificationsLib.TemplateUserAccountDeleted: {},
|
||||
notificationsLib.TemplateTemplateDeleted: {},
|
||||
}
|
||||
|
||||
triggerTimes := make(map[uuid.UUID]chan time.Time, len(expectedNotificationIDs))
|
||||
@@ -142,19 +143,20 @@ func (r *RootCmd) scaletestNotifications() *serpent.Command {
|
||||
}
|
||||
|
||||
configs := make([]notifications.Config, 0, userCount)
|
||||
for range ownerUserCount {
|
||||
for range templateAdminCount {
|
||||
config := notifications.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
},
|
||||
Roles: []string{codersdk.RoleOwner},
|
||||
Roles: []string{codersdk.RoleTemplateAdmin},
|
||||
NotificationTimeout: notificationTimeout,
|
||||
DialTimeout: dialTimeout,
|
||||
DialBarrier: dialBarrier,
|
||||
ReceivingWatchBarrier: ownerWatchBarrier,
|
||||
ReceivingWatchBarrier: templateAdminWatchBarrier,
|
||||
ExpectedNotificationsIDs: expectedNotificationIDs,
|
||||
Metrics: metrics,
|
||||
SMTPApiURL: smtpAPIURL,
|
||||
SMTPRequestTimeout: smtpRequestTimeout,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
@@ -170,9 +172,8 @@ func (r *RootCmd) scaletestNotifications() *serpent.Command {
|
||||
NotificationTimeout: notificationTimeout,
|
||||
DialTimeout: dialTimeout,
|
||||
DialBarrier: dialBarrier,
|
||||
ReceivingWatchBarrier: ownerWatchBarrier,
|
||||
ReceivingWatchBarrier: templateAdminWatchBarrier,
|
||||
Metrics: metrics,
|
||||
SMTPApiURL: smtpAPIURL,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
@@ -180,7 +181,7 @@ func (r *RootCmd) scaletestNotifications() *serpent.Command {
|
||||
configs = append(configs, config)
|
||||
}
|
||||
|
||||
go triggerUserNotifications(
|
||||
go triggerNotifications(
|
||||
ctx,
|
||||
logger,
|
||||
client,
|
||||
@@ -261,23 +262,30 @@ func (r *RootCmd) scaletestNotifications() *serpent.Command {
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Flag: "owner-user-percentage",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_OWNER_USER_PERCENTAGE",
|
||||
Flag: "template-admin-percentage",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_TEMPLATE_ADMIN_PERCENTAGE",
|
||||
Default: "20.0",
|
||||
Description: "Percentage of users to assign Owner role to (0-100).",
|
||||
Value: serpent.Float64Of(&ownerUserPercentage),
|
||||
Description: "Percentage of users to assign Template Admin role to (0-100).",
|
||||
Value: serpent.Float64Of(&templateAdminPercentage),
|
||||
},
|
||||
{
|
||||
Flag: "notification-timeout",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_TIMEOUT",
|
||||
Default: "5m",
|
||||
Default: "10m",
|
||||
Description: "How long to wait for notifications after triggering.",
|
||||
Value: serpent.DurationOf(¬ificationTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "smtp-request-timeout",
|
||||
Env: "CODER_SCALETEST_SMTP_REQUEST_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "Timeout for SMTP requests.",
|
||||
Value: serpent.DurationOf(&smtpRequestTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "dial-timeout",
|
||||
Env: "CODER_SCALETEST_DIAL_TIMEOUT",
|
||||
Default: "2m",
|
||||
Default: "10m",
|
||||
Description: "Timeout for dialing the notification websocket endpoint.",
|
||||
Value: serpent.DurationOf(&dialTimeout),
|
||||
},
|
||||
@@ -379,9 +387,9 @@ func computeNotificationLatencies(
|
||||
return nil
|
||||
}
|
||||
|
||||
// triggerUserNotifications waits for all test users to connect,
|
||||
// then creates and deletes a test user to trigger notification events for testing.
|
||||
func triggerUserNotifications(
|
||||
// triggerNotifications waits for all test users to connect,
|
||||
// then creates and deletes a test template to trigger notification events for testing.
|
||||
func triggerNotifications(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
client *codersdk.Client,
|
||||
@@ -414,34 +422,49 @@ func triggerUserNotifications(
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
triggerUsername = "scaletest-trigger-user"
|
||||
triggerEmail = "scaletest-trigger@example.com"
|
||||
)
|
||||
logger.Info(ctx, "creating test template to test notifications")
|
||||
|
||||
logger.Info(ctx, "creating test user to test notifications",
|
||||
slog.F("username", triggerUsername),
|
||||
slog.F("email", triggerEmail),
|
||||
slog.F("org_id", orgID))
|
||||
// Upload empty template file.
|
||||
file, err := client.Upload(ctx, codersdk.ContentTypeTar, bytes.NewReader([]byte{}))
|
||||
if err != nil {
|
||||
logger.Error(ctx, "upload test template", slog.Error(err))
|
||||
return
|
||||
}
|
||||
logger.Info(ctx, "test template uploaded", slog.F("file_id", file.ID))
|
||||
|
||||
testUser, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||
OrganizationIDs: []uuid.UUID{orgID},
|
||||
Username: triggerUsername,
|
||||
Email: triggerEmail,
|
||||
Password: "test-password-123",
|
||||
// Create template version.
|
||||
version, err := client.CreateTemplateVersion(ctx, orgID, codersdk.CreateTemplateVersionRequest{
|
||||
StorageMethod: codersdk.ProvisionerStorageMethodFile,
|
||||
FileID: file.ID,
|
||||
Provisioner: codersdk.ProvisionerTypeEcho,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "create test user", slog.Error(err))
|
||||
logger.Error(ctx, "create test template version", slog.Error(err))
|
||||
return
|
||||
}
|
||||
expectedNotifications[notificationsLib.TemplateUserAccountCreated] <- time.Now()
|
||||
logger.Info(ctx, "test template version created", slog.F("template_version_id", version.ID))
|
||||
|
||||
err = client.DeleteUser(ctx, testUser.ID)
|
||||
// Create template.
|
||||
testTemplate, err := client.CreateTemplate(ctx, orgID, codersdk.CreateTemplateRequest{
|
||||
Name: "scaletest-test-template",
|
||||
Description: "scaletest-test-template",
|
||||
VersionID: version.ID,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "delete test user", slog.Error(err))
|
||||
logger.Error(ctx, "create test template", slog.Error(err))
|
||||
return
|
||||
}
|
||||
expectedNotifications[notificationsLib.TemplateUserAccountDeleted] <- time.Now()
|
||||
close(expectedNotifications[notificationsLib.TemplateUserAccountCreated])
|
||||
close(expectedNotifications[notificationsLib.TemplateUserAccountDeleted])
|
||||
logger.Info(ctx, "test template created", slog.F("template_id", testTemplate.ID))
|
||||
|
||||
// Delete template to trigger notification.
|
||||
err = client.DeleteTemplate(ctx, testTemplate.ID)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "delete test template", slog.Error(err))
|
||||
return
|
||||
}
|
||||
logger.Info(ctx, "test template deleted", slog.F("template_id", testTemplate.ID))
|
||||
|
||||
// Record expected notification.
|
||||
expectedNotifications[notificationsLib.TemplateTemplateDeleted] <- time.Now()
|
||||
close(expectedNotifications[notificationsLib.TemplateTemplateDeleted])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"sync"
|
||||
"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/harness"
|
||||
"github.com/coder/coder/v2/scaletest/prebuilds"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestPrebuilds() *serpent.Command {
|
||||
var (
|
||||
numTemplates int64
|
||||
numPresets int64
|
||||
numPresetPrebuilds int64
|
||||
templateVersionJobTimeout time.Duration
|
||||
prebuildWorkspaceTimeout time.Duration
|
||||
noCleanup bool
|
||||
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "prebuilds",
|
||||
Short: "Creates prebuild workspaces on the Coder server.",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if numTemplates <= 0 {
|
||||
return xerrors.Errorf("--num-templates must be greater than 0")
|
||||
}
|
||||
if numPresets <= 0 {
|
||||
return xerrors.Errorf("--num-presets must be greater than 0")
|
||||
}
|
||||
if numPresetPrebuilds <= 0 {
|
||||
return xerrors.Errorf("--num-preset-prebuilds must be greater than 0")
|
||||
}
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse output flags: %w", err)
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := prebuilds.NewMetrics(reg)
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("pause prebuilds: %w", err)
|
||||
}
|
||||
|
||||
setupBarrier := new(sync.WaitGroup)
|
||||
setupBarrier.Add(int(numTemplates))
|
||||
creationBarrier := new(sync.WaitGroup)
|
||||
creationBarrier.Add(int(numTemplates))
|
||||
deletionSetupBarrier := new(sync.WaitGroup)
|
||||
deletionSetupBarrier.Add(1)
|
||||
deletionBarrier := new(sync.WaitGroup)
|
||||
deletionBarrier.Add(int(numTemplates))
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
|
||||
for i := range numTemplates {
|
||||
id := strconv.Itoa(int(i))
|
||||
cfg := prebuilds.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
NumPresets: int(numPresets),
|
||||
NumPresetPrebuilds: int(numPresetPrebuilds),
|
||||
TemplateVersionJobTimeout: templateVersionJobTimeout,
|
||||
PrebuildWorkspaceTimeout: prebuildWorkspaceTimeout,
|
||||
Metrics: metrics,
|
||||
SetupBarrier: setupBarrier,
|
||||
CreationBarrier: creationBarrier,
|
||||
DeletionSetupBarrier: deletionSetupBarrier,
|
||||
DeletionBarrier: deletionBarrier,
|
||||
Clock: quartz.NewReal(),
|
||||
}
|
||||
err := cfg.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
|
||||
var runner harness.Runnable = prebuilds.NewRunner(client, cfg)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("prebuilds/%s", id),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
th.AddRun("prebuilds", id, runner)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Creating %d templates with %d presets and %d prebuilds per preset...\n",
|
||||
numTemplates, numPresets, numPresetPrebuilds)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Total expected prebuilds: %d\n", numTemplates*numPresets*numPresetPrebuilds)
|
||||
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
|
||||
runErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
runErrCh <- th.Run(testCtx)
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all templates to be created...")
|
||||
setupBarrier.Wait()
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "All templates created")
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: false,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resume prebuilds: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all prebuilds to be created...")
|
||||
creationBarrier.Wait()
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "All prebuilds created")
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("pause prebuilds before deletion: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Prebuilds paused, signaling runners to prepare for deletion")
|
||||
deletionSetupBarrier.Done()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all templates to be updated with 0 prebuilds...")
|
||||
deletionBarrier.Wait()
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "All templates updated")
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: false,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resume prebuilds for deletion: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all prebuilds to be deleted...")
|
||||
err = <-runErrCh
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip cleanup & 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, "\nStarting cleanup (deleting templates)...")
|
||||
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
|
||||
// If the cleanup was interrupted, skip stats
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("prebuild creation test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "num-templates",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_NUM_TEMPLATES",
|
||||
Default: "1",
|
||||
Description: "Number of templates to create for the test.",
|
||||
Value: serpent.Int64Of(&numTemplates),
|
||||
},
|
||||
{
|
||||
Flag: "num-presets",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_NUM_PRESETS",
|
||||
Default: "1",
|
||||
Description: "Number of presets per template.",
|
||||
Value: serpent.Int64Of(&numPresets),
|
||||
},
|
||||
{
|
||||
Flag: "num-preset-prebuilds",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_NUM_PRESET_PREBUILDS",
|
||||
Default: "1",
|
||||
Description: "Number of prebuilds per preset.",
|
||||
Value: serpent.Int64Of(&numPresetPrebuilds),
|
||||
},
|
||||
{
|
||||
Flag: "template-version-job-timeout",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_TEMPLATE_VERSION_JOB_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "Timeout for template version provisioning jobs.",
|
||||
Value: serpent.DurationOf(&templateVersionJobTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "prebuild-workspace-timeout",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_WORKSPACE_TIMEOUT",
|
||||
Default: "10m",
|
||||
Description: "Timeout for all prebuild workspaces to be created/deleted.",
|
||||
Value: serpent.DurationOf(&prebuildWorkspaceTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "skip-cleanup",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_SKIP_CLEANUP",
|
||||
Description: "Skip cleanup (deletion test) and leave resources intact.",
|
||||
Value: serpent.BoolOf(&noCleanup),
|
||||
},
|
||||
}
|
||||
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/serpent"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
"github.com/coder/coder/v2/scaletest/taskstatus"
|
||||
)
|
||||
|
||||
const (
|
||||
taskStatusTestName = "task-status"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestTaskStatus() *serpent.Command {
|
||||
var (
|
||||
count int64
|
||||
template string
|
||||
workspaceNamePrefix string
|
||||
appSlug string
|
||||
reportStatusPeriod time.Duration
|
||||
reportStatusDuration time.Duration
|
||||
baselineDuration time.Duration
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
)
|
||||
orgContext := NewOrganizationContext()
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "task-status",
|
||||
Short: "Generates load on the Coder server by simulating task status reporting",
|
||||
Long: `This test creates external workspaces and simulates AI agents reporting task status.
|
||||
After all runners connect, it waits for the baseline duration before triggering status reporting.`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags: %w", err)
|
||||
}
|
||||
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Disable rate limits for this test
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Find the template
|
||||
tpl, err := parseTemplate(ctx, client, []uuid.UUID{org.ID}, template)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse template %q: %w", template, err)
|
||||
}
|
||||
templateID := tpl.ID
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := taskstatus.NewMetrics(reg)
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Allow time for traces to flush even if command context is
|
||||
// canceled. This is a no-op if tracing is not enabled.
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
// Setup shared resources for coordination
|
||||
connectedWaitGroup := &sync.WaitGroup{}
|
||||
connectedWaitGroup.Add(int(count))
|
||||
startReporting := make(chan struct{})
|
||||
|
||||
// Create the test harness
|
||||
th := harness.NewTestHarness(
|
||||
timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}),
|
||||
cleanupStrategy.toStrategy(),
|
||||
)
|
||||
|
||||
// Create runners
|
||||
for i := range count {
|
||||
workspaceName := fmt.Sprintf("%s-%d", workspaceNamePrefix, i)
|
||||
cfg := taskstatus.Config{
|
||||
TemplateID: templateID,
|
||||
WorkspaceName: workspaceName,
|
||||
AppSlug: appSlug,
|
||||
ConnectedWaitGroup: connectedWaitGroup,
|
||||
StartReporting: startReporting,
|
||||
ReportStatusPeriod: reportStatusPeriod,
|
||||
ReportStatusDuration: reportStatusDuration,
|
||||
Metrics: metrics,
|
||||
MetricLabelValues: []string{},
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config for runner %d: %w", i, err)
|
||||
}
|
||||
|
||||
var runner harness.Runnable = taskstatus.NewRunner(client, cfg)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("%s/%d", taskStatusTestName, i),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
th.AddRun(taskStatusTestName, workspaceName, runner)
|
||||
}
|
||||
|
||||
// Start the test in a separate goroutine so we can coordinate timing
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
testDone := make(chan error)
|
||||
go func() {
|
||||
testDone <- th.Run(testCtx)
|
||||
}()
|
||||
|
||||
// Wait for all runners to connect
|
||||
logger.Info(ctx, "waiting for all runners to connect")
|
||||
waitCtx, waitCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer waitCancel()
|
||||
|
||||
connectDone := make(chan struct{})
|
||||
go func() {
|
||||
connectedWaitGroup.Wait()
|
||||
close(connectDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
return xerrors.Errorf("timeout waiting for runners to connect")
|
||||
case <-connectDone:
|
||||
logger.Info(ctx, "all runners connected")
|
||||
}
|
||||
|
||||
// Wait for baseline duration
|
||||
logger.Info(ctx, "waiting for baseline duration", slog.F("duration", baselineDuration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(baselineDuration):
|
||||
}
|
||||
|
||||
// Trigger all runners to start reporting
|
||||
logger.Info(ctx, "triggering runners to start reporting task status")
|
||||
close(startReporting)
|
||||
|
||||
// Wait for the test to complete
|
||||
err = <-testDone
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness: %w", 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)
|
||||
}
|
||||
}
|
||||
|
||||
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: "count",
|
||||
Description: "Number of concurrent runners to create.",
|
||||
Default: "10",
|
||||
Value: serpent.Int64Of(&count),
|
||||
},
|
||||
{
|
||||
Flag: "template",
|
||||
Description: "Name or UUID of the template to use for the scale test. The template MUST include a coder_external_agent and a coder_app.",
|
||||
Default: "scaletest-task-status",
|
||||
Value: serpent.StringOf(&template),
|
||||
},
|
||||
{
|
||||
Flag: "workspace-name-prefix",
|
||||
Description: "Prefix for workspace names (will be suffixed with index).",
|
||||
Default: "scaletest-task-status",
|
||||
Value: serpent.StringOf(&workspaceNamePrefix),
|
||||
},
|
||||
{
|
||||
Flag: "app-slug",
|
||||
Description: "Slug of the app designated as the AI Agent.",
|
||||
Default: "ai-agent",
|
||||
Value: serpent.StringOf(&appSlug),
|
||||
},
|
||||
{
|
||||
Flag: "report-status-period",
|
||||
Description: "Time between reporting task statuses.",
|
||||
Default: "10s",
|
||||
Value: serpent.DurationOf(&reportStatusPeriod),
|
||||
},
|
||||
{
|
||||
Flag: "report-status-duration",
|
||||
Description: "Total time to report task statuses after baseline.",
|
||||
Default: "15m",
|
||||
Value: serpent.DurationOf(&reportStatusDuration),
|
||||
},
|
||||
{
|
||||
Flag: "baseline-duration",
|
||||
Description: "Duration to wait after all runners connect before starting to report status.",
|
||||
Default: "10m",
|
||||
Value: serpent.DurationOf(&baselineDuration),
|
||||
},
|
||||
}
|
||||
orgContext.AttachOptions(cmd)
|
||||
output.attach(&cmd.Options)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
+18
-35
@@ -56,19 +56,14 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
taskID := uuid.MustParse(id1)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/exists":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
httpapi.Write(r.Context(), w, http.StatusOK,
|
||||
codersdk.Task{
|
||||
ID: taskID,
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
}},
|
||||
Count: 1,
|
||||
})
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id1:
|
||||
c.deleteCalls.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
@@ -107,27 +102,21 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
name: "Multiple_YesFlag",
|
||||
args: []string{"--yes", "first", id4},
|
||||
buildHandler: func(c *testCounters) http.HandlerFunc {
|
||||
firstID := uuid.MustParse(id3)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/first":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
ID: firstID,
|
||||
Name: "first",
|
||||
OwnerName: "me",
|
||||
}},
|
||||
Count: 1,
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse(id3),
|
||||
Name: "first",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id4:
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse(id4),
|
||||
OwnerName: "me",
|
||||
Name: "uuid-task-2",
|
||||
Name: "uuid-task-4",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id3:
|
||||
c.deleteCalls.Add(1)
|
||||
@@ -141,7 +130,7 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
}
|
||||
},
|
||||
wantDeleteCalls: 2,
|
||||
wantNameResolves: 1,
|
||||
wantNameResolves: 2,
|
||||
wantDeletedMessage: 2,
|
||||
},
|
||||
{
|
||||
@@ -174,20 +163,14 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
taskID := uuid.MustParse(id5)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/bad":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
ID: taskID,
|
||||
Name: "bad",
|
||||
OwnerName: "me",
|
||||
}},
|
||||
Count: 1,
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
ID: taskID,
|
||||
Name: "bad",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id5:
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/bad":
|
||||
httpapi.InternalServerError(w, xerrors.New("boom"))
|
||||
default:
|
||||
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -19,10 +18,7 @@ import (
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
@@ -43,76 +39,22 @@ func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UU
|
||||
},
|
||||
}).Do()
|
||||
|
||||
ws := database.WorkspaceTable{
|
||||
build := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: orgID,
|
||||
OwnerID: ownerID,
|
||||
TemplateID: tv.Template.ID,
|
||||
}
|
||||
build := dbfake.WorkspaceBuild(t, db, ws).
|
||||
}).
|
||||
Seed(database.WorkspaceBuild{
|
||||
TemplateVersionID: tv.TemplateVersion.ID,
|
||||
Transition: transition,
|
||||
}).WithAgent().Do()
|
||||
dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{
|
||||
{
|
||||
WorkspaceBuildID: build.Build.ID,
|
||||
Name: codersdk.AITaskPromptParameterName,
|
||||
Value: prompt,
|
||||
},
|
||||
})
|
||||
agents, err := db.GetWorkspaceAgentsByWorkspaceAndBuildNumber(
|
||||
dbauthz.AsSystemRestricted(context.Background()),
|
||||
database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{
|
||||
WorkspaceID: build.Workspace.ID,
|
||||
BuildNumber: build.Build.BuildNumber,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, agents)
|
||||
agentID := agents[0].ID
|
||||
}).
|
||||
WithAgent().
|
||||
WithTask(database.TaskTable{
|
||||
Prompt: prompt,
|
||||
}, nil).
|
||||
Do()
|
||||
|
||||
// Create a workspace app and set it as the sidebar app.
|
||||
app := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{
|
||||
AgentID: agentID,
|
||||
Slug: "task-sidebar",
|
||||
DisplayName: "Task Sidebar",
|
||||
External: false,
|
||||
})
|
||||
|
||||
// Update build flags to reference the sidebar app and HasAITask=true.
|
||||
err = db.UpdateWorkspaceBuildFlagsByID(
|
||||
dbauthz.AsSystemRestricted(context.Background()),
|
||||
database.UpdateWorkspaceBuildFlagsByIDParams{
|
||||
ID: build.Build.ID,
|
||||
HasAITask: sql.NullBool{Bool: true, Valid: true},
|
||||
HasExternalAgent: sql.NullBool{Bool: false, Valid: false},
|
||||
SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true},
|
||||
UpdatedAt: build.Build.UpdatedAt,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a task record in the tasks table for the new data model.
|
||||
task := dbgen.Task(t, db, database.TaskTable{
|
||||
OrganizationID: orgID,
|
||||
OwnerID: ownerID,
|
||||
Name: build.Workspace.Name,
|
||||
WorkspaceID: uuid.NullUUID{UUID: build.Workspace.ID, Valid: true},
|
||||
TemplateVersionID: tv.TemplateVersion.ID,
|
||||
TemplateParameters: []byte("{}"),
|
||||
Prompt: prompt,
|
||||
CreatedAt: dbtime.Now(),
|
||||
})
|
||||
|
||||
// Link the task to the workspace app.
|
||||
dbgen.TaskWorkspaceApp(t, db, database.TaskWorkspaceApp{
|
||||
TaskID: task.ID,
|
||||
WorkspaceBuildNumber: build.Build.BuildNumber,
|
||||
WorkspaceAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
|
||||
WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true},
|
||||
})
|
||||
|
||||
return task
|
||||
return build.Task
|
||||
}
|
||||
|
||||
func TestExpTaskList(t *testing.T) {
|
||||
|
||||
+30
-97
@@ -36,17 +36,9 @@ func Test_TaskStatus(t *testing.T) {
|
||||
hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/tasks":
|
||||
if r.URL.Query().Get("q") == "owner:\"me\"" {
|
||||
httpapi.Write(ctx, w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{},
|
||||
Count: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "/api/experimental/tasks/me/doesnotexist":
|
||||
httpapi.ResourceNotFound(w)
|
||||
return
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
@@ -60,35 +52,7 @@ func Test_TaskStatus(t *testing.T) {
|
||||
hf: func(ctx context.Context, now time.Time) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/tasks":
|
||||
if r.URL.Query().Get("q") == "owner:\"me\"" {
|
||||
httpapi.Write(ctx, w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateWorking,
|
||||
Timestamp: now,
|
||||
Message: "Thinking furiously...",
|
||||
},
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
Status: codersdk.TaskStatusActive,
|
||||
}},
|
||||
Count: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
case "/api/experimental/tasks/me/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
@@ -124,30 +88,21 @@ func Test_TaskStatus(t *testing.T) {
|
||||
var calls atomic.Int64
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/tasks":
|
||||
if r.URL.Query().Get("q") == "owner:\"me\"" {
|
||||
// Return initial task state for --watch test
|
||||
httpapi.Write(ctx, w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusPending,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
UpdatedAt: now.Add(-5 * time.Second),
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
Status: codersdk.TaskStatusPending,
|
||||
}},
|
||||
Count: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "/api/experimental/tasks/me/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusPending,
|
||||
CreatedAt: now.Add(-5 * time.Second),
|
||||
UpdatedAt: now.Add(-5 * time.Second),
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
Status: codersdk.TaskStatusPending,
|
||||
})
|
||||
return
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
defer calls.Add(1)
|
||||
switch calls.Load() {
|
||||
@@ -263,40 +218,18 @@ func Test_TaskStatus(t *testing.T) {
|
||||
ts := time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/tasks":
|
||||
if r.URL.Query().Get("q") == "owner:\"me\"" {
|
||||
httpapi.Write(ctx, w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateWorking,
|
||||
Timestamp: ts.Add(time.Second),
|
||||
Message: "Thinking furiously...",
|
||||
},
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
Status: codersdk.TaskStatusActive,
|
||||
}},
|
||||
Count: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111":
|
||||
case "/api/experimental/tasks/me/exists":
|
||||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{
|
||||
Healthy: true,
|
||||
},
|
||||
WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
WorkspaceStatus: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
CurrentState: &codersdk.TaskStateEntry{
|
||||
State: codersdk.TaskStateWorking,
|
||||
Timestamp: ts.Add(time.Second),
|
||||
|
||||
+10
-15
@@ -53,7 +53,6 @@ func Test_Tasks(t *testing.T) {
|
||||
taskName = strings.ReplaceAll(testutil.GetRandomName(t), "_", "-")
|
||||
)
|
||||
|
||||
//nolint:paralleltest // The sub-tests of this test must be run sequentially.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmdArgs []string
|
||||
@@ -135,16 +134,15 @@ func Test_Tasks(t *testing.T) {
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, tc.cmdArgs...)
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
require.NoError(t, inv.WithContext(ctx).Run())
|
||||
if tc.assertFn != nil {
|
||||
tc.assertFn(stdout.String(), userClient)
|
||||
}
|
||||
})
|
||||
t.Logf("test case: %q", tc.name)
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, tc.cmdArgs...)
|
||||
inv.Stdout = &stdout
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
require.NoError(t, inv.WithContext(ctx).Run(), tc.name)
|
||||
if tc.assertFn != nil {
|
||||
tc.assertFn(stdout.String(), userClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +291,6 @@ func createAITaskTemplate(t *testing.T, client *codersdk.Client, orgID uuid.UUID
|
||||
{
|
||||
Type: &proto.Response_Plan{
|
||||
Plan: &proto.PlanComplete{
|
||||
Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}},
|
||||
HasAiTasks: true,
|
||||
},
|
||||
},
|
||||
@@ -328,9 +325,7 @@ func createAITaskTemplate(t *testing.T, client *codersdk.Client, orgID uuid.UUID
|
||||
},
|
||||
AiTasks: []*proto.AITask{
|
||||
{
|
||||
SidebarApp: &proto.AITaskSidebarApp{
|
||||
Id: taskAppID.String(),
|
||||
},
|
||||
AppId: taskAppID.String(),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
)
|
||||
|
||||
// mockKeyring is a mock sessionstore.Backend implementation.
|
||||
type mockKeyring struct {
|
||||
credentials map[string]string // service name -> credential
|
||||
}
|
||||
|
||||
const mockServiceName = "mock-service-name"
|
||||
|
||||
func newMockKeyring() *mockKeyring {
|
||||
return &mockKeyring{credentials: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (m *mockKeyring) Read(_ *url.URL) (string, error) {
|
||||
cred, ok := m.credentials[mockServiceName]
|
||||
if !ok {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
func (m *mockKeyring) Write(_ *url.URL, token string) error {
|
||||
m.credentials[mockServiceName] = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockKeyring) Delete(_ *url.URL) error {
|
||||
_, ok := m.credentials[mockServiceName]
|
||||
if !ok {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
delete(m.credentials, mockServiceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUseKeyring(t *testing.T) {
|
||||
// Verify that the --use-keyring flag opts into using a keyring backend for
|
||||
// storing session tokens instead of plain text files.
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Login", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a test server
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// Create CLI invocation with --use-keyring flag
|
||||
inv, cfg := clitest.New(t,
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--use-keyring",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
// Inject the mock backend before running the command
|
||||
var root cli.RootCmd
|
||||
cmd, err := root.Command(root.AGPL())
|
||||
require.NoError(t, err)
|
||||
mockBackend := newMockKeyring()
|
||||
root.WithSessionStorageBackend(mockBackend)
|
||||
inv.Command = cmd
|
||||
|
||||
// Run login in background
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
// Provide the token when prompted
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file was NOT created (using keyring instead)
|
||||
sessionFile := path.Join(string(cfg), "session")
|
||||
_, err = os.Stat(sessionFile)
|
||||
require.True(t, os.IsNotExist(err), "session file should not exist when using keyring")
|
||||
|
||||
// Verify that the credential IS stored in mock keyring
|
||||
cred, err := mockBackend.Read(nil)
|
||||
require.NoError(t, err, "credential should be stored in mock keyring")
|
||||
require.Equal(t, client.SessionToken(), cred, "stored token should match login token")
|
||||
})
|
||||
|
||||
t.Run("Logout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a test server
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// First, login with --use-keyring
|
||||
loginInv, cfg := clitest.New(t,
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--use-keyring",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
loginInv.Stdin = pty.Input()
|
||||
loginInv.Stdout = pty.Output()
|
||||
|
||||
// Inject the mock backend
|
||||
var loginRoot cli.RootCmd
|
||||
loginCmd, err := loginRoot.Command(loginRoot.AGPL())
|
||||
require.NoError(t, err)
|
||||
mockBackend := newMockKeyring()
|
||||
loginRoot.WithSessionStorageBackend(mockBackend)
|
||||
loginInv.Command = loginCmd
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := loginInv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify credential exists in mock keyring
|
||||
cred, err := mockBackend.Read(nil)
|
||||
require.NoError(t, err, "read credential should succeed before logout")
|
||||
require.NotEmpty(t, cred, "credential should exist after logout")
|
||||
|
||||
// Now run logout with --use-keyring
|
||||
logoutInv, _ := clitest.New(t,
|
||||
"logout",
|
||||
"--use-keyring",
|
||||
"--yes",
|
||||
"--global-config", string(cfg),
|
||||
)
|
||||
|
||||
// Inject the same mock backend
|
||||
var logoutRoot cli.RootCmd
|
||||
logoutCmd, err := logoutRoot.Command(logoutRoot.AGPL())
|
||||
require.NoError(t, err)
|
||||
logoutRoot.WithSessionStorageBackend(mockBackend)
|
||||
logoutInv.Command = logoutCmd
|
||||
|
||||
var logoutOut bytes.Buffer
|
||||
logoutInv.Stdout = &logoutOut
|
||||
|
||||
err = logoutInv.Run()
|
||||
require.NoError(t, err, "logout should succeed")
|
||||
|
||||
// Verify the credential was deleted from mock keyring
|
||||
_, err = mockBackend.Read(nil)
|
||||
require.ErrorIs(t, err, os.ErrNotExist, "credential should be deleted from keyring after logout")
|
||||
})
|
||||
|
||||
t.Run("OmitFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a test server
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// --use-keyring flag omitted (should use file-based storage)
|
||||
inv, cfg := clitest.New(t,
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file WAS created (not using keyring)
|
||||
sessionFile := path.Join(string(cfg), "session")
|
||||
_, err := os.Stat(sessionFile)
|
||||
require.NoError(t, err, "session file should exist when NOT using --use-keyring")
|
||||
|
||||
// Read and verify the token from file
|
||||
content, err := os.ReadFile(sessionFile)
|
||||
require.NoError(t, err, "should be able to read session file")
|
||||
require.Equal(t, client.SessionToken(), string(content), "file should contain the session token")
|
||||
})
|
||||
|
||||
t.Run("EnvironmentVariable", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a test server
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a pty for interactive prompts
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// Login using CODER_USE_KEYRING environment variable instead of flag
|
||||
inv, cfg := clitest.New(t,
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Environ.Set("CODER_USE_KEYRING", "true")
|
||||
|
||||
// Inject the mock backend
|
||||
var root cli.RootCmd
|
||||
cmd, err := root.Command(root.AGPL())
|
||||
require.NoError(t, err)
|
||||
mockBackend := newMockKeyring()
|
||||
root.WithSessionStorageBackend(mockBackend)
|
||||
inv.Command = cmd
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Verify that session file was NOT created (using keyring via env var)
|
||||
sessionFile := path.Join(string(cfg), "session")
|
||||
_, err = os.Stat(sessionFile)
|
||||
require.True(t, os.IsNotExist(err), "session file should not exist when using keyring via env var")
|
||||
|
||||
// Verify credential is in mock keyring
|
||||
cred, err := mockBackend.Read(nil)
|
||||
require.NoError(t, err, "credential should be stored in keyring when CODER_USE_KEYRING=true")
|
||||
require.NotEmpty(t, cred)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUseKeyringUnsupportedOS(t *testing.T) {
|
||||
// Verify that trying to use --use-keyring on an unsupported operating system produces
|
||||
// a helpful error message.
|
||||
t.Parallel()
|
||||
|
||||
// Only run this on an unsupported OS.
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||
t.Skipf("Skipping unsupported OS test on %s where keyring is supported", runtime.GOOS)
|
||||
}
|
||||
|
||||
const expMessage = "keyring storage is not supported on this operating system; remove the --use-keyring flag"
|
||||
|
||||
t.Run("LoginWithUnsupportedKeyring", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Try to login with --use-keyring on an unsupported OS
|
||||
inv, _ := clitest.New(t,
|
||||
"login",
|
||||
"--use-keyring",
|
||||
client.URL.String(),
|
||||
)
|
||||
|
||||
// The error should occur immediately, before any prompts
|
||||
loginErr := inv.Run()
|
||||
|
||||
// Verify we got an error about unsupported OS
|
||||
require.Error(t, loginErr)
|
||||
require.Contains(t, loginErr.Error(), expMessage)
|
||||
})
|
||||
|
||||
t.Run("LogoutWithUnsupportedKeyring", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
pty := ptytest.New(t)
|
||||
|
||||
// First login without keyring to create a session
|
||||
loginInv, cfg := clitest.New(t,
|
||||
"login",
|
||||
"--force-tty",
|
||||
"--no-open",
|
||||
client.URL.String(),
|
||||
)
|
||||
loginInv.Stdin = pty.Input()
|
||||
loginInv.Stdout = pty.Output()
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := loginInv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
// Now try to logout with --use-keyring on an unsupported OS
|
||||
logoutInv, _ := clitest.New(t,
|
||||
"logout",
|
||||
"--use-keyring",
|
||||
"--yes",
|
||||
"--global-config", string(cfg),
|
||||
)
|
||||
|
||||
err := logoutInv.Run()
|
||||
// Verify we got an error about unsupported OS
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), expMessage)
|
||||
})
|
||||
}
|
||||
+24
-5
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
"github.com/coder/coder/v2/coderd/userpassword"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
@@ -114,9 +115,11 @@ func (r *RootCmd) loginWithPassword(
|
||||
}
|
||||
|
||||
sessionToken := resp.SessionToken
|
||||
config := r.createConfig()
|
||||
err = config.Session().Write(sessionToken)
|
||||
err = r.ensureTokenBackend().Write(client.URL, sessionToken)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
return errKeyringNotSupported
|
||||
}
|
||||
return xerrors.Errorf("write session token: %w", err)
|
||||
}
|
||||
|
||||
@@ -149,11 +152,15 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
useTokenForSession bool
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "login [<url>]",
|
||||
Short: "Authenticate with Coder deployment",
|
||||
Use: "login [<url>]",
|
||||
Short: "Authenticate with Coder deployment",
|
||||
Long: "By default, the session token is stored in a plain text file. Use the " +
|
||||
"--use-keyring flag or set CODER_USE_KEYRING=true to store the token in " +
|
||||
"the operating system keyring instead.",
|
||||
Middleware: serpent.RequireRangeArgs(0, 1),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
|
||||
rawURL := ""
|
||||
var urlSource string
|
||||
|
||||
@@ -198,6 +205,15 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check keyring availability before prompting the user for a token to fail fast.
|
||||
if r.useKeyring {
|
||||
backend := r.ensureTokenBackend()
|
||||
_, err := backend.Read(client.URL)
|
||||
if err != nil && xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
return errKeyringNotSupported
|
||||
}
|
||||
}
|
||||
|
||||
hasFirstUser, err := client.HasFirstUser(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err)
|
||||
@@ -394,8 +410,11 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
}
|
||||
|
||||
config := r.createConfig()
|
||||
err = config.Session().Write(sessionToken)
|
||||
err = r.ensureTokenBackend().Write(client.URL, sessionToken)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
return errKeyringNotSupported
|
||||
}
|
||||
return xerrors.Errorf("write session token: %w", err)
|
||||
}
|
||||
err = config.URL().Write(serverURL.String())
|
||||
|
||||
+8
-3
@@ -8,6 +8,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
@@ -46,11 +47,15 @@ func (r *RootCmd) logout() *serpent.Command {
|
||||
errors = append(errors, xerrors.Errorf("remove URL file: %w", err))
|
||||
}
|
||||
|
||||
err = config.Session().Delete()
|
||||
err = r.ensureTokenBackend().Delete(client.URL)
|
||||
// Only throw error if the session configuration file is present,
|
||||
// otherwise the user is already logged out, and we proceed
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
errors = append(errors, xerrors.Errorf("remove session file: %w", err))
|
||||
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
|
||||
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
errors = append(errors, errKeyringNotSupported)
|
||||
} else {
|
||||
errors = append(errors, xerrors.Errorf("remove session token: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
err = config.Organization().Delete()
|
||||
|
||||
+72
-8
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
"github.com/coder/coder/v2/cli/gitauth"
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
"github.com/coder/coder/v2/cli/telemetry"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
@@ -54,6 +55,8 @@ var (
|
||||
// ErrSilent is a sentinel error that tells the command handler to just exit with a non-zero error, but not print
|
||||
// anything.
|
||||
ErrSilent = xerrors.New("silent error")
|
||||
|
||||
errKeyringNotSupported = xerrors.New("keyring storage is not supported on this operating system; remove the --use-keyring flag to use file-based storage")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -68,12 +71,14 @@ const (
|
||||
varVerbose = "verbose"
|
||||
varDisableDirect = "disable-direct-connections"
|
||||
varDisableNetworkTelemetry = "disable-network-telemetry"
|
||||
varUseKeyring = "use-keyring"
|
||||
|
||||
notLoggedInMessage = "You are not logged in. Try logging in using '%s login <url>'."
|
||||
|
||||
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
|
||||
envNoFeatureWarning = "CODER_NO_FEATURE_WARNING"
|
||||
envSessionToken = "CODER_SESSION_TOKEN"
|
||||
envUseKeyring = "CODER_USE_KEYRING"
|
||||
//nolint:gosec
|
||||
envAgentToken = "CODER_AGENT_TOKEN"
|
||||
//nolint:gosec
|
||||
@@ -474,6 +479,15 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
|
||||
Value: serpent.BoolOf(&r.disableNetworkTelemetry),
|
||||
Group: globalGroup,
|
||||
},
|
||||
{
|
||||
Flag: varUseKeyring,
|
||||
Env: envUseKeyring,
|
||||
Description: "Store and retrieve session tokens using the operating system " +
|
||||
"keyring. Currently only supported on Windows. By default, tokens are " +
|
||||
"stored in plain text files.",
|
||||
Value: serpent.BoolOf(&r.useKeyring),
|
||||
Group: globalGroup,
|
||||
},
|
||||
{
|
||||
Flag: "debug-http",
|
||||
Description: "Debug codersdk HTTP requests.",
|
||||
@@ -508,6 +522,7 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
|
||||
type RootCmd struct {
|
||||
clientURL *url.URL
|
||||
token string
|
||||
tokenBackend sessionstore.Backend
|
||||
globalConfig string
|
||||
header []string
|
||||
headerCommand string
|
||||
@@ -522,6 +537,24 @@ type RootCmd struct {
|
||||
disableNetworkTelemetry bool
|
||||
noVersionCheck bool
|
||||
noFeatureWarning bool
|
||||
useKeyring bool
|
||||
|
||||
// TestOverrideSDKClient is returned by GetClient if set. This is used in unit testing to mock or fake the
|
||||
// *codersdk.Client
|
||||
TestOverrideSDKClient any
|
||||
}
|
||||
|
||||
// GetClient gets the SDK client of the given type. In tests, this can be used with TestOverrideSDKClient to mock or
|
||||
// fake the client. In production, this returns a *codersdk.Client.
|
||||
func GetClient[T any](r *RootCmd, inv *serpent.Invocation) (T, error) {
|
||||
if r.TestOverrideSDKClient != nil {
|
||||
// nolint: forcetypeassert
|
||||
return r.TestOverrideSDKClient.(T), nil
|
||||
}
|
||||
var a any
|
||||
a, err := r.InitClient(inv)
|
||||
// nolint: forcetypeassert
|
||||
return a.(T), err
|
||||
}
|
||||
|
||||
// InitClient creates and configures a new client with authentication, telemetry,
|
||||
@@ -549,14 +582,19 @@ func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Read the token stored on disk.
|
||||
if r.token == "" {
|
||||
r.token, err = conf.Session().Read()
|
||||
tok, err := r.ensureTokenBackend().Read(r.clientURL)
|
||||
// Even if there isn't a token, we don't care.
|
||||
// Some API routes can be unauthenticated.
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
|
||||
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
return nil, errKeyringNotSupported
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if tok != "" {
|
||||
r.token = tok
|
||||
}
|
||||
}
|
||||
|
||||
// Configure HTTP client with transport wrappers
|
||||
@@ -588,7 +626,6 @@ func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error)
|
||||
// This allows commands to run without requiring authentication, but still use auth if available.
|
||||
func (r *RootCmd) TryInitClient(inv *serpent.Invocation) (*codersdk.Client, error) {
|
||||
conf := r.createConfig()
|
||||
var err error
|
||||
// Read the client URL stored on disk.
|
||||
if r.clientURL == nil || r.clientURL.String() == "" {
|
||||
rawURL, err := conf.URL().Read()
|
||||
@@ -605,14 +642,19 @@ func (r *RootCmd) TryInitClient(inv *serpent.Invocation) (*codersdk.Client, erro
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read the token stored on disk.
|
||||
if r.token == "" {
|
||||
r.token, err = conf.Session().Read()
|
||||
tok, err := r.ensureTokenBackend().Read(r.clientURL)
|
||||
// Even if there isn't a token, we don't care.
|
||||
// Some API routes can be unauthenticated.
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
|
||||
if xerrors.Is(err, sessionstore.ErrNotImplemented) {
|
||||
return nil, errKeyringNotSupported
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if tok != "" {
|
||||
r.token = tok
|
||||
}
|
||||
}
|
||||
|
||||
// Only configure the client if we have a URL
|
||||
@@ -688,6 +730,24 @@ func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *ur
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// ensureTokenBackend returns the session token storage backend, creating it if necessary.
|
||||
// This must be called after flags are parsed so we can respect the value of the --use-keyring
|
||||
// flag.
|
||||
func (r *RootCmd) ensureTokenBackend() sessionstore.Backend {
|
||||
if r.tokenBackend == nil {
|
||||
if r.useKeyring {
|
||||
r.tokenBackend = sessionstore.NewKeyring()
|
||||
} else {
|
||||
r.tokenBackend = sessionstore.NewFile(r.createConfig)
|
||||
}
|
||||
}
|
||||
return r.tokenBackend
|
||||
}
|
||||
|
||||
func (r *RootCmd) WithSessionStorageBackend(backend sessionstore.Backend) {
|
||||
r.tokenBackend = backend
|
||||
}
|
||||
|
||||
type AgentAuth struct {
|
||||
// Agent Client config
|
||||
agentToken string
|
||||
@@ -850,10 +910,14 @@ func splitNamedWorkspace(identifier string) (owner string, workspaceName string,
|
||||
return owner, workspaceName, nil
|
||||
}
|
||||
|
||||
type workspaceGetterByName interface {
|
||||
WorkspaceByOwnerAndName(context.Context, string, string, codersdk.WorkspaceOptions) (codersdk.Workspace, error)
|
||||
}
|
||||
|
||||
// namedWorkspace fetches and returns a workspace by an identifier, which may be either
|
||||
// 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) {
|
||||
func namedWorkspace(ctx context.Context, client workspaceGetterByName, identifier string) (codersdk.Workspace, error) {
|
||||
owner, name, err := splitNamedWorkspace(identifier)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, err
|
||||
|
||||
@@ -1476,6 +1476,7 @@ func newProvisionerDaemon(
|
||||
Listener: terraformServer,
|
||||
Logger: provisionerLogger,
|
||||
WorkDirectory: workDir,
|
||||
Experiments: coderAPI.Experiments,
|
||||
},
|
||||
CachePath: tfDir,
|
||||
Tracer: tracer,
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// Package sessionstore provides CLI session token storage mechanisms.
|
||||
// Operating system keyring storage is intended to have compatibility with other Coder
|
||||
// applications (e.g. Coder Desktop, Coder provider for JetBrains Toolbox, etc) so that
|
||||
// applications can read/write the same credential stored in the keyring.
|
||||
//
|
||||
// Note that we aren't using an existing Go package zalando/go-keyring here for a few
|
||||
// reasons. 1) It prescribes the format of the target credential name in the OS keyrings,
|
||||
// which makes our life difficult for compatibility with other Coder applications. 2)
|
||||
// It uses init functions that make it difficult to test with. As a result, the OS
|
||||
// keyring implementations may be adapted from zalando/go-keyring source (i.e. Windows).
|
||||
package sessionstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
)
|
||||
|
||||
// Backend is a storage backend for session tokens.
|
||||
type Backend interface {
|
||||
// Read returns the session token for the given server URL or an error, if any. It
|
||||
// will return os.ErrNotExist if no token exists for the given URL.
|
||||
Read(serverURL *url.URL) (string, error)
|
||||
// Write stores the session token for the given server URL.
|
||||
Write(serverURL *url.URL, token string) error
|
||||
// Delete removes the session token for the given server URL or an error, if any.
|
||||
// It will return os.ErrNotExist error if no token exists to delete.
|
||||
Delete(serverURL *url.URL) error
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
// ErrSetDataTooBig is returned if `keyringProvider.Set` was called with too much data.
|
||||
// On macOS: The combination of service, username & password should not exceed ~3000 bytes
|
||||
// On Windows: The service is limited to 32KiB while the password is limited to 2560 bytes
|
||||
ErrSetDataTooBig = xerrors.New("data passed to Set was too big")
|
||||
|
||||
// ErrNotImplemented represents when keyring usage is not implemented on the current
|
||||
// operating system.
|
||||
ErrNotImplemented = xerrors.New("not implemented")
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultServiceName is the service name used in keyrings for storing Coder CLI session
|
||||
// tokens.
|
||||
defaultServiceName = "coder-v2-credentials"
|
||||
)
|
||||
|
||||
// keyringProvider represents an operating system keyring. The expectation
|
||||
// is these methods operate on the user/login keyring.
|
||||
type keyringProvider interface {
|
||||
// Set stores the given credential for a service name in the operating system
|
||||
// keyring.
|
||||
Set(service, credential string) error
|
||||
// Get retrieves the credential from the keyring. It must return os.ErrNotExist
|
||||
// if the credential is not found.
|
||||
Get(service string) ([]byte, error)
|
||||
// Delete deletes the credential from the keyring. It must return os.ErrNotExist
|
||||
// if the credential is not found.
|
||||
Delete(service string) error
|
||||
}
|
||||
|
||||
// credential represents a single credential entry.
|
||||
type credential struct {
|
||||
CoderURL string `json:"coder_url"`
|
||||
APIToken string `json:"api_token"`
|
||||
}
|
||||
|
||||
// credentialsMap represents the JSON structure stored in the operating system keyring.
|
||||
// It supports storing multiple credentials for different server URLs.
|
||||
type credentialsMap map[string]credential
|
||||
|
||||
// normalizeHost returns a normalized version of the URL host for use as a map key.
|
||||
func normalizeHost(u *url.URL) (string, error) {
|
||||
if u == nil || u.Host == "" {
|
||||
return "", xerrors.New("nil server URL")
|
||||
}
|
||||
return strings.TrimSpace(strings.ToLower(u.Host)), nil
|
||||
}
|
||||
|
||||
// parseCredentialsJSON parses the JSON from the keyring into a credentialsMap.
|
||||
func parseCredentialsJSON(jsonData []byte) (credentialsMap, error) {
|
||||
if len(jsonData) == 0 {
|
||||
return make(credentialsMap), nil
|
||||
}
|
||||
|
||||
var creds credentialsMap
|
||||
if err := json.Unmarshal(jsonData, &creds); err != nil {
|
||||
return nil, xerrors.Errorf("unmarshal credentials: %w", err)
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// Keyring is a Backend that exclusively stores the session token in the operating
|
||||
// system keyring. Happy path usage of this type should start with NewKeyring.
|
||||
// It stores a JSON object in the keyring that supports multiple credentials for
|
||||
// different server URLs, providing compatibility with Coder Desktop and other Coder
|
||||
// applications.
|
||||
type Keyring struct {
|
||||
provider keyringProvider
|
||||
serviceName string
|
||||
}
|
||||
|
||||
// NewKeyring creates a Keyring with the default service name for production use.
|
||||
func NewKeyring() Keyring {
|
||||
return Keyring{
|
||||
provider: operatingSystemKeyring{},
|
||||
serviceName: defaultServiceName,
|
||||
}
|
||||
}
|
||||
|
||||
// NewKeyringWithService creates a Keyring Backend that stores credentials under the
|
||||
// specified service name. This is primarily intended for testing to avoid conflicts
|
||||
// with production credentials and collisions between tests.
|
||||
func NewKeyringWithService(serviceName string) Keyring {
|
||||
return Keyring{
|
||||
provider: operatingSystemKeyring{},
|
||||
serviceName: serviceName,
|
||||
}
|
||||
}
|
||||
|
||||
func (o Keyring) Read(serverURL *url.URL) (string, error) {
|
||||
host, err := normalizeHost(serverURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
credJSON, err := o.provider.Get(o.serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(credJSON) == 0 {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
||||
creds, err := parseCredentialsJSON(credJSON)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("read: parse existing credentials: %w", err)
|
||||
}
|
||||
|
||||
// Return the credential for the specified URL
|
||||
cred, ok := creds[host]
|
||||
if !ok {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
return cred.APIToken, nil
|
||||
}
|
||||
|
||||
func (o Keyring) Write(serverURL *url.URL, token string) error {
|
||||
host, err := normalizeHost(serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingJSON, err := o.provider.Get(o.serviceName)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return xerrors.Errorf("read existing credentials: %w", err)
|
||||
}
|
||||
|
||||
creds, err := parseCredentialsJSON(existingJSON)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write: parse existing credentials: %w", err)
|
||||
}
|
||||
|
||||
// Upsert the credential for this URL.
|
||||
creds[host] = credential{
|
||||
CoderURL: host,
|
||||
APIToken: token,
|
||||
}
|
||||
|
||||
credsJSON, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("marshal credentials: %w", err)
|
||||
}
|
||||
|
||||
err = o.provider.Set(o.serviceName, string(credsJSON))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write credentials to keyring: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o Keyring) Delete(serverURL *url.URL) error {
|
||||
host, err := normalizeHost(serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingJSON, err := o.provider.Get(o.serviceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
creds, err := parseCredentialsJSON(existingJSON)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to parse existing credentials: %w", err)
|
||||
}
|
||||
|
||||
if _, ok := creds[host]; !ok {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
delete(creds, host)
|
||||
|
||||
// Delete the entire keyring entry when no credentials remain.
|
||||
if len(creds) == 0 {
|
||||
return o.provider.Delete(o.serviceName)
|
||||
}
|
||||
|
||||
// Write back the updated credentials map.
|
||||
credsJSON, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to marshal credentials: %w", err)
|
||||
}
|
||||
|
||||
return o.provider.Set(o.serviceName, string(credsJSON))
|
||||
}
|
||||
|
||||
// File is a Backend that exclusively stores the session token in a file on disk.
|
||||
type File struct {
|
||||
config func() config.Root
|
||||
}
|
||||
|
||||
func NewFile(f func() config.Root) *File {
|
||||
return &File{config: f}
|
||||
}
|
||||
|
||||
func (f *File) Read(_ *url.URL) (string, error) {
|
||||
return f.config().Session().Read()
|
||||
}
|
||||
|
||||
func (f *File) Write(_ *url.URL, token string) error {
|
||||
return f.config().Session().Write(token)
|
||||
}
|
||||
|
||||
func (f *File) Delete(_ *url.URL) error {
|
||||
return f.config().Session().Delete()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//go:build darwin
|
||||
|
||||
package sessionstore
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// fixedUsername is the fixed username used for all keychain entries.
|
||||
// Since our interface only uses service names, we use a constant username.
|
||||
fixedUsername = "coder-login-credentials"
|
||||
|
||||
execPathKeychain = "/usr/bin/security"
|
||||
notFoundStr = "could not be found"
|
||||
)
|
||||
|
||||
// operatingSystemKeyring implements keyringProvider for macOS.
|
||||
// It is largely adapted from the zalando/go-keyring package.
|
||||
type operatingSystemKeyring struct{}
|
||||
|
||||
func (operatingSystemKeyring) Set(service, credential string) error {
|
||||
// if the added secret has multiple lines or some non ascii,
|
||||
// macOS will hex encode it on return. To avoid getting garbage, we
|
||||
// encode all passwords
|
||||
password := base64.StdEncoding.EncodeToString([]byte(credential))
|
||||
|
||||
cmd := exec.Command(execPathKeychain, "-i")
|
||||
stdIn, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
command := fmt.Sprintf("add-generic-password -U -s %s -a %s -w %s\n",
|
||||
shellEscape(service),
|
||||
shellEscape(fixedUsername),
|
||||
shellEscape(password))
|
||||
if len(command) > 4096 {
|
||||
return ErrSetDataTooBig
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(stdIn, command); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = stdIn.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Get(service string) ([]byte, error) {
|
||||
out, err := exec.Command(
|
||||
execPathKeychain,
|
||||
"find-generic-password",
|
||||
"-s", service,
|
||||
"-wa", fixedUsername).CombinedOutput()
|
||||
if err != nil {
|
||||
if strings.Contains(string(out), notFoundStr) {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trimStr := strings.TrimSpace(string(out))
|
||||
return base64.StdEncoding.DecodeString(trimStr)
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Delete(service string) error {
|
||||
out, err := exec.Command(
|
||||
execPathKeychain,
|
||||
"delete-generic-password",
|
||||
"-s", service,
|
||||
"-a", fixedUsername).CombinedOutput()
|
||||
if strings.Contains(string(out), notFoundStr) {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// shellEscape returns a shell-escaped version of the string s.
|
||||
// This is adapted from github.com/zalando/go-keyring/internal/shellescape.
|
||||
func shellEscape(s string) string {
|
||||
if len(s) == 0 {
|
||||
return "''"
|
||||
}
|
||||
|
||||
pattern := regexp.MustCompile(`[^\w@%+=:,./-]`)
|
||||
if pattern.MatchString(s) {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build darwin
|
||||
|
||||
package sessionstore_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
execPathKeychain = "/usr/bin/security"
|
||||
fixedUsername = "coder-login-credentials"
|
||||
)
|
||||
|
||||
func readRawKeychainCredential(t *testing.T, service string) []byte {
|
||||
t.Helper()
|
||||
|
||||
out, err := exec.Command(
|
||||
execPathKeychain,
|
||||
"find-generic-password",
|
||||
"-s", service,
|
||||
"-wa", fixedUsername).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst := make([]byte, base64.StdEncoding.DecodedLen(len(out)))
|
||||
n, err := base64.StdEncoding.Decode(dst, out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dst[:n]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package sessionstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url *url.URL
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "StandardHost",
|
||||
url: &url.URL{Host: "coder.example.com"},
|
||||
want: "coder.example.com",
|
||||
},
|
||||
{
|
||||
name: "HostWithPort",
|
||||
url: &url.URL{Host: "coder.example.com:8080"},
|
||||
want: "coder.example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "UppercaseHost",
|
||||
url: &url.URL{Host: "CODER.EXAMPLE.COM"},
|
||||
want: "coder.example.com",
|
||||
},
|
||||
{
|
||||
name: "HostWithWhitespace",
|
||||
url: &url.URL{Host: " coder.example.com "},
|
||||
want: "coder.example.com",
|
||||
},
|
||||
{
|
||||
name: "NilURL",
|
||||
url: nil,
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "EmptyHost",
|
||||
url: &url.URL{Host: ""},
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeHost(tt.url)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCredentialsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
creds, err := parseCredentialsJSON(nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, creds)
|
||||
require.Empty(t, creds)
|
||||
})
|
||||
|
||||
t.Run("NewFormat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
jsonData := []byte(`{
|
||||
"coder1.example.com": {"coder_url": "coder1.example.com", "api_token": "token1"},
|
||||
"coder2.example.com": {"coder_url": "coder2.example.com", "api_token": "token2"}
|
||||
}`)
|
||||
creds, err := parseCredentialsJSON(jsonData)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, creds, 2)
|
||||
require.Equal(t, "token1", creds["coder1.example.com"].APIToken)
|
||||
require.Equal(t, "token2", creds["coder2.example.com"].APIToken)
|
||||
})
|
||||
|
||||
t.Run("InvalidJSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
jsonData := []byte(`{invalid json}`)
|
||||
_, err := parseCredentialsJSON(jsonData)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCredentialsMap_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
creds := credentialsMap{
|
||||
"coder1.example.com": {
|
||||
CoderURL: "coder1.example.com",
|
||||
APIToken: "token1",
|
||||
},
|
||||
"coder2.example.com:8080": {
|
||||
CoderURL: "coder2.example.com:8080",
|
||||
APIToken: "token2",
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(creds)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := parseCredentialsJSON(jsonData)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, creds, parsed)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package sessionstore
|
||||
|
||||
type operatingSystemKeyring struct{}
|
||||
|
||||
func (operatingSystemKeyring) Set(_, _ string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Get(_ string) ([]byte, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Delete(_ string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package sessionstore_test
|
||||
|
||||
import "testing"
|
||||
|
||||
func readRawKeychainCredential(t *testing.T, _ string) []byte {
|
||||
t.Fatal("not implemented")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package sessionstore_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
)
|
||||
|
||||
type storedCredentials map[string]struct {
|
||||
CoderURL string `json:"coder_url"`
|
||||
APIToken string `json:"api_token"`
|
||||
}
|
||||
|
||||
// Generate a test service name for use with the OS keyring. It uses a combination
|
||||
// of the test name and a nanosecond timestamp to prevent collisions.
|
||||
func keyringTestServiceName(t *testing.T) string {
|
||||
t.Helper()
|
||||
return t.Name() + "_" + fmt.Sprintf("%v", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func TestKeyring(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
|
||||
t.Skip("linux is not supported yet")
|
||||
}
|
||||
|
||||
// This test exercises use of the operating system keyring. As a result,
|
||||
// the operating system keyring is expected to be available.
|
||||
|
||||
const (
|
||||
testURL = "http://127.0.0.1:1337"
|
||||
testURL2 = "http://127.0.0.1:1338"
|
||||
)
|
||||
|
||||
t.Run("ReadNonExistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
_, err = backend.Read(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err), "expected os.ErrNotExist when reading non-existent token")
|
||||
})
|
||||
|
||||
t.Run("DeleteNonExistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
err = backend.Delete(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, os.ErrNotExist), "expected os.ErrNotExist when deleting non-existent token")
|
||||
})
|
||||
|
||||
t.Run("WriteAndRead", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
dir := t.TempDir()
|
||||
expSessionFile := path.Join(dir, "session")
|
||||
|
||||
const inputToken = "test-keyring-token-12345"
|
||||
err = backend.Write(srvURL, inputToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify no session file was created (keyring stores in OS keyring, not file)
|
||||
_, err = os.Stat(expSessionFile)
|
||||
require.True(t, errors.Is(err, os.ErrNotExist), "expected session token file to not exist when using keyring")
|
||||
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, inputToken, token)
|
||||
|
||||
// Clean up
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("WriteAndDelete", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
const inputToken = "test-keyring-token-67890"
|
||||
err = backend.Write(srvURL, inputToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, inputToken, token)
|
||||
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = backend.Read(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err), "expected os.ErrNotExist after deleting token")
|
||||
})
|
||||
|
||||
t.Run("OverwriteToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
// Write first token
|
||||
const firstToken = "first-keyring-token"
|
||||
err = backend.Write(srvURL, firstToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, firstToken, token)
|
||||
|
||||
// Overwrite with second token
|
||||
const secondToken = "second-keyring-token"
|
||||
err = backend.Write(srvURL, secondToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err = backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, secondToken, token)
|
||||
|
||||
// Clean up
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("MultipleServers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := sessionstore.NewKeyringWithService(keyringTestServiceName(t))
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
srvURL2, err := url.Parse(testURL2)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = backend.Delete(srvURL)
|
||||
_ = backend.Delete(srvURL2)
|
||||
})
|
||||
|
||||
// Write token for server 1
|
||||
const token1 = "token-for-server-1"
|
||||
err = backend.Write(srvURL, token1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write token for server 2 (should NOT overwrite server 1)
|
||||
const token2 = "token-for-server-2"
|
||||
err = backend.Write(srvURL2, token2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read server 1's credential
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token1, token)
|
||||
|
||||
// Read server 2's credential
|
||||
token, err = backend.Read(srvURL2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token2, token)
|
||||
|
||||
// Delete server 1's credential
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify server 1's credential is gone
|
||||
_, err = backend.Read(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err))
|
||||
|
||||
// Verify server 2's credential still exists
|
||||
token, err = backend.Read(srvURL2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token2, token)
|
||||
|
||||
// Clean up remaining credentials
|
||||
err = backend.Delete(srvURL2)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("StorageFormat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The storage format must remain consistent to ensure we don't break
|
||||
// compatibility with other Coder related applications that may read
|
||||
// or decode the same credential.
|
||||
|
||||
const testURL1 = "http://127.0.0.1:1337"
|
||||
srv1URL, err := url.Parse(testURL1)
|
||||
require.NoError(t, err)
|
||||
|
||||
const testURL2 = "http://127.0.0.1:1338"
|
||||
srv2URL, err := url.Parse(testURL2)
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceName := keyringTestServiceName(t)
|
||||
backend := sessionstore.NewKeyringWithService(serviceName)
|
||||
t.Cleanup(func() {
|
||||
_ = backend.Delete(srv1URL)
|
||||
_ = backend.Delete(srv2URL)
|
||||
})
|
||||
|
||||
// Write token for server 1
|
||||
const token1 = "token-server-1"
|
||||
err = backend.Write(srv1URL, token1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write token for server 2 (should NOT overwrite server 1's token)
|
||||
const token2 = "token-server-2"
|
||||
err = backend.Write(srv2URL, token2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both credentials are stored in the raw format and can
|
||||
// be extracted through the Backend API.
|
||||
rawCredential := readRawKeychainCredential(t, serviceName)
|
||||
|
||||
storedCreds := make(storedCredentials)
|
||||
err = json.Unmarshal(rawCredential, &storedCreds)
|
||||
require.NoError(t, err, "unmarshalling stored credentials")
|
||||
|
||||
// Both credentials should exist
|
||||
require.Len(t, storedCreds, 2)
|
||||
require.Equal(t, token1, storedCreds[srv1URL.Host].APIToken)
|
||||
require.Equal(t, token2, storedCreds[srv2URL.Host].APIToken)
|
||||
|
||||
// Read individual credentials
|
||||
token, err := backend.Read(srv1URL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token1, token)
|
||||
|
||||
token, err = backend.Read(srv2URL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token2, token)
|
||||
|
||||
// Cleanup
|
||||
err = backend.Delete(srv1URL)
|
||||
require.NoError(t, err)
|
||||
err = backend.Delete(srv2URL)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFile(t *testing.T) {
|
||||
const (
|
||||
testURL = "http://127.0.0.1:1337"
|
||||
testURL2 = "http://127.0.0.1:1338"
|
||||
)
|
||||
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ReadNonExistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = backend.Read(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("WriteAndRead", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write a token
|
||||
const inputToken = "test-token-12345"
|
||||
err = backend.Write(srvURL, inputToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the session file was created
|
||||
sessionFile := config.Root(dir).Session()
|
||||
require.True(t, sessionFile.Exists())
|
||||
|
||||
// Read the token back
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, inputToken, token)
|
||||
})
|
||||
|
||||
t.Run("WriteAndDelete", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write a token
|
||||
const inputToken = "test-token-67890"
|
||||
err = backend.Write(srvURL, inputToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the token was written
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, inputToken, token)
|
||||
|
||||
// Delete the token
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the token is gone
|
||||
_, err = backend.Read(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("DeleteNonExistent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to delete a non-existent token
|
||||
err = backend.Delete(srvURL)
|
||||
require.Error(t, err)
|
||||
require.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("OverwriteToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write first token
|
||||
const firstToken = "first-token"
|
||||
err = backend.Write(srvURL, firstToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, firstToken, token)
|
||||
|
||||
// Overwrite with second token
|
||||
const secondToken = "second-token"
|
||||
err = backend.Write(srvURL, secondToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err = backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, secondToken, token)
|
||||
})
|
||||
|
||||
t.Run("WriteIgnoresURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
backend := sessionstore.NewFile(func() config.Root { return config.Root(dir) })
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
srvURL2, err := url.Parse(testURL2)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec // Write with first URL test token
|
||||
const firstToken = "token-for-url1"
|
||||
err = backend.Write(srvURL, firstToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec // Write with second URL - should overwrite
|
||||
const secondToken = "token-for-url2"
|
||||
err = backend.Write(srvURL2, secondToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have the second token (File backend doesn't differentiate by URL)
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, secondToken, token)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build windows
|
||||
|
||||
package sessionstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/danieljoos/wincred"
|
||||
)
|
||||
|
||||
// operatingSystemKeyring implements keyringProvider and uses Windows Credential Manager.
|
||||
// It is largely adapted from the zalando/go-keyring package.
|
||||
type operatingSystemKeyring struct{}
|
||||
|
||||
func (operatingSystemKeyring) Set(service, credential string) error {
|
||||
// password may not exceed 2560 bytes (https://github.com/jaraco/keyring/issues/540#issuecomment-968329967)
|
||||
if len(credential) > 2560 {
|
||||
return ErrSetDataTooBig
|
||||
}
|
||||
|
||||
// service may not exceed 512 bytes (might need more testing)
|
||||
if len(service) >= 512 {
|
||||
return ErrSetDataTooBig
|
||||
}
|
||||
|
||||
// service may not exceed 32k but problems occur before that
|
||||
// so we limit it to 30k
|
||||
if len(service) > 1024*30 {
|
||||
return ErrSetDataTooBig
|
||||
}
|
||||
|
||||
cred := wincred.NewGenericCredential(service)
|
||||
cred.CredentialBlob = []byte(credential)
|
||||
cred.Persist = wincred.PersistLocalMachine
|
||||
return cred.Write()
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Get(service string) ([]byte, error) {
|
||||
cred, err := wincred.GetGenericCredential(service)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.ERROR_NOT_FOUND) {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return cred.CredentialBlob, nil
|
||||
}
|
||||
|
||||
func (operatingSystemKeyring) Delete(service string) error {
|
||||
cred, err := wincred.GetGenericCredential(service)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.ERROR_NOT_FOUND) {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return err
|
||||
}
|
||||
return cred.Delete()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build windows
|
||||
|
||||
package sessionstore_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/danieljoos/wincred"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/sessionstore"
|
||||
)
|
||||
|
||||
func readRawKeychainCredential(t *testing.T, serviceName string) []byte {
|
||||
t.Helper()
|
||||
|
||||
winCred, err := wincred.GetGenericCredential(serviceName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return winCred.CredentialBlob
|
||||
}
|
||||
|
||||
func TestWindowsKeyring_WriteReadDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const testURL = "http://127.0.0.1:1337"
|
||||
srvURL, err := url.Parse(testURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceName := keyringTestServiceName(t)
|
||||
backend := sessionstore.NewKeyringWithService(serviceName)
|
||||
t.Cleanup(func() { _ = backend.Delete(srvURL) })
|
||||
|
||||
// Verify no token exists initially
|
||||
_, err = backend.Read(srvURL)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
|
||||
// Write a token
|
||||
const inputToken = "test-token-12345"
|
||||
err = backend.Write(srvURL, inputToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the credential is stored in Windows Credential Manager with correct format
|
||||
winCred, err := wincred.GetGenericCredential(serviceName)
|
||||
require.NoError(t, err, "getting windows credential")
|
||||
|
||||
storedCreds := make(storedCredentials)
|
||||
err = json.Unmarshal(winCred.CredentialBlob, &storedCreds)
|
||||
require.NoError(t, err, "unmarshalling stored credentials")
|
||||
|
||||
// Verify the stored values
|
||||
require.Len(t, storedCreds, 1)
|
||||
cred, ok := storedCreds[srvURL.Host]
|
||||
require.True(t, ok, "credential for URL should exist")
|
||||
require.Equal(t, inputToken, cred.APIToken)
|
||||
require.Equal(t, srvURL.Host, cred.CoderURL)
|
||||
|
||||
// Read the token back
|
||||
token, err := backend.Read(srvURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, inputToken, token)
|
||||
|
||||
// Delete the token
|
||||
err = backend.Delete(srvURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify token is deleted
|
||||
_, err = backend.Read(srvURL)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
+14
-2
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
@@ -14,6 +15,17 @@ import (
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
// showSDKClient is the subset of codersdk.Client that the show command uses.
|
||||
type showSDKClient interface {
|
||||
workspaceGetterByName
|
||||
BuildInfo(context.Context) (codersdk.BuildInfoResponse, error)
|
||||
WorkspaceAgentListeningPorts(context.Context, uuid.UUID) (codersdk.WorkspaceAgentListeningPortsResponse, error)
|
||||
WorkspaceAgentListContainers(context.Context, uuid.UUID, map[string]string) (codersdk.WorkspaceAgentListContainersResponse, error)
|
||||
}
|
||||
|
||||
// ensure methods track the SDK
|
||||
var _ showSDKClient = &codersdk.Client{}
|
||||
|
||||
func (r *RootCmd) show() *serpent.Command {
|
||||
var details bool
|
||||
return &serpent.Command{
|
||||
@@ -31,7 +43,7 @@ func (r *RootCmd) show() *serpent.Command {
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
client, err := r.InitClient(inv)
|
||||
client, err := GetClient[showSDKClient](r, inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -62,7 +74,7 @@ func (r *RootCmd) show() *serpent.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRuntimeResources(inv *serpent.Invocation, client *codersdk.Client, resources ...codersdk.WorkspaceResource) (map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse, map[uuid.UUID]codersdk.WorkspaceAgentListContainersResponse) {
|
||||
func fetchRuntimeResources(inv *serpent.Invocation, client showSDKClient, resources ...codersdk.WorkspaceResource) (map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse, map[uuid.UUID]codersdk.WorkspaceAgentListContainersResponse) {
|
||||
ports := make(map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse)
|
||||
devcontainers := make(map[uuid.UUID]codersdk.WorkspaceAgentListContainersResponse)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
+69
-15
@@ -2,6 +2,7 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
)
|
||||
@@ -21,21 +21,36 @@ func TestShow(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Exists", 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, completeWithAgent())
|
||||
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)
|
||||
|
||||
args := []string{
|
||||
"show",
|
||||
workspace.Name,
|
||||
agentID := uuid.UUID{1}
|
||||
workspaceID := uuid.UUID{2}
|
||||
workspace := codersdk.Workspace{
|
||||
Name: "test",
|
||||
ID: workspaceID,
|
||||
LatestBuild: codersdk.WorkspaceBuild{
|
||||
Status: codersdk.WorkspaceStatusRunning,
|
||||
Resources: []codersdk.WorkspaceResource{
|
||||
{
|
||||
Name: "main",
|
||||
Type: "compute",
|
||||
Agents: []codersdk.WorkspaceAgent{
|
||||
{Name: "smith", ID: agentID, Architecture: "i386", OperatingSystem: "linux"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
fSDK := &fakeShowSDK{
|
||||
t: t,
|
||||
expectedOwner: codersdk.Me,
|
||||
returnedWorkspace: workspace,
|
||||
expectedAgentID: agentID,
|
||||
expectedContainerLabels: map[string]string{
|
||||
agentcontainers.DevcontainerConfigFileLabel: "",
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "",
|
||||
},
|
||||
}
|
||||
|
||||
inv, _ := clitest.NewWithSDKOverride(t, fSDK, "show", workspace.Name)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
@@ -61,6 +76,45 @@ func TestShow(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
type fakeShowSDK struct {
|
||||
t *testing.T
|
||||
expectedOwner string
|
||||
returnedWorkspace codersdk.Workspace
|
||||
expectedAgentID uuid.UUID
|
||||
expectedContainerLabels map[string]string
|
||||
}
|
||||
|
||||
func (f *fakeShowSDK) WorkspaceByOwnerAndName(_ context.Context, owner string, name string, _ codersdk.WorkspaceOptions) (codersdk.Workspace, error) {
|
||||
assert.Equal(f.t, f.expectedOwner, owner)
|
||||
assert.Equal(f.t, f.returnedWorkspace.Name, name)
|
||||
return f.returnedWorkspace, nil
|
||||
}
|
||||
|
||||
func (*fakeShowSDK) BuildInfo(_ context.Context) (codersdk.BuildInfoResponse, error) {
|
||||
return codersdk.BuildInfoResponse{
|
||||
Version: "test-version",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeShowSDK) WorkspaceAgentListeningPorts(_ context.Context, u uuid.UUID) (codersdk.WorkspaceAgentListeningPortsResponse, error) {
|
||||
assert.Equal(f.t, f.expectedAgentID, u)
|
||||
return codersdk.WorkspaceAgentListeningPortsResponse{
|
||||
Ports: []codersdk.WorkspaceAgentListeningPort{
|
||||
{
|
||||
ProcessName: "postgres",
|
||||
Port: 5432,
|
||||
Network: "tcp",
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeShowSDK) WorkspaceAgentListContainers(_ context.Context, u uuid.UUID, m map[string]string) (codersdk.WorkspaceAgentListContainersResponse, error) {
|
||||
assert.Equal(f.t, f.expectedAgentID, u)
|
||||
assert.Equal(f.t, f.expectedContainerLabels, m)
|
||||
return codersdk.WorkspaceAgentListContainersResponse{}, nil
|
||||
}
|
||||
|
||||
func TestShowDevcontainers_Golden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Vendored
+5
@@ -108,6 +108,11 @@ variables or flags.
|
||||
--url url, $CODER_URL
|
||||
URL to a deployment.
|
||||
|
||||
--use-keyring bool, $CODER_USE_KEYRING
|
||||
Store and retrieve session tokens using the operating system keyring.
|
||||
Currently only supported on Windows. By default, tokens are stored in
|
||||
plain text files.
|
||||
|
||||
-v, --verbose bool, $CODER_VERBOSE
|
||||
Enable verbose output.
|
||||
|
||||
|
||||
+2
-1
@@ -90,6 +90,7 @@
|
||||
"allow_renames": false,
|
||||
"favorite": false,
|
||||
"next_start_at": "====[timestamp]=====",
|
||||
"is_prebuild": false
|
||||
"is_prebuild": false,
|
||||
"task_id": null
|
||||
}
|
||||
]
|
||||
|
||||
+4
@@ -5,6 +5,10 @@ USAGE:
|
||||
|
||||
Authenticate with Coder deployment
|
||||
|
||||
By default, the session token is stored in a plain text file. Use the
|
||||
--use-keyring flag or set CODER_USE_KEYRING=true to store the token in the
|
||||
operating system keyring instead.
|
||||
|
||||
OPTIONS:
|
||||
--first-user-email string, $CODER_FIRST_USER_EMAIL
|
||||
Specifies an email address to use if creating the first user for the
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"last_seen_at": "====[timestamp]=====",
|
||||
"name": "test-daemon",
|
||||
"version": "v0.0.0-devel",
|
||||
"api_version": "1.11",
|
||||
"api_version": "1.12",
|
||||
"provisioners": [
|
||||
"echo"
|
||||
],
|
||||
|
||||
+40
@@ -80,6 +80,46 @@ OPTIONS:
|
||||
Periodically check for new releases of Coder and inform the owner. The
|
||||
check is performed once per day.
|
||||
|
||||
AIBRIDGE OPTIONS:
|
||||
--aibridge-inject-coder-mcp-tools bool, $CODER_AIBRIDGE_INJECT_CODER_MCP_TOOLS (default: false)
|
||||
Whether to inject Coder's MCP tools into intercepted AI Bridge
|
||||
requests (requires the "oauth2" and "mcp-server-http" experiments to
|
||||
be enabled).
|
||||
|
||||
--aibridge-anthropic-base-url string, $CODER_AIBRIDGE_ANTHROPIC_BASE_URL (default: https://api.anthropic.com/)
|
||||
The base URL of the Anthropic API.
|
||||
|
||||
--aibridge-anthropic-key string, $CODER_AIBRIDGE_ANTHROPIC_KEY
|
||||
The key to authenticate against the Anthropic API.
|
||||
|
||||
--aibridge-bedrock-access-key string, $CODER_AIBRIDGE_BEDROCK_ACCESS_KEY
|
||||
The access key to authenticate against the AWS Bedrock API.
|
||||
|
||||
--aibridge-bedrock-access-key-secret string, $CODER_AIBRIDGE_BEDROCK_ACCESS_KEY_SECRET
|
||||
The access key secret to use with the access key to authenticate
|
||||
against the AWS Bedrock API.
|
||||
|
||||
--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.
|
||||
|
||||
--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
|
||||
API. Claude Code uses Haiku-class models to perform background tasks.
|
||||
See
|
||||
https://docs.claude.com/en/docs/claude-code/settings#environment-variables.
|
||||
|
||||
--aibridge-enabled bool, $CODER_AIBRIDGE_ENABLED (default: false)
|
||||
Whether to start an in-memory aibridged instance.
|
||||
|
||||
--aibridge-openai-base-url string, $CODER_AIBRIDGE_OPENAI_BASE_URL (default: https://api.openai.com/v1/)
|
||||
The base URL of the OpenAI API.
|
||||
|
||||
--aibridge-openai-key string, $CODER_AIBRIDGE_OPENAI_KEY
|
||||
The key to authenticate against the OpenAI API.
|
||||
|
||||
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.
|
||||
|
||||
+5
-2
@@ -714,8 +714,7 @@ workspace_prebuilds:
|
||||
# (default: 3, type: int)
|
||||
failure_hard_limit: 3
|
||||
aibridge:
|
||||
# Whether to start an in-memory aibridged instance ("aibridge" experiment must be
|
||||
# enabled, too).
|
||||
# Whether to start an in-memory aibridged instance.
|
||||
# (default: false, type: bool)
|
||||
enabled: false
|
||||
# The base URL of the OpenAI API.
|
||||
@@ -748,3 +747,7 @@ aibridge:
|
||||
# https://docs.claude.com/en/docs/claude-code/settings#environment-variables.
|
||||
# (default: global.anthropic.claude-haiku-4-5-20251001-v1:0, type: string)
|
||||
bedrock_small_fast_model: global.anthropic.claude-haiku-4-5-20251001-v1:0
|
||||
# Whether to inject Coder's MCP tools into intercepted AI Bridge requests
|
||||
# (requires the "oauth2" and "mcp-server-http" experiments to be enabled).
|
||||
# (default: false, type: bool)
|
||||
inject_coder_mcp_tools: false
|
||||
|
||||
+102
-74
@@ -7,13 +7,13 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
@@ -24,62 +24,13 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/searchquery"
|
||||
"github.com/coder/coder/v2/coderd/taskname"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
|
||||
aiagentapi "github.com/coder/agentapi-sdk-go"
|
||||
)
|
||||
|
||||
// This endpoint is experimental and not guaranteed to be stable, so we're not
|
||||
// generating public-facing documentation for it.
|
||||
func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
buildIDsParam := r.URL.Query().Get("build_ids")
|
||||
if buildIDsParam == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "build_ids query parameter is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse build IDs
|
||||
buildIDStrings := strings.Split(buildIDsParam, ",")
|
||||
buildIDs := make([]uuid.UUID, 0, len(buildIDStrings))
|
||||
for _, idStr := range buildIDStrings {
|
||||
id, err := uuid.Parse(strings.TrimSpace(idStr))
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Invalid build ID format: %s", idStr),
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
buildIDs = append(buildIDs, id)
|
||||
}
|
||||
|
||||
parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace build parameters.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
promptsByBuildID := make(map[string]string, len(parameters))
|
||||
for _, param := range parameters {
|
||||
if param.Name != codersdk.AITaskPromptParameterName {
|
||||
continue
|
||||
}
|
||||
buildID := param.WorkspaceBuildID.String()
|
||||
promptsByBuildID[buildID] = param.Value
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AITasksPromptsResponse{
|
||||
Prompts: promptsByBuildID,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Create a new AI task
|
||||
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
||||
// @ID create-task
|
||||
@@ -143,7 +94,7 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if !templateVersion.HasAITask.Valid || !templateVersion.HasAITask.Bool {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf(`Template does not have required parameter %q`, codersdk.AITaskPromptParameterName),
|
||||
Message: `Template does not have a valid "coder_ai_task" resource.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -174,13 +125,31 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the template defines the AI Prompt parameter.
|
||||
templateParams, err := api.Database.GetTemplateVersionParameters(ctx, req.TemplateVersionID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching template parameters.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var richParams []codersdk.WorkspaceBuildParameter
|
||||
if _, hasAIPromptParam := slice.Find(templateParams, func(param database.TemplateVersionParameter) bool {
|
||||
return param.Name == codersdk.AITaskPromptParameterName
|
||||
}); hasAIPromptParam {
|
||||
// Only add the AI Prompt parameter if the template defines it.
|
||||
richParams = []codersdk.WorkspaceBuildParameter{
|
||||
{Name: codersdk.AITaskPromptParameterName, Value: req.Input},
|
||||
}
|
||||
}
|
||||
|
||||
createReq := codersdk.CreateWorkspaceRequest{
|
||||
Name: taskName,
|
||||
TemplateVersionID: req.TemplateVersionID,
|
||||
TemplateVersionPresetID: req.TemplateVersionPresetID,
|
||||
RichParameterValues: []codersdk.WorkspaceBuildParameter{
|
||||
{Name: codersdk.AITaskPromptParameterName, Value: req.Input},
|
||||
},
|
||||
RichParameterValues: richParams,
|
||||
}
|
||||
|
||||
var owner workspaceOwner
|
||||
@@ -303,15 +272,21 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
|
||||
func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) codersdk.Task {
|
||||
var taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle
|
||||
var taskAgentHealth *codersdk.WorkspaceAgentHealth
|
||||
var taskAppHealth *codersdk.WorkspaceAppHealth
|
||||
|
||||
// If we have an agent ID from the task, find the agent details in the
|
||||
// workspace.
|
||||
if dbTask.WorkspaceAgentLifecycleState.Valid {
|
||||
taskAgentLifecycle = ptr.Ref(codersdk.WorkspaceAgentLifecycle(dbTask.WorkspaceAgentLifecycleState.WorkspaceAgentLifecycleState))
|
||||
}
|
||||
if dbTask.WorkspaceAppHealth.Valid {
|
||||
taskAppHealth = ptr.Ref(codersdk.WorkspaceAppHealth(dbTask.WorkspaceAppHealth.WorkspaceAppHealth))
|
||||
}
|
||||
|
||||
// If we have an agent ID from the task, find the agent health info
|
||||
if dbTask.WorkspaceAgentID.Valid {
|
||||
findTaskAgentLoop:
|
||||
for _, resource := range ws.LatestBuild.Resources {
|
||||
for _, agent := range resource.Agents {
|
||||
if agent.ID == dbTask.WorkspaceAgentID.UUID {
|
||||
taskAgentLifecycle = &agent.LifecycleState
|
||||
taskAgentHealth = &agent.Health
|
||||
break findTaskAgentLoop
|
||||
}
|
||||
@@ -319,21 +294,7 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore 'latest app status' if it is older than the latest build and the
|
||||
// latest build is a 'start' transition. This ensures that you don't show a
|
||||
// stale app status from a previous build. For stop transitions, there is
|
||||
// still value in showing the latest app status.
|
||||
var currentState *codersdk.TaskStateEntry
|
||||
if ws.LatestAppStatus != nil {
|
||||
if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) {
|
||||
currentState = &codersdk.TaskStateEntry{
|
||||
Timestamp: ws.LatestAppStatus.CreatedAt,
|
||||
State: codersdk.TaskState(ws.LatestAppStatus.State),
|
||||
Message: ws.LatestAppStatus.Message,
|
||||
URI: ws.LatestAppStatus.URI,
|
||||
}
|
||||
}
|
||||
}
|
||||
currentState := deriveTaskCurrentState(dbTask, ws, taskAgentLifecycle, taskAppHealth)
|
||||
|
||||
return codersdk.Task{
|
||||
ID: dbTask.ID,
|
||||
@@ -363,6 +324,73 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod
|
||||
}
|
||||
}
|
||||
|
||||
// deriveTaskCurrentState determines the current state of a task based on the
|
||||
// workspace's latest app status and initialization phase.
|
||||
// Returns nil if no valid state can be determined.
|
||||
func deriveTaskCurrentState(
|
||||
dbTask database.Task,
|
||||
ws codersdk.Workspace,
|
||||
taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle,
|
||||
taskAppHealth *codersdk.WorkspaceAppHealth,
|
||||
) *codersdk.TaskStateEntry {
|
||||
var currentState *codersdk.TaskStateEntry
|
||||
|
||||
// Ignore 'latest app status' if it is older than the latest build and the
|
||||
// latest build is a 'start' transition. This ensures that you don't show a
|
||||
// stale app status from a previous build. For stop transitions, there is
|
||||
// still value in showing the latest app status.
|
||||
if ws.LatestAppStatus != nil {
|
||||
if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) {
|
||||
currentState = &codersdk.TaskStateEntry{
|
||||
Timestamp: ws.LatestAppStatus.CreatedAt,
|
||||
State: codersdk.TaskState(ws.LatestAppStatus.State),
|
||||
Message: ws.LatestAppStatus.Message,
|
||||
URI: ws.LatestAppStatus.URI,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid agent state was found for the current build and the task is initializing,
|
||||
// provide a descriptive initialization message.
|
||||
if currentState == nil && dbTask.Status == database.TaskStatusInitializing {
|
||||
message := "Initializing workspace"
|
||||
|
||||
switch {
|
||||
case ws.LatestBuild.Status == codersdk.WorkspaceStatusPending ||
|
||||
ws.LatestBuild.Status == codersdk.WorkspaceStatusStarting:
|
||||
message = fmt.Sprintf("Workspace is %s", ws.LatestBuild.Status)
|
||||
case taskAgentLifecycle != nil:
|
||||
switch {
|
||||
case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleCreated:
|
||||
message = "Agent is connecting"
|
||||
case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleStarting:
|
||||
message = "Agent is starting"
|
||||
case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleReady:
|
||||
if taskAppHealth != nil && *taskAppHealth == codersdk.WorkspaceAppHealthInitializing {
|
||||
message = "App is initializing"
|
||||
} else {
|
||||
// In case the workspace app is not initializing,
|
||||
// the overall task status should be updated accordingly
|
||||
message = "Initializing workspace applications"
|
||||
}
|
||||
default:
|
||||
// In case the workspace agent is not initializing,
|
||||
// the overall task status should be updated accordingly
|
||||
message = "Initializing workspace agent"
|
||||
}
|
||||
}
|
||||
|
||||
currentState = &codersdk.TaskStateEntry{
|
||||
Timestamp: ws.LatestBuild.CreatedAt,
|
||||
State: codersdk.TaskStateWorking,
|
||||
Message: message,
|
||||
URI: "",
|
||||
}
|
||||
}
|
||||
|
||||
return currentState
|
||||
}
|
||||
|
||||
// @Summary List AI tasks
|
||||
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
|
||||
// @ID list-tasks
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestDeriveTaskCurrentState_Unit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
task database.Task
|
||||
agentLifecycle *codersdk.WorkspaceAgentLifecycle
|
||||
appHealth *codersdk.WorkspaceAppHealth
|
||||
latestAppStatus *codersdk.WorkspaceAppStatus
|
||||
latestBuild codersdk.WorkspaceBuild
|
||||
expectCurrentState bool
|
||||
expectedTimestamp time.Time
|
||||
expectedState codersdk.TaskState
|
||||
expectedMessage string
|
||||
}{
|
||||
{
|
||||
name: "NoAppStatus",
|
||||
task: database.Task{
|
||||
ID: uuid.New(),
|
||||
Status: database.TaskStatusActive,
|
||||
},
|
||||
agentLifecycle: nil,
|
||||
appHealth: nil,
|
||||
latestAppStatus: nil,
|
||||
latestBuild: codersdk.WorkspaceBuild{
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
CreatedAt: now,
|
||||
},
|
||||
expectCurrentState: false,
|
||||
},
|
||||
{
|
||||
name: "BuildStartTransition_AppStatus_NewerThanBuild",
|
||||
task: database.Task{
|
||||
ID: uuid.New(),
|
||||
Status: database.TaskStatusActive,
|
||||
},
|
||||
agentLifecycle: nil,
|
||||
appHealth: nil,
|
||||
latestAppStatus: &codersdk.WorkspaceAppStatus{
|
||||
State: codersdk.WorkspaceAppStatusStateWorking,
|
||||
Message: "Task is working",
|
||||
CreatedAt: now.Add(1 * time.Minute),
|
||||
},
|
||||
latestBuild: codersdk.WorkspaceBuild{
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
CreatedAt: now,
|
||||
},
|
||||
expectCurrentState: true,
|
||||
expectedTimestamp: now.Add(1 * time.Minute),
|
||||
expectedState: codersdk.TaskState(codersdk.WorkspaceAppStatusStateWorking),
|
||||
expectedMessage: "Task is working",
|
||||
},
|
||||
{
|
||||
name: "BuildStartTransition_StaleAppStatus_OlderThanBuild",
|
||||
task: database.Task{
|
||||
ID: uuid.New(),
|
||||
Status: database.TaskStatusActive,
|
||||
},
|
||||
agentLifecycle: nil,
|
||||
appHealth: nil,
|
||||
latestAppStatus: &codersdk.WorkspaceAppStatus{
|
||||
State: codersdk.WorkspaceAppStatusStateComplete,
|
||||
Message: "Previous task completed",
|
||||
CreatedAt: now.Add(-1 * time.Minute),
|
||||
},
|
||||
latestBuild: codersdk.WorkspaceBuild{
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
CreatedAt: now,
|
||||
},
|
||||
expectCurrentState: false,
|
||||
},
|
||||
{
|
||||
name: "BuildStopTransition",
|
||||
task: database.Task{
|
||||
ID: uuid.New(),
|
||||
Status: database.TaskStatusActive,
|
||||
},
|
||||
agentLifecycle: nil,
|
||||
appHealth: nil,
|
||||
latestAppStatus: &codersdk.WorkspaceAppStatus{
|
||||
State: codersdk.WorkspaceAppStatusStateComplete,
|
||||
Message: "Task completed before stop",
|
||||
CreatedAt: now.Add(-1 * time.Minute),
|
||||
},
|
||||
latestBuild: codersdk.WorkspaceBuild{
|
||||
Transition: codersdk.WorkspaceTransitionStop,
|
||||
CreatedAt: now,
|
||||
},
|
||||
expectCurrentState: true,
|
||||
expectedTimestamp: now.Add(-1 * time.Minute),
|
||||
expectedState: codersdk.TaskState(codersdk.WorkspaceAppStatusStateComplete),
|
||||
expectedMessage: "Task completed before stop",
|
||||
},
|
||||
{
|
||||
name: "TaskInitializing_WorkspacePending",
|
||||
task: database.Task{
|
||||
ID: uuid.New(),
|
||||
Status: database.TaskStatusInitializing,
|
||||
},
|
||||
agentLifecycle: nil,
|
||||
appHealth: nil,
|
||||
latestAppStatus: nil,
|
||||
latestBuild: codersdk.WorkspaceBuild{
|
||||
Status: codersdk.WorkspaceStatusPending,
|
||||
CreatedAt: now,
|
||||
},
|
||||
expectCurrentState: true,
|
||||
expectedTimestamp: now,
|
||||
expectedState: codersdk.TaskStateWorking,
|
||||
expectedMessage: "Workspace is pending",
|
||||
},
|
||||
{
|
||||
name: "TaskInitializing_WorkspaceStarting",
|
||||
task: database.Task{
|
||||
ID: uuid.New(),
|
||||
Status: database.TaskStatusInitializing,
|
||||
},
|
||||
agentLifecycle: nil,
|
||||
appHealth: nil,
|
||||
latestAppStatus: nil,
|
||||
latestBuild: codersdk.WorkspaceBuild{
|
||||
Status: codersdk.WorkspaceStatusStarting,
|
||||
CreatedAt: now,
|
||||
},
|
||||
expectCurrentState: true,
|
||||
expectedTimestamp: now,
|
||||
expectedState: codersdk.TaskStateWorking,
|
||||
expectedMessage: "Workspace is starting",
|
||||
},
|
||||
{
|
||||
name: "TaskInitializing_AgentConnecting",
|
||||
task: database.Task{
|
||||
ID: uuid.New(),
|
||||
Status: database.TaskStatusInitializing,
|
||||
},
|
||||
agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleCreated),
|
||||
appHealth: nil,
|
||||
latestAppStatus: nil,
|
||||
latestBuild: codersdk.WorkspaceBuild{
|
||||
Status: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now,
|
||||
},
|
||||
expectCurrentState: true,
|
||||
expectedTimestamp: now,
|
||||
expectedState: codersdk.TaskStateWorking,
|
||||
expectedMessage: "Agent is connecting",
|
||||
},
|
||||
{
|
||||
name: "TaskInitializing_AgentStarting",
|
||||
task: database.Task{
|
||||
ID: uuid.New(),
|
||||
Status: database.TaskStatusInitializing,
|
||||
},
|
||||
agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleStarting),
|
||||
appHealth: nil,
|
||||
latestAppStatus: nil,
|
||||
latestBuild: codersdk.WorkspaceBuild{
|
||||
Status: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now,
|
||||
},
|
||||
expectCurrentState: true,
|
||||
expectedTimestamp: now,
|
||||
expectedState: codersdk.TaskStateWorking,
|
||||
expectedMessage: "Agent is starting",
|
||||
},
|
||||
{
|
||||
name: "TaskInitializing_AppInitializing",
|
||||
task: database.Task{
|
||||
ID: uuid.New(),
|
||||
Status: database.TaskStatusInitializing,
|
||||
},
|
||||
agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
|
||||
appHealth: ptr.Ref(codersdk.WorkspaceAppHealthInitializing),
|
||||
latestAppStatus: nil,
|
||||
latestBuild: codersdk.WorkspaceBuild{
|
||||
Status: codersdk.WorkspaceStatusRunning,
|
||||
CreatedAt: now,
|
||||
},
|
||||
expectCurrentState: true,
|
||||
expectedTimestamp: now,
|
||||
expectedState: codersdk.TaskStateWorking,
|
||||
expectedMessage: "App is initializing",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ws := codersdk.Workspace{
|
||||
LatestBuild: tt.latestBuild,
|
||||
LatestAppStatus: tt.latestAppStatus,
|
||||
}
|
||||
|
||||
currentState := deriveTaskCurrentState(tt.task, ws, tt.agentLifecycle, tt.appHealth)
|
||||
|
||||
if tt.expectCurrentState {
|
||||
require.NotNil(t, currentState)
|
||||
assert.Equal(t, tt.expectedTimestamp.UTC(), currentState.Timestamp.UTC())
|
||||
assert.Equal(t, tt.expectedState, currentState.State)
|
||||
assert.Equal(t, tt.expectedMessage, currentState.Message)
|
||||
} else {
|
||||
assert.Nil(t, currentState)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+225
-176
@@ -7,10 +7,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -35,128 +33,6 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestAITasksPrompts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("EmptyBuildIDs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
experimentalClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Test with empty build IDs
|
||||
prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, prompts.Prompts)
|
||||
})
|
||||
|
||||
t.Run("MultipleBuilds", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
first := coderdtest.CreateFirstUser(t, adminClient)
|
||||
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, first.OrganizationID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Create a template with parameters
|
||||
version := coderdtest.CreateTemplateVersion(t, adminClient, first.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: []*proto.Response{{
|
||||
Type: &proto.Response_Plan{
|
||||
Plan: &proto.PlanComplete{
|
||||
Parameters: []*proto.RichParameter{
|
||||
{
|
||||
Name: "param1",
|
||||
Type: "string",
|
||||
DefaultValue: "default1",
|
||||
},
|
||||
{
|
||||
Name: codersdk.AITaskPromptParameterName,
|
||||
Type: "string",
|
||||
DefaultValue: "default2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, adminClient, first.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID)
|
||||
|
||||
// Create two workspaces with different parameters
|
||||
workspace1 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
|
||||
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
|
||||
{Name: "param1", Value: "value1a"},
|
||||
{Name: codersdk.AITaskPromptParameterName, Value: "value2a"},
|
||||
}
|
||||
})
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace1.LatestBuild.ID)
|
||||
|
||||
workspace2 := coderdtest.CreateWorkspace(t, memberClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
|
||||
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
|
||||
{Name: "param1", Value: "value1b"},
|
||||
{Name: codersdk.AITaskPromptParameterName, Value: "value2b"},
|
||||
}
|
||||
})
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace2.LatestBuild.ID)
|
||||
|
||||
workspace3 := coderdtest.CreateWorkspace(t, adminClient, template.ID, func(request *codersdk.CreateWorkspaceRequest) {
|
||||
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
|
||||
{Name: "param1", Value: "value1c"},
|
||||
{Name: codersdk.AITaskPromptParameterName, Value: "value2c"},
|
||||
}
|
||||
})
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, adminClient, workspace3.LatestBuild.ID)
|
||||
allBuildIDs := []uuid.UUID{workspace1.LatestBuild.ID, workspace2.LatestBuild.ID, workspace3.LatestBuild.ID}
|
||||
|
||||
experimentalMemberClient := codersdk.NewExperimentalClient(memberClient)
|
||||
// Test parameters endpoint as member
|
||||
prompts, err := experimentalMemberClient.AITaskPrompts(ctx, allBuildIDs)
|
||||
require.NoError(t, err)
|
||||
// we expect 2 prompts because the member client does not have access to workspace3
|
||||
// since it was created by the admin client
|
||||
require.Len(t, prompts.Prompts, 2)
|
||||
|
||||
// Check workspace1 parameters
|
||||
build1Prompt := prompts.Prompts[workspace1.LatestBuild.ID.String()]
|
||||
require.Equal(t, "value2a", build1Prompt)
|
||||
|
||||
// Check workspace2 parameters
|
||||
build2Prompt := prompts.Prompts[workspace2.LatestBuild.ID.String()]
|
||||
require.Equal(t, "value2b", build2Prompt)
|
||||
|
||||
experimentalAdminClient := codersdk.NewExperimentalClient(adminClient)
|
||||
// Test parameters endpoint as admin
|
||||
// we expect 3 prompts because the admin client has access to all workspaces
|
||||
prompts, err = experimentalAdminClient.AITaskPrompts(ctx, allBuildIDs)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, prompts.Prompts, 3)
|
||||
|
||||
// Check workspace3 parameters
|
||||
build3Prompt := prompts.Prompts[workspace3.LatestBuild.ID.String()]
|
||||
require.Equal(t, "value2c", build3Prompt)
|
||||
})
|
||||
|
||||
t.Run("NonExistentBuildIDs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Test with non-existent build IDs
|
||||
nonExistentID := uuid.New()
|
||||
experimentalClient := codersdk.NewExperimentalClient(client)
|
||||
prompts, err := experimentalClient.AITaskPrompts(ctx, []uuid.UUID{nonExistentID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, prompts.Prompts)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -188,7 +64,6 @@ func TestTasks(t *testing.T) {
|
||||
{
|
||||
Type: &proto.Response_Plan{
|
||||
Plan: &proto.PlanComplete{
|
||||
Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}},
|
||||
HasAiTasks: true,
|
||||
},
|
||||
},
|
||||
@@ -259,6 +134,9 @@ func TestTasks(t *testing.T) {
|
||||
// Wait for the workspace to be built.
|
||||
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
if assert.True(t, workspace.TaskID.Valid, "task id should be set on workspace") {
|
||||
assert.Equal(t, task.ID, workspace.TaskID.UUID, "workspace task id should match")
|
||||
}
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// List tasks via experimental API and verify the prompt and status mapping.
|
||||
@@ -279,12 +157,13 @@ func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
ctx = testutil.Context(t, testutil.WaitLong)
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
template = createAITemplate(t, client, user)
|
||||
wantPrompt = "review my code"
|
||||
exp = codersdk.NewExperimentalClient(client)
|
||||
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
ctx = testutil.Context(t, testutil.WaitLong)
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
anotherUser, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
template = createAITemplate(t, client, user)
|
||||
wantPrompt = "review my code"
|
||||
exp = codersdk.NewExperimentalClient(client)
|
||||
)
|
||||
|
||||
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
@@ -297,6 +176,9 @@ func TestTasks(t *testing.T) {
|
||||
// Get the workspace and wait for it to be ready.
|
||||
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
if assert.True(t, ws.TaskID.Valid, "task id should be set on workspace") {
|
||||
assert.Equal(t, task.ID, ws.TaskID.UUID, "workspace task id should match")
|
||||
}
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
ws = coderdtest.MustWorkspace(t, client, task.WorkspaceID.UUID)
|
||||
// Assert invariant: the workspace has exactly one resource with one agent with one app.
|
||||
@@ -331,6 +213,24 @@ func TestTasks(t *testing.T) {
|
||||
assert.Equal(t, taskAppID, updated.WorkspaceAppID.UUID, "workspace app id should match")
|
||||
assert.NotEmpty(t, updated.WorkspaceStatus, "task status should not be empty")
|
||||
|
||||
// Fetch the task by name and verify the same result
|
||||
byName, err := exp.TaskByOwnerAndName(ctx, codersdk.Me, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, byName, updated)
|
||||
|
||||
// Another member user should not be able to fetch the task
|
||||
otherClient := codersdk.NewExperimentalClient(anotherUser)
|
||||
_, err = otherClient.TaskByID(ctx, task.ID)
|
||||
require.Error(t, err, "fetching task should fail by ID for another member user")
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
// Also test by name
|
||||
_, err = otherClient.TaskByOwnerAndName(ctx, task.OwnerName, task.Name)
|
||||
require.Error(t, err, "fetching task should fail by name for another member user")
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
|
||||
// Stop the workspace
|
||||
coderdtest.MustTransitionWorkspace(t, client, task.WorkspaceID.UUID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
|
||||
|
||||
@@ -340,14 +240,18 @@ func TestTasks(t *testing.T) {
|
||||
assert.NotNil(t, updated.CurrentState, "current state should not be nil")
|
||||
assert.Equal(t, "all done", updated.CurrentState.Message)
|
||||
assert.Equal(t, codersdk.TaskStateComplete, updated.CurrentState.State)
|
||||
previousCurrentState := updated.CurrentState
|
||||
|
||||
// Start the workspace again
|
||||
coderdtest.MustTransitionWorkspace(t, client, task.WorkspaceID.UUID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart)
|
||||
|
||||
// Verify that the status from the previous build is no longer present
|
||||
// Verify that the status from the previous build has been cleared
|
||||
// and replaced by the agent initialization status.
|
||||
updated, err = exp.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, updated.CurrentState, "current state should be nil")
|
||||
assert.NotEqual(t, previousCurrentState, updated.CurrentState)
|
||||
assert.Equal(t, codersdk.TaskStateWorking, updated.CurrentState.State)
|
||||
assert.NotEqual(t, "all done", updated.CurrentState.Message)
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
@@ -371,6 +275,9 @@ func TestTasks(t *testing.T) {
|
||||
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
|
||||
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
if assert.True(t, ws.TaskID.Valid, "task id should be set on workspace") {
|
||||
assert.Equal(t, task.ID, ws.TaskID.UUID, "workspace task id should match")
|
||||
}
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
|
||||
err = exp.DeleteTask(ctx, "me", task.ID)
|
||||
@@ -417,6 +324,9 @@ func TestTasks(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
ws := coderdtest.CreateWorkspace(t, client, template.ID)
|
||||
if assert.False(t, ws.TaskID.Valid, "task id should not be set on non-task workspace") {
|
||||
assert.Zero(t, ws.TaskID, "non-task workspace task id should be empty")
|
||||
}
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
@@ -466,10 +376,10 @@ func TestTasks(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoWorkspace", func(t *testing.T) {
|
||||
t.Run("DeletedWorkspace", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
template := createAITemplate(t, client, user)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
@@ -483,14 +393,54 @@ func TestTasks(t *testing.T) {
|
||||
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
// Delete the task workspace
|
||||
coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionDelete)
|
||||
// We should still be able to fetch the task after deleting its workspace
|
||||
|
||||
// Mark the workspace as deleted directly in the database, bypassing provisionerd.
|
||||
require.NoError(t, db.UpdateWorkspaceDeletedByID(dbauthz.AsProvisionerd(ctx), database.UpdateWorkspaceDeletedByIDParams{
|
||||
ID: ws.ID,
|
||||
Deleted: true,
|
||||
}))
|
||||
// We should still be able to fetch the task if its workspace was deleted.
|
||||
// Provisionerdserver will attempt delete the related task when deleting a workspace.
|
||||
// This test ensures that we can still handle the case where, for some reason, the
|
||||
// task has not been marked as deleted, but the workspace has.
|
||||
task, err = exp.TaskByID(ctx, task.ID)
|
||||
require.NoError(t, err, "fetching a task should still work after deleting its related workspace")
|
||||
require.NoError(t, err, "fetching a task should still work if its related workspace is deleted")
|
||||
err = exp.DeleteTask(ctx, task.OwnerID.String(), task.ID)
|
||||
require.NoError(t, err, "should be possible to delete a task with no workspace")
|
||||
})
|
||||
|
||||
t.Run("DeletingTaskWorkspaceDeletesTask", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
template := createAITemplate(t, client, user)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "delete me",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
|
||||
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
if assert.True(t, ws.TaskID.Valid, "task id should be set on workspace") {
|
||||
assert.Equal(t, task.ID, ws.TaskID.UUID, "workspace task id should match")
|
||||
}
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
|
||||
// When; the task workspace is deleted
|
||||
coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionDelete)
|
||||
// Then: the task associated with the workspace is also deleted
|
||||
_, err = exp.TaskByID(ctx, task.ID)
|
||||
require.Error(t, err, "expected an error fetching the task")
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr, "expected a codersdk.Error")
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Send", func(t *testing.T) {
|
||||
@@ -728,7 +678,7 @@ func TestTasks(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
|
||||
// Fetch the task by ID via experimental API and verify fields.
|
||||
task, err = exp.TaskByID(ctx, task.ID)
|
||||
task, err = exp.TaskByIdentifier(ctx, task.ID.String())
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, task.WorkspaceBuildNumber)
|
||||
require.True(t, task.WorkspaceAgentID.Valid)
|
||||
@@ -805,6 +755,51 @@ func TestTasksCreate(t *testing.T) {
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
ProvisionPlan: []*proto.Response{
|
||||
{Type: &proto.Response_Plan{Plan: &proto.PlanComplete{
|
||||
HasAiTasks: true,
|
||||
}}},
|
||||
},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: taskPrompt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, task.WorkspaceID.Valid)
|
||||
|
||||
ws, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
|
||||
|
||||
assert.NotEmpty(t, task.Name)
|
||||
assert.Equal(t, template.ID, task.TemplateID)
|
||||
|
||||
parameters, err := client.WorkspaceBuildParameters(ctx, ws.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, parameters, 0)
|
||||
})
|
||||
|
||||
t.Run("OK AIPromptBackCompat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
taskPrompt = "Some task prompt"
|
||||
)
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Given: A template with an "AI Prompt" parameter
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
@@ -884,7 +879,6 @@ func TestTasksCreate(t *testing.T) {
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
ProvisionPlan: []*proto.Response{
|
||||
{Type: &proto.Response_Plan{Plan: &proto.PlanComplete{
|
||||
Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}},
|
||||
HasAiTasks: true,
|
||||
}}},
|
||||
},
|
||||
@@ -1000,7 +994,6 @@ func TestTasksCreate(t *testing.T) {
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
ProvisionPlan: []*proto.Response{
|
||||
{Type: &proto.Response_Plan{Plan: &proto.PlanComplete{
|
||||
Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}},
|
||||
HasAiTasks: true,
|
||||
}}},
|
||||
},
|
||||
@@ -1060,7 +1053,6 @@ func TestTasksCreate(t *testing.T) {
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
ProvisionPlan: []*proto.Response{
|
||||
{Type: &proto.Response_Plan{Plan: &proto.PlanComplete{
|
||||
Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}},
|
||||
HasAiTasks: true,
|
||||
}}},
|
||||
},
|
||||
@@ -1097,7 +1089,6 @@ func TestTasksCreate(t *testing.T) {
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
ProvisionPlan: []*proto.Response{
|
||||
{Type: &proto.Response_Plan{Plan: &proto.PlanComplete{
|
||||
Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}},
|
||||
HasAiTasks: true,
|
||||
}}},
|
||||
},
|
||||
@@ -1150,7 +1141,6 @@ func TestTasksCreate(t *testing.T) {
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
ProvisionPlan: []*proto.Response{
|
||||
{Type: &proto.Response_Plan{Plan: &proto.PlanComplete{
|
||||
Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}},
|
||||
HasAiTasks: true,
|
||||
}}},
|
||||
},
|
||||
@@ -1163,7 +1153,6 @@ func TestTasksCreate(t *testing.T) {
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
ProvisionPlan: []*proto.Response{
|
||||
{Type: &proto.Response_Plan{Plan: &proto.PlanComplete{
|
||||
Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}},
|
||||
HasAiTasks: true,
|
||||
}}},
|
||||
},
|
||||
@@ -1200,6 +1189,7 @@ func TestTasksNotification(t *testing.T) {
|
||||
isNotificationSent bool
|
||||
notificationTemplate uuid.UUID
|
||||
taskPrompt string
|
||||
agentLifecycle database.WorkspaceAgentLifecycleState
|
||||
}{
|
||||
// Should not send a notification when the agent app is not an AI task.
|
||||
{
|
||||
@@ -1247,6 +1237,7 @@ func TestTasksNotification(t *testing.T) {
|
||||
isNotificationSent: true,
|
||||
notificationTemplate: notifications.TemplateTaskIdle,
|
||||
taskPrompt: "InitialTemplateTaskIdle",
|
||||
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
|
||||
},
|
||||
// Should send TemplateTaskWorking when the AI task transitions to 'Working' from 'Idle'.
|
||||
{
|
||||
@@ -1260,6 +1251,7 @@ func TestTasksNotification(t *testing.T) {
|
||||
isNotificationSent: true,
|
||||
notificationTemplate: notifications.TemplateTaskWorking,
|
||||
taskPrompt: "TemplateTaskWorkingFromIdle",
|
||||
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
|
||||
},
|
||||
// Should send TemplateTaskIdle when the AI task transitions to 'Idle'.
|
||||
{
|
||||
@@ -1270,6 +1262,7 @@ func TestTasksNotification(t *testing.T) {
|
||||
isNotificationSent: true,
|
||||
notificationTemplate: notifications.TemplateTaskIdle,
|
||||
taskPrompt: "TemplateTaskIdle",
|
||||
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
|
||||
},
|
||||
// Long task prompts should be truncated to 160 characters.
|
||||
{
|
||||
@@ -1280,6 +1273,7 @@ func TestTasksNotification(t *testing.T) {
|
||||
isNotificationSent: true,
|
||||
notificationTemplate: notifications.TemplateTaskIdle,
|
||||
taskPrompt: "This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
|
||||
},
|
||||
// Should send TemplateTaskCompleted when the AI task transitions to 'Complete'.
|
||||
{
|
||||
@@ -1290,6 +1284,7 @@ func TestTasksNotification(t *testing.T) {
|
||||
isNotificationSent: true,
|
||||
notificationTemplate: notifications.TemplateTaskCompleted,
|
||||
taskPrompt: "TemplateTaskCompleted",
|
||||
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
|
||||
},
|
||||
// Should send TemplateTaskFailed when the AI task transitions to 'Failure'.
|
||||
{
|
||||
@@ -1300,6 +1295,7 @@ func TestTasksNotification(t *testing.T) {
|
||||
isNotificationSent: true,
|
||||
notificationTemplate: notifications.TemplateTaskFailed,
|
||||
taskPrompt: "TemplateTaskFailed",
|
||||
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
|
||||
},
|
||||
// Should send TemplateTaskCompleted when the AI task transitions from 'Idle' to 'Complete'.
|
||||
{
|
||||
@@ -1310,6 +1306,7 @@ func TestTasksNotification(t *testing.T) {
|
||||
isNotificationSent: true,
|
||||
notificationTemplate: notifications.TemplateTaskCompleted,
|
||||
taskPrompt: "TemplateTaskCompletedFromIdle",
|
||||
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
|
||||
},
|
||||
// Should send TemplateTaskFailed when the AI task transitions from 'Idle' to 'Failure'.
|
||||
{
|
||||
@@ -1320,6 +1317,7 @@ func TestTasksNotification(t *testing.T) {
|
||||
isNotificationSent: true,
|
||||
notificationTemplate: notifications.TemplateTaskFailed,
|
||||
taskPrompt: "TemplateTaskFailedFromIdle",
|
||||
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
|
||||
},
|
||||
// Should NOT send notification when transitioning from 'Complete' to 'Complete' (no change).
|
||||
{
|
||||
@@ -1339,6 +1337,37 @@ func TestTasksNotification(t *testing.T) {
|
||||
isNotificationSent: false,
|
||||
taskPrompt: "NoNotificationFailureToFailure",
|
||||
},
|
||||
// Should NOT send notification when agent is in 'starting' lifecycle state (agent startup).
|
||||
{
|
||||
name: "AgentStarting_NoNotification",
|
||||
latestAppStatuses: nil,
|
||||
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
|
||||
isAITask: true,
|
||||
isNotificationSent: false,
|
||||
taskPrompt: "AgentStarting_NoNotification",
|
||||
agentLifecycle: database.WorkspaceAgentLifecycleStateStarting,
|
||||
},
|
||||
// Should NOT send notification when agent is in 'created' lifecycle state (agent not started).
|
||||
{
|
||||
name: "AgentCreated_NoNotification",
|
||||
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
|
||||
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
|
||||
isAITask: true,
|
||||
isNotificationSent: false,
|
||||
taskPrompt: "AgentCreated_NoNotification",
|
||||
agentLifecycle: database.WorkspaceAgentLifecycleStateCreated,
|
||||
},
|
||||
// Should send notification when agent is in 'ready' lifecycle state (agent fully started).
|
||||
{
|
||||
name: "AgentReady_SendNotification",
|
||||
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
|
||||
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
|
||||
isAITask: true,
|
||||
isNotificationSent: true,
|
||||
notificationTemplate: notifications.TemplateTaskIdle,
|
||||
taskPrompt: "AgentReady_SendNotification",
|
||||
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -1357,31 +1386,57 @@ func TestTasksNotification(t *testing.T) {
|
||||
// Given: a workspace build with an agent containing an App
|
||||
workspaceAgentAppID := uuid.New()
|
||||
workspaceBuildID := uuid.New()
|
||||
workspaceBuildSeed := database.WorkspaceBuild{
|
||||
ID: workspaceBuildID,
|
||||
}
|
||||
if tc.isAITask {
|
||||
workspaceBuildSeed = database.WorkspaceBuild{
|
||||
ID: workspaceBuildID,
|
||||
// AI Task configuration
|
||||
HasAITask: sql.NullBool{Bool: true, Valid: true},
|
||||
AITaskSidebarAppID: uuid.NullUUID{UUID: workspaceAgentAppID, Valid: true},
|
||||
}
|
||||
}
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
workspaceBuilder := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: ownerUser.OrganizationID,
|
||||
OwnerID: memberUser.ID,
|
||||
}).Seed(workspaceBuildSeed).Params(database.WorkspaceBuildParameter{
|
||||
WorkspaceBuildID: workspaceBuildID,
|
||||
Name: codersdk.AITaskPromptParameterName,
|
||||
Value: tc.taskPrompt,
|
||||
}).WithAgent(func(agent []*proto.Agent) []*proto.Agent {
|
||||
agent[0].Apps = []*proto.App{{
|
||||
Id: workspaceAgentAppID.String(),
|
||||
Slug: "ccw",
|
||||
}}
|
||||
return agent
|
||||
}).Do()
|
||||
}).Seed(database.WorkspaceBuild{
|
||||
ID: workspaceBuildID,
|
||||
})
|
||||
if tc.isAITask {
|
||||
workspaceBuilder = workspaceBuilder.
|
||||
WithTask(database.TaskTable{
|
||||
Prompt: tc.taskPrompt,
|
||||
}, &proto.App{
|
||||
Id: workspaceAgentAppID.String(),
|
||||
Slug: "ccw",
|
||||
})
|
||||
} else {
|
||||
workspaceBuilder = workspaceBuilder.
|
||||
WithAgent(func(agent []*proto.Agent) []*proto.Agent {
|
||||
agent[0].Apps = []*proto.App{{
|
||||
Id: workspaceAgentAppID.String(),
|
||||
Slug: "ccw",
|
||||
}}
|
||||
return agent
|
||||
})
|
||||
}
|
||||
workspaceBuild := workspaceBuilder.Do()
|
||||
|
||||
// Given: set the agent lifecycle state if specified
|
||||
if tc.agentLifecycle != "" {
|
||||
workspace := coderdtest.MustWorkspace(t, client, workspaceBuild.Workspace.ID)
|
||||
agentID := workspace.LatestBuild.Resources[0].Agents[0].ID
|
||||
|
||||
var (
|
||||
startedAt sql.NullTime
|
||||
readyAt sql.NullTime
|
||||
)
|
||||
if tc.agentLifecycle == database.WorkspaceAgentLifecycleStateReady {
|
||||
startedAt = sql.NullTime{Time: dbtime.Now(), Valid: true}
|
||||
readyAt = sql.NullTime{Time: dbtime.Now(), Valid: true}
|
||||
} else if tc.agentLifecycle == database.WorkspaceAgentLifecycleStateStarting {
|
||||
startedAt = sql.NullTime{Time: dbtime.Now(), Valid: true}
|
||||
}
|
||||
|
||||
// nolint:gocritic // This is a system restricted operation for test setup.
|
||||
err := db.UpdateWorkspaceAgentLifecycleStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAgentLifecycleStateByIDParams{
|
||||
ID: agentID,
|
||||
LifecycleState: tc.agentLifecycle,
|
||||
StartedAt: startedAt,
|
||||
ReadyAt: readyAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Given: the workspace agent app has previous statuses
|
||||
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(workspaceBuild.AgentToken))
|
||||
@@ -1422,13 +1477,7 @@ func TestTasksNotification(t *testing.T) {
|
||||
require.Len(t, sent, 1)
|
||||
require.Equal(t, memberUser.ID, sent[0].UserID)
|
||||
require.Len(t, sent[0].Labels, 2)
|
||||
// NOTE: len(string) is the number of bytes in the string, not the number of runes.
|
||||
require.LessOrEqual(t, utf8.RuneCountInString(sent[0].Labels["task"]), 160)
|
||||
if len(tc.taskPrompt) > 160 {
|
||||
require.Contains(t, tc.taskPrompt, strings.TrimSuffix(sent[0].Labels["task"], "…"))
|
||||
} else {
|
||||
require.Equal(t, tc.taskPrompt, sent[0].Labels["task"])
|
||||
}
|
||||
require.Equal(t, workspaceBuild.Task.Name, sent[0].Labels["task"])
|
||||
require.Equal(t, workspace.Name, sent[0].Labels["workspace"])
|
||||
} else {
|
||||
// Then: No notification is sent
|
||||
|
||||
Generated
+89
-13
@@ -85,7 +85,7 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/aibridge/interceptions": {
|
||||
"/aibridge/interceptions": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
@@ -6002,6 +6002,41 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/templates/{template}/prebuilds/invalidate": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Invalidate presets for template",
|
||||
"operationId": "invalidate-presets-for-template",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Template ID",
|
||||
"name": "template",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.InvalidatePresetsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/templates/{template}/versions": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -11700,6 +11735,9 @@ const docTemplate = `{
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"inject_coder_mcp_tools": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"openai": {
|
||||
"$ref": "#/definitions/codersdk.AIBridgeOpenAIConfig"
|
||||
}
|
||||
@@ -11708,6 +11746,9 @@ const docTemplate = `{
|
||||
"codersdk.AIBridgeInterception": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api_key_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -14317,15 +14358,15 @@ const docTemplate = `{
|
||||
"oauth2",
|
||||
"mcp-server-http",
|
||||
"workspace-sharing",
|
||||
"aibridge"
|
||||
"terraform-directory-reuse"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAIBridge": "Enables AI Bridge functionality.",
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentExample": "This isn't used for anything.",
|
||||
"ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.",
|
||||
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
|
||||
"ExperimentOAuth2": "Enables OAuth2 provider functionality.",
|
||||
"ExperimentTerraformWorkspace": "Enables reuse of existing terraform directory for builds",
|
||||
"ExperimentWebPush": "Enables web push notifications through the browser.",
|
||||
"ExperimentWorkspaceSharing": "Enables updating workspace ACLs for sharing with users and groups.",
|
||||
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking."
|
||||
@@ -14339,7 +14380,7 @@ const docTemplate = `{
|
||||
"ExperimentOAuth2",
|
||||
"ExperimentMCPServerHTTP",
|
||||
"ExperimentWorkspaceSharing",
|
||||
"ExperimentAIBridge"
|
||||
"ExperimentTerraformWorkspace"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAPIKeyScopes": {
|
||||
@@ -14883,6 +14924,31 @@ const docTemplate = `{
|
||||
"InsightsReportIntervalWeek"
|
||||
]
|
||||
},
|
||||
"codersdk.InvalidatePresetsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"invalidated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.InvalidatedPreset"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.InvalidatedPreset": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"preset_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"template_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"template_version_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.IssueReconnectingPTYSignedTokenRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -15438,6 +15504,9 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"revocation_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"scopes_supported": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -18129,6 +18198,9 @@ const docTemplate = `{
|
||||
},
|
||||
"use_classic_parameter_flow": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_terraform_workspace_cache": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19057,6 +19129,10 @@ const docTemplate = `{
|
||||
"use_classic_parameter_flow": {
|
||||
"description": "UseClassicParameterFlow is a flag that switches the default behavior to use the classic\nparameter flow when creating a workspace. This only affects deployments with the experiment\n\"dynamic-parameters\" enabled. This setting will live for a period after the experiment is\nmade the default.\nAn \"opt-out\" is present in case the new feature breaks some existing templates.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_terraform_workspace_cache": {
|
||||
"description": "UseTerraformWorkspaceCache allows optionally specifying whether to use cached\nterraform directories for workspaces created from this template. This field\nonly applies when the correct experiment is enabled. This field is subject to\nbeing removed in the future.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19715,6 +19791,14 @@ const docTemplate = `{
|
||||
"description": "OwnerName is the username of the owner of the workspace.",
|
||||
"type": "string"
|
||||
},
|
||||
"task_id": {
|
||||
"description": "TaskID, if set, indicates that the workspace is relevant to the given codersdk.Task.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/uuid.NullUUID"
|
||||
}
|
||||
]
|
||||
},
|
||||
"template_active_version_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
@@ -20521,11 +20605,6 @@ const docTemplate = `{
|
||||
"codersdk.WorkspaceBuild": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ai_task_sidebar_app_id": {
|
||||
"description": "Deprecated: This field has been replaced with ` + "`" + `TaskAppID` + "`" + `",
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"build_number": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -20541,6 +20620,7 @@ const docTemplate = `{
|
||||
"format": "date-time"
|
||||
},
|
||||
"has_ai_task": {
|
||||
"description": "Deprecated: This field has been deprecated in favor of Task WorkspaceID.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"has_external_agent": {
|
||||
@@ -20604,10 +20684,6 @@ const docTemplate = `{
|
||||
}
|
||||
]
|
||||
},
|
||||
"task_app_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"template_version_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
|
||||
Generated
+85
-13
@@ -65,7 +65,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/aibridge/interceptions": {
|
||||
"/aibridge/interceptions": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
@@ -5309,6 +5309,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/templates/{template}/prebuilds/invalidate": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Invalidate presets for template",
|
||||
"operationId": "invalidate-presets-for-template",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Template ID",
|
||||
"name": "template",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.InvalidatePresetsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/templates/{template}/versions": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -10396,6 +10427,9 @@
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"inject_coder_mcp_tools": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"openai": {
|
||||
"$ref": "#/definitions/codersdk.AIBridgeOpenAIConfig"
|
||||
}
|
||||
@@ -10404,6 +10438,9 @@
|
||||
"codersdk.AIBridgeInterception": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api_key_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -12924,15 +12961,15 @@
|
||||
"oauth2",
|
||||
"mcp-server-http",
|
||||
"workspace-sharing",
|
||||
"aibridge"
|
||||
"terraform-directory-reuse"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAIBridge": "Enables AI Bridge functionality.",
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentExample": "This isn't used for anything.",
|
||||
"ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.",
|
||||
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
|
||||
"ExperimentOAuth2": "Enables OAuth2 provider functionality.",
|
||||
"ExperimentTerraformWorkspace": "Enables reuse of existing terraform directory for builds",
|
||||
"ExperimentWebPush": "Enables web push notifications through the browser.",
|
||||
"ExperimentWorkspaceSharing": "Enables updating workspace ACLs for sharing with users and groups.",
|
||||
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking."
|
||||
@@ -12946,7 +12983,7 @@
|
||||
"ExperimentOAuth2",
|
||||
"ExperimentMCPServerHTTP",
|
||||
"ExperimentWorkspaceSharing",
|
||||
"ExperimentAIBridge"
|
||||
"ExperimentTerraformWorkspace"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAPIKeyScopes": {
|
||||
@@ -13481,6 +13518,31 @@
|
||||
"InsightsReportIntervalWeek"
|
||||
]
|
||||
},
|
||||
"codersdk.InvalidatePresetsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"invalidated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.InvalidatedPreset"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.InvalidatedPreset": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"preset_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"template_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"template_version_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.IssueReconnectingPTYSignedTokenRequest": {
|
||||
"type": "object",
|
||||
"required": ["agentID", "url"],
|
||||
@@ -13992,6 +14054,9 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"revocation_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"scopes_supported": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -16604,6 +16669,9 @@
|
||||
},
|
||||
"use_classic_parameter_flow": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_terraform_workspace_cache": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17488,6 +17556,10 @@
|
||||
"use_classic_parameter_flow": {
|
||||
"description": "UseClassicParameterFlow is a flag that switches the default behavior to use the classic\nparameter flow when creating a workspace. This only affects deployments with the experiment\n\"dynamic-parameters\" enabled. This setting will live for a period after the experiment is\nmade the default.\nAn \"opt-out\" is present in case the new feature breaks some existing templates.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_terraform_workspace_cache": {
|
||||
"description": "UseTerraformWorkspaceCache allows optionally specifying whether to use cached\nterraform directories for workspaces created from this template. This field\nonly applies when the correct experiment is enabled. This field is subject to\nbeing removed in the future.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -18101,6 +18173,14 @@
|
||||
"description": "OwnerName is the username of the owner of the workspace.",
|
||||
"type": "string"
|
||||
},
|
||||
"task_id": {
|
||||
"description": "TaskID, if set, indicates that the workspace is relevant to the given codersdk.Task.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/uuid.NullUUID"
|
||||
}
|
||||
]
|
||||
},
|
||||
"template_active_version_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
@@ -18855,11 +18935,6 @@
|
||||
"codersdk.WorkspaceBuild": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ai_task_sidebar_app_id": {
|
||||
"description": "Deprecated: This field has been replaced with `TaskAppID`",
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"build_number": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -18875,6 +18950,7 @@
|
||||
"format": "date-time"
|
||||
},
|
||||
"has_ai_task": {
|
||||
"description": "Deprecated: This field has been deprecated in favor of Task WorkspaceID.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"has_external_agent": {
|
||||
@@ -18934,10 +19010,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"task_app_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"template_version_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
|
||||
+2
-2
@@ -509,11 +509,11 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID)
|
||||
user, err := api.Database.GetUserByID(ctx, task.OwnerID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/tasks/%s/%s", workspace.OwnerName, task.Name)
|
||||
return fmt.Sprintf("/tasks/%s/%s", user.Username, task.ID)
|
||||
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -1764,3 +1764,175 @@ func TestExecutorAutostartSkipsWhenNoProvisionersAvailable(t *testing.T) {
|
||||
|
||||
assert.Len(t, stats.Transitions, 1, "should create builds when provisioners are available")
|
||||
}
|
||||
|
||||
func TestExecutorTaskWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
createTaskTemplate := func(t *testing.T, client *codersdk.Client, orgID uuid.UUID, ctx context.Context, defaultTTL time.Duration) codersdk.Template {
|
||||
t.Helper()
|
||||
|
||||
taskAppID := uuid.New()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: []*proto.Response{
|
||||
{
|
||||
Type: &proto.Response_Plan{
|
||||
Plan: &proto.PlanComplete{HasAiTasks: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
ProvisionApply: []*proto.Response{
|
||||
{
|
||||
Type: &proto.Response_Apply{
|
||||
Apply: &proto.ApplyComplete{
|
||||
Resources: []*proto.Resource{
|
||||
{
|
||||
Agents: []*proto.Agent{
|
||||
{
|
||||
Id: uuid.NewString(),
|
||||
Name: "dev",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: uuid.NewString(),
|
||||
},
|
||||
Apps: []*proto.App{
|
||||
{
|
||||
Id: taskAppID.String(),
|
||||
Slug: "task-app",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AiTasks: []*proto.AITask{
|
||||
{
|
||||
AppId: taskAppID.String(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, orgID, version.ID)
|
||||
|
||||
if defaultTTL > 0 {
|
||||
_, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
|
||||
DefaultTTLMillis: defaultTTL.Milliseconds(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
createTaskWorkspace := func(t *testing.T, client *codersdk.Client, template codersdk.Template, ctx context.Context, input string) codersdk.Workspace {
|
||||
t.Helper()
|
||||
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: input,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, task.WorkspaceID.Valid, "task should have a workspace")
|
||||
|
||||
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
return workspace
|
||||
}
|
||||
|
||||
t.Run("Autostart", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *")
|
||||
tickCh = make(chan time.Time)
|
||||
statsCh = make(chan autobuild.Stats)
|
||||
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
AutobuildTicker: tickCh,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statsCh,
|
||||
})
|
||||
admin = coderdtest.CreateFirstUser(t, client)
|
||||
)
|
||||
|
||||
// Given: A task workspace
|
||||
template := createTaskTemplate(t, client, admin.OrganizationID, ctx, 0)
|
||||
workspace := createTaskWorkspace(t, client, template, ctx, "test task for autostart")
|
||||
|
||||
// Given: The task workspace has an autostart schedule
|
||||
err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
Schedule: ptr.Ref(sched.String()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: That the workspace is in a stopped state.
|
||||
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
|
||||
|
||||
p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, map[string]string{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: the autobuild executor ticks after the scheduled time
|
||||
go func() {
|
||||
tickTime := sched.Next(workspace.LatestBuild.CreatedAt)
|
||||
coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime)
|
||||
tickCh <- tickTime
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: We expect to see a start transition
|
||||
stats := <-statsCh
|
||||
require.Len(t, stats.Transitions, 1, "lifecycle executor should transition the task workspace")
|
||||
assert.Contains(t, stats.Transitions, workspace.ID, "task workspace should be in transitions")
|
||||
assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID], "should autostart the workspace")
|
||||
require.Empty(t, stats.Errors, "should have no errors when managing task workspaces")
|
||||
})
|
||||
|
||||
t.Run("Autostop", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
tickCh = make(chan time.Time)
|
||||
statsCh = make(chan autobuild.Stats)
|
||||
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
AutobuildTicker: tickCh,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AutobuildStats: statsCh,
|
||||
})
|
||||
admin = coderdtest.CreateFirstUser(t, client)
|
||||
)
|
||||
|
||||
// Given: A task workspace with an 8 hour deadline
|
||||
template := createTaskTemplate(t, client, admin.OrganizationID, ctx, 8*time.Hour)
|
||||
workspace := createTaskWorkspace(t, client, template, ctx, "test task for autostop")
|
||||
|
||||
// Given: The workspace is currently running
|
||||
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
|
||||
require.NotZero(t, workspace.LatestBuild.Deadline, "workspace should have a deadline for autostop")
|
||||
|
||||
p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, map[string]string{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: the autobuild executor ticks after the deadline
|
||||
go func() {
|
||||
tickTime := workspace.LatestBuild.Deadline.Time.Add(time.Minute)
|
||||
coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime)
|
||||
tickCh <- tickTime
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: We expect to see a stop transition
|
||||
stats := <-statsCh
|
||||
require.Len(t, stats.Transitions, 1, "lifecycle executor should transition the task workspace")
|
||||
assert.Contains(t, stats.Transitions, workspace.ID, "task workspace should be in transitions")
|
||||
assert.Equal(t, database.WorkspaceTransitionStop, stats.Transitions[workspace.ID], "should autostop the workspace")
|
||||
require.Empty(t, stats.Errors, "should have no errors when managing task workspaces")
|
||||
})
|
||||
}
|
||||
|
||||
+3
-4
@@ -610,6 +610,7 @@ func New(options *Options) *API {
|
||||
dbRolluper: options.DatabaseRolluper,
|
||||
}
|
||||
api.WorkspaceAppsProvider = workspaceapps.NewDBTokenProvider(
|
||||
ctx,
|
||||
options.Logger.Named("workspaceapps"),
|
||||
options.AccessURL,
|
||||
options.Authorizer,
|
||||
@@ -1021,10 +1022,7 @@ func New(options *Options) *API {
|
||||
apiRateLimiter,
|
||||
httpmw.ReportCLITelemetry(api.Logger, options.Telemetry),
|
||||
)
|
||||
r.Route("/aitasks", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Get("/prompts", api.aiTasksPrompts)
|
||||
})
|
||||
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
|
||||
@@ -2002,6 +2000,7 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n
|
||||
api.NotificationsEnqueuer,
|
||||
&api.PrebuildsReconciler,
|
||||
api.ProvisionerdServerMetrics,
|
||||
api.Experiments,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1604,7 +1604,7 @@ func (nopcloser) Close() error { return nil }
|
||||
// SDKError coerces err into an SDK error.
|
||||
func SDKError(t testing.TB, err error) *codersdk.Error {
|
||||
var cerr *codersdk.Error
|
||||
require.True(t, errors.As(err, &cerr), "should be SDK error, got %w", err)
|
||||
require.True(t, errors.As(err, &cerr), "should be SDK error, got %s", err)
|
||||
return cerr
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,14 @@ type CheckConstraint string
|
||||
|
||||
// CheckConstraint enums.
|
||||
const (
|
||||
CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys
|
||||
CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users
|
||||
CheckUsersUsernameMinLength CheckConstraint = "users_username_min_length" // users
|
||||
CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs
|
||||
CheckMaxLogsLength CheckConstraint = "max_logs_length" // workspace_agents
|
||||
CheckSubsystemsNotNone CheckConstraint = "subsystems_not_none" // workspace_agents
|
||||
CheckWorkspaceBuildsAiTaskSidebarAppIDRequired CheckConstraint = "workspace_builds_ai_task_sidebar_app_id_required" // workspace_builds
|
||||
CheckWorkspaceBuildsDeadlineBelowMaxDeadline CheckConstraint = "workspace_builds_deadline_below_max_deadline" // workspace_builds
|
||||
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
|
||||
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
|
||||
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
|
||||
CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys
|
||||
CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users
|
||||
CheckUsersUsernameMinLength CheckConstraint = "users_username_min_length" // users
|
||||
CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs
|
||||
CheckMaxLogsLength CheckConstraint = "max_logs_length" // workspace_agents
|
||||
CheckSubsystemsNotNone CheckConstraint = "subsystems_not_none" // workspace_agents
|
||||
CheckWorkspaceBuildsDeadlineBelowMaxDeadline CheckConstraint = "workspace_builds_deadline_below_max_deadline" // workspace_builds
|
||||
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
|
||||
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
|
||||
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
|
||||
)
|
||||
|
||||
@@ -974,6 +974,9 @@ func AIBridgeInterception(interception database.AIBridgeInterception, initiator
|
||||
UserPrompts: sdkUserPrompts,
|
||||
ToolUsages: sdkToolUsages,
|
||||
}
|
||||
if interception.APIKeyID.Valid {
|
||||
intc.APIKeyID = &interception.APIKeyID.String
|
||||
}
|
||||
if interception.EndedAt.Valid {
|
||||
intc.EndedAt = &interception.EndedAt.Time
|
||||
}
|
||||
@@ -1018,6 +1021,18 @@ func AIBridgeToolUsage(usage database.AIBridgeToolUsage) codersdk.AIBridgeToolUs
|
||||
}
|
||||
}
|
||||
|
||||
func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidatedAtRow) []codersdk.InvalidatedPreset {
|
||||
var presets []codersdk.InvalidatedPreset
|
||||
for _, p := range invalidatedPresets {
|
||||
presets = append(presets, codersdk.InvalidatedPreset{
|
||||
TemplateName: p.TemplateName,
|
||||
TemplateVersionName: p.TemplateVersionName,
|
||||
PresetName: p.TemplateVersionPresetName,
|
||||
})
|
||||
}
|
||||
return presets
|
||||
}
|
||||
|
||||
func jsonOrEmptyMap(rawMessage pqtype.NullRawMessage) map[string]any {
|
||||
var m map[string]any
|
||||
if !rawMessage.Valid {
|
||||
|
||||
@@ -219,8 +219,8 @@ var (
|
||||
rbac.ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal},
|
||||
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop},
|
||||
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionCreateAgent},
|
||||
// Provisionerd needs to read and update tasks associated with workspaces.
|
||||
rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
// Provisionerd needs to read, update, and delete tasks associated with workspaces.
|
||||
rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
rbac.ResourceApiKey.Type: {policy.WildcardSymbol},
|
||||
// When org scoped provisioner credentials are implemented,
|
||||
// this can be reduced to read a specific org.
|
||||
@@ -254,6 +254,7 @@ var (
|
||||
rbac.ResourceFile.Type: {policy.ActionRead}, // Required to read terraform files
|
||||
rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead},
|
||||
rbac.ResourceSystem.Type: {policy.WildcardSymbol},
|
||||
rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
rbac.ResourceUser.Type: {policy.ActionRead},
|
||||
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop},
|
||||
@@ -2648,6 +2649,13 @@ func (q *querier) GetOrganizationsByUserID(ctx context.Context, userID database.
|
||||
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOrganizationsByUserID)(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetOrganizationsWithPrebuildStatus(ctx context.Context, arg database.GetOrganizationsWithPrebuildStatusParams) ([]database.GetOrganizationsWithPrebuildStatusRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOrganization.All()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetOrganizationsWithPrebuildStatus(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) {
|
||||
version, err := q.db.GetTemplateVersionByJobID(ctx, jobID)
|
||||
if err != nil {
|
||||
@@ -2981,6 +2989,10 @@ func (q *querier) GetTaskByID(ctx context.Context, id uuid.UUID) (database.Task,
|
||||
return fetch(q.log, q.auth, q.db.GetTaskByID)(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) GetTaskByOwnerIDAndName(ctx context.Context, arg database.GetTaskByOwnerIDAndNameParams) (database.Task, error) {
|
||||
return fetch(q.log, q.auth, q.db.GetTaskByOwnerIDAndName)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.Task, error) {
|
||||
return fetch(q.log, q.auth, q.db.GetTaskByWorkspaceID)(ctx, workspaceID)
|
||||
}
|
||||
@@ -4933,10 +4945,10 @@ func (q *querier) UpdateOrganizationDeletedByID(ctx context.Context, arg databas
|
||||
return deleteQ(q.log, q.auth, q.db.GetOrganizationByID, deleteF)(ctx, arg.ID)
|
||||
}
|
||||
|
||||
func (q *querier) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]uuid.UUID, error) {
|
||||
func (q *querier) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) {
|
||||
// Prebuild operation for canceling pending prebuild jobs from non-active template versions
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourcePrebuiltWorkspace); err != nil {
|
||||
return []uuid.UUID{}, err
|
||||
return []database.UpdatePrebuildProvisionerJobWithCancelRow{}, err
|
||||
}
|
||||
return q.db.UpdatePrebuildProvisionerJobWithCancel(ctx, arg)
|
||||
}
|
||||
@@ -4960,6 +4972,20 @@ func (q *querier) UpdatePresetPrebuildStatus(ctx context.Context, arg database.U
|
||||
return q.db.UpdatePresetPrebuildStatus(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdatePresetsLastInvalidatedAt(ctx context.Context, arg database.UpdatePresetsLastInvalidatedAtParams) ([]database.UpdatePresetsLastInvalidatedAtRow, error) {
|
||||
// Fetch template to check authorization
|
||||
template, err := q.db.GetTemplateByID(ctx, arg.TemplateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, template); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return q.db.UpdatePresetsLastInvalidatedAt(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceProvisionerDaemon); err != nil {
|
||||
return err
|
||||
|
||||
@@ -646,10 +646,13 @@ func (s *MethodTestSuite) TestProvisionerJob() {
|
||||
PresetID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
Now: dbtime.Now(),
|
||||
}
|
||||
jobIDs := []uuid.UUID{uuid.New(), uuid.New()}
|
||||
canceledJobs := []database.UpdatePrebuildProvisionerJobWithCancelRow{
|
||||
{ID: uuid.New(), WorkspaceID: uuid.New(), TemplateID: uuid.New(), TemplateVersionPresetID: uuid.NullUUID{UUID: uuid.New(), Valid: true}},
|
||||
{ID: uuid.New(), WorkspaceID: uuid.New(), TemplateID: uuid.New(), TemplateVersionPresetID: uuid.NullUUID{UUID: uuid.New(), Valid: true}},
|
||||
}
|
||||
|
||||
dbm.EXPECT().UpdatePrebuildProvisionerJobWithCancel(gomock.Any(), arg).Return(jobIDs, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourcePrebuiltWorkspace, policy.ActionUpdate).Returns(jobIDs)
|
||||
dbm.EXPECT().UpdatePrebuildProvisionerJobWithCancel(gomock.Any(), arg).Return(canceledJobs, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourcePrebuiltWorkspace, policy.ActionUpdate).Returns(canceledJobs)
|
||||
}))
|
||||
s.Run("GetProvisionerJobsByIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
org := testutil.Fake(s.T(), faker, database.Organization{})
|
||||
@@ -1312,6 +1315,13 @@ func (s *MethodTestSuite) TestTemplate() {
|
||||
dbm.EXPECT().UpsertTemplateUsageStats(gomock.Any()).Return(nil).AnyTimes()
|
||||
check.Asserts(rbac.ResourceSystem, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("UpdatePresetsLastInvalidatedAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
t1 := testutil.Fake(s.T(), faker, database.Template{})
|
||||
arg := database.UpdatePresetsLastInvalidatedAtParams{LastInvalidatedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, TemplateID: t1.ID}
|
||||
dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes()
|
||||
dbm.EXPECT().UpdatePresetsLastInvalidatedAt(gomock.Any(), arg).Return([]database.UpdatePresetsLastInvalidatedAtRow{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(t1, policy.ActionUpdate)
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *MethodTestSuite) TestUser() {
|
||||
@@ -2158,7 +2168,7 @@ func (s *MethodTestSuite) TestWorkspace() {
|
||||
})
|
||||
res := testutil.Fake(s.T(), faker, database.WorkspaceResource{JobID: b.JobID})
|
||||
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{ResourceID: res.ID})
|
||||
app := testutil.Fake(s.T(), faker, database.WorkspaceApp{AgentID: agt.ID})
|
||||
_ = testutil.Fake(s.T(), faker, database.WorkspaceApp{AgentID: agt.ID})
|
||||
|
||||
dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes()
|
||||
dbm.EXPECT().GetWorkspaceBuildByID(gomock.Any(), b.ID).Return(b, nil).AnyTimes()
|
||||
@@ -2167,7 +2177,6 @@ func (s *MethodTestSuite) TestWorkspace() {
|
||||
ID: b.ID,
|
||||
HasAITask: sql.NullBool{Bool: true, Valid: true},
|
||||
HasExternalAgent: sql.NullBool{Bool: true, Valid: true},
|
||||
SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true},
|
||||
UpdatedAt: b.UpdatedAt,
|
||||
}).Asserts(w, policy.ActionUpdate)
|
||||
}))
|
||||
@@ -2372,6 +2381,17 @@ func (s *MethodTestSuite) TestTasks() {
|
||||
dbm.EXPECT().GetTaskByID(gomock.Any(), task.ID).Return(task, nil).AnyTimes()
|
||||
check.Args(task.ID).Asserts(task, policy.ActionRead).Returns(task)
|
||||
}))
|
||||
s.Run("GetTaskByOwnerIDAndName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
task := testutil.Fake(s.T(), faker, database.Task{})
|
||||
dbm.EXPECT().GetTaskByOwnerIDAndName(gomock.Any(), database.GetTaskByOwnerIDAndNameParams{
|
||||
OwnerID: task.OwnerID,
|
||||
Name: task.Name,
|
||||
}).Return(task, nil).AnyTimes()
|
||||
check.Args(database.GetTaskByOwnerIDAndNameParams{
|
||||
OwnerID: task.OwnerID,
|
||||
Name: task.Name,
|
||||
}).Asserts(task, policy.ActionRead).Returns(task)
|
||||
}))
|
||||
s.Run("DeleteTask", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
task := testutil.Fake(s.T(), faker, database.Task{})
|
||||
arg := database.DeleteTaskParams{
|
||||
@@ -3756,6 +3776,14 @@ func (s *MethodTestSuite) TestPrebuilds() {
|
||||
dbm.EXPECT().GetPrebuildMetrics(gomock.Any()).Return([]database.GetPrebuildMetricsRow{}, nil).AnyTimes()
|
||||
check.Args().Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetOrganizationsWithPrebuildStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
arg := database.GetOrganizationsWithPrebuildStatusParams{
|
||||
UserID: uuid.New(),
|
||||
GroupName: "test",
|
||||
}
|
||||
dbm.EXPECT().GetOrganizationsWithPrebuildStatus(gomock.Any(), arg).Return([]database.GetOrganizationsWithPrebuildStatusRow{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceOrganization.All(), policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetPrebuildsSettings", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetPrebuildsSettings(gomock.Any()).Return("{}", nil).AnyTimes()
|
||||
check.Args().Asserts()
|
||||
|
||||
@@ -189,7 +189,6 @@ func (b WorkspaceBuildBuilder) doInTX() WorkspaceResponse {
|
||||
Bool: true,
|
||||
Valid: true,
|
||||
}
|
||||
b.seed.AITaskSidebarAppID = uuid.NullUUID{UUID: b.taskAppID, Valid: true}
|
||||
}
|
||||
|
||||
resp := WorkspaceResponse{
|
||||
@@ -362,12 +361,20 @@ func (b WorkspaceBuildBuilder) doInTX() WorkspaceResponse {
|
||||
require.Fail(b.t, "task app not configured but workspace is a task workspace")
|
||||
}
|
||||
|
||||
app := mustWorkspaceAppByWorkspaceAndBuildAndAppID(ownerCtx, b.t, b.db, resp.Workspace.ID, resp.Build.BuildNumber, b.taskAppID)
|
||||
workspaceAgentID := uuid.NullUUID{}
|
||||
workspaceAppID := uuid.NullUUID{}
|
||||
// Workspace agent and app are only properly set upon job completion
|
||||
if b.jobStatus != database.ProvisionerJobStatusPending && b.jobStatus != database.ProvisionerJobStatusRunning {
|
||||
app := mustWorkspaceAppByWorkspaceAndBuildAndAppID(ownerCtx, b.t, b.db, resp.Workspace.ID, resp.Build.BuildNumber, b.taskAppID)
|
||||
workspaceAgentID = uuid.NullUUID{UUID: app.AgentID, Valid: true}
|
||||
workspaceAppID = uuid.NullUUID{UUID: app.ID, Valid: true}
|
||||
}
|
||||
|
||||
_, err = b.db.UpsertTaskWorkspaceApp(ownerCtx, database.UpsertTaskWorkspaceAppParams{
|
||||
TaskID: task.ID,
|
||||
WorkspaceBuildNumber: resp.Build.BuildNumber,
|
||||
WorkspaceAgentID: uuid.NullUUID{UUID: app.AgentID, Valid: true},
|
||||
WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true},
|
||||
WorkspaceAgentID: workspaceAgentID,
|
||||
WorkspaceAppID: workspaceAppID,
|
||||
})
|
||||
require.NoError(b.t, err, "upsert task workspace app")
|
||||
b.logger.Debug(context.Background(), "linked task to workspace build",
|
||||
@@ -606,6 +613,7 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse {
|
||||
IsDefault: false,
|
||||
Description: preset.Description,
|
||||
Icon: preset.Icon,
|
||||
LastInvalidatedAt: preset.LastInvalidatedAt,
|
||||
})
|
||||
t.logger.Debug(context.Background(), "added preset",
|
||||
slog.F("preset_id", prst.ID),
|
||||
@@ -623,6 +631,7 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse {
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(provisionerdserver.TemplateVersionImportJob{
|
||||
TemplateID: t.seed.TemplateID,
|
||||
TemplateVersionID: t.seed.ID,
|
||||
})
|
||||
require.NoError(t.t, err)
|
||||
|
||||
@@ -451,7 +451,6 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil
|
||||
buildID := takeFirst(orig.ID, uuid.New())
|
||||
jobID := takeFirst(orig.JobID, uuid.New())
|
||||
hasAITask := takeFirst(orig.HasAITask, sql.NullBool{})
|
||||
sidebarAppID := takeFirst(orig.AITaskSidebarAppID, uuid.NullUUID{})
|
||||
hasExternalAgent := takeFirst(orig.HasExternalAgent, sql.NullBool{})
|
||||
var build database.WorkspaceBuild
|
||||
err := db.InTx(func(db database.Store) error {
|
||||
@@ -491,7 +490,6 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil
|
||||
ID: buildID,
|
||||
HasAITask: hasAITask,
|
||||
HasExternalAgent: hasExternalAgent,
|
||||
SidebarAppID: sidebarAppID,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
}))
|
||||
}
|
||||
@@ -1430,6 +1428,7 @@ func Preset(t testing.TB, db database.Store, seed database.InsertPresetParams) d
|
||||
IsDefault: seed.IsDefault,
|
||||
Description: seed.Description,
|
||||
Icon: seed.Icon,
|
||||
LastInvalidatedAt: seed.LastInvalidatedAt,
|
||||
})
|
||||
require.NoError(t, err, "insert preset")
|
||||
return preset
|
||||
@@ -1498,6 +1497,7 @@ func ClaimPrebuild(
|
||||
func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertAIBridgeInterceptionParams, endedAt *time.Time) database.AIBridgeInterception {
|
||||
interception, err := db.InsertAIBridgeInterception(genCtx, database.InsertAIBridgeInterceptionParams{
|
||||
ID: takeFirst(seed.ID, uuid.New()),
|
||||
APIKeyID: seed.APIKeyID,
|
||||
InitiatorID: takeFirst(seed.InitiatorID, uuid.New()),
|
||||
Provider: takeFirst(seed.Provider, "provider"),
|
||||
Model: takeFirst(seed.Model, "model"),
|
||||
|
||||
@@ -1243,6 +1243,13 @@ func (m queryMetricsStore) GetOrganizationsByUserID(ctx context.Context, userID
|
||||
return organizations, err
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetOrganizationsWithPrebuildStatus(ctx context.Context, arg database.GetOrganizationsWithPrebuildStatusParams) ([]database.GetOrganizationsWithPrebuildStatusRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetOrganizationsWithPrebuildStatus(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetOrganizationsWithPrebuildStatus").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) {
|
||||
start := time.Now()
|
||||
schemas, err := m.s.GetParameterSchemasByJobID(ctx, jobID)
|
||||
@@ -1523,6 +1530,13 @@ func (m queryMetricsStore) GetTaskByID(ctx context.Context, id uuid.UUID) (datab
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetTaskByOwnerIDAndName(ctx context.Context, arg database.GetTaskByOwnerIDAndNameParams) (database.Task, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetTaskByOwnerIDAndName(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetTaskByOwnerIDAndName").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.Task, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetTaskByWorkspaceID(ctx, workspaceID)
|
||||
@@ -3042,7 +3056,7 @@ func (m queryMetricsStore) UpdateOrganizationDeletedByID(ctx context.Context, ar
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]uuid.UUID, error) {
|
||||
func (m queryMetricsStore) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdatePrebuildProvisionerJobWithCancel(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdatePrebuildProvisionerJobWithCancel").Observe(time.Since(start).Seconds())
|
||||
@@ -3056,6 +3070,13 @@ func (m queryMetricsStore) UpdatePresetPrebuildStatus(ctx context.Context, arg d
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdatePresetsLastInvalidatedAt(ctx context.Context, arg database.UpdatePresetsLastInvalidatedAtParams) ([]database.UpdatePresetsLastInvalidatedAtRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdatePresetsLastInvalidatedAt(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdatePresetsLastInvalidatedAt").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpdateProvisionerDaemonLastSeenAt(ctx, arg)
|
||||
|
||||
@@ -2622,6 +2622,21 @@ func (mr *MockStoreMockRecorder) GetOrganizationsByUserID(ctx, arg any) *gomock.
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationsByUserID", reflect.TypeOf((*MockStore)(nil).GetOrganizationsByUserID), ctx, arg)
|
||||
}
|
||||
|
||||
// GetOrganizationsWithPrebuildStatus mocks base method.
|
||||
func (m *MockStore) GetOrganizationsWithPrebuildStatus(ctx context.Context, arg database.GetOrganizationsWithPrebuildStatusParams) ([]database.GetOrganizationsWithPrebuildStatusRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetOrganizationsWithPrebuildStatus", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.GetOrganizationsWithPrebuildStatusRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetOrganizationsWithPrebuildStatus indicates an expected call of GetOrganizationsWithPrebuildStatus.
|
||||
func (mr *MockStoreMockRecorder) GetOrganizationsWithPrebuildStatus(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationsWithPrebuildStatus", reflect.TypeOf((*MockStore)(nil).GetOrganizationsWithPrebuildStatus), ctx, arg)
|
||||
}
|
||||
|
||||
// GetParameterSchemasByJobID mocks base method.
|
||||
func (m *MockStore) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -3222,6 +3237,21 @@ func (mr *MockStoreMockRecorder) GetTaskByID(ctx, id any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTaskByID", reflect.TypeOf((*MockStore)(nil).GetTaskByID), ctx, id)
|
||||
}
|
||||
|
||||
// GetTaskByOwnerIDAndName mocks base method.
|
||||
func (m *MockStore) GetTaskByOwnerIDAndName(ctx context.Context, arg database.GetTaskByOwnerIDAndNameParams) (database.Task, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTaskByOwnerIDAndName", ctx, arg)
|
||||
ret0, _ := ret[0].(database.Task)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTaskByOwnerIDAndName indicates an expected call of GetTaskByOwnerIDAndName.
|
||||
func (mr *MockStoreMockRecorder) GetTaskByOwnerIDAndName(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTaskByOwnerIDAndName", reflect.TypeOf((*MockStore)(nil).GetTaskByOwnerIDAndName), ctx, arg)
|
||||
}
|
||||
|
||||
// GetTaskByWorkspaceID mocks base method.
|
||||
func (m *MockStore) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.Task, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -6540,10 +6570,10 @@ func (mr *MockStoreMockRecorder) UpdateOrganizationDeletedByID(ctx, arg any) *go
|
||||
}
|
||||
|
||||
// UpdatePrebuildProvisionerJobWithCancel mocks base method.
|
||||
func (m *MockStore) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]uuid.UUID, error) {
|
||||
func (m *MockStore) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdatePrebuildProvisionerJobWithCancel", ctx, arg)
|
||||
ret0, _ := ret[0].([]uuid.UUID)
|
||||
ret0, _ := ret[0].([]database.UpdatePrebuildProvisionerJobWithCancelRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
@@ -6568,6 +6598,21 @@ func (mr *MockStoreMockRecorder) UpdatePresetPrebuildStatus(ctx, arg any) *gomoc
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePresetPrebuildStatus", reflect.TypeOf((*MockStore)(nil).UpdatePresetPrebuildStatus), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdatePresetsLastInvalidatedAt mocks base method.
|
||||
func (m *MockStore) UpdatePresetsLastInvalidatedAt(ctx context.Context, arg database.UpdatePresetsLastInvalidatedAtParams) ([]database.UpdatePresetsLastInvalidatedAtRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdatePresetsLastInvalidatedAt", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.UpdatePresetsLastInvalidatedAtRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdatePresetsLastInvalidatedAt indicates an expected call of UpdatePresetsLastInvalidatedAt.
|
||||
func (mr *MockStoreMockRecorder) UpdatePresetsLastInvalidatedAt(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePresetsLastInvalidatedAt", reflect.TypeOf((*MockStore)(nil).UpdatePresetsLastInvalidatedAt), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateProvisionerDaemonLastSeenAt mocks base method.
|
||||
func (m *MockStore) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+50
-40
@@ -1056,7 +1056,8 @@ CREATE TABLE aibridge_interceptions (
|
||||
model text NOT NULL,
|
||||
started_at timestamp with time zone NOT NULL,
|
||||
metadata jsonb,
|
||||
ended_at timestamp with time zone
|
||||
ended_at timestamp with time zone,
|
||||
api_key_id text
|
||||
);
|
||||
|
||||
COMMENT ON TABLE aibridge_interceptions IS 'Audit log of requests intercepted by AI Bridge';
|
||||
@@ -1948,9 +1949,7 @@ CREATE TABLE workspace_builds (
|
||||
max_deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL,
|
||||
template_version_preset_id uuid,
|
||||
has_ai_task boolean,
|
||||
ai_task_sidebar_app_id uuid,
|
||||
has_external_agent boolean,
|
||||
CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL)))),
|
||||
CONSTRAINT workspace_builds_deadline_below_max_deadline CHECK ((((deadline <> '0001-01-01 00:00:00+00'::timestamp with time zone) AND (deadline <= max_deadline)) OR (max_deadline = '0001-01-01 00:00:00+00'::timestamp with time zone)))
|
||||
);
|
||||
|
||||
@@ -1966,32 +1965,21 @@ CREATE VIEW tasks_with_status AS
|
||||
tasks.created_at,
|
||||
tasks.deleted_at,
|
||||
CASE
|
||||
WHEN ((tasks.workspace_id IS NULL) OR (latest_build.job_status IS NULL)) THEN 'pending'::task_status
|
||||
WHEN (latest_build.job_status = 'failed'::provisioner_job_status) THEN 'error'::task_status
|
||||
WHEN ((latest_build.transition = ANY (ARRAY['stop'::workspace_transition, 'delete'::workspace_transition])) AND (latest_build.job_status = 'succeeded'::provisioner_job_status)) THEN 'paused'::task_status
|
||||
WHEN ((latest_build.transition = 'start'::workspace_transition) AND (latest_build.job_status = 'pending'::provisioner_job_status)) THEN 'initializing'::task_status
|
||||
WHEN ((latest_build.transition = 'start'::workspace_transition) AND (latest_build.job_status = ANY (ARRAY['running'::provisioner_job_status, 'succeeded'::provisioner_job_status]))) THEN
|
||||
CASE
|
||||
WHEN agent_status."none" THEN 'initializing'::task_status
|
||||
WHEN agent_status.connecting THEN 'initializing'::task_status
|
||||
WHEN agent_status.connected THEN
|
||||
CASE
|
||||
WHEN app_status.any_unhealthy THEN 'error'::task_status
|
||||
WHEN app_status.any_initializing THEN 'initializing'::task_status
|
||||
WHEN app_status.all_healthy_or_disabled THEN 'active'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END
|
||||
ELSE 'unknown'::task_status
|
||||
END
|
||||
ELSE 'unknown'::task_status
|
||||
WHEN (tasks.workspace_id IS NULL) THEN 'pending'::task_status
|
||||
WHEN (build_status.status <> 'active'::task_status) THEN build_status.status
|
||||
WHEN (agent_status.status <> 'active'::task_status) THEN agent_status.status
|
||||
ELSE app_status.status
|
||||
END AS status,
|
||||
jsonb_build_object('build', jsonb_build_object('transition', latest_build_raw.transition, 'job_status', latest_build_raw.job_status, 'computed', build_status.status), 'agent', jsonb_build_object('lifecycle_state', agent_raw.lifecycle_state, 'computed', agent_status.status), 'app', jsonb_build_object('health', app_raw.health, 'computed', app_status.status)) AS status_debug,
|
||||
task_app.workspace_build_number,
|
||||
task_app.workspace_agent_id,
|
||||
task_app.workspace_app_id,
|
||||
agent_raw.lifecycle_state AS workspace_agent_lifecycle_state,
|
||||
app_raw.health AS workspace_app_health,
|
||||
task_owner.owner_username,
|
||||
task_owner.owner_name,
|
||||
task_owner.owner_avatar_url
|
||||
FROM (((((tasks
|
||||
FROM ((((((((tasks
|
||||
CROSS JOIN LATERAL ( SELECT vu.username AS owner_username,
|
||||
vu.name AS owner_name,
|
||||
vu.avatar_url AS owner_avatar_url
|
||||
@@ -2009,17 +1997,36 @@ CREATE VIEW tasks_with_status AS
|
||||
workspace_build.job_id
|
||||
FROM (workspace_builds workspace_build
|
||||
JOIN provisioner_jobs provisioner_job ON ((provisioner_job.id = workspace_build.job_id)))
|
||||
WHERE ((workspace_build.workspace_id = tasks.workspace_id) AND (workspace_build.build_number = task_app.workspace_build_number))) latest_build ON (true))
|
||||
CROSS JOIN LATERAL ( SELECT (count(*) = 0) AS "none",
|
||||
bool_or((workspace_agent.lifecycle_state = ANY (ARRAY['created'::workspace_agent_lifecycle_state, 'starting'::workspace_agent_lifecycle_state]))) AS connecting,
|
||||
bool_and((workspace_agent.lifecycle_state = 'ready'::workspace_agent_lifecycle_state)) AS connected
|
||||
WHERE ((workspace_build.workspace_id = tasks.workspace_id) AND (workspace_build.build_number = task_app.workspace_build_number))) latest_build_raw ON (true))
|
||||
LEFT JOIN LATERAL ( SELECT workspace_agent.lifecycle_state
|
||||
FROM workspace_agents workspace_agent
|
||||
WHERE (workspace_agent.id = task_app.workspace_agent_id)) agent_status)
|
||||
CROSS JOIN LATERAL ( SELECT bool_or((workspace_app.health = 'unhealthy'::workspace_app_health)) AS any_unhealthy,
|
||||
bool_or((workspace_app.health = 'initializing'::workspace_app_health)) AS any_initializing,
|
||||
bool_and((workspace_app.health = ANY (ARRAY['healthy'::workspace_app_health, 'disabled'::workspace_app_health]))) AS all_healthy_or_disabled
|
||||
WHERE (workspace_agent.id = task_app.workspace_agent_id)) agent_raw ON (true))
|
||||
LEFT JOIN LATERAL ( SELECT workspace_app.health
|
||||
FROM workspace_apps workspace_app
|
||||
WHERE (workspace_app.id = task_app.workspace_app_id)) app_status)
|
||||
WHERE (workspace_app.id = task_app.workspace_app_id)) app_raw ON (true))
|
||||
CROSS JOIN LATERAL ( SELECT
|
||||
CASE
|
||||
WHEN (latest_build_raw.job_status IS NULL) THEN 'pending'::task_status
|
||||
WHEN (latest_build_raw.job_status = ANY (ARRAY['failed'::provisioner_job_status, 'canceling'::provisioner_job_status, 'canceled'::provisioner_job_status])) THEN 'error'::task_status
|
||||
WHEN ((latest_build_raw.transition = ANY (ARRAY['stop'::workspace_transition, 'delete'::workspace_transition])) AND (latest_build_raw.job_status = 'succeeded'::provisioner_job_status)) THEN 'paused'::task_status
|
||||
WHEN ((latest_build_raw.transition = 'start'::workspace_transition) AND (latest_build_raw.job_status = 'pending'::provisioner_job_status)) THEN 'initializing'::task_status
|
||||
WHEN ((latest_build_raw.transition = 'start'::workspace_transition) AND (latest_build_raw.job_status = ANY (ARRAY['running'::provisioner_job_status, 'succeeded'::provisioner_job_status]))) THEN 'active'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END AS status) build_status)
|
||||
CROSS JOIN LATERAL ( SELECT
|
||||
CASE
|
||||
WHEN ((agent_raw.lifecycle_state IS NULL) OR (agent_raw.lifecycle_state = ANY (ARRAY['created'::workspace_agent_lifecycle_state, 'starting'::workspace_agent_lifecycle_state]))) THEN 'initializing'::task_status
|
||||
WHEN (agent_raw.lifecycle_state = ANY (ARRAY['ready'::workspace_agent_lifecycle_state, 'start_timeout'::workspace_agent_lifecycle_state, 'start_error'::workspace_agent_lifecycle_state])) THEN 'active'::task_status
|
||||
WHEN (agent_raw.lifecycle_state <> ALL (ARRAY['created'::workspace_agent_lifecycle_state, 'starting'::workspace_agent_lifecycle_state, 'ready'::workspace_agent_lifecycle_state, 'start_timeout'::workspace_agent_lifecycle_state, 'start_error'::workspace_agent_lifecycle_state])) THEN 'unknown'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END AS status) agent_status)
|
||||
CROSS JOIN LATERAL ( SELECT
|
||||
CASE
|
||||
WHEN (app_raw.health = 'initializing'::workspace_app_health) THEN 'initializing'::task_status
|
||||
WHEN (app_raw.health = 'unhealthy'::workspace_app_health) THEN 'error'::task_status
|
||||
WHEN (app_raw.health = ANY (ARRAY['healthy'::workspace_app_health, 'disabled'::workspace_app_health])) THEN 'active'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END AS status) app_status)
|
||||
WHERE (tasks.deleted_at IS NULL);
|
||||
|
||||
CREATE TABLE telemetry_items (
|
||||
@@ -2163,7 +2170,8 @@ CREATE TABLE template_version_presets (
|
||||
scheduling_timezone text DEFAULT ''::text NOT NULL,
|
||||
is_default boolean DEFAULT false NOT NULL,
|
||||
description character varying(128) DEFAULT ''::character varying NOT NULL,
|
||||
icon character varying(256) DEFAULT ''::character varying NOT NULL
|
||||
icon character varying(256) DEFAULT ''::character varying NOT NULL,
|
||||
last_invalidated_at timestamp with time zone
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN template_version_presets.description IS 'Short text describing the preset (max 128 characters).';
|
||||
@@ -2287,7 +2295,8 @@ CREATE TABLE templates (
|
||||
activity_bump bigint DEFAULT '3600000000000'::bigint NOT NULL,
|
||||
max_port_sharing_level app_sharing_level DEFAULT 'owner'::app_sharing_level NOT NULL,
|
||||
use_classic_parameter_flow boolean DEFAULT false NOT NULL,
|
||||
cors_behavior cors_behavior DEFAULT 'simple'::cors_behavior NOT NULL
|
||||
cors_behavior cors_behavior DEFAULT 'simple'::cors_behavior NOT NULL,
|
||||
use_terraform_workspace_cache boolean DEFAULT false NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.';
|
||||
@@ -2310,6 +2319,8 @@ COMMENT ON COLUMN templates.deprecated IS 'If set to a non empty string, the tem
|
||||
|
||||
COMMENT ON COLUMN templates.use_classic_parameter_flow IS 'Determines whether to default to the dynamic parameter creation flow for this template or continue using the legacy classic parameter creation flow.This is a template wide setting, the template admin can revert to the classic flow if there are any issues. An escape hatch is required, as workspace creation is a core workflow and cannot break. This column will be removed when the dynamic parameter creation flow is stable.';
|
||||
|
||||
COMMENT ON COLUMN templates.use_terraform_workspace_cache IS 'Determines whether to keep terraform directories cached between runs for workspaces created from this template. When enabled, this can significantly speed up the `terraform init` step at the cost of increased disk usage. This is an opt-in experience, as it prevents modules from being updated, and therefore is a behavioral difference from the default.';
|
||||
|
||||
CREATE VIEW template_with_names AS
|
||||
SELECT templates.id,
|
||||
templates.created_at,
|
||||
@@ -2341,6 +2352,7 @@ CREATE VIEW template_with_names AS
|
||||
templates.max_port_sharing_level,
|
||||
templates.use_classic_parameter_flow,
|
||||
templates.cors_behavior,
|
||||
templates.use_terraform_workspace_cache,
|
||||
COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url,
|
||||
COALESCE(visible_users.username, ''::text) AS created_by_username,
|
||||
COALESCE(visible_users.name, ''::text) AS created_by_name,
|
||||
@@ -2703,7 +2715,6 @@ CREATE VIEW workspace_build_with_user AS
|
||||
workspace_builds.max_deadline,
|
||||
workspace_builds.template_version_preset_id,
|
||||
workspace_builds.has_ai_task,
|
||||
workspace_builds.ai_task_sidebar_app_id,
|
||||
workspace_builds.has_external_agent,
|
||||
COALESCE(visible_users.avatar_url, ''::text) AS initiator_by_avatar_url,
|
||||
COALESCE(visible_users.username, ''::text) AS initiator_by_username,
|
||||
@@ -2922,11 +2933,13 @@ CREATE VIEW workspaces_expanded AS
|
||||
templates.name AS template_name,
|
||||
templates.display_name AS template_display_name,
|
||||
templates.icon AS template_icon,
|
||||
templates.description AS template_description
|
||||
FROM (((workspaces
|
||||
templates.description AS template_description,
|
||||
tasks.id AS task_id
|
||||
FROM ((((workspaces
|
||||
JOIN visible_users ON ((workspaces.owner_id = visible_users.id)))
|
||||
JOIN organizations ON ((workspaces.organization_id = organizations.id)))
|
||||
JOIN templates ON ((workspaces.template_id = templates.id)));
|
||||
JOIN templates ON ((workspaces.template_id = templates.id)))
|
||||
LEFT JOIN tasks ON ((workspaces.id = tasks.workspace_id)));
|
||||
|
||||
COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.';
|
||||
|
||||
@@ -3802,9 +3815,6 @@ ALTER TABLE ONLY workspace_apps
|
||||
ALTER TABLE ONLY workspace_build_parameters
|
||||
ADD CONSTRAINT workspace_build_parameters_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY workspace_builds
|
||||
ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_fkey FOREIGN KEY (ai_task_sidebar_app_id) REFERENCES workspace_apps(id);
|
||||
|
||||
ALTER TABLE ONLY workspace_builds
|
||||
ADD CONSTRAINT workspace_builds_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@ const (
|
||||
ForeignKeyWorkspaceAppStatusesWorkspaceID ForeignKeyConstraint = "workspace_app_statuses_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id);
|
||||
ForeignKeyWorkspaceAppsAgentID ForeignKeyConstraint = "workspace_apps_agent_id_fkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceBuildParametersWorkspaceBuildID ForeignKeyConstraint = "workspace_build_parameters_workspace_build_id_fkey" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceBuildsAiTaskSidebarAppID ForeignKeyConstraint = "workspace_builds_ai_task_sidebar_app_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_fkey FOREIGN KEY (ai_task_sidebar_app_id) REFERENCES workspace_apps(id);
|
||||
ForeignKeyWorkspaceBuildsJobID ForeignKeyConstraint = "workspace_builds_job_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceBuildsTemplateVersionID ForeignKeyConstraint = "workspace_builds_template_version_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceBuildsTemplateVersionPresetID ForeignKeyConstraint = "workspace_builds_template_version_preset_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_preset_id_fkey FOREIGN KEY (template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE SET NULL;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
DROP VIEW workspaces_expanded;
|
||||
|
||||
-- Recreate the view from 000354_workspace_acl.up.sql
|
||||
CREATE VIEW workspaces_expanded AS
|
||||
SELECT workspaces.id,
|
||||
workspaces.created_at,
|
||||
workspaces.updated_at,
|
||||
workspaces.owner_id,
|
||||
workspaces.organization_id,
|
||||
workspaces.template_id,
|
||||
workspaces.deleted,
|
||||
workspaces.name,
|
||||
workspaces.autostart_schedule,
|
||||
workspaces.ttl,
|
||||
workspaces.last_used_at,
|
||||
workspaces.dormant_at,
|
||||
workspaces.deleting_at,
|
||||
workspaces.automatic_updates,
|
||||
workspaces.favorite,
|
||||
workspaces.next_start_at,
|
||||
workspaces.group_acl,
|
||||
workspaces.user_acl,
|
||||
visible_users.avatar_url AS owner_avatar_url,
|
||||
visible_users.username AS owner_username,
|
||||
visible_users.name AS owner_name,
|
||||
organizations.name AS organization_name,
|
||||
organizations.display_name AS organization_display_name,
|
||||
organizations.icon AS organization_icon,
|
||||
organizations.description AS organization_description,
|
||||
templates.name AS template_name,
|
||||
templates.display_name AS template_display_name,
|
||||
templates.icon AS template_icon,
|
||||
templates.description AS template_description
|
||||
FROM (((workspaces
|
||||
JOIN visible_users ON ((workspaces.owner_id = visible_users.id)))
|
||||
JOIN organizations ON ((workspaces.organization_id = organizations.id)))
|
||||
JOIN templates ON ((workspaces.template_id = templates.id)));
|
||||
|
||||
COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.';
|
||||
@@ -0,0 +1,42 @@
|
||||
DROP VIEW workspaces_expanded;
|
||||
|
||||
-- Add nullable task_id to workspaces_expanded view
|
||||
CREATE VIEW workspaces_expanded AS
|
||||
SELECT workspaces.id,
|
||||
workspaces.created_at,
|
||||
workspaces.updated_at,
|
||||
workspaces.owner_id,
|
||||
workspaces.organization_id,
|
||||
workspaces.template_id,
|
||||
workspaces.deleted,
|
||||
workspaces.name,
|
||||
workspaces.autostart_schedule,
|
||||
workspaces.ttl,
|
||||
workspaces.last_used_at,
|
||||
workspaces.dormant_at,
|
||||
workspaces.deleting_at,
|
||||
workspaces.automatic_updates,
|
||||
workspaces.favorite,
|
||||
workspaces.next_start_at,
|
||||
workspaces.group_acl,
|
||||
workspaces.user_acl,
|
||||
visible_users.avatar_url AS owner_avatar_url,
|
||||
visible_users.username AS owner_username,
|
||||
visible_users.name AS owner_name,
|
||||
organizations.name AS organization_name,
|
||||
organizations.display_name AS organization_display_name,
|
||||
organizations.icon AS organization_icon,
|
||||
organizations.description AS organization_description,
|
||||
templates.name AS template_name,
|
||||
templates.display_name AS template_display_name,
|
||||
templates.icon AS template_icon,
|
||||
templates.description AS template_description,
|
||||
tasks.id AS task_id
|
||||
FROM ((((workspaces
|
||||
JOIN visible_users ON ((workspaces.owner_id = visible_users.id)))
|
||||
JOIN organizations ON ((workspaces.organization_id = organizations.id)))
|
||||
JOIN templates ON ((workspaces.template_id = templates.id)))
|
||||
LEFT JOIN tasks ON ((workspaces.id = tasks.workspace_id)));
|
||||
|
||||
COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.';
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- WARNING: Restoring this constraint after running a newer version of coderd
|
||||
-- and using tasks is bound to break this constraint.
|
||||
ALTER TABLE workspace_builds
|
||||
ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL))));
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- We no longer need to enforce this constraint as tasks have their own data
|
||||
-- model.
|
||||
ALTER TABLE workspace_builds
|
||||
DROP CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required;
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
ALTER TABLE workspace_builds ADD COLUMN ai_task_sidebar_app_id UUID;
|
||||
ALTER TABLE workspace_builds ADD CONSTRAINT workspace_builds_ai_task_sidebar_app_id_fkey FOREIGN KEY (ai_task_sidebar_app_id) REFERENCES workspace_apps(id);
|
||||
|
||||
DROP VIEW workspace_build_with_user;
|
||||
-- Restore view.
|
||||
CREATE VIEW workspace_build_with_user AS
|
||||
SELECT
|
||||
workspace_builds.id,
|
||||
workspace_builds.created_at,
|
||||
workspace_builds.updated_at,
|
||||
workspace_builds.workspace_id,
|
||||
workspace_builds.template_version_id,
|
||||
workspace_builds.build_number,
|
||||
workspace_builds.transition,
|
||||
workspace_builds.initiator_id,
|
||||
workspace_builds.provisioner_state,
|
||||
workspace_builds.job_id,
|
||||
workspace_builds.deadline,
|
||||
workspace_builds.reason,
|
||||
workspace_builds.daily_cost,
|
||||
workspace_builds.max_deadline,
|
||||
workspace_builds.template_version_preset_id,
|
||||
workspace_builds.has_ai_task,
|
||||
workspace_builds.ai_task_sidebar_app_id,
|
||||
workspace_builds.has_external_agent,
|
||||
COALESCE(
|
||||
visible_users.avatar_url,
|
||||
'' :: text
|
||||
) AS initiator_by_avatar_url,
|
||||
COALESCE(
|
||||
visible_users.username,
|
||||
'' :: text
|
||||
) AS initiator_by_username,
|
||||
COALESCE(visible_users.name, '' :: text) AS initiator_by_name
|
||||
FROM
|
||||
(
|
||||
workspace_builds
|
||||
LEFT JOIN visible_users ON (
|
||||
(
|
||||
workspace_builds.initiator_id = visible_users.id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.';
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
-- We're dropping the ai_task_sidebar_app_id column.
|
||||
DROP VIEW workspace_build_with_user;
|
||||
CREATE VIEW workspace_build_with_user AS
|
||||
SELECT
|
||||
workspace_builds.id,
|
||||
workspace_builds.created_at,
|
||||
workspace_builds.updated_at,
|
||||
workspace_builds.workspace_id,
|
||||
workspace_builds.template_version_id,
|
||||
workspace_builds.build_number,
|
||||
workspace_builds.transition,
|
||||
workspace_builds.initiator_id,
|
||||
workspace_builds.provisioner_state,
|
||||
workspace_builds.job_id,
|
||||
workspace_builds.deadline,
|
||||
workspace_builds.reason,
|
||||
workspace_builds.daily_cost,
|
||||
workspace_builds.max_deadline,
|
||||
workspace_builds.template_version_preset_id,
|
||||
workspace_builds.has_ai_task,
|
||||
workspace_builds.has_external_agent,
|
||||
COALESCE(
|
||||
visible_users.avatar_url,
|
||||
'' :: text
|
||||
) AS initiator_by_avatar_url,
|
||||
COALESCE(
|
||||
visible_users.username,
|
||||
'' :: text
|
||||
) AS initiator_by_username,
|
||||
COALESCE(visible_users.name, '' :: text) AS initiator_by_name
|
||||
FROM
|
||||
(
|
||||
workspace_builds
|
||||
LEFT JOIN visible_users ON (
|
||||
(
|
||||
workspace_builds.initiator_id = visible_users.id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.';
|
||||
|
||||
ALTER TABLE workspace_builds DROP COLUMN ai_task_sidebar_app_id;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE aibridge_interceptions DROP COLUMN api_key_id;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- column is nullable to not break interceptions recorded before this column was added
|
||||
ALTER TABLE aibridge_interceptions ADD COLUMN api_key_id text;
|
||||
@@ -0,0 +1,26 @@
|
||||
DROP VIEW template_with_names;
|
||||
-- Drop the column
|
||||
ALTER TABLE templates DROP COLUMN use_terraform_workspace_cache;
|
||||
|
||||
-- Update the template_with_names view by recreating it.
|
||||
CREATE VIEW template_with_names AS
|
||||
SELECT
|
||||
templates.*,
|
||||
COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url,
|
||||
COALESCE(visible_users.username, ''::text) AS created_by_username,
|
||||
COALESCE(visible_users.name, ''::text) AS created_by_name,
|
||||
COALESCE(organizations.name, ''::text) AS organization_name,
|
||||
COALESCE(organizations.display_name, ''::text) AS organization_display_name,
|
||||
COALESCE(organizations.icon, ''::text) AS organization_icon
|
||||
FROM
|
||||
templates
|
||||
LEFT JOIN
|
||||
visible_users
|
||||
ON
|
||||
templates.created_by = visible_users.id
|
||||
LEFT JOIN
|
||||
organizations
|
||||
ON templates.organization_id = organizations.id
|
||||
;
|
||||
|
||||
COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.';
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Default to `false`. Users will have to manually opt into the terraform workspace cache feature.
|
||||
ALTER TABLE templates ADD COLUMN use_terraform_workspace_cache BOOL NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN templates.use_terraform_workspace_cache IS
|
||||
'Determines whether to keep terraform directories cached between runs for workspaces created from this template. '
|
||||
'When enabled, this can significantly speed up the `terraform init` step at the cost of increased disk usage. '
|
||||
'This is an opt-in experience, as it prevents modules from being updated, and therefore is a behavioral difference '
|
||||
'from the default.';
|
||||
;
|
||||
|
||||
-- Update the template_with_names view by recreating it.
|
||||
DROP VIEW template_with_names;
|
||||
CREATE VIEW template_with_names AS
|
||||
SELECT
|
||||
templates.*,
|
||||
COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url,
|
||||
COALESCE(visible_users.username, ''::text) AS created_by_username,
|
||||
COALESCE(visible_users.name, ''::text) AS created_by_name,
|
||||
COALESCE(organizations.name, ''::text) AS organization_name,
|
||||
COALESCE(organizations.display_name, ''::text) AS organization_display_name,
|
||||
COALESCE(organizations.icon, ''::text) AS organization_icon
|
||||
FROM
|
||||
templates
|
||||
LEFT JOIN
|
||||
visible_users
|
||||
ON
|
||||
templates.created_by = visible_users.id
|
||||
LEFT JOIN
|
||||
organizations
|
||||
ON templates.organization_id = organizations.id
|
||||
;
|
||||
|
||||
COMMENT ON VIEW template_with_names IS 'Joins in the display name information such as username, avatar, and organization name.';
|
||||
@@ -0,0 +1,82 @@
|
||||
-- Restore previous view.
|
||||
DROP VIEW IF EXISTS tasks_with_status;
|
||||
|
||||
CREATE VIEW
|
||||
tasks_with_status
|
||||
AS
|
||||
SELECT
|
||||
tasks.*,
|
||||
CASE
|
||||
WHEN tasks.workspace_id IS NULL OR latest_build.job_status IS NULL THEN 'pending'::task_status
|
||||
|
||||
WHEN latest_build.job_status = 'failed' THEN 'error'::task_status
|
||||
|
||||
WHEN latest_build.transition IN ('stop', 'delete')
|
||||
AND latest_build.job_status = 'succeeded' THEN 'paused'::task_status
|
||||
|
||||
WHEN latest_build.transition = 'start'
|
||||
AND latest_build.job_status = 'pending' THEN 'initializing'::task_status
|
||||
|
||||
WHEN latest_build.transition = 'start' AND latest_build.job_status IN ('running', 'succeeded') THEN
|
||||
CASE
|
||||
WHEN agent_status.none THEN 'initializing'::task_status
|
||||
WHEN agent_status.connecting THEN 'initializing'::task_status
|
||||
WHEN agent_status.connected THEN
|
||||
CASE
|
||||
WHEN app_status.any_unhealthy THEN 'error'::task_status
|
||||
WHEN app_status.any_initializing THEN 'initializing'::task_status
|
||||
WHEN app_status.all_healthy_or_disabled THEN 'active'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END
|
||||
ELSE 'unknown'::task_status
|
||||
END
|
||||
|
||||
ELSE 'unknown'::task_status
|
||||
END AS status,
|
||||
task_app.*,
|
||||
task_owner.*
|
||||
FROM
|
||||
tasks
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
vu.username AS owner_username,
|
||||
vu.name AS owner_name,
|
||||
vu.avatar_url AS owner_avatar_url
|
||||
FROM visible_users vu
|
||||
WHERE vu.id = tasks.owner_id
|
||||
) task_owner
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT workspace_build_number, workspace_agent_id, workspace_app_id
|
||||
FROM task_workspace_apps task_app
|
||||
WHERE task_id = tasks.id
|
||||
ORDER BY workspace_build_number DESC
|
||||
LIMIT 1
|
||||
) task_app ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
workspace_build.transition,
|
||||
provisioner_job.job_status,
|
||||
workspace_build.job_id
|
||||
FROM workspace_builds workspace_build
|
||||
JOIN provisioner_jobs provisioner_job ON provisioner_job.id = workspace_build.job_id
|
||||
WHERE workspace_build.workspace_id = tasks.workspace_id
|
||||
AND workspace_build.build_number = task_app.workspace_build_number
|
||||
) latest_build ON TRUE
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*) = 0 AS none,
|
||||
bool_or(workspace_agent.lifecycle_state IN ('created', 'starting')) AS connecting,
|
||||
bool_and(workspace_agent.lifecycle_state = 'ready') AS connected
|
||||
FROM workspace_agents workspace_agent
|
||||
WHERE workspace_agent.id = task_app.workspace_agent_id
|
||||
) agent_status
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
bool_or(workspace_app.health = 'unhealthy') AS any_unhealthy,
|
||||
bool_or(workspace_app.health = 'initializing') AS any_initializing,
|
||||
bool_and(workspace_app.health IN ('healthy', 'disabled')) AS all_healthy_or_disabled
|
||||
FROM workspace_apps workspace_app
|
||||
WHERE workspace_app.id = task_app.workspace_app_id
|
||||
) app_status
|
||||
WHERE
|
||||
tasks.deleted_at IS NULL;
|
||||
@@ -0,0 +1,142 @@
|
||||
-- Update task status in view.
|
||||
DROP VIEW IF EXISTS tasks_with_status;
|
||||
|
||||
CREATE VIEW
|
||||
tasks_with_status
|
||||
AS
|
||||
SELECT
|
||||
tasks.*,
|
||||
-- Combine component statuses with precedence: build -> agent -> app.
|
||||
CASE
|
||||
WHEN tasks.workspace_id IS NULL THEN 'pending'::task_status
|
||||
WHEN build_status.status != 'active' THEN build_status.status::task_status
|
||||
WHEN agent_status.status != 'active' THEN agent_status.status::task_status
|
||||
ELSE app_status.status::task_status
|
||||
END AS status,
|
||||
-- Attach debug information for troubleshooting status.
|
||||
jsonb_build_object(
|
||||
'build', jsonb_build_object(
|
||||
'transition', latest_build_raw.transition,
|
||||
'job_status', latest_build_raw.job_status,
|
||||
'computed', build_status.status
|
||||
),
|
||||
'agent', jsonb_build_object(
|
||||
'lifecycle_state', agent_raw.lifecycle_state,
|
||||
'computed', agent_status.status
|
||||
),
|
||||
'app', jsonb_build_object(
|
||||
'health', app_raw.health,
|
||||
'computed', app_status.status
|
||||
)
|
||||
) AS status_debug,
|
||||
task_app.*,
|
||||
agent_raw.lifecycle_state AS workspace_agent_lifecycle_state,
|
||||
app_raw.health AS workspace_app_health,
|
||||
task_owner.*
|
||||
FROM
|
||||
tasks
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
vu.username AS owner_username,
|
||||
vu.name AS owner_name,
|
||||
vu.avatar_url AS owner_avatar_url
|
||||
FROM
|
||||
visible_users vu
|
||||
WHERE
|
||||
vu.id = tasks.owner_id
|
||||
) task_owner
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
task_app.workspace_build_number,
|
||||
task_app.workspace_agent_id,
|
||||
task_app.workspace_app_id
|
||||
FROM
|
||||
task_workspace_apps task_app
|
||||
WHERE
|
||||
task_id = tasks.id
|
||||
ORDER BY
|
||||
task_app.workspace_build_number DESC
|
||||
LIMIT 1
|
||||
) task_app ON TRUE
|
||||
|
||||
-- Join the raw data for computing task status.
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
workspace_build.transition,
|
||||
provisioner_job.job_status,
|
||||
workspace_build.job_id
|
||||
FROM
|
||||
workspace_builds workspace_build
|
||||
JOIN
|
||||
provisioner_jobs provisioner_job
|
||||
ON provisioner_job.id = workspace_build.job_id
|
||||
WHERE
|
||||
workspace_build.workspace_id = tasks.workspace_id
|
||||
AND workspace_build.build_number = task_app.workspace_build_number
|
||||
) latest_build_raw ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
workspace_agent.lifecycle_state
|
||||
FROM
|
||||
workspace_agents workspace_agent
|
||||
WHERE
|
||||
workspace_agent.id = task_app.workspace_agent_id
|
||||
) agent_raw ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
workspace_app.health
|
||||
FROM
|
||||
workspace_apps workspace_app
|
||||
WHERE
|
||||
workspace_app.id = task_app.workspace_app_id
|
||||
) app_raw ON TRUE
|
||||
|
||||
-- Compute the status for each component.
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN latest_build_raw.job_status IS NULL THEN 'pending'::task_status
|
||||
WHEN latest_build_raw.job_status IN ('failed', 'canceling', 'canceled') THEN 'error'::task_status
|
||||
WHEN
|
||||
latest_build_raw.transition IN ('stop', 'delete')
|
||||
AND latest_build_raw.job_status = 'succeeded' THEN 'paused'::task_status
|
||||
WHEN
|
||||
latest_build_raw.transition = 'start'
|
||||
AND latest_build_raw.job_status = 'pending' THEN 'initializing'::task_status
|
||||
-- Build is running or done, defer to agent/app status.
|
||||
WHEN
|
||||
latest_build_raw.transition = 'start'
|
||||
AND latest_build_raw.job_status IN ('running', 'succeeded') THEN 'active'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END AS status
|
||||
) build_status
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
CASE
|
||||
-- No agent or connecting.
|
||||
WHEN
|
||||
agent_raw.lifecycle_state IS NULL
|
||||
OR agent_raw.lifecycle_state IN ('created', 'starting') THEN 'initializing'::task_status
|
||||
-- Agent is running, defer to app status.
|
||||
-- NOTE(mafredri): The start_error/start_timeout states means connected, but some startup script failed.
|
||||
-- This may or may not affect the task status but this has to be caught by app health check.
|
||||
WHEN agent_raw.lifecycle_state IN ('ready', 'start_timeout', 'start_error') THEN 'active'::task_status
|
||||
-- If the agent is shutting down or turned off, this is an unknown state because we would expect a stop
|
||||
-- build to be running.
|
||||
-- This is essentially equal to: `IN ('shutting_down', 'shutdown_timeout', 'shutdown_error', 'off')`,
|
||||
-- but we cannot use them because the values were added in a migration.
|
||||
WHEN agent_raw.lifecycle_state NOT IN ('created', 'starting', 'ready', 'start_timeout', 'start_error') THEN 'unknown'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END AS status
|
||||
) agent_status
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN app_raw.health = 'initializing' THEN 'initializing'::task_status
|
||||
WHEN app_raw.health = 'unhealthy' THEN 'error'::task_status
|
||||
WHEN app_raw.health IN ('healthy', 'disabled') THEN 'active'::task_status
|
||||
ELSE 'unknown'::task_status
|
||||
END AS status
|
||||
) app_status
|
||||
WHERE
|
||||
tasks.deleted_at IS NULL;
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE template_version_presets DROP COLUMN last_invalidated_at;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE template_version_presets ADD COLUMN last_invalidated_at TIMESTAMPTZ;
|
||||
@@ -127,6 +127,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate
|
||||
&i.MaxPortSharingLevel,
|
||||
&i.UseClassicParameterFlow,
|
||||
&i.CorsBehavior,
|
||||
&i.UseTerraformWorkspaceCache,
|
||||
&i.CreatedByAvatarURL,
|
||||
&i.CreatedByUsername,
|
||||
&i.CreatedByName,
|
||||
@@ -321,6 +322,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
|
||||
&i.TemplateDisplayName,
|
||||
&i.TemplateIcon,
|
||||
&i.TemplateDescription,
|
||||
&i.TaskID,
|
||||
&i.TemplateVersionID,
|
||||
&i.TemplateVersionName,
|
||||
&i.LatestBuildCompletedAt,
|
||||
@@ -328,7 +330,6 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
|
||||
&i.LatestBuildError,
|
||||
&i.LatestBuildTransition,
|
||||
&i.LatestBuildStatus,
|
||||
&i.LatestBuildHasAITask,
|
||||
&i.LatestBuildHasExternalAgent,
|
||||
&i.Count,
|
||||
); err != nil {
|
||||
@@ -805,6 +806,7 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, ar
|
||||
&i.AIBridgeInterception.StartedAt,
|
||||
&i.AIBridgeInterception.Metadata,
|
||||
&i.AIBridgeInterception.EndedAt,
|
||||
&i.AIBridgeInterception.APIKeyID,
|
||||
&i.VisibleUser.ID,
|
||||
&i.VisibleUser.Username,
|
||||
&i.VisibleUser.Name,
|
||||
|
||||
+28
-21
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
@@ -3614,6 +3614,7 @@ type AIBridgeInterception struct {
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"`
|
||||
EndedAt sql.NullTime `db:"ended_at" json:"ended_at"`
|
||||
APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"`
|
||||
}
|
||||
|
||||
// Audit log of tokens used by intercepted requests in AI Bridge
|
||||
@@ -4207,23 +4208,26 @@ type TailnetTunnel struct {
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
|
||||
TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"`
|
||||
TemplateParameters json.RawMessage `db:"template_parameters" json:"template_parameters"`
|
||||
Prompt string `db:"prompt" json:"prompt"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"`
|
||||
Status TaskStatus `db:"status" json:"status"`
|
||||
WorkspaceBuildNumber sql.NullInt32 `db:"workspace_build_number" json:"workspace_build_number"`
|
||||
WorkspaceAgentID uuid.NullUUID `db:"workspace_agent_id" json:"workspace_agent_id"`
|
||||
WorkspaceAppID uuid.NullUUID `db:"workspace_app_id" json:"workspace_app_id"`
|
||||
OwnerUsername string `db:"owner_username" json:"owner_username"`
|
||||
OwnerName string `db:"owner_name" json:"owner_name"`
|
||||
OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
|
||||
TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"`
|
||||
TemplateParameters json.RawMessage `db:"template_parameters" json:"template_parameters"`
|
||||
Prompt string `db:"prompt" json:"prompt"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"`
|
||||
Status TaskStatus `db:"status" json:"status"`
|
||||
StatusDebug json.RawMessage `db:"status_debug" json:"status_debug"`
|
||||
WorkspaceBuildNumber sql.NullInt32 `db:"workspace_build_number" json:"workspace_build_number"`
|
||||
WorkspaceAgentID uuid.NullUUID `db:"workspace_agent_id" json:"workspace_agent_id"`
|
||||
WorkspaceAppID uuid.NullUUID `db:"workspace_app_id" json:"workspace_app_id"`
|
||||
WorkspaceAgentLifecycleState NullWorkspaceAgentLifecycleState `db:"workspace_agent_lifecycle_state" json:"workspace_agent_lifecycle_state"`
|
||||
WorkspaceAppHealth NullWorkspaceAppHealth `db:"workspace_app_health" json:"workspace_app_health"`
|
||||
OwnerUsername string `db:"owner_username" json:"owner_username"`
|
||||
OwnerName string `db:"owner_name" json:"owner_name"`
|
||||
OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"`
|
||||
}
|
||||
|
||||
type TaskTable struct {
|
||||
@@ -4293,6 +4297,7 @@ type Template struct {
|
||||
MaxPortSharingLevel AppSharingLevel `db:"max_port_sharing_level" json:"max_port_sharing_level"`
|
||||
UseClassicParameterFlow bool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"`
|
||||
CorsBehavior CorsBehavior `db:"cors_behavior" json:"cors_behavior"`
|
||||
UseTerraformWorkspaceCache bool `db:"use_terraform_workspace_cache" json:"use_terraform_workspace_cache"`
|
||||
CreatedByAvatarURL string `db:"created_by_avatar_url" json:"created_by_avatar_url"`
|
||||
CreatedByUsername string `db:"created_by_username" json:"created_by_username"`
|
||||
CreatedByName string `db:"created_by_name" json:"created_by_name"`
|
||||
@@ -4342,6 +4347,8 @@ type TemplateTable struct {
|
||||
// Determines whether to default to the dynamic parameter creation flow for this template or continue using the legacy classic parameter creation flow.This is a template wide setting, the template admin can revert to the classic flow if there are any issues. An escape hatch is required, as workspace creation is a core workflow and cannot break. This column will be removed when the dynamic parameter creation flow is stable.
|
||||
UseClassicParameterFlow bool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"`
|
||||
CorsBehavior CorsBehavior `db:"cors_behavior" json:"cors_behavior"`
|
||||
// Determines whether to keep terraform directories cached between runs for workspaces created from this template. When enabled, this can significantly speed up the `terraform init` step at the cost of increased disk usage. This is an opt-in experience, as it prevents modules from being updated, and therefore is a behavioral difference from the default.
|
||||
UseTerraformWorkspaceCache bool `db:"use_terraform_workspace_cache" json:"use_terraform_workspace_cache"`
|
||||
}
|
||||
|
||||
// Records aggregated usage statistics for templates/users. All usage is rounded up to the nearest minute.
|
||||
@@ -4445,7 +4452,8 @@ type TemplateVersionPreset struct {
|
||||
// Short text describing the preset (max 128 characters).
|
||||
Description string `db:"description" json:"description"`
|
||||
// URL or path to an icon representing the preset (max 256 characters).
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
LastInvalidatedAt sql.NullTime `db:"last_invalidated_at" json:"last_invalidated_at"`
|
||||
}
|
||||
|
||||
type TemplateVersionPresetParameter struct {
|
||||
@@ -4663,6 +4671,7 @@ type Workspace struct {
|
||||
TemplateDisplayName string `db:"template_display_name" json:"template_display_name"`
|
||||
TemplateIcon string `db:"template_icon" json:"template_icon"`
|
||||
TemplateDescription string `db:"template_description" json:"template_description"`
|
||||
TaskID uuid.NullUUID `db:"task_id" json:"task_id"`
|
||||
}
|
||||
|
||||
type WorkspaceAgent struct {
|
||||
@@ -4938,7 +4947,6 @@ type WorkspaceBuild struct {
|
||||
MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"`
|
||||
TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"`
|
||||
HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"`
|
||||
AITaskSidebarAppID uuid.NullUUID `db:"ai_task_sidebar_app_id" json:"ai_task_sidebar_app_id"`
|
||||
HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"`
|
||||
InitiatorByAvatarUrl string `db:"initiator_by_avatar_url" json:"initiator_by_avatar_url"`
|
||||
InitiatorByUsername string `db:"initiator_by_username" json:"initiator_by_username"`
|
||||
@@ -4970,7 +4978,6 @@ type WorkspaceBuildTable struct {
|
||||
MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"`
|
||||
TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"`
|
||||
HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"`
|
||||
AITaskSidebarAppID uuid.NullUUID `db:"ai_task_sidebar_app_id" json:"ai_task_sidebar_app_id"`
|
||||
HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
@@ -269,6 +269,9 @@ type sqlcQuerier interface {
|
||||
GetOrganizationResourceCountByID(ctx context.Context, organizationID uuid.UUID) (GetOrganizationResourceCountByIDRow, error)
|
||||
GetOrganizations(ctx context.Context, arg GetOrganizationsParams) ([]Organization, error)
|
||||
GetOrganizationsByUserID(ctx context.Context, arg GetOrganizationsByUserIDParams) ([]Organization, error)
|
||||
// GetOrganizationsWithPrebuildStatus returns organizations with prebuilds configured and their
|
||||
// membership status for the prebuilds system user (org membership, group existence, group membership).
|
||||
GetOrganizationsWithPrebuildStatus(ctx context.Context, arg GetOrganizationsWithPrebuildStatusParams) ([]GetOrganizationsWithPrebuildStatusRow, error)
|
||||
GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error)
|
||||
GetPrebuildMetrics(ctx context.Context) ([]GetPrebuildMetricsRow, error)
|
||||
GetPrebuildsSettings(ctx context.Context) (string, error)
|
||||
@@ -340,6 +343,7 @@ type sqlcQuerier interface {
|
||||
GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerBindingsRow, error)
|
||||
GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerIDsRow, error)
|
||||
GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error)
|
||||
GetTaskByOwnerIDAndName(ctx context.Context, arg GetTaskByOwnerIDAndNameParams) (Task, error)
|
||||
GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (Task, error)
|
||||
GetTelemetryItem(ctx context.Context, key string) (TelemetryItem, error)
|
||||
GetTelemetryItems(ctx context.Context) ([]TelemetryItem, error)
|
||||
@@ -667,8 +671,9 @@ type sqlcQuerier interface {
|
||||
// Cancels all pending provisioner jobs for prebuilt workspaces on a specific preset from an
|
||||
// inactive template version.
|
||||
// This is an optimization to clean up stale pending jobs.
|
||||
UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg UpdatePrebuildProvisionerJobWithCancelParams) ([]uuid.UUID, error)
|
||||
UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg UpdatePrebuildProvisionerJobWithCancelParams) ([]UpdatePrebuildProvisionerJobWithCancelRow, error)
|
||||
UpdatePresetPrebuildStatus(ctx context.Context, arg UpdatePresetPrebuildStatusParams) error
|
||||
UpdatePresetsLastInvalidatedAt(ctx context.Context, arg UpdatePresetsLastInvalidatedAtParams) ([]UpdatePresetsLastInvalidatedAtRow, error)
|
||||
UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg UpdateProvisionerDaemonLastSeenAtParams) error
|
||||
UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error
|
||||
UpdateProvisionerJobLogsLength(ctx context.Context, arg UpdateProvisionerJobLogsLengthParams) error
|
||||
|
||||
@@ -6664,6 +6664,23 @@ func TestTasksWithStatusView(t *testing.T) {
|
||||
StartedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
}
|
||||
case database.ProvisionerJobStatusCanceling:
|
||||
jobParams = database.ProvisionerJob{
|
||||
OrganizationID: org.ID,
|
||||
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
||||
InitiatorID: user.ID,
|
||||
StartedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
CanceledAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
}
|
||||
case database.ProvisionerJobStatusCanceled:
|
||||
jobParams = database.ProvisionerJob{
|
||||
OrganizationID: org.ID,
|
||||
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
||||
InitiatorID: user.ID,
|
||||
StartedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
CanceledAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
}
|
||||
default:
|
||||
t.Errorf("invalid build status: %v", buildStatus)
|
||||
}
|
||||
@@ -6816,6 +6833,28 @@ func TestTasksWithStatusView(t *testing.T) {
|
||||
expectWorkspaceAgentValid: false,
|
||||
expectWorkspaceAppValid: false,
|
||||
},
|
||||
{
|
||||
name: "CancelingBuild",
|
||||
buildStatus: database.ProvisionerJobStatusCanceling,
|
||||
buildTransition: database.WorkspaceTransitionStart,
|
||||
expectedStatus: database.TaskStatusError,
|
||||
description: "Latest workspace build is canceling",
|
||||
expectBuildNumberValid: true,
|
||||
expectBuildNumber: 1,
|
||||
expectWorkspaceAgentValid: false,
|
||||
expectWorkspaceAppValid: false,
|
||||
},
|
||||
{
|
||||
name: "CanceledBuild",
|
||||
buildStatus: database.ProvisionerJobStatusCanceled,
|
||||
buildTransition: database.WorkspaceTransitionStart,
|
||||
expectedStatus: database.TaskStatusError,
|
||||
description: "Latest workspace build was canceled",
|
||||
expectBuildNumberValid: true,
|
||||
expectBuildNumber: 1,
|
||||
expectWorkspaceAgentValid: false,
|
||||
expectWorkspaceAppValid: false,
|
||||
},
|
||||
{
|
||||
name: "StoppedWorkspace",
|
||||
buildStatus: database.ProvisionerJobStatusSucceeded,
|
||||
@@ -6943,24 +6982,26 @@ func TestTasksWithStatusView(t *testing.T) {
|
||||
buildStatus: database.ProvisionerJobStatusSucceeded,
|
||||
buildTransition: database.WorkspaceTransitionStart,
|
||||
agentState: database.WorkspaceAgentLifecycleStateStartTimeout,
|
||||
expectedStatus: database.TaskStatusUnknown,
|
||||
description: "Agent start timed out",
|
||||
appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthHealthy},
|
||||
expectedStatus: database.TaskStatusActive,
|
||||
description: "Agent start timed out but app is healthy, defer to app",
|
||||
expectBuildNumberValid: true,
|
||||
expectBuildNumber: 1,
|
||||
expectWorkspaceAgentValid: true,
|
||||
expectWorkspaceAppValid: false,
|
||||
expectWorkspaceAppValid: true,
|
||||
},
|
||||
{
|
||||
name: "AgentStartError",
|
||||
buildStatus: database.ProvisionerJobStatusSucceeded,
|
||||
buildTransition: database.WorkspaceTransitionStart,
|
||||
agentState: database.WorkspaceAgentLifecycleStateStartError,
|
||||
expectedStatus: database.TaskStatusUnknown,
|
||||
description: "Agent failed to start",
|
||||
appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthHealthy},
|
||||
expectedStatus: database.TaskStatusActive,
|
||||
description: "Agent start failed but app is healthy, defer to app",
|
||||
expectBuildNumberValid: true,
|
||||
expectBuildNumber: 1,
|
||||
expectWorkspaceAgentValid: true,
|
||||
expectWorkspaceAppValid: false,
|
||||
expectWorkspaceAppValid: true,
|
||||
},
|
||||
{
|
||||
name: "AgentShuttingDown",
|
||||
@@ -7081,6 +7122,8 @@ func TestTasksWithStatusView(t *testing.T) {
|
||||
got, err := db.GetTaskByID(ctx, task.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Logf("Task status debug: %s", got.StatusDebug)
|
||||
|
||||
require.Equal(t, tt.expectedStatus, got.Status)
|
||||
|
||||
require.Equal(t, tt.expectBuildNumberValid, got.WorkspaceBuildNumber.Valid)
|
||||
|
||||
+315
-94
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user