Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 885201bc1b | |||
| bab99db9e7 | |||
| 089a1d6b25 | |||
| 455e711e4d | |||
| 65e83992fe | |||
| 50e672e480 | |||
| 26205e5850 | |||
| 2ee54b0af1 | |||
| d737f8c104 | |||
| f8eea54e97 | |||
| 90c11f3386 | |||
| 81a928915c | |||
| 4a3304fc38 | |||
| a5f3acac2f | |||
| 63563e57db | |||
| b40ebfb7e8 | |||
| 06cfe2705a | |||
| c247dc04a7 | |||
| b12b389455 | |||
| ca1016c6ca | |||
| 65fb0e22a8 | |||
| 5e7b3c3c28 | |||
| 2ed9e7fa6d | |||
| 4b3889e4f9 | |||
| 7224977fa6 | |||
| 47a621cd4e | |||
| a35f9810d0 | |||
| 06039a51ff | |||
| 1591f42d9b | |||
| 0822cbdafe | |||
| 6ed10c05af | |||
| 0df864fb88 | |||
| ebd7ab11cb | |||
| 00713385fb | |||
| 6d41d98b65 | |||
| 01f06671a1 | |||
| a613ffa3d6 | |||
| df84cea924 | |||
| 55d1a32424 | |||
| bcb437d281 | |||
| 45280d5516 | |||
| 8e947e506f | |||
| b7f08811c3 | |||
| a9180d406e | |||
| b1d5f77cf0 | |||
| ef25baf581 | |||
| 2cc8cc59fa | |||
| 5f3be6b288 | |||
| b1f48f8d47 | |||
| 21d4d0196d | |||
| 1e1d312cab | |||
| c9909817a8 | |||
| 72438a0e55 | |||
| 220b9f3cc5 | |||
| 60e3ab7632 | |||
| 35c7cda760 | |||
| adc7775405 | |||
| 194d79402e | |||
| 47b8ca940c | |||
| 7f7ff9cd40 | |||
| 5cf97955a0 | |||
| 8e9638c750 | |||
| fcf431c1d7 | |||
| 0938981ebf | |||
| 87b382cc85 | |||
| be94af386c | |||
| e27c4dcd92 | |||
| c2c2b6f16f | |||
| 058f8f1f7c | |||
| 0ab54fd63a | |||
| 6ac0244960 | |||
| 6338be3b30 | |||
| 72d7b6567b | |||
| 342d2e4bed | |||
| 8bcfeab500 | |||
| 5224387c5a | |||
| 52af6eac68 |
@@ -0,0 +1,4 @@
|
||||
# All artifacts of the build processed are dumped here.
|
||||
# Ignore it for docker context, as all Dockerfiles should build their own
|
||||
# binaries.
|
||||
build
|
||||
+17
-17
@@ -35,7 +35,7 @@ jobs:
|
||||
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -272,7 +272,7 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -329,7 +329,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -381,7 +381,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -578,7 +578,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -640,7 +640,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -712,7 +712,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -739,7 +739,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -772,7 +772,7 @@ jobs:
|
||||
name: ${{ matrix.variant.name }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -852,7 +852,7 @@ jobs:
|
||||
if: needs.changes.outputs.site == 'true' || needs.changes.outputs.ci == 'true'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -933,7 +933,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1005,7 +1005,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1120,7 +1120,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1175,7 +1175,7 @@ jobs:
|
||||
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1572,7 +1572,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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
needs: deploy
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
if: github.repository_owner == 'coder'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -58,11 +58,11 @@ jobs:
|
||||
run: mkdir base-build-context
|
||||
|
||||
- name: Install depot.dev CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
|
||||
# This uses OIDC authentication, so no auth variables are required.
|
||||
- name: Build base Docker image via depot.dev
|
||||
uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # v1.16.2
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
||||
with:
|
||||
project: wl5hnrrkns
|
||||
context: base-build-context
|
||||
|
||||
@@ -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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
BRANCH_NAME: ${{ steps.branch-name.outputs.current_branch }}
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build and push Non-Nix image
|
||||
uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # v1.16.2
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
||||
with:
|
||||
project: b4q6ltmpzh
|
||||
token: ${{ secrets.DEPOT_TOKEN }}
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -386,12 +386,12 @@ jobs:
|
||||
|
||||
- name: Install depot.dev CLI
|
||||
if: steps.image-base-tag.outputs.tag != ''
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
|
||||
# This uses OIDC authentication, so no auth variables are required.
|
||||
- name: Build base Docker image via depot.dev
|
||||
if: steps.image-base-tag.outputs.tag != ''
|
||||
uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # v1.16.2
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
||||
with:
|
||||
project: wl5hnrrkns
|
||||
context: base-build-context
|
||||
@@ -796,7 +796,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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -872,7 +872,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -965,7 +965,7 @@ jobs:
|
||||
if: ${{ !inputs.dry_run }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -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@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -909,7 +909,10 @@ site/src/api/countriesGenerated.ts: site/node_modules/.installed scripts/typegen
|
||||
(cd site/ && pnpm exec biome format --write src/api/countriesGenerated.ts)
|
||||
touch "$@"
|
||||
|
||||
docs/admin/integrations/prometheus.md: node_modules/.installed scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
|
||||
scripts/metricsdocgen/generated_metrics: $(GO_SRC_FILES)
|
||||
go run ./scripts/metricsdocgen/scanner > $@
|
||||
|
||||
docs/admin/integrations/prometheus.md: node_modules/.installed scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics scripts/metricsdocgen/generated_metrics
|
||||
go run scripts/metricsdocgen/main.go
|
||||
pnpm exec markdownlint-cli2 --fix ./docs/admin/integrations/prometheus.md
|
||||
pnpm exec markdown-table-formatter ./docs/admin/integrations/prometheus.md
|
||||
|
||||
+25
-21
@@ -3,11 +3,11 @@
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
"defaultBranch": "main",
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!**/pnpm-lock.yaml"],
|
||||
"ignoreUnknown": true
|
||||
"ignoreUnknown": true,
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
@@ -15,18 +15,18 @@
|
||||
"noSvgWithoutTitle": "off",
|
||||
"useButtonType": "off",
|
||||
"useSemanticElements": "off",
|
||||
"noStaticElementInteractions": "off"
|
||||
"noStaticElementInteractions": "off",
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedImports": "warn",
|
||||
"correctness": {
|
||||
"noUnusedImports": "warn",
|
||||
"useUniqueElementIds": "off", // TODO: This is new but we want to fix it
|
||||
"noNestedComponentDefinitions": "off", // TODO: Investigate, since it is used by shadcn components
|
||||
"noUnusedVariables": {
|
||||
"level": "warn",
|
||||
"noUnusedVariables": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
}
|
||||
"ignoreRestSiblings": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
@@ -45,6 +45,10 @@
|
||||
"level": "error",
|
||||
"options": {
|
||||
"paths": {
|
||||
"react": {
|
||||
"message": "React 19 no longer requires forwardRef. Use ref as a prop instead.",
|
||||
"importNames": ["forwardRef"],
|
||||
},
|
||||
// "@mui/material/Alert": "Use components/Alert/Alert instead.",
|
||||
// "@mui/material/AlertTitle": "Use components/Alert/Alert instead.",
|
||||
// "@mui/material/Autocomplete": "Use shadcn/ui Combobox instead.",
|
||||
@@ -111,10 +115,10 @@
|
||||
"@emotion/styled": "Use Tailwind CSS instead.",
|
||||
// "@emotion/cache": "Use Tailwind CSS instead.",
|
||||
// "components/Stack/Stack": "Use Tailwind flex utilities instead (e.g., <div className='flex flex-col gap-4'>).",
|
||||
"lodash": "Use lodash/<name> instead."
|
||||
}
|
||||
}
|
||||
}
|
||||
"lodash": "Use lodash/<name> instead.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"suspicious": {
|
||||
"noArrayIndexKey": "off",
|
||||
@@ -125,14 +129,14 @@
|
||||
"noConsole": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"allow": ["error", "info", "warn"]
|
||||
}
|
||||
}
|
||||
"allow": ["error", "info", "warn"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"complexity": {
|
||||
"noImportantStyles": "off" // TODO: check and fix !important styles
|
||||
}
|
||||
}
|
||||
"noImportantStyles": "off", // TODO: check and fix !important styles
|
||||
},
|
||||
},
|
||||
},
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json"
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
}
|
||||
|
||||
+15
-4
@@ -884,16 +884,27 @@ func (o *OrganizationContext) Selected(inv *serpent.Invocation, client *codersdk
|
||||
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
|
||||
return org.Name == o.FlagSelect || org.ID.String() == o.FlagSelect
|
||||
})
|
||||
if index >= 0 {
|
||||
return orgs[index], nil
|
||||
}
|
||||
|
||||
if index < 0 {
|
||||
// Not in membership list - try direct fetch.
|
||||
// This allows site-wide admins (e.g., Owners) to use orgs they aren't
|
||||
// members of.
|
||||
org, err := client.OrganizationByName(inv.Context(), o.FlagSelect)
|
||||
if err != nil {
|
||||
var names []string
|
||||
for _, org := range orgs {
|
||||
names = append(names, org.Name)
|
||||
}
|
||||
return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization? "+
|
||||
"Valid options for '--org=' are [%s].", o.FlagSelect, strings.Join(names, ", "))
|
||||
var sdkErr *codersdk.Error
|
||||
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
|
||||
return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization? "+
|
||||
"Valid options for '--org=' are [%s].", o.FlagSelect, strings.Join(names, ", "))
|
||||
}
|
||||
return codersdk.Organization{}, xerrors.Errorf("get organization %q: %w", o.FlagSelect, err)
|
||||
}
|
||||
return orgs[index], nil
|
||||
return org, nil
|
||||
}
|
||||
|
||||
if len(orgs) == 1 {
|
||||
|
||||
+8
-1
@@ -95,6 +95,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/webpush"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/coderd/wsbuilder"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/drpcsdk"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
@@ -935,6 +936,12 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
options.StatsBatcher = batcher
|
||||
defer closeBatcher()
|
||||
|
||||
wsBuilderMetrics, err := wsbuilder.NewMetrics(options.PrometheusRegistry)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to register workspace builder metrics: %w", err)
|
||||
}
|
||||
options.WorkspaceBuilderMetrics = wsBuilderMetrics
|
||||
|
||||
// Manage notifications.
|
||||
var (
|
||||
notificationsCfg = options.DeploymentValues.Notifications
|
||||
@@ -1118,7 +1125,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value())
|
||||
defer autobuildTicker.Stop()
|
||||
autobuildExecutor := autobuild.NewExecutor(
|
||||
ctx, options.Database, options.Pubsub, coderAPI.FileCache, options.PrometheusRegistry, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, coderAPI.BuildUsageChecker, logger, autobuildTicker.C, options.NotificationsEnqueuer, coderAPI.Experiments)
|
||||
ctx, options.Database, options.Pubsub, coderAPI.FileCache, options.PrometheusRegistry, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, coderAPI.BuildUsageChecker, logger, autobuildTicker.C, options.NotificationsEnqueuer, coderAPI.Experiments, coderAPI.WorkspaceBuilderMetrics)
|
||||
autobuildExecutor.Run()
|
||||
|
||||
jobReaperTicker := time.NewTicker(vals.JobReaperDetectorInterval.Value())
|
||||
|
||||
@@ -17,6 +17,8 @@ func (r *RootCmd) tasksCommand() *serpent.Command {
|
||||
r.taskDelete(),
|
||||
r.taskList(),
|
||||
r.taskLogs(),
|
||||
r.taskPause(),
|
||||
r.taskResume(),
|
||||
r.taskSend(),
|
||||
r.taskStatus(),
|
||||
},
|
||||
|
||||
+5
-10
@@ -41,8 +41,7 @@ func Test_TaskLogs_Golden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
userClient := client // user already has access to their own workspace
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json")
|
||||
output := clitest.Capture(inv)
|
||||
@@ -65,8 +64,7 @@ func Test_TaskLogs_Golden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
userClient := client
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.ID.String(), "--output", "json")
|
||||
output := clitest.Capture(inv)
|
||||
@@ -89,8 +87,7 @@ func Test_TaskLogs_Golden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
userClient := client
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages))
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.ID.String())
|
||||
output := clitest.Capture(inv)
|
||||
@@ -144,8 +141,7 @@ func Test_TaskLogs_Golden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError))
|
||||
userClient := client
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError))
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.ID.String())
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
@@ -201,8 +197,7 @@ func Test_TaskLogs_Golden(t *testing.T) {
|
||||
t.Run("SnapshotWithoutLogs_NoSnapshotCaptured", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused)
|
||||
userClient := client
|
||||
userClient, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused)
|
||||
|
||||
inv, root := clitest.New(t, "task", "logs", task.Name)
|
||||
output := clitest.Capture(inv)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) taskPause() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "pause <task>",
|
||||
Short: "Pause a task",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Pause a task by name",
|
||||
Command: "coder task pause my-task",
|
||||
},
|
||||
Example{
|
||||
Description: "Pause another user's task",
|
||||
Command: "coder task pause alice/my-task",
|
||||
},
|
||||
Example{
|
||||
Description: "Pause a task without confirmation",
|
||||
Command: "coder task pause my-task --yes",
|
||||
},
|
||||
),
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
Options: serpent.OptionSet{
|
||||
cliui.SkipPromptOption(),
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task, err := client.TaskByIdentifier(ctx, inv.Args[0])
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", inv.Args[0], err)
|
||||
}
|
||||
|
||||
display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
|
||||
|
||||
if task.Status == codersdk.TaskStatusPaused {
|
||||
return xerrors.Errorf("task %q is already paused", display)
|
||||
}
|
||||
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Pause task %s?", pretty.Sprint(cliui.DefaultStyles.Code, display)),
|
||||
IsConfirm: true,
|
||||
Default: cliui.ConfirmNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.PauseTask(ctx, task.OwnerName, task.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("pause task %q: %w", display, err)
|
||||
}
|
||||
|
||||
if resp.WorkspaceBuild == nil {
|
||||
return xerrors.Errorf("pause task %q: no workspace build returned", display)
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceBuild(ctx, inv.Stdout, client, resp.WorkspaceBuild.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("watch pause build for task %q: %w", display, err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(
|
||||
inv.Stdout,
|
||||
"\nThe %s task has been paused at %s!\n",
|
||||
cliui.Keyword(task.Name),
|
||||
cliui.Timestamp(time.Now()),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestExpTaskPause(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("WithYesFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// When: We attempt to pause the task
|
||||
inv, root := clitest.New(t, "task", "pause", task.Name, "--yes")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// Then: Expect the task to be paused
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, output.Stdout(), "has been paused")
|
||||
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
|
||||
})
|
||||
|
||||
// OtherUserTask verifies that an admin can pause a task owned by
|
||||
// another user using the "owner/name" identifier format.
|
||||
t.Run("OtherUserTask", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A different user's running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
adminClient, _, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// When: We attempt to pause their task
|
||||
identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
|
||||
inv, root := clitest.New(t, "task", "pause", identifier, "--yes")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, adminClient, root)
|
||||
|
||||
// Then: We expect the task to be paused
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, output.Stdout(), "has been paused")
|
||||
|
||||
updated, err := adminClient.TaskByIdentifier(ctx, identifier)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("PromptConfirm", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// When: We attempt to pause the task
|
||||
inv, root := clitest.New(t, "task", "pause", task.Name)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// And: We confirm we want to pause the task
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
inv = inv.WithContext(ctx)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatchContext(ctx, "Pause task")
|
||||
pty.WriteLine("yes")
|
||||
|
||||
// Then: We expect the task to be paused
|
||||
pty.ExpectMatchContext(ctx, "has been paused")
|
||||
require.NoError(t, w.Wait())
|
||||
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("PromptDecline", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// When: We attempt to pause the task
|
||||
inv, root := clitest.New(t, "task", "pause", task.Name)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// But: We say no at the confirmation screen
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
inv = inv.WithContext(ctx)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatchContext(ctx, "Pause task")
|
||||
pty.WriteLine("no")
|
||||
require.Error(t, w.Wait())
|
||||
|
||||
// Then: We expect the task to not be paused
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, codersdk.TaskStatusPaused, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("TaskAlreadyPaused", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// And: We paused the running task
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := userClient.PauseTask(ctx, task.OwnerName, task.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.WorkspaceBuild)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, resp.WorkspaceBuild.ID)
|
||||
|
||||
// When: We attempt to pause the task again
|
||||
inv, root := clitest.New(t, "task", "pause", task.Name, "--yes")
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// Then: We expect to get an error that the task is already paused
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, "is already paused")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) taskResume() *serpent.Command {
|
||||
var noWait bool
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "resume <task>",
|
||||
Short: "Resume a task",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Resume a task by name",
|
||||
Command: "coder task resume my-task",
|
||||
},
|
||||
Example{
|
||||
Description: "Resume another user's task",
|
||||
Command: "coder task resume alice/my-task",
|
||||
},
|
||||
Example{
|
||||
Description: "Resume a task without confirmation",
|
||||
Command: "coder task resume my-task --yes",
|
||||
},
|
||||
),
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
Flag: "no-wait",
|
||||
Description: "Return immediately after resuming the task.",
|
||||
Value: serpent.BoolOf(&noWait),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task, err := client.TaskByIdentifier(ctx, inv.Args[0])
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", inv.Args[0], err)
|
||||
}
|
||||
|
||||
display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
|
||||
|
||||
if task.Status == codersdk.TaskStatusError || task.Status == codersdk.TaskStatusUnknown {
|
||||
return xerrors.Errorf("task %q is in %s state and cannot be resumed; check the workspace build logs and agent status for details", display, task.Status)
|
||||
} else if task.Status != codersdk.TaskStatusPaused {
|
||||
return xerrors.Errorf("task %q cannot be resumed (current status: %s)", display, task.Status)
|
||||
}
|
||||
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Resume task %s?", pretty.Sprint(cliui.DefaultStyles.Code, display)),
|
||||
IsConfirm: true,
|
||||
Default: cliui.ConfirmNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.ResumeTask(ctx, task.OwnerName, task.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resume task %q: %w", display, err)
|
||||
} else if resp.WorkspaceBuild == nil {
|
||||
return xerrors.Errorf("resume task %q: no workspace build returned", display)
|
||||
}
|
||||
|
||||
if noWait {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Resuming task %q in the background.\n", cliui.Keyword(display))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = cliui.WorkspaceBuild(ctx, inv.Stdout, client, resp.WorkspaceBuild.ID); err != nil {
|
||||
return xerrors.Errorf("watch resume build for task %q: %w", display, err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "\nThe %s task has been resumed.\n", cliui.Keyword(display))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestExpTaskResume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// pauseTask is a helper that pauses a task and waits for the stop
|
||||
// build to complete.
|
||||
pauseTask := func(ctx context.Context, t *testing.T, client *codersdk.Client, task codersdk.Task) {
|
||||
t.Helper()
|
||||
|
||||
pauseResp, err := client.PauseTask(ctx, task.OwnerName, task.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pauseResp.WorkspaceBuild)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID)
|
||||
}
|
||||
|
||||
t.Run("WithYesFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A paused task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, userClient, task)
|
||||
|
||||
// When: We attempt to resume the task
|
||||
inv, root := clitest.New(t, "task", "resume", task.Name, "--yes")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// Then: We expect the task to be resumed
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, output.Stdout(), "has been resumed")
|
||||
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
|
||||
})
|
||||
|
||||
// OtherUserTask verifies that an admin can resume a task owned by
|
||||
// another user using the "owner/name" identifier format.
|
||||
t.Run("OtherUserTask", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A different user's paused task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
adminClient, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, userClient, task)
|
||||
|
||||
// When: We attempt to resume their task
|
||||
identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
|
||||
inv, root := clitest.New(t, "task", "resume", identifier, "--yes")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, adminClient, root)
|
||||
|
||||
// Then: We expect the task to be resumed
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, output.Stdout(), "has been resumed")
|
||||
|
||||
updated, err := adminClient.TaskByIdentifier(ctx, identifier)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("NoWait", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A paused task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, userClient, task)
|
||||
|
||||
// When: We attempt to resume the task (and specify no wait)
|
||||
inv, root := clitest.New(t, "task", "resume", task.Name, "--yes", "--no-wait")
|
||||
output := clitest.Capture(inv)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// Then: We expect the task to be resumed in the background
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, output.Stdout(), "in the background")
|
||||
|
||||
// And: The task to eventually be resumed
|
||||
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
|
||||
ws := coderdtest.MustWorkspace(t, userClient, task.WorkspaceID.UUID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, ws.LatestBuild.ID)
|
||||
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("PromptConfirm", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A paused task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, userClient, task)
|
||||
|
||||
// When: We attempt to resume the task
|
||||
inv, root := clitest.New(t, "task", "resume", task.Name)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// And: We confirm we want to resume the task
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
inv = inv.WithContext(ctx)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatchContext(ctx, "Resume task")
|
||||
pty.WriteLine("yes")
|
||||
|
||||
// Then: We expect the task to be resumed
|
||||
pty.ExpectMatchContext(ctx, "has been resumed")
|
||||
require.NoError(t, w.Wait())
|
||||
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusInitializing, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("PromptDecline", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A paused task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
pauseTask(setupCtx, t, userClient, task)
|
||||
|
||||
// When: We attempt to resume the task
|
||||
inv, root := clitest.New(t, "task", "resume", task.Name)
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// But: Say no at the confirmation screen
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
inv = inv.WithContext(ctx)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatchContext(ctx, "Resume task")
|
||||
pty.WriteLine("no")
|
||||
require.Error(t, w.Wait())
|
||||
|
||||
// Then: We expect the task to still be paused
|
||||
updated, err := userClient.TaskByIdentifier(ctx, task.Name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.TaskStatusPaused, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("TaskNotPaused", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: A running task
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, nil)
|
||||
|
||||
// When: We attempt to resume the task that is not paused
|
||||
inv, root := clitest.New(t, "task", "resume", task.Name, "--yes")
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
// Then: We expect to get an error that the task is not paused
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, "cannot be resumed")
|
||||
})
|
||||
}
|
||||
@@ -25,8 +25,7 @@ func Test_TaskSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
userClient := client
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", task.Name, "carry on with the task")
|
||||
@@ -42,8 +41,7 @@ func Test_TaskSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
userClient := client
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", task.ID.String(), "carry on with the task")
|
||||
@@ -59,8 +57,7 @@ func Test_TaskSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
userClient := client
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it"))
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", task.Name, "--stdin")
|
||||
@@ -113,7 +110,7 @@ func Test_TaskSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(t, assert.AnError))
|
||||
_, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(t, assert.AnError))
|
||||
|
||||
var stdout strings.Builder
|
||||
inv, root := clitest.New(t, "task", "send", task.Name, "some task input")
|
||||
|
||||
+44
-10
@@ -120,6 +120,40 @@ func Test_Tasks(t *testing.T) {
|
||||
require.Equal(t, logs[2].Type, codersdk.TaskLogTypeOutput, "third message should be an output")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pause task",
|
||||
cmdArgs: []string{"task", "pause", taskName, "--yes"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
require.Contains(t, stdout, "has been paused", "pause output should confirm task was paused")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get task status after pause",
|
||||
cmdArgs: []string{"task", "status", taskName, "--output", "json"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
var task codersdk.Task
|
||||
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status")
|
||||
require.Equal(t, taskName, task.Name, "task name should match")
|
||||
require.Equal(t, codersdk.TaskStatusPaused, task.Status, "task should be paused")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resume task",
|
||||
cmdArgs: []string{"task", "resume", taskName, "--yes"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
require.Contains(t, stdout, "has been resumed", "resume output should confirm task was resumed")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get task status after resume",
|
||||
cmdArgs: []string{"task", "status", taskName, "--output", "json"},
|
||||
assertFn: func(stdout string, userClient *codersdk.Client) {
|
||||
var task codersdk.Task
|
||||
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status")
|
||||
require.Equal(t, taskName, task.Name, "task name should match")
|
||||
require.Equal(t, codersdk.TaskStatusInitializing, task.Status, "task should be initializing after resume")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete task",
|
||||
cmdArgs: []string{"task", "delete", taskName, "--yes"},
|
||||
@@ -238,17 +272,17 @@ func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Mes
|
||||
// setupCLITaskTest creates a test workspace with an AI task template and agent,
|
||||
// with a fake agent API configured with the provided set of handlers.
|
||||
// Returns the user client and workspace.
|
||||
func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (*codersdk.Client, codersdk.Task) {
|
||||
func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (ownerClient *codersdk.Client, memberClient *codersdk.Client, task codersdk.Task) {
|
||||
t.Helper()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
ownerClient = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
fakeAPI := startFakeAgentAPI(t, agentAPIHandlers)
|
||||
|
||||
authToken := uuid.NewString()
|
||||
template := createAITaskTemplate(t, client, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken))
|
||||
template := createAITaskTemplate(t, ownerClient, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken))
|
||||
|
||||
wantPrompt := "test prompt"
|
||||
task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
@@ -262,17 +296,17 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st
|
||||
require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID")
|
||||
workspace, err := userClient.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken))
|
||||
_ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) {
|
||||
agentClient := agentsdk.New(userClient.URL, agentsdk.WithFixedToken(authToken))
|
||||
_ = agenttest.New(t, userClient.URL, authToken, func(o *agent.Options) {
|
||||
o.Client = agentClient
|
||||
})
|
||||
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, userClient, workspace.ID).
|
||||
WaitFor(coderdtest.AgentsReady)
|
||||
|
||||
return userClient, task
|
||||
return ownerClient, userClient, task
|
||||
}
|
||||
|
||||
// setupCLITaskTestWithSnapshot creates a task in the specified status with a log snapshot.
|
||||
|
||||
+2
@@ -12,6 +12,8 @@ SUBCOMMANDS:
|
||||
delete Delete tasks
|
||||
list List tasks
|
||||
logs Show a task's logs
|
||||
pause Pause a task
|
||||
resume Resume a task
|
||||
send Send input to a task
|
||||
status Show the status of a task.
|
||||
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder task pause [flags] <task>
|
||||
|
||||
Pause a task
|
||||
|
||||
- Pause a task by name:
|
||||
|
||||
$ coder task pause my-task
|
||||
|
||||
- Pause another user's task:
|
||||
|
||||
$ coder task pause alice/my-task
|
||||
|
||||
- Pause a task without confirmation:
|
||||
|
||||
$ coder task pause my-task --yes
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder task resume [flags] <task>
|
||||
|
||||
Resume a task
|
||||
|
||||
- Resume a task by name:
|
||||
|
||||
$ coder task resume my-task
|
||||
|
||||
- Resume another user's task:
|
||||
|
||||
$ coder task resume alice/my-task
|
||||
|
||||
- Resume a task without confirmation:
|
||||
|
||||
$ coder task resume my-task --yes
|
||||
|
||||
OPTIONS:
|
||||
--no-wait bool
|
||||
Return immediately after resuming the task.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
+1
-1
@@ -27,7 +27,7 @@ USAGE:
|
||||
SUBCOMMANDS:
|
||||
create Create a token
|
||||
list List tokens
|
||||
remove Delete a token
|
||||
remove Expire or delete a token
|
||||
view Display detailed information about a token
|
||||
|
||||
———
|
||||
|
||||
@@ -15,6 +15,10 @@ OPTIONS:
|
||||
-c, --column [id|name|scopes|allow list|last used|expires at|created at|owner] (default: id,name,scopes,allow list,last used,expires at,created at)
|
||||
Columns to display in table output.
|
||||
|
||||
--include-expired bool
|
||||
Include expired tokens in the output. By default, expired tokens are
|
||||
hidden.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
Output format.
|
||||
|
||||
|
||||
+10
-2
@@ -1,11 +1,19 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder tokens remove <name|id|token>
|
||||
coder tokens remove [flags] <name|id|token>
|
||||
|
||||
Delete a token
|
||||
Expire or delete a token
|
||||
|
||||
Aliases: delete, rm
|
||||
|
||||
Remove a token by expiring it. Use --delete to permanently hard-delete the
|
||||
token instead.
|
||||
|
||||
OPTIONS:
|
||||
--delete bool
|
||||
Permanently delete the token instead of expiring it. This removes the
|
||||
audit trail.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
+49
-13
@@ -218,9 +218,10 @@ func (r *RootCmd) listTokens() *serpent.Command {
|
||||
}
|
||||
|
||||
var (
|
||||
all bool
|
||||
displayTokens []tokenListRow
|
||||
formatter = cliui.NewOutputFormatter(
|
||||
all bool
|
||||
includeExpired bool
|
||||
displayTokens []tokenListRow
|
||||
formatter = cliui.NewOutputFormatter(
|
||||
cliui.TableFormat([]tokenListRow{}, defaultCols),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
@@ -246,6 +247,20 @@ func (r *RootCmd) listTokens() *serpent.Command {
|
||||
return xerrors.Errorf("list tokens: %w", err)
|
||||
}
|
||||
|
||||
// Filter out expired tokens unless --include-expired is set
|
||||
// TODO(Cian): This _could_ get too big for client-side filtering.
|
||||
// If it causes issues, we can filter server-side.
|
||||
if !includeExpired {
|
||||
now := time.Now()
|
||||
filtered := make([]codersdk.APIKeyWithOwner, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if token.ExpiresAt.After(now) {
|
||||
filtered = append(filtered, token)
|
||||
}
|
||||
}
|
||||
tokens = filtered
|
||||
}
|
||||
|
||||
displayTokens = make([]tokenListRow, len(tokens))
|
||||
|
||||
for i, token := range tokens {
|
||||
@@ -274,6 +289,12 @@ func (r *RootCmd) listTokens() *serpent.Command {
|
||||
Description: "Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens).",
|
||||
Value: serpent.BoolOf(&all),
|
||||
},
|
||||
{
|
||||
Name: "include-expired",
|
||||
Flag: "include-expired",
|
||||
Description: "Include expired tokens in the output. By default, expired tokens are hidden.",
|
||||
Value: serpent.BoolOf(&includeExpired),
|
||||
},
|
||||
}
|
||||
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
@@ -323,10 +344,13 @@ func (r *RootCmd) viewToken() *serpent.Command {
|
||||
}
|
||||
|
||||
func (r *RootCmd) removeToken() *serpent.Command {
|
||||
var deleteToken bool
|
||||
cmd := &serpent.Command{
|
||||
Use: "remove <name|id|token>",
|
||||
Aliases: []string{"delete"},
|
||||
Short: "Delete a token",
|
||||
Short: "Expire or delete a token",
|
||||
Long: "Remove a token by expiring it. Use --delete to permanently hard-" +
|
||||
"delete the token instead.",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
@@ -338,7 +362,7 @@ func (r *RootCmd) removeToken() *serpent.Command {
|
||||
|
||||
token, err := client.APIKeyByName(inv.Context(), codersdk.Me, inv.Args[0])
|
||||
if err != nil {
|
||||
// If it's a token, we need to extract the ID
|
||||
// If it's a token, we need to extract the ID.
|
||||
maybeID := strings.Split(inv.Args[0], "-")[0]
|
||||
token, err = client.APIKeyByID(inv.Context(), codersdk.Me, maybeID)
|
||||
if err != nil {
|
||||
@@ -346,19 +370,31 @@ func (r *RootCmd) removeToken() *serpent.Command {
|
||||
}
|
||||
}
|
||||
|
||||
err = client.DeleteAPIKey(inv.Context(), codersdk.Me, token.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete api key: %w", err)
|
||||
if deleteToken {
|
||||
err = client.DeleteAPIKey(inv.Context(), codersdk.Me, token.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete api key: %w", err)
|
||||
}
|
||||
cliui.Infof(inv.Stdout, "Token has been deleted.")
|
||||
return nil
|
||||
}
|
||||
|
||||
cliui.Infof(
|
||||
inv.Stdout,
|
||||
"Token has been deleted.",
|
||||
)
|
||||
|
||||
err = client.ExpireAPIKey(inv.Context(), codersdk.Me, token.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("expire api key: %w", err)
|
||||
}
|
||||
cliui.Infof(inv.Stdout, "Token has been expired.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "delete",
|
||||
Description: "Permanently delete the token instead of expiring it. This removes the audit trail.",
|
||||
Value: serpent.BoolOf(&deleteToken),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
+144
-8
@@ -6,12 +6,16 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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/dbgen"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -22,7 +26,7 @@ func TestTokens(t *testing.T) {
|
||||
adminUser := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
secondUserClient, secondUser := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID)
|
||||
_, thirdUser := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID)
|
||||
thirdUserClient, thirdUser := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID)
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
@@ -155,7 +159,7 @@ func TestTokens(t *testing.T) {
|
||||
require.Len(t, scopedToken.AllowList, 1)
|
||||
require.Equal(t, allowSpec, scopedToken.AllowList[0].String())
|
||||
|
||||
// Delete by name
|
||||
// Delete by name (default behavior is now expire)
|
||||
inv, root = clitest.New(t, "tokens", "rm", "token-one")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
@@ -164,10 +168,31 @@ func TestTokens(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
res = buf.String()
|
||||
require.NotEmpty(t, res)
|
||||
require.Contains(t, res, "deleted")
|
||||
require.Contains(t, res, "expired")
|
||||
|
||||
// Delete by ID
|
||||
// Regular users cannot expire other users' tokens (expire is default now).
|
||||
inv, root = clitest.New(t, "tokens", "rm", secondTokenID)
|
||||
clitest.SetupConfig(t, thirdUserClient, root)
|
||||
buf = new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "not found")
|
||||
|
||||
// Only admin users can expire other users' tokens (expire is default now).
|
||||
inv, root = clitest.New(t, "tokens", "rm", secondTokenID)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
// Validate that token was expired
|
||||
if token, err := client.APIKeyByName(ctx, secondUser.ID.String(), "token-two"); assert.NoError(t, err) {
|
||||
require.True(t, token.ExpiresAt.Before(time.Now()))
|
||||
}
|
||||
|
||||
// Delete by ID (explicit delete flag)
|
||||
inv, root = clitest.New(t, "tokens", "rm", "--delete", secondTokenID)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
@@ -177,8 +202,8 @@ func TestTokens(t *testing.T) {
|
||||
require.NotEmpty(t, res)
|
||||
require.Contains(t, res, "deleted")
|
||||
|
||||
// Delete scoped token by ID
|
||||
inv, root = clitest.New(t, "tokens", "rm", scopedTokenID)
|
||||
// Delete scoped token by ID (explicit delete flag)
|
||||
inv, root = clitest.New(t, "tokens", "rm", "--delete", scopedTokenID)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
@@ -199,8 +224,8 @@ func TestTokens(t *testing.T) {
|
||||
require.NotEmpty(t, res)
|
||||
fourthToken := res
|
||||
|
||||
// Delete by token
|
||||
inv, root = clitest.New(t, "tokens", "rm", fourthToken)
|
||||
// Delete by token (explicit delete flag)
|
||||
inv, root = clitest.New(t, "tokens", "rm", "--delete", fourthToken)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
@@ -210,3 +235,114 @@ func TestTokens(t *testing.T) {
|
||||
require.NotEmpty(t, res)
|
||||
require.Contains(t, res, "deleted")
|
||||
}
|
||||
|
||||
func TestTokensListExpiredFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, api := coderdtest.NewWithAPI(t, nil)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a valid (non-expired) token
|
||||
validToken, _ := dbgen.APIKey(t, api.Database, database.APIKey{
|
||||
UserID: owner.UserID,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
LoginType: database.LoginTypeToken,
|
||||
TokenName: "valid-token",
|
||||
})
|
||||
|
||||
// Create an expired token
|
||||
expiredToken, _ := dbgen.APIKey(t, api.Database, database.APIKey{
|
||||
UserID: owner.UserID,
|
||||
ExpiresAt: time.Now().Add(-24 * time.Hour),
|
||||
LoginType: database.LoginTypeToken,
|
||||
TokenName: "expired-token",
|
||||
})
|
||||
|
||||
t.Run("HidesExpiredByDefault", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
inv, root := clitest.New(t, "tokens", "ls")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
res := buf.String()
|
||||
require.Contains(t, res, validToken.ID)
|
||||
require.Contains(t, res, "valid-token")
|
||||
require.NotContains(t, res, expiredToken.ID)
|
||||
require.NotContains(t, res, "expired-token")
|
||||
})
|
||||
|
||||
t.Run("ShowsExpiredWithFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
inv, root := clitest.New(t, "tokens", "ls", "--include-expired")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
res := buf.String()
|
||||
require.Contains(t, res, validToken.ID)
|
||||
require.Contains(t, res, "valid-token")
|
||||
require.Contains(t, res, expiredToken.ID)
|
||||
require.Contains(t, res, "expired-token")
|
||||
})
|
||||
|
||||
t.Run("JSONOutputRespectsFilter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// Default (no expired)
|
||||
inv, root := clitest.New(t, "tokens", "ls", "--output=json")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
res := buf.String()
|
||||
require.Contains(t, res, "valid-token")
|
||||
require.NotContains(t, res, "expired-token")
|
||||
|
||||
// With --include-expired
|
||||
inv, root = clitest.New(t, "tokens", "ls", "--output=json", "--include-expired")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf = new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
res = buf.String()
|
||||
require.Contains(t, res, "valid-token")
|
||||
require.Contains(t, res, "expired-token")
|
||||
})
|
||||
|
||||
t.Run("AllUsersWithIncludeExpired", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
inv, root := clitest.New(t, "tokens", "ls", "--all", "--include-expired")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
res := buf.String()
|
||||
// Should show both valid and expired tokens
|
||||
require.Contains(t, res, validToken.ID)
|
||||
require.Contains(t, res, "valid-token")
|
||||
require.Contains(t, res, expiredToken.ID)
|
||||
require.Contains(t, res, "expired-token")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (*RootCmd) vpnDaemonRun() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "run",
|
||||
Short: "Run the VPN daemon on Windows.",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
),
|
||||
Handler: func(_ *serpent.Invocation) error {
|
||||
return xerrors.New("vpn-daemon subcommand is not supported on this platform")
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build windows
|
||||
//go:build windows || linux
|
||||
|
||||
package cli
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) vpnDaemonRun() *serpent.Command {
|
||||
func (*RootCmd) vpnDaemonRun() *serpent.Command {
|
||||
var (
|
||||
rpcReadHandleInt int64
|
||||
rpcWriteHandleInt int64
|
||||
@@ -19,7 +19,7 @@ func (r *RootCmd) vpnDaemonRun() *serpent.Command {
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "run",
|
||||
Short: "Run the VPN daemon on Windows.",
|
||||
Short: "Run the VPN daemon on Windows and Linux.",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
),
|
||||
@@ -53,8 +53,8 @@ func (r *RootCmd) vpnDaemonRun() *serpent.Command {
|
||||
return xerrors.Errorf("rpc-read-handle (%v) and rpc-write-handle (%v) must be different", rpcReadHandleInt, rpcWriteHandleInt)
|
||||
}
|
||||
|
||||
// We don't need to worry about duplicating the handles on Windows,
|
||||
// which is different from Unix.
|
||||
// The manager passes the read and write descriptors directly to the
|
||||
// daemon, so we can open the RPC pipe from the raw values.
|
||||
logger.Info(ctx, "opening bidirectional RPC pipe", slog.F("rpc_read_handle", rpcReadHandleInt), slog.F("rpc_write_handle", rpcWriteHandleInt))
|
||||
pipe, err := vpn.NewBidirectionalPipe(uintptr(rpcReadHandleInt), uintptr(rpcWriteHandleInt))
|
||||
if err != nil {
|
||||
@@ -62,7 +62,7 @@ func (r *RootCmd) vpnDaemonRun() *serpent.Command {
|
||||
}
|
||||
defer pipe.Close()
|
||||
|
||||
logger.Info(ctx, "starting tunnel")
|
||||
logger.Info(ctx, "starting VPN tunnel")
|
||||
tunnel, err := vpn.NewTunnel(ctx, logger, pipe, vpn.NewClient(), vpn.UseOSNetworkingStack())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create new tunnel for client: %w", err)
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build windows
|
||||
//go:build windows || linux
|
||||
|
||||
package cli_test
|
||||
|
||||
@@ -67,22 +67,35 @@ func TestVPNDaemonRun(t *testing.T) {
|
||||
|
||||
r1, w1, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
defer r1.Close()
|
||||
defer w1.Close()
|
||||
|
||||
r2, w2, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
defer r2.Close()
|
||||
defer w2.Close()
|
||||
|
||||
// The daemon closes the handles passed via NewBidirectionalPipe. Since our
|
||||
// CLI tests run in-process, pass duplicated handles so we can close the
|
||||
// originals without risking a double-close on FD reuse.
|
||||
rpcReadHandle := dupHandle(t, r1)
|
||||
rpcWriteHandle := dupHandle(t, w2)
|
||||
require.NoError(t, r1.Close())
|
||||
require.NoError(t, w2.Close())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
inv, _ := clitest.New(t, "vpn-daemon", "run", "--rpc-read-handle", fmt.Sprint(r1.Fd()), "--rpc-write-handle", fmt.Sprint(w2.Fd()))
|
||||
inv, _ := clitest.New(t,
|
||||
"vpn-daemon",
|
||||
"run",
|
||||
"--rpc-read-handle",
|
||||
fmt.Sprint(rpcReadHandle),
|
||||
"--rpc-write-handle",
|
||||
fmt.Sprint(rpcWriteHandle),
|
||||
)
|
||||
waiter := clitest.StartWithWaiter(t, inv.WithContext(ctx))
|
||||
|
||||
// Send garbage which should cause the handshake to fail and the daemon
|
||||
// to exit.
|
||||
_, err = w1.Write([]byte("garbage"))
|
||||
// Send an invalid header, including a newline delimiter, so the handshake
|
||||
// fails without requiring context cancellation.
|
||||
_, err = w1.Write([]byte("garbage\n"))
|
||||
require.NoError(t, err)
|
||||
waiter.Cancel()
|
||||
err = waiter.Wait()
|
||||
require.ErrorContains(t, err, "handshake failed")
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build linux
|
||||
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func dupHandle(t *testing.T, f *os.File) uintptr {
|
||||
t.Helper()
|
||||
|
||||
dupFD, err := unix.Dup(int(f.Fd()))
|
||||
require.NoError(t, err)
|
||||
return uintptr(dupFD)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build windows
|
||||
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func dupHandle(t *testing.T, f *os.File) uintptr {
|
||||
t.Helper()
|
||||
|
||||
src := syscall.Handle(f.Fd())
|
||||
var dup syscall.Handle
|
||||
|
||||
proc, err := syscall.GetCurrentProcess()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = syscall.DuplicateHandle(
|
||||
proc,
|
||||
src,
|
||||
proc,
|
||||
&dup,
|
||||
0,
|
||||
false,
|
||||
syscall.DUPLICATE_SAME_ACCESS,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
return uintptr(dup)
|
||||
}
|
||||
@@ -1304,3 +1304,90 @@ func (api *API) pauseTask(rw http.ResponseWriter, r *http.Request) {
|
||||
WorkspaceBuild: &build,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Resume task
|
||||
// @ID resume-task
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Tags Tasks
|
||||
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
||||
// @Param task path string true "Task ID" format(uuid)
|
||||
// @Success 202 {object} codersdk.ResumeTaskResponse
|
||||
// @Router /tasks/{user}/{task}/resume [post]
|
||||
func (api *API) resumeTask(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
apiKey = httpmw.APIKey(r)
|
||||
task = httpmw.TaskParam(r)
|
||||
)
|
||||
|
||||
if !task.WorkspaceID.Valid {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Task does not have a workspace.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching task workspace.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching task workspace build.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
job, err := api.Database.GetProvisionerJobByID(ctx, latestBuild.JobID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching task workspace build job.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
workspaceStatus := codersdk.ConvertWorkspaceStatus(
|
||||
codersdk.ProvisionerJobStatus(job.JobStatus),
|
||||
codersdk.WorkspaceTransition(latestBuild.Transition),
|
||||
)
|
||||
if workspaceStatus == codersdk.WorkspaceStatusRunning {
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "Task workspace is already running.",
|
||||
Detail: fmt.Sprintf("Workspace status is %q.", workspaceStatus),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
buildReq := codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
Reason: codersdk.CreateWorkspaceBuildReasonTaskResume,
|
||||
}
|
||||
build, err := api.postWorkspaceBuildsInternal(
|
||||
ctx,
|
||||
apiKey,
|
||||
workspace,
|
||||
buildReq,
|
||||
func(action policy.Action, object rbac.Objecter) bool {
|
||||
return api.Authorize(r, action, object)
|
||||
},
|
||||
audit.WorkspaceBuildBaggageFromRequest(r),
|
||||
)
|
||||
if err != nil {
|
||||
httperror.WriteWorkspaceBuildError(ctx, rw, err)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.ResumeTaskResponse{
|
||||
WorkspaceBuild: &build,
|
||||
})
|
||||
}
|
||||
|
||||
+337
-1
@@ -2512,13 +2512,20 @@ func TestPauseTask(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
resp, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
|
||||
// Verify that the request was accepted correctly:
|
||||
require.NoError(t, err)
|
||||
build := *resp.WorkspaceBuild
|
||||
require.NotNil(t, build)
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStop, build.Transition)
|
||||
require.Equal(t, task.WorkspaceID.UUID, build.WorkspaceID)
|
||||
require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber)
|
||||
require.Equal(t, string(codersdk.CreateWorkspaceBuildReasonTaskManualPause), string(build.Reason))
|
||||
|
||||
// Verify that the accepted request was processed correctly:
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
|
||||
workspace, err = client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.WorkspaceStatusStopped, workspace.LatestBuild.Status)
|
||||
})
|
||||
|
||||
t.Run("Non-owner role access", func(t *testing.T) {
|
||||
@@ -2781,3 +2788,332 @@ func TestPauseTask(t *testing.T) {
|
||||
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
func TestResumeTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupClient := func(t *testing.T, db database.Store, ps pubsub.Pubsub, authorizer rbac.Authorizer) *codersdk.Client {
|
||||
t.Helper()
|
||||
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: ps,
|
||||
Authorizer: authorizer,
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
return client
|
||||
}
|
||||
|
||||
setupWorkspaceTask := func(t *testing.T, db database.Store, user codersdk.CreateFirstUserResponse) (database.Task, uuid.UUID) {
|
||||
t.Helper()
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithTask(database.TaskTable{
|
||||
Prompt: "resume me",
|
||||
}, nil).Do()
|
||||
return workspaceBuild.Task, workspaceBuild.Workspace.ID
|
||||
}
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
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,
|
||||
ProvisionGraph: []*proto.Response{
|
||||
{Type: &proto.Response_Graph{Graph: &proto.GraphComplete{
|
||||
HasAiTasks: true,
|
||||
}}},
|
||||
},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "resume me",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
pauseResp, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID)
|
||||
|
||||
resumeResp, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
|
||||
require.NoError(t, err)
|
||||
build := *resumeResp.WorkspaceBuild
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStart, build.Transition)
|
||||
require.Equal(t, task.WorkspaceID.UUID, build.WorkspaceID)
|
||||
require.Equal(t, workspace.LatestBuild.BuildNumber+2, build.BuildNumber)
|
||||
require.Equal(t, string(codersdk.CreateWorkspaceBuildReasonTaskResume), string(build.Reason))
|
||||
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
|
||||
workspace, err = client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status)
|
||||
})
|
||||
|
||||
t.Run("Resume a task that is not paused", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client := setupClient(t, db, ps, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).
|
||||
WithTask(database.TaskTable{
|
||||
Prompt: "pause me",
|
||||
}, nil).
|
||||
Succeeded().
|
||||
Do()
|
||||
|
||||
_, err := client.ResumeTask(ctx, codersdk.Me, workspaceBuild.Task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Task not found", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
_, err := client.ResumeTask(ctx, codersdk.Me, uuid.New())
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Task lookup forbidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
auth := &coderdtest.FakeAuthorizer{
|
||||
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
|
||||
if action == policy.ActionRead && object.Type == rbac.ResourceTask.Type {
|
||||
return rbac.UnauthorizedError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
client := setupClient(t, db, ps, auth)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
task, _ := setupWorkspaceTask(t, db, user)
|
||||
|
||||
_, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Workspace lookup forbidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
auth := &coderdtest.FakeAuthorizer{
|
||||
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
|
||||
if action == policy.ActionRead && object.Type == rbac.ResourceWorkspace.Type {
|
||||
return rbac.UnauthorizedError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
client := setupClient(t, db, ps, auth)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
task, _ := setupWorkspaceTask(t, db, user)
|
||||
|
||||
_, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("No Workspace for Task", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client := setupClient(t, db, ps, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).Do()
|
||||
task := dbgen.Task(t, db, database.TaskTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
TemplateVersionID: workspaceBuild.Build.TemplateVersionID,
|
||||
Prompt: "no workspace",
|
||||
})
|
||||
|
||||
_, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
|
||||
require.Equal(t, "Task does not have a workspace.", apiErr.Message)
|
||||
})
|
||||
|
||||
t.Run("Workspace not found", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
var workspaceID uuid.UUID
|
||||
wrapped := aiTaskStoreWrapper{
|
||||
Store: db,
|
||||
getWorkspaceByID: func(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
|
||||
if id == workspaceID && id != uuid.Nil {
|
||||
return database.Workspace{}, sql.ErrNoRows
|
||||
}
|
||||
return db.GetWorkspaceByID(ctx, id)
|
||||
},
|
||||
}
|
||||
client := setupClient(t, wrapped, ps, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
task, workspaceIDValue := setupWorkspaceTask(t, db, user)
|
||||
workspaceID = workspaceIDValue
|
||||
|
||||
_, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Workspace lookup internal error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
var workspaceID uuid.UUID
|
||||
wrapped := aiTaskStoreWrapper{
|
||||
Store: db,
|
||||
getWorkspaceByID: func(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
|
||||
if id == workspaceID && id != uuid.Nil {
|
||||
return database.Workspace{}, xerrors.New("boom")
|
||||
}
|
||||
return db.GetWorkspaceByID(ctx, id)
|
||||
},
|
||||
}
|
||||
client := setupClient(t, wrapped, ps, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
task, workspaceIDValue := setupWorkspaceTask(t, db, user)
|
||||
workspaceID = workspaceIDValue
|
||||
|
||||
_, err := client.ResumeTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
|
||||
require.Equal(t, "Internal error fetching task workspace.", apiErr.Message)
|
||||
})
|
||||
|
||||
t.Run("Build Forbidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
auth := &coderdtest.FakeAuthorizer{
|
||||
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
|
||||
if action == policy.ActionWorkspaceStart && object.Type == rbac.ResourceWorkspace.Type {
|
||||
return rbac.UnauthorizedError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
client := setupClient(t, db, ps, auth)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
task, _ := setupWorkspaceTask(t, db, user)
|
||||
|
||||
pauseResp, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID)
|
||||
|
||||
_, err = client.ResumeTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Job already in progress", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client := setupClient(t, db, ps, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).
|
||||
WithTask(database.TaskTable{
|
||||
Prompt: "resume me",
|
||||
}, nil).
|
||||
Starting().
|
||||
Do()
|
||||
|
||||
_, err := client.ResumeTask(ctx, codersdk.Me, workspaceBuild.Task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Build Internal Error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
wrapped := aiTaskStoreWrapper{
|
||||
Store: db,
|
||||
}
|
||||
|
||||
client := setupClient(t, &wrapped, ps, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
ProvisionGraph: []*proto.Response{
|
||||
{Type: &proto.Response_Graph{Graph: &proto.GraphComplete{
|
||||
HasAiTasks: true,
|
||||
}}},
|
||||
},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "resume me",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
pauseResp, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID)
|
||||
|
||||
// Induce a transient failure in the database after the task has been paused.
|
||||
wrapped.insertWorkspaceBuild = func(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
|
||||
return xerrors.New("insert failed")
|
||||
}
|
||||
_, err = client.ResumeTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+116
-4
@@ -5866,6 +5866,48 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/resume": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Tasks"
|
||||
],
|
||||
"summary": "Resume task",
|
||||
"operationId": "resume-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.ResumeTaskResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/send": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -8344,6 +8386,54 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/keys/{keyid}/expire": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Expire API key",
|
||||
"operationId": "expire-api-key",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "string",
|
||||
"description": "Key ID",
|
||||
"name": "keyid",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Response"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/login-type": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -12261,6 +12351,9 @@ const docTemplate = `{
|
||||
"api_key_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"client": {
|
||||
"type": "string"
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -13460,7 +13553,10 @@ const docTemplate = `{
|
||||
"cli",
|
||||
"ssh_connection",
|
||||
"vscode_connection",
|
||||
"jetbrains_connection"
|
||||
"jetbrains_connection",
|
||||
"task_auto_pause",
|
||||
"task_manual_pause",
|
||||
"task_resume"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"BuildReasonInitiator",
|
||||
@@ -13471,7 +13567,10 @@ const docTemplate = `{
|
||||
"BuildReasonCLI",
|
||||
"BuildReasonSSHConnection",
|
||||
"BuildReasonVSCodeConnection",
|
||||
"BuildReasonJetbrainsConnection"
|
||||
"BuildReasonJetbrainsConnection",
|
||||
"BuildReasonTaskAutoPause",
|
||||
"BuildReasonTaskManualPause",
|
||||
"BuildReasonTaskResume"
|
||||
]
|
||||
},
|
||||
"codersdk.CORSBehavior": {
|
||||
@@ -14145,7 +14244,8 @@ const docTemplate = `{
|
||||
"ssh_connection",
|
||||
"vscode_connection",
|
||||
"jetbrains_connection",
|
||||
"task_manual_pause"
|
||||
"task_manual_pause",
|
||||
"task_resume"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"CreateWorkspaceBuildReasonDashboard",
|
||||
@@ -14153,7 +14253,8 @@ const docTemplate = `{
|
||||
"CreateWorkspaceBuildReasonSSHConnection",
|
||||
"CreateWorkspaceBuildReasonVSCodeConnection",
|
||||
"CreateWorkspaceBuildReasonJetbrainsConnection",
|
||||
"CreateWorkspaceBuildReasonTaskManualPause"
|
||||
"CreateWorkspaceBuildReasonTaskManualPause",
|
||||
"CreateWorkspaceBuildReasonTaskResume"
|
||||
]
|
||||
},
|
||||
"codersdk.CreateWorkspaceBuildRequest": {
|
||||
@@ -18235,6 +18336,14 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ResumeTaskResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace_build": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceBuild"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.RetentionConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18867,6 +18976,9 @@ const docTemplate = `{
|
||||
"default_ttl_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"deprecated": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
Generated
+110
-4
@@ -5185,6 +5185,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/resume": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"tags": ["Tasks"],
|
||||
"summary": "Resume task",
|
||||
"operationId": "resume-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.ResumeTaskResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/send": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -7379,6 +7417,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/keys/{keyid}/expire": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": ["Users"],
|
||||
"summary": "Expire API key",
|
||||
"operationId": "expire-api-key",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, name, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "string",
|
||||
"description": "Key ID",
|
||||
"name": "keyid",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Response"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}/login-type": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -10887,6 +10971,9 @@
|
||||
"api_key_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"client": {
|
||||
"type": "string"
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -12061,7 +12148,10 @@
|
||||
"cli",
|
||||
"ssh_connection",
|
||||
"vscode_connection",
|
||||
"jetbrains_connection"
|
||||
"jetbrains_connection",
|
||||
"task_auto_pause",
|
||||
"task_manual_pause",
|
||||
"task_resume"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"BuildReasonInitiator",
|
||||
@@ -12072,7 +12162,10 @@
|
||||
"BuildReasonCLI",
|
||||
"BuildReasonSSHConnection",
|
||||
"BuildReasonVSCodeConnection",
|
||||
"BuildReasonJetbrainsConnection"
|
||||
"BuildReasonJetbrainsConnection",
|
||||
"BuildReasonTaskAutoPause",
|
||||
"BuildReasonTaskManualPause",
|
||||
"BuildReasonTaskResume"
|
||||
]
|
||||
},
|
||||
"codersdk.CORSBehavior": {
|
||||
@@ -12701,7 +12794,8 @@
|
||||
"ssh_connection",
|
||||
"vscode_connection",
|
||||
"jetbrains_connection",
|
||||
"task_manual_pause"
|
||||
"task_manual_pause",
|
||||
"task_resume"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"CreateWorkspaceBuildReasonDashboard",
|
||||
@@ -12709,7 +12803,8 @@
|
||||
"CreateWorkspaceBuildReasonSSHConnection",
|
||||
"CreateWorkspaceBuildReasonVSCodeConnection",
|
||||
"CreateWorkspaceBuildReasonJetbrainsConnection",
|
||||
"CreateWorkspaceBuildReasonTaskManualPause"
|
||||
"CreateWorkspaceBuildReasonTaskManualPause",
|
||||
"CreateWorkspaceBuildReasonTaskResume"
|
||||
]
|
||||
},
|
||||
"codersdk.CreateWorkspaceBuildRequest": {
|
||||
@@ -16647,6 +16742,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ResumeTaskResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace_build": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceBuild"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.RetentionConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17258,6 +17361,9 @@
|
||||
"default_ttl_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"deprecated": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -421,6 +421,69 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Expire API key
|
||||
// @ID expire-api-key
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Users
|
||||
// @Param user path string true "User ID, name, or me"
|
||||
// @Param keyid path string true "Key ID" format(string)
|
||||
// @Success 204
|
||||
// @Failure 404 {object} codersdk.Response
|
||||
// @Failure 500 {object} codersdk.Response
|
||||
// @Router /users/{user}/keys/{keyid}/expire [put]
|
||||
func (api *API) expireAPIKey(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
keyID = chi.URLParam(r, "keyid")
|
||||
auditor = api.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionWrite,
|
||||
})
|
||||
)
|
||||
defer commitAudit()
|
||||
|
||||
if err := api.Database.InTx(func(db database.Store) error {
|
||||
key, err := db.GetAPIKeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch API key: %w", err)
|
||||
}
|
||||
if !key.ExpiresAt.After(api.Clock.Now()) {
|
||||
return nil // Already expired
|
||||
}
|
||||
aReq.Old = key
|
||||
if err := db.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{
|
||||
ID: key.ID,
|
||||
LastUsed: key.LastUsed,
|
||||
ExpiresAt: dbtime.Now(),
|
||||
IPAddress: key.IPAddress,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("expire API key: %w", err)
|
||||
}
|
||||
// Fetch the updated key for audit log.
|
||||
newKey, err := db.GetAPIKeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "failed to fetch updated API key for audit log", slog.Error(err))
|
||||
} else {
|
||||
aReq.New = newKey
|
||||
}
|
||||
return nil
|
||||
}, nil); httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
} else if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error expiring API key.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Get token config
|
||||
// @ID get-token-config
|
||||
// @Security CoderSessionToken
|
||||
|
||||
+159
-4
@@ -400,7 +400,7 @@ func TestAPIKey_Deleted(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
}
|
||||
|
||||
func TestAPIKey_SetDefault(t *testing.T) {
|
||||
@@ -439,7 +439,7 @@ func TestAPIKey_PrebuildsNotAllowed(t *testing.T) {
|
||||
DeploymentValues: dc,
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Given: an existing api token for the prebuilds user
|
||||
_, prebuildsToken := dbgen.APIKey(t, db, database.APIKey{
|
||||
@@ -448,12 +448,167 @@ func TestAPIKey_PrebuildsNotAllowed(t *testing.T) {
|
||||
client.SetSessionToken(prebuildsToken)
|
||||
|
||||
// When: the prebuilds user tries to create an API key
|
||||
_, err := client.CreateAPIKey(ctx, database.PrebuildsSystemUserID.String())
|
||||
_, err := client.CreateAPIKey(setupCtx, database.PrebuildsSystemUserID.String())
|
||||
// Then: denied.
|
||||
require.ErrorContains(t, err, httpapi.ResourceForbiddenResponse.Message)
|
||||
|
||||
// When: the prebuilds user tries to create a token
|
||||
_, err = client.CreateToken(ctx, database.PrebuildsSystemUserID.String(), codersdk.CreateTokenRequest{})
|
||||
_, err = client.CreateToken(setupCtx, database.PrebuildsSystemUserID.String(), codersdk.CreateTokenRequest{})
|
||||
// Then: also denied.
|
||||
require.ErrorContains(t, err, httpapi.ResourceForbiddenResponse.Message)
|
||||
}
|
||||
|
||||
//nolint:tparallel,paralleltest // Subtests share the same coderdtest instance and auditor.
|
||||
func TestExpireAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
auditor := audit.NewMock()
|
||||
adminClient := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
admin := coderdtest.CreateFirstUser(t, adminClient)
|
||||
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, admin.OrganizationID)
|
||||
|
||||
t.Run("OwnerCanExpireOwnToken", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Create a token.
|
||||
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 7,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
keyID := strings.Split(res.Key, "-")[0]
|
||||
|
||||
// Verify the token is not expired.
|
||||
key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, key.ExpiresAt.After(time.Now()))
|
||||
|
||||
auditor.ResetLogs()
|
||||
|
||||
// Expire the token.
|
||||
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the token is expired.
|
||||
key, err = adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, key.ExpiresAt.Before(time.Now()))
|
||||
|
||||
// Verify audit log.
|
||||
als := auditor.AuditLogs()
|
||||
require.Len(t, als, 1)
|
||||
require.Equal(t, database.AuditActionWrite, als[0].Action)
|
||||
require.Equal(t, database.ResourceTypeApiKey, als[0].ResourceType)
|
||||
require.Equal(t, admin.UserID.String(), als[0].UserID.String())
|
||||
})
|
||||
|
||||
t.Run("AdminCanExpireOtherUsersToken", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Create a token for the member.
|
||||
res, err := memberClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 7,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
keyID := strings.Split(res.Key, "-")[0]
|
||||
|
||||
// Admin expires the member's token.
|
||||
err = adminClient.ExpireAPIKey(ctx, member.ID.String(), keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the token is expired.
|
||||
key, err := memberClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, key.ExpiresAt.Before(time.Now()))
|
||||
})
|
||||
|
||||
t.Run("MemberCannotExpireOtherUsersToken", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Create a token for the admin.
|
||||
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 7,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
keyID := strings.Split(res.Key, "-")[0]
|
||||
|
||||
// Member attempts to expire admin's token.
|
||||
err = memberClient.ExpireAPIKey(ctx, admin.UserID.String(), keyID)
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
// Members cannot read other users, so they get a 404 Not Found
|
||||
// from the authorization layer.
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Try to expire a non-existent token.
|
||||
err := adminClient.ExpireAPIKey(ctx, codersdk.Me, "nonexistent")
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("ExpiringAlreadyExpiredTokenSucceeds", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Create and expire a token.
|
||||
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 7,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
keyID := strings.Split(res.Key, "-")[0]
|
||||
|
||||
// Expire it once.
|
||||
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Invariant: make sure it's actually expired
|
||||
key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
require.LessOrEqual(t, key.ExpiresAt, time.Now(), "key should be expired")
|
||||
|
||||
// Expire it again - should succeed (idempotent).
|
||||
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Token should still be just as expired as before. No more, no less.
|
||||
keyAgain, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, key.ExpiresAt, keyAgain.ExpiresAt, "expiration should be idempotent")
|
||||
})
|
||||
|
||||
t.Run("DeletingExpiredTokenSucceeds", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Create a token.
|
||||
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Lifetime: time.Hour * 24 * 7,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
keyID := strings.Split(res.Key, "-")[0]
|
||||
|
||||
// Expire it first.
|
||||
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's expired.
|
||||
key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, key.ExpiresAt.Before(time.Now()))
|
||||
|
||||
// Delete the expired token - should succeed.
|
||||
err = adminClient.DeleteAPIKey(ctx, codersdk.Me, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's gone.
|
||||
_, err = adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,9 +48,10 @@ type Executor struct {
|
||||
tick <-chan time.Time
|
||||
statsCh chan<- Stats
|
||||
// NotificationsEnqueuer handles enqueueing notifications for delivery by SMTP, webhook, etc.
|
||||
notificationsEnqueuer notifications.Enqueuer
|
||||
reg prometheus.Registerer
|
||||
experiments codersdk.Experiments
|
||||
notificationsEnqueuer notifications.Enqueuer
|
||||
reg prometheus.Registerer
|
||||
experiments codersdk.Experiments
|
||||
workspaceBuilderMetrics *wsbuilder.Metrics
|
||||
|
||||
metrics executorMetrics
|
||||
}
|
||||
@@ -67,23 +68,24 @@ type Stats struct {
|
||||
}
|
||||
|
||||
// New returns a new wsactions executor.
|
||||
func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, fc *files.Cache, reg prometheus.Registerer, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], buildUsageChecker *atomic.Pointer[wsbuilder.UsageChecker], log slog.Logger, tick <-chan time.Time, enqueuer notifications.Enqueuer, exp codersdk.Experiments) *Executor {
|
||||
func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, fc *files.Cache, reg prometheus.Registerer, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], buildUsageChecker *atomic.Pointer[wsbuilder.UsageChecker], log slog.Logger, tick <-chan time.Time, enqueuer notifications.Enqueuer, exp codersdk.Experiments, workspaceBuilderMetrics *wsbuilder.Metrics) *Executor {
|
||||
factory := promauto.With(reg)
|
||||
le := &Executor{
|
||||
//nolint:gocritic // Autostart has a limited set of permissions.
|
||||
ctx: dbauthz.AsAutostart(ctx),
|
||||
db: db,
|
||||
ps: ps,
|
||||
fileCache: fc,
|
||||
templateScheduleStore: tss,
|
||||
tick: tick,
|
||||
log: log.Named("autobuild"),
|
||||
auditor: auditor,
|
||||
accessControlStore: acs,
|
||||
buildUsageChecker: buildUsageChecker,
|
||||
notificationsEnqueuer: enqueuer,
|
||||
reg: reg,
|
||||
experiments: exp,
|
||||
ctx: dbauthz.AsAutostart(ctx),
|
||||
db: db,
|
||||
ps: ps,
|
||||
fileCache: fc,
|
||||
templateScheduleStore: tss,
|
||||
tick: tick,
|
||||
log: log.Named("autobuild"),
|
||||
auditor: auditor,
|
||||
accessControlStore: acs,
|
||||
buildUsageChecker: buildUsageChecker,
|
||||
notificationsEnqueuer: enqueuer,
|
||||
reg: reg,
|
||||
experiments: exp,
|
||||
workspaceBuilderMetrics: workspaceBuilderMetrics,
|
||||
metrics: executorMetrics{
|
||||
autobuildExecutionDuration: factory.NewHistogram(prometheus.HistogramOpts{
|
||||
Namespace: "coderd",
|
||||
@@ -335,7 +337,8 @@ func (e *Executor) runOnce(t time.Time) Stats {
|
||||
SetLastWorkspaceBuildInTx(&latestBuild).
|
||||
SetLastWorkspaceBuildJobInTx(&latestJob).
|
||||
Experiments(e.experiments).
|
||||
Reason(reason)
|
||||
Reason(reason).
|
||||
BuildMetrics(e.workspaceBuilderMetrics)
|
||||
log.Debug(e.ctx, "auto building workspace", slog.F("transition", nextTransition))
|
||||
if nextTransition == database.WorkspaceTransitionStart &&
|
||||
useActiveVersion(accessControl, ws) {
|
||||
@@ -522,10 +525,18 @@ func getNextTransition(
|
||||
) {
|
||||
switch {
|
||||
case isEligibleForAutostop(user, ws, latestBuild, latestJob, currentTick):
|
||||
// Use task-specific reason for AI task workspaces.
|
||||
if ws.TaskID.Valid {
|
||||
return database.WorkspaceTransitionStop, database.BuildReasonTaskAutoPause, nil
|
||||
}
|
||||
return database.WorkspaceTransitionStop, database.BuildReasonAutostop, nil
|
||||
case isEligibleForAutostart(user, ws, latestBuild, latestJob, templateSchedule, currentTick):
|
||||
return database.WorkspaceTransitionStart, database.BuildReasonAutostart, nil
|
||||
case isEligibleForFailedStop(latestBuild, latestJob, templateSchedule, currentTick):
|
||||
// Use task-specific reason for AI task workspaces.
|
||||
if ws.TaskID.Valid {
|
||||
return database.WorkspaceTransitionStop, database.BuildReasonTaskAutoPause, nil
|
||||
}
|
||||
return database.WorkspaceTransitionStop, database.BuildReasonAutostop, nil
|
||||
case isEligibleForDormantStop(ws, templateSchedule, currentTick):
|
||||
// Only stop started workspaces.
|
||||
|
||||
@@ -5,12 +5,113 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/schedule"
|
||||
)
|
||||
|
||||
func Test_getNextTransition_TaskAutoPause(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Set up a workspace that is eligible for autostop (past deadline).
|
||||
now := time.Now()
|
||||
pastDeadline := now.Add(-time.Hour)
|
||||
|
||||
okUser := database.User{Status: database.UserStatusActive}
|
||||
okBuild := database.WorkspaceBuild{
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
Deadline: pastDeadline,
|
||||
}
|
||||
okJob := database.ProvisionerJob{
|
||||
JobStatus: database.ProvisionerJobStatusSucceeded,
|
||||
}
|
||||
okTemplateSchedule := schedule.TemplateScheduleOptions{}
|
||||
|
||||
// Failed build setup for failedstop tests.
|
||||
failedBuild := database.WorkspaceBuild{
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
}
|
||||
failedJob := database.ProvisionerJob{
|
||||
JobStatus: database.ProvisionerJobStatusFailed,
|
||||
CompletedAt: sql.NullTime{Time: now.Add(-time.Hour), Valid: true},
|
||||
}
|
||||
failedTemplateSchedule := schedule.TemplateScheduleOptions{
|
||||
FailureTTL: time.Minute, // TTL already elapsed since job completed an hour ago.
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
Name string
|
||||
Workspace database.Workspace
|
||||
Build database.WorkspaceBuild
|
||||
Job database.ProvisionerJob
|
||||
TemplateSchedule schedule.TemplateScheduleOptions
|
||||
ExpectedReason database.BuildReason
|
||||
}{
|
||||
{
|
||||
Name: "RegularWorkspace_Autostop",
|
||||
Workspace: database.Workspace{
|
||||
DormantAt: sql.NullTime{Valid: false},
|
||||
},
|
||||
Build: okBuild,
|
||||
Job: okJob,
|
||||
TemplateSchedule: okTemplateSchedule,
|
||||
ExpectedReason: database.BuildReasonAutostop,
|
||||
},
|
||||
{
|
||||
Name: "TaskWorkspace_Autostop_UsesTaskAutoPause",
|
||||
Workspace: database.Workspace{
|
||||
DormantAt: sql.NullTime{Valid: false},
|
||||
TaskID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
},
|
||||
Build: okBuild,
|
||||
Job: okJob,
|
||||
TemplateSchedule: okTemplateSchedule,
|
||||
ExpectedReason: database.BuildReasonTaskAutoPause,
|
||||
},
|
||||
{
|
||||
Name: "RegularWorkspace_FailedStop",
|
||||
Workspace: database.Workspace{
|
||||
DormantAt: sql.NullTime{Valid: false},
|
||||
},
|
||||
Build: failedBuild,
|
||||
Job: failedJob,
|
||||
TemplateSchedule: failedTemplateSchedule,
|
||||
ExpectedReason: database.BuildReasonAutostop,
|
||||
},
|
||||
{
|
||||
Name: "TaskWorkspace_FailedStop_UsesTaskAutoPause",
|
||||
Workspace: database.Workspace{
|
||||
DormantAt: sql.NullTime{Valid: false},
|
||||
TaskID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
},
|
||||
Build: failedBuild,
|
||||
Job: failedJob,
|
||||
TemplateSchedule: failedTemplateSchedule,
|
||||
ExpectedReason: database.BuildReasonTaskAutoPause,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transition, reason, err := getNextTransition(
|
||||
okUser,
|
||||
tc.Workspace,
|
||||
tc.Build,
|
||||
tc.Job,
|
||||
tc.TemplateSchedule,
|
||||
now,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, database.WorkspaceTransitionStop, transition)
|
||||
require.Equal(t, tc.ExpectedReason, reason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_isEligibleForAutostart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -2019,5 +2019,11 @@ func TestExecutorTaskWorkspace(t *testing.T) {
|
||||
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")
|
||||
|
||||
// Then: The build reason should be TaskAutoPause (not regular Autostop)
|
||||
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
|
||||
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
|
||||
assert.Equal(t, codersdk.BuildReasonTaskAutoPause, workspace.LatestBuild.Reason, "task workspace should use TaskAutoPause build reason")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@ type Options struct {
|
||||
MetadataBatcherOptions []metadatabatcher.Option
|
||||
|
||||
ProvisionerdServerMetrics *provisionerdserver.Metrics
|
||||
WorkspaceBuilderMetrics *wsbuilder.Metrics
|
||||
|
||||
// WorkspaceAppAuditSessionTimeout allows changing the timeout for audit
|
||||
// sessions. Raising or lowering this value will directly affect the write
|
||||
@@ -1079,6 +1080,7 @@ func New(options *Options) *API {
|
||||
r.Post("/send", api.taskSend)
|
||||
r.Get("/logs", api.taskLogs)
|
||||
r.Post("/pause", api.pauseTask)
|
||||
r.Post("/resume", api.resumeTask)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1397,6 +1399,7 @@ func New(options *Options) *API {
|
||||
r.Route("/{keyid}", func(r chi.Router) {
|
||||
r.Get("/", api.apiKeyByID)
|
||||
r.Delete("/", api.deleteAPIKey)
|
||||
r.Put("/expire", api.expireAPIKey)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ type Options struct {
|
||||
TelemetryReporter telemetry.Reporter
|
||||
|
||||
ProvisionerdServerMetrics *provisionerdserver.Metrics
|
||||
WorkspaceBuilderMetrics *wsbuilder.Metrics
|
||||
UsageInserter usage.Inserter
|
||||
}
|
||||
|
||||
@@ -399,6 +400,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
|
||||
options.AutobuildTicker,
|
||||
options.NotificationsEnqueuer,
|
||||
experiments,
|
||||
options.WorkspaceBuilderMetrics,
|
||||
).WithStatsChannel(options.AutobuildStats)
|
||||
|
||||
lifecycleExecutor.Run()
|
||||
@@ -620,6 +622,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
|
||||
AppEncryptionKeyCache: options.APIKeyEncryptionCache,
|
||||
OIDCConvertKeyCache: options.OIDCConvertKeyCache,
|
||||
ProvisionerdServerMetrics: options.ProvisionerdServerMetrics,
|
||||
WorkspaceBuilderMetrics: options.WorkspaceBuilderMetrics,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,4 +17,6 @@ const (
|
||||
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
|
||||
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
|
||||
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
|
||||
CheckGroupAclIsObject CheckConstraint = "group_acl_is_object" // workspaces
|
||||
CheckUserAclIsObject CheckConstraint = "user_acl_is_object" // workspaces
|
||||
)
|
||||
|
||||
@@ -93,7 +93,6 @@ type TxOptions struct {
|
||||
|
||||
// IncrementExecutionCount is a helper function for external packages
|
||||
// to increment the unexported count.
|
||||
// Mainly for `dbmem`.
|
||||
func IncrementExecutionCount(opts *TxOptions) {
|
||||
opts.executionCount++
|
||||
}
|
||||
|
||||
@@ -981,6 +981,9 @@ func AIBridgeInterception(interception database.AIBridgeInterception, initiator
|
||||
if interception.EndedAt.Valid {
|
||||
intc.EndedAt = &interception.EndedAt.Time
|
||||
}
|
||||
if interception.Client.Valid {
|
||||
intc.Client = &interception.Client.String
|
||||
}
|
||||
return intc
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
@@ -206,3 +207,231 @@ func TestTemplateVersionParameter_BadDescription(t *testing.T) {
|
||||
req.NoError(err)
|
||||
req.NotEmpty(sdk.DescriptionPlaintext, "broke the markdown parser with %v", desc)
|
||||
}
|
||||
|
||||
func TestAIBridgeInterception(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := dbtime.Now()
|
||||
interceptionID := uuid.New()
|
||||
initiatorID := uuid.New()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
interception database.AIBridgeInterception
|
||||
initiator database.VisibleUser
|
||||
tokenUsages []database.AIBridgeTokenUsage
|
||||
userPrompts []database.AIBridgeUserPrompt
|
||||
toolUsages []database.AIBridgeToolUsage
|
||||
expected codersdk.AIBridgeInterception
|
||||
}{
|
||||
{
|
||||
name: "all_optional_values_set",
|
||||
interception: database.AIBridgeInterception{
|
||||
ID: interceptionID,
|
||||
InitiatorID: initiatorID,
|
||||
Provider: "anthropic",
|
||||
Model: "claude-3-opus",
|
||||
StartedAt: now,
|
||||
Metadata: pqtype.NullRawMessage{
|
||||
RawMessage: json.RawMessage(`{"key":"value"}`),
|
||||
Valid: true,
|
||||
},
|
||||
EndedAt: sql.NullTime{
|
||||
Time: now.Add(time.Minute),
|
||||
Valid: true,
|
||||
},
|
||||
APIKeyID: sql.NullString{
|
||||
String: "api-key-123",
|
||||
Valid: true,
|
||||
},
|
||||
Client: sql.NullString{
|
||||
String: "claude-code/1.0.0",
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
initiator: database.VisibleUser{
|
||||
ID: initiatorID,
|
||||
Username: "testuser",
|
||||
Name: "Test User",
|
||||
AvatarURL: "https://example.com/avatar.png",
|
||||
},
|
||||
tokenUsages: []database.AIBridgeTokenUsage{
|
||||
{
|
||||
ID: uuid.New(),
|
||||
InterceptionID: interceptionID,
|
||||
ProviderResponseID: "resp-123",
|
||||
InputTokens: 100,
|
||||
OutputTokens: 200,
|
||||
Metadata: pqtype.NullRawMessage{
|
||||
RawMessage: json.RawMessage(`{"cache":"hit"}`),
|
||||
Valid: true,
|
||||
},
|
||||
CreatedAt: now.Add(10 * time.Second),
|
||||
},
|
||||
},
|
||||
userPrompts: []database.AIBridgeUserPrompt{
|
||||
{
|
||||
ID: uuid.New(),
|
||||
InterceptionID: interceptionID,
|
||||
ProviderResponseID: "resp-123",
|
||||
Prompt: "Hello, world!",
|
||||
Metadata: pqtype.NullRawMessage{
|
||||
RawMessage: json.RawMessage(`{"role":"user"}`),
|
||||
Valid: true,
|
||||
},
|
||||
CreatedAt: now.Add(5 * time.Second),
|
||||
},
|
||||
},
|
||||
toolUsages: []database.AIBridgeToolUsage{
|
||||
{
|
||||
ID: uuid.New(),
|
||||
InterceptionID: interceptionID,
|
||||
ProviderResponseID: "resp-123",
|
||||
ServerUrl: sql.NullString{
|
||||
String: "https://mcp.example.com",
|
||||
Valid: true,
|
||||
},
|
||||
Tool: "read_file",
|
||||
Input: `{"path":"/tmp/test.txt"}`,
|
||||
Injected: true,
|
||||
InvocationError: sql.NullString{
|
||||
String: "file not found",
|
||||
Valid: true,
|
||||
},
|
||||
Metadata: pqtype.NullRawMessage{
|
||||
RawMessage: json.RawMessage(`{"duration_ms":50}`),
|
||||
Valid: true,
|
||||
},
|
||||
CreatedAt: now.Add(15 * time.Second),
|
||||
},
|
||||
},
|
||||
expected: codersdk.AIBridgeInterception{
|
||||
ID: interceptionID,
|
||||
Initiator: codersdk.MinimalUser{
|
||||
ID: initiatorID,
|
||||
Username: "testuser",
|
||||
Name: "Test User",
|
||||
AvatarURL: "https://example.com/avatar.png",
|
||||
},
|
||||
Provider: "anthropic",
|
||||
Model: "claude-3-opus",
|
||||
Metadata: map[string]any{"key": "value"},
|
||||
StartedAt: now,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no_optional_values_set",
|
||||
interception: database.AIBridgeInterception{
|
||||
ID: interceptionID,
|
||||
InitiatorID: initiatorID,
|
||||
Provider: "openai",
|
||||
Model: "gpt-4",
|
||||
StartedAt: now,
|
||||
Metadata: pqtype.NullRawMessage{Valid: false},
|
||||
EndedAt: sql.NullTime{Valid: false},
|
||||
APIKeyID: sql.NullString{Valid: false},
|
||||
Client: sql.NullString{Valid: false},
|
||||
},
|
||||
initiator: database.VisibleUser{
|
||||
ID: initiatorID,
|
||||
Username: "minimaluser",
|
||||
Name: "",
|
||||
AvatarURL: "",
|
||||
},
|
||||
tokenUsages: nil,
|
||||
userPrompts: nil,
|
||||
toolUsages: nil,
|
||||
expected: codersdk.AIBridgeInterception{
|
||||
ID: interceptionID,
|
||||
Initiator: codersdk.MinimalUser{
|
||||
ID: initiatorID,
|
||||
Username: "minimaluser",
|
||||
Name: "",
|
||||
AvatarURL: "",
|
||||
},
|
||||
Provider: "openai",
|
||||
Model: "gpt-4",
|
||||
Metadata: nil,
|
||||
StartedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := db2sdk.AIBridgeInterception(
|
||||
tc.interception,
|
||||
tc.initiator,
|
||||
tc.tokenUsages,
|
||||
tc.userPrompts,
|
||||
tc.toolUsages,
|
||||
)
|
||||
|
||||
// Check basic fields.
|
||||
require.Equal(t, tc.expected.ID, result.ID)
|
||||
require.Equal(t, tc.expected.Initiator, result.Initiator)
|
||||
require.Equal(t, tc.expected.Provider, result.Provider)
|
||||
require.Equal(t, tc.expected.Model, result.Model)
|
||||
require.Equal(t, tc.expected.StartedAt.UTC(), result.StartedAt.UTC())
|
||||
require.Equal(t, tc.expected.Metadata, result.Metadata)
|
||||
|
||||
// Check optional pointer fields.
|
||||
if tc.interception.APIKeyID.Valid {
|
||||
require.NotNil(t, result.APIKeyID)
|
||||
require.Equal(t, tc.interception.APIKeyID.String, *result.APIKeyID)
|
||||
} else {
|
||||
require.Nil(t, result.APIKeyID)
|
||||
}
|
||||
|
||||
if tc.interception.EndedAt.Valid {
|
||||
require.NotNil(t, result.EndedAt)
|
||||
require.Equal(t, tc.interception.EndedAt.Time.UTC(), result.EndedAt.UTC())
|
||||
} else {
|
||||
require.Nil(t, result.EndedAt)
|
||||
}
|
||||
|
||||
if tc.interception.Client.Valid {
|
||||
require.NotNil(t, result.Client)
|
||||
require.Equal(t, tc.interception.Client.String, *result.Client)
|
||||
} else {
|
||||
require.Nil(t, result.Client)
|
||||
}
|
||||
|
||||
// Check slices.
|
||||
require.Len(t, result.TokenUsages, len(tc.tokenUsages))
|
||||
require.Len(t, result.UserPrompts, len(tc.userPrompts))
|
||||
require.Len(t, result.ToolUsages, len(tc.toolUsages))
|
||||
|
||||
// Verify token usages are converted correctly.
|
||||
for i, tu := range tc.tokenUsages {
|
||||
require.Equal(t, tu.ID, result.TokenUsages[i].ID)
|
||||
require.Equal(t, tu.InterceptionID, result.TokenUsages[i].InterceptionID)
|
||||
require.Equal(t, tu.ProviderResponseID, result.TokenUsages[i].ProviderResponseID)
|
||||
require.Equal(t, tu.InputTokens, result.TokenUsages[i].InputTokens)
|
||||
require.Equal(t, tu.OutputTokens, result.TokenUsages[i].OutputTokens)
|
||||
}
|
||||
|
||||
// Verify user prompts are converted correctly.
|
||||
for i, up := range tc.userPrompts {
|
||||
require.Equal(t, up.ID, result.UserPrompts[i].ID)
|
||||
require.Equal(t, up.InterceptionID, result.UserPrompts[i].InterceptionID)
|
||||
require.Equal(t, up.ProviderResponseID, result.UserPrompts[i].ProviderResponseID)
|
||||
require.Equal(t, up.Prompt, result.UserPrompts[i].Prompt)
|
||||
}
|
||||
|
||||
// Verify tool usages are converted correctly.
|
||||
for i, toolUsage := range tc.toolUsages {
|
||||
require.Equal(t, toolUsage.ID, result.ToolUsages[i].ID)
|
||||
require.Equal(t, toolUsage.InterceptionID, result.ToolUsages[i].InterceptionID)
|
||||
require.Equal(t, toolUsage.ProviderResponseID, result.ToolUsages[i].ProviderResponseID)
|
||||
require.Equal(t, toolUsage.ServerUrl.String, result.ToolUsages[i].ServerURL)
|
||||
require.Equal(t, toolUsage.Tool, result.ToolUsages[i].Tool)
|
||||
require.Equal(t, toolUsage.Input, result.ToolUsages[i].Input)
|
||||
require.Equal(t, toolUsage.Injected, result.ToolUsages[i].Injected)
|
||||
require.Equal(t, toolUsage.InvocationError.String, result.ToolUsages[i].InvocationError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/apikey"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
@@ -30,7 +29,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/rbac/rolestore"
|
||||
"github.com/coder/coder/v2/coderd/taskname"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
"github.com/coder/coder/v2/provisionerd/proto"
|
||||
@@ -1592,6 +1590,7 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA
|
||||
Model: takeFirst(seed.Model, "model"),
|
||||
Metadata: takeFirstSlice(seed.Metadata, json.RawMessage("{}")),
|
||||
StartedAt: takeFirst(seed.StartedAt, dbtime.Now()),
|
||||
Client: seed.Client,
|
||||
})
|
||||
if endedAt != nil {
|
||||
interception, err = db.UpdateAIBridgeInterceptionEnded(genCtx, database.UpdateAIBridgeInterceptionEndedParams{
|
||||
@@ -1664,13 +1663,12 @@ func Task(t testing.TB, db database.Store, orig database.TaskTable) database.Tas
|
||||
parameters = json.RawMessage([]byte("{}"))
|
||||
}
|
||||
|
||||
taskName := taskname.Generate(genCtx, slog.Make(), orig.Prompt)
|
||||
task, err := db.InsertTask(genCtx, database.InsertTaskParams{
|
||||
ID: takeFirst(orig.ID, uuid.New()),
|
||||
OrganizationID: orig.OrganizationID,
|
||||
OwnerID: orig.OwnerID,
|
||||
Name: takeFirst(orig.Name, taskName.Name),
|
||||
DisplayName: takeFirst(orig.DisplayName, taskName.DisplayName),
|
||||
Name: takeFirst(orig.Name, testutil.GetRandomNameHyphenated(t)),
|
||||
DisplayName: takeFirst(orig.DisplayName, testutil.GetRandomNameHyphenated(t)),
|
||||
WorkspaceID: orig.WorkspaceID,
|
||||
TemplateVersionID: orig.TemplateVersionID,
|
||||
TemplateParameters: parameters,
|
||||
|
||||
Generated
+7
-2
@@ -1023,7 +1023,8 @@ CREATE TABLE aibridge_interceptions (
|
||||
started_at timestamp with time zone NOT NULL,
|
||||
metadata jsonb,
|
||||
ended_at timestamp with time zone,
|
||||
api_key_id text
|
||||
api_key_id text,
|
||||
client character varying(64) DEFAULT 'Unknown'::character varying
|
||||
);
|
||||
|
||||
COMMENT ON TABLE aibridge_interceptions IS 'Audit log of requests intercepted by AI Bridge';
|
||||
@@ -2736,7 +2737,9 @@ CREATE TABLE workspaces (
|
||||
favorite boolean DEFAULT false NOT NULL,
|
||||
next_start_at timestamp with time zone,
|
||||
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL
|
||||
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
CONSTRAINT group_acl_is_object CHECK ((jsonb_typeof(group_acl) = 'object'::text)),
|
||||
CONSTRAINT user_acl_is_object CHECK ((jsonb_typeof(user_acl) = 'object'::text))
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN workspaces.favorite IS 'Favorite is true if the workspace owner has favorited the workspace.';
|
||||
@@ -3272,6 +3275,8 @@ CREATE INDEX idx_agent_stats_created_at ON workspace_agent_stats USING btree (cr
|
||||
|
||||
CREATE INDEX idx_agent_stats_user_id ON workspace_agent_stats USING btree (user_id);
|
||||
|
||||
CREATE INDEX idx_aibridge_interceptions_client ON aibridge_interceptions USING btree (client);
|
||||
|
||||
CREATE INDEX idx_aibridge_interceptions_initiator_id ON aibridge_interceptions USING btree (initiator_id);
|
||||
|
||||
CREATE INDEX idx_aibridge_interceptions_model ON aibridge_interceptions USING btree (model);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE workspaces
|
||||
DROP CONSTRAINT IF EXISTS group_acl_is_object,
|
||||
DROP CONSTRAINT IF EXISTS user_acl_is_object;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Add constraints that reject 'null'::jsonb for group and user ACLs
|
||||
-- because they would break the new workspace_expanded view.
|
||||
|
||||
UPDATE workspaces SET group_acl = '{}'::jsonb WHERE group_acl = 'null'::jsonb;
|
||||
UPDATE workspaces SET user_acl = '{}'::jsonb WHERE user_acl = 'null'::jsonb;
|
||||
|
||||
ALTER TABLE workspaces
|
||||
ADD CONSTRAINT group_acl_is_object CHECK (jsonb_typeof(group_acl) = 'object'),
|
||||
ADD CONSTRAINT user_acl_is_object CHECK (jsonb_typeof(user_acl) = 'object');
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE aibridge_interceptions
|
||||
DROP COLUMN client;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE aibridge_interceptions
|
||||
ADD COLUMN client VARCHAR(64)
|
||||
DEFAULT 'Unknown';
|
||||
|
||||
CREATE INDEX idx_aibridge_interceptions_client ON aibridge_interceptions (client);
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
-- Fixture for migration 000417_workspace_acl_object_constraint.
|
||||
-- Inserts a workspace with 'null'::json ACLs to ensure the migration
|
||||
-- correctly normalizes such values.
|
||||
|
||||
INSERT INTO workspaces (
|
||||
id,
|
||||
created_at,
|
||||
updated_at,
|
||||
owner_id,
|
||||
organization_id,
|
||||
template_id,
|
||||
deleted,
|
||||
name,
|
||||
last_used_at,
|
||||
automatic_updates,
|
||||
favorite,
|
||||
group_acl,
|
||||
user_acl
|
||||
)
|
||||
VALUES (
|
||||
'6f6fdbee-4c18-4a5c-8a8d-9b811c9f0a28',
|
||||
'2024-02-10 00:00:00+00',
|
||||
'2024-02-10 00:00:00+00',
|
||||
'30095c71-380b-457a-8995-97b8ee6e5307',
|
||||
'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1',
|
||||
'4cc1f466-f326-477e-8762-9d0c6781fc56',
|
||||
false,
|
||||
'acl-null-workspace',
|
||||
'0001-01-01 00:00:00+00',
|
||||
'never',
|
||||
false,
|
||||
'null'::jsonb,
|
||||
'null'::jsonb
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -790,6 +790,7 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, ar
|
||||
arg.InitiatorID,
|
||||
arg.Provider,
|
||||
arg.Model,
|
||||
arg.Client,
|
||||
arg.AfterID,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
@@ -810,6 +811,7 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, ar
|
||||
&i.AIBridgeInterception.Metadata,
|
||||
&i.AIBridgeInterception.EndedAt,
|
||||
&i.AIBridgeInterception.APIKeyID,
|
||||
&i.AIBridgeInterception.Client,
|
||||
&i.VisibleUser.ID,
|
||||
&i.VisibleUser.Username,
|
||||
&i.VisibleUser.Name,
|
||||
@@ -847,6 +849,7 @@ func (q *sqlQuerier) CountAuthorizedAIBridgeInterceptions(ctx context.Context, a
|
||||
arg.InitiatorID,
|
||||
arg.Provider,
|
||||
arg.Model,
|
||||
arg.Client,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -3642,6 +3642,7 @@ type AIBridgeInterception struct {
|
||||
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"`
|
||||
Client sql.NullString `db:"client" json:"client"`
|
||||
}
|
||||
|
||||
// Audit log of tokens used by intercepted requests in AI Bridge
|
||||
|
||||
@@ -6765,6 +6765,65 @@ func TestWorkspaceBuildDeadlineConstraint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceACLObjectConstraint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
template := dbgen.Template(t, db, database.Template{
|
||||
CreatedBy: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
})
|
||||
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: user.ID,
|
||||
TemplateID: template.ID,
|
||||
Deleted: false,
|
||||
})
|
||||
|
||||
t.Run("GroupACLNull", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var nilACL database.WorkspaceACL
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
|
||||
ID: workspace.ID,
|
||||
GroupACL: nilACL,
|
||||
UserACL: database.WorkspaceACL{},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, database.IsCheckViolation(err, database.CheckGroupAclIsObject))
|
||||
})
|
||||
|
||||
t.Run("UserACLNull", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var nilACL database.WorkspaceACL
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
|
||||
ID: workspace.ID,
|
||||
GroupACL: database.WorkspaceACL{},
|
||||
UserACL: nilACL,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, database.IsCheckViolation(err, database.CheckUserAclIsObject))
|
||||
})
|
||||
|
||||
t.Run("ValidEmptyObjects", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
|
||||
ID: workspace.ID,
|
||||
GroupACL: database.WorkspaceACL{},
|
||||
UserACL: database.WorkspaceACL{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetLatestWorkspaceBuildsByWorkspaceIDs populates the database with
|
||||
// workspaces and builds. It then tests that
|
||||
// GetLatestWorkspaceBuildsByWorkspaceIDs returns the latest build for some
|
||||
@@ -8011,12 +8070,15 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) {
|
||||
ID: uid,
|
||||
InitiatorID: user.ID,
|
||||
Metadata: json.RawMessage("{}"),
|
||||
Client: sql.NullString{String: "client", Valid: true},
|
||||
}
|
||||
|
||||
intc, err := db.InsertAIBridgeInterception(ctx, insertParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uid, intc.ID)
|
||||
require.False(t, intc.EndedAt.Valid)
|
||||
require.True(t, intc.Client.Valid)
|
||||
require.Equal(t, "client", intc.Client.String)
|
||||
interceptions = append(interceptions, intc)
|
||||
}
|
||||
|
||||
|
||||
@@ -123,8 +123,7 @@ WITH interceptions_in_range AS (
|
||||
WHERE
|
||||
provider = $1::text
|
||||
AND model = $2::text
|
||||
-- TODO: use the client value once we have it (see https://github.com/coder/aibridge/issues/31)
|
||||
AND 'unknown' = $3::text
|
||||
AND COALESCE(client, 'Unknown') = $3::text
|
||||
AND ended_at IS NOT NULL -- incomplete interceptions are not included in summaries
|
||||
AND ended_at >= $4::timestamptz
|
||||
AND ended_at < $5::timestamptz
|
||||
@@ -301,6 +300,11 @@ WHERE
|
||||
WHEN $5::text != '' THEN aibridge_interceptions.model = $5::text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter client
|
||||
AND CASE
|
||||
WHEN $6::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = $6::text
|
||||
ELSE true
|
||||
END
|
||||
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeInterceptions
|
||||
-- @authorize_filter
|
||||
`
|
||||
@@ -311,6 +315,7 @@ type CountAIBridgeInterceptionsParams struct {
|
||||
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
|
||||
Provider string `db:"provider" json:"provider"`
|
||||
Model string `db:"model" json:"model"`
|
||||
Client string `db:"client" json:"client"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) CountAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams) (int64, error) {
|
||||
@@ -320,6 +325,7 @@ func (q *sqlQuerier) CountAIBridgeInterceptions(ctx context.Context, arg CountAI
|
||||
arg.InitiatorID,
|
||||
arg.Provider,
|
||||
arg.Model,
|
||||
arg.Client,
|
||||
)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
@@ -372,7 +378,7 @@ func (q *sqlQuerier) DeleteOldAIBridgeRecords(ctx context.Context, beforeTime ti
|
||||
|
||||
const getAIBridgeInterceptionByID = `-- name: GetAIBridgeInterceptionByID :one
|
||||
SELECT
|
||||
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id
|
||||
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client
|
||||
FROM
|
||||
aibridge_interceptions
|
||||
WHERE
|
||||
@@ -391,13 +397,14 @@ func (q *sqlQuerier) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UU
|
||||
&i.Metadata,
|
||||
&i.EndedAt,
|
||||
&i.APIKeyID,
|
||||
&i.Client,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAIBridgeInterceptions = `-- name: GetAIBridgeInterceptions :many
|
||||
SELECT
|
||||
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id
|
||||
id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client
|
||||
FROM
|
||||
aibridge_interceptions
|
||||
`
|
||||
@@ -420,6 +427,7 @@ func (q *sqlQuerier) GetAIBridgeInterceptions(ctx context.Context) ([]AIBridgeIn
|
||||
&i.Metadata,
|
||||
&i.EndedAt,
|
||||
&i.APIKeyID,
|
||||
&i.Client,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -565,11 +573,11 @@ func (q *sqlQuerier) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context,
|
||||
|
||||
const insertAIBridgeInterception = `-- name: InsertAIBridgeInterception :one
|
||||
INSERT INTO aibridge_interceptions (
|
||||
id, api_key_id, initiator_id, provider, model, metadata, started_at
|
||||
id, api_key_id, initiator_id, provider, model, metadata, started_at, client
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, COALESCE($6::jsonb, '{}'::jsonb), $7
|
||||
$1, $2, $3, $4, $5, COALESCE($6::jsonb, '{}'::jsonb), $7, $8
|
||||
)
|
||||
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id
|
||||
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client
|
||||
`
|
||||
|
||||
type InsertAIBridgeInterceptionParams struct {
|
||||
@@ -580,6 +588,7 @@ type InsertAIBridgeInterceptionParams struct {
|
||||
Model string `db:"model" json:"model"`
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
Client sql.NullString `db:"client" json:"client"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertAIBridgeInterception(ctx context.Context, arg InsertAIBridgeInterceptionParams) (AIBridgeInterception, error) {
|
||||
@@ -591,6 +600,7 @@ func (q *sqlQuerier) InsertAIBridgeInterception(ctx context.Context, arg InsertA
|
||||
arg.Model,
|
||||
arg.Metadata,
|
||||
arg.StartedAt,
|
||||
arg.Client,
|
||||
)
|
||||
var i AIBridgeInterception
|
||||
err := row.Scan(
|
||||
@@ -602,6 +612,7 @@ func (q *sqlQuerier) InsertAIBridgeInterception(ctx context.Context, arg InsertA
|
||||
&i.Metadata,
|
||||
&i.EndedAt,
|
||||
&i.APIKeyID,
|
||||
&i.Client,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -740,7 +751,7 @@ func (q *sqlQuerier) InsertAIBridgeUserPrompt(ctx context.Context, arg InsertAIB
|
||||
|
||||
const listAIBridgeInterceptions = `-- name: ListAIBridgeInterceptions :many
|
||||
SELECT
|
||||
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, aibridge_interceptions.api_key_id,
|
||||
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, aibridge_interceptions.api_key_id, aibridge_interceptions.client,
|
||||
visible_users.id, visible_users.username, visible_users.name, visible_users.avatar_url
|
||||
FROM
|
||||
aibridge_interceptions
|
||||
@@ -773,9 +784,14 @@ WHERE
|
||||
WHEN $5::text != '' THEN aibridge_interceptions.model = $5::text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter client
|
||||
AND CASE
|
||||
WHEN $6::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = $6::text
|
||||
ELSE true
|
||||
END
|
||||
-- Cursor pagination
|
||||
AND CASE
|
||||
WHEN $6::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
|
||||
WHEN $7::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
|
||||
-- The pagination cursor is the last ID of the previous page.
|
||||
-- The query is ordered by the started_at field, so select all
|
||||
-- rows before the cursor and before the after_id UUID.
|
||||
@@ -783,8 +799,8 @@ WHERE
|
||||
-- "after_id" terminology comes from our pagination parser in
|
||||
-- coderd.
|
||||
(aibridge_interceptions.started_at, aibridge_interceptions.id) < (
|
||||
(SELECT started_at FROM aibridge_interceptions WHERE id = $6),
|
||||
$6::uuid
|
||||
(SELECT started_at FROM aibridge_interceptions WHERE id = $7),
|
||||
$7::uuid
|
||||
)
|
||||
)
|
||||
ELSE true
|
||||
@@ -794,8 +810,8 @@ WHERE
|
||||
ORDER BY
|
||||
aibridge_interceptions.started_at DESC,
|
||||
aibridge_interceptions.id DESC
|
||||
LIMIT COALESCE(NULLIF($8::integer, 0), 100)
|
||||
OFFSET $7
|
||||
LIMIT COALESCE(NULLIF($9::integer, 0), 100)
|
||||
OFFSET $8
|
||||
`
|
||||
|
||||
type ListAIBridgeInterceptionsParams struct {
|
||||
@@ -804,6 +820,7 @@ type ListAIBridgeInterceptionsParams struct {
|
||||
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
|
||||
Provider string `db:"provider" json:"provider"`
|
||||
Model string `db:"model" json:"model"`
|
||||
Client string `db:"client" json:"client"`
|
||||
AfterID uuid.UUID `db:"after_id" json:"after_id"`
|
||||
Offset int32 `db:"offset_" json:"offset_"`
|
||||
Limit int32 `db:"limit_" json:"limit_"`
|
||||
@@ -821,6 +838,7 @@ func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBr
|
||||
arg.InitiatorID,
|
||||
arg.Provider,
|
||||
arg.Model,
|
||||
arg.Client,
|
||||
arg.AfterID,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
@@ -841,6 +859,7 @@ func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBr
|
||||
&i.AIBridgeInterception.Metadata,
|
||||
&i.AIBridgeInterception.EndedAt,
|
||||
&i.AIBridgeInterception.APIKeyID,
|
||||
&i.AIBridgeInterception.Client,
|
||||
&i.VisibleUser.ID,
|
||||
&i.VisibleUser.Username,
|
||||
&i.VisibleUser.Name,
|
||||
@@ -864,8 +883,7 @@ SELECT
|
||||
DISTINCT ON (provider, model, client)
|
||||
provider,
|
||||
model,
|
||||
-- TODO: use the client value once we have it (see https://github.com/coder/aibridge/issues/31)
|
||||
'unknown' AS client
|
||||
COALESCE(client, 'Unknown') AS client
|
||||
FROM
|
||||
aibridge_interceptions
|
||||
WHERE
|
||||
@@ -1047,7 +1065,7 @@ UPDATE aibridge_interceptions
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
AND ended_at IS NULL
|
||||
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id
|
||||
RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client
|
||||
`
|
||||
|
||||
type UpdateAIBridgeInterceptionEndedParams struct {
|
||||
@@ -1067,6 +1085,7 @@ func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg Up
|
||||
&i.Metadata,
|
||||
&i.EndedAt,
|
||||
&i.APIKeyID,
|
||||
&i.Client,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
-- name: InsertAIBridgeInterception :one
|
||||
INSERT INTO aibridge_interceptions (
|
||||
id, api_key_id, initiator_id, provider, model, metadata, started_at
|
||||
id, api_key_id, initiator_id, provider, model, metadata, started_at, client
|
||||
) VALUES (
|
||||
@id, @api_key_id, @initiator_id, @provider, @model, COALESCE(@metadata::jsonb, '{}'::jsonb), @started_at
|
||||
@id, @api_key_id, @initiator_id, @provider, @model, COALESCE(@metadata::jsonb, '{}'::jsonb), @started_at, @client
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
@@ -115,6 +115,11 @@ WHERE
|
||||
WHEN @model::text != '' THEN aibridge_interceptions.model = @model::text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter client
|
||||
AND CASE
|
||||
WHEN @client::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = @client::text
|
||||
ELSE true
|
||||
END
|
||||
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeInterceptions
|
||||
-- @authorize_filter
|
||||
;
|
||||
@@ -154,6 +159,11 @@ WHERE
|
||||
WHEN @model::text != '' THEN aibridge_interceptions.model = @model::text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter client
|
||||
AND CASE
|
||||
WHEN @client::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = @client::text
|
||||
ELSE true
|
||||
END
|
||||
-- Cursor pagination
|
||||
AND CASE
|
||||
WHEN @after_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
|
||||
@@ -219,8 +229,7 @@ SELECT
|
||||
DISTINCT ON (provider, model, client)
|
||||
provider,
|
||||
model,
|
||||
-- TODO: use the client value once we have it (see https://github.com/coder/aibridge/issues/31)
|
||||
'unknown' AS client
|
||||
COALESCE(client, 'Unknown') AS client
|
||||
FROM
|
||||
aibridge_interceptions
|
||||
WHERE
|
||||
@@ -242,8 +251,7 @@ WITH interceptions_in_range AS (
|
||||
WHERE
|
||||
provider = @provider::text
|
||||
AND model = @model::text
|
||||
-- TODO: use the client value once we have it (see https://github.com/coder/aibridge/issues/31)
|
||||
AND 'unknown' = @client::text
|
||||
AND COALESCE(client, 'Unknown') = @client::text
|
||||
AND ended_at IS NOT NULL -- incomplete interceptions are not included in summaries
|
||||
AND ended_at >= @ended_at_after::timestamptz
|
||||
AND ended_at < @ended_at_before::timestamptz
|
||||
|
||||
@@ -106,6 +106,10 @@ func ExtractUserContext(ctx context.Context, db database.Store, rw http.Response
|
||||
if userID, err := uuid.Parse(userQuery); err == nil {
|
||||
user, err = db.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return database.User{}, false
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: userErrorMessage,
|
||||
Detail: fmt.Sprintf("queried user=%q", userQuery),
|
||||
@@ -120,6 +124,10 @@ func ExtractUserContext(ctx context.Context, db database.Store, rw http.Response
|
||||
Username: userQuery,
|
||||
})
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return database.User{}, false
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: userErrorMessage,
|
||||
Detail: fmt.Sprintf("queried user=%q", userQuery),
|
||||
|
||||
@@ -71,7 +71,53 @@ func TestUserParam(t *testing.T) {
|
||||
})).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, res.StatusCode)
|
||||
// User "ben" doesn't exist, so expect 404.
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("NotFoundByUsername", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, rw, r := setup(t)
|
||||
|
||||
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: db,
|
||||
RedirectToLogin: false,
|
||||
})(http.HandlerFunc(func(rw http.ResponseWriter, returnedRequest *http.Request) {
|
||||
r = returnedRequest
|
||||
})).ServeHTTP(rw, r)
|
||||
|
||||
routeContext := chi.NewRouteContext()
|
||||
routeContext.URLParams.Add("user", "nonexistent-user")
|
||||
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext))
|
||||
httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("NotFoundByUUID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, rw, r := setup(t)
|
||||
|
||||
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: db,
|
||||
RedirectToLogin: false,
|
||||
})(http.HandlerFunc(func(rw http.ResponseWriter, returnedRequest *http.Request) {
|
||||
r = returnedRequest
|
||||
})).ServeHTTP(rw, r)
|
||||
|
||||
routeContext := chi.NewRouteContext()
|
||||
// Use a valid UUID that doesn't exist in the database.
|
||||
routeContext.URLParams.Add("user", "88888888-4444-4444-4444-121212121212")
|
||||
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext))
|
||||
httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("me", func(t *testing.T) {
|
||||
|
||||
@@ -262,8 +262,6 @@ func TestWebhookDispatch(t *testing.T) {
|
||||
// This is not strictly necessary for this test, but it's testing some side logic which is too small for its own test.
|
||||
require.Equal(t, payload.Payload.UserName, name)
|
||||
require.Equal(t, payload.Payload.UserUsername, username)
|
||||
// Right now we don't have a way to query notification templates by ID in dbmem, and it's not necessary to add this
|
||||
// just to satisfy this test. We can safely assume that as long as this value is not empty that the given value was delivered.
|
||||
require.NotEmpty(t, payload.Payload.NotificationName)
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ func TestNotificationPreferences(t *testing.T) {
|
||||
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
|
||||
// NOTE: ExtractUserParam gets in the way here, and returns a 400 Bad Request instead of a 403 Forbidden.
|
||||
// This is not ideal, and we should probably change this behavior.
|
||||
require.Equal(t, http.StatusBadRequest, sdkError.StatusCode())
|
||||
require.Equal(t, http.StatusNotFound, sdkError.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Admin may read any users' preferences", func(t *testing.T) {
|
||||
|
||||
@@ -13,6 +13,7 @@ type Metrics struct {
|
||||
logger slog.Logger
|
||||
workspaceCreationTimings *prometheus.HistogramVec
|
||||
workspaceClaimTimings *prometheus.HistogramVec
|
||||
jobQueueWait *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
type WorkspaceTimingType int
|
||||
@@ -29,6 +30,12 @@ const (
|
||||
workspaceTypePrebuild = "prebuild"
|
||||
)
|
||||
|
||||
// BuildReasonPrebuild is the build_reason metric label value for prebuild
|
||||
// operations. This is distinct from database.BuildReason values since prebuilds
|
||||
// use BuildReasonInitiator in the database but we want to track them separately
|
||||
// in metrics. This is also used as a label value by the metrics in wsbuilder.
|
||||
const BuildReasonPrebuild = workspaceTypePrebuild
|
||||
|
||||
type WorkspaceTimingFlags struct {
|
||||
IsPrebuild bool
|
||||
IsClaim bool
|
||||
@@ -90,6 +97,30 @@ func NewMetrics(logger slog.Logger) *Metrics {
|
||||
NativeHistogramZeroThreshold: 0,
|
||||
NativeHistogramMaxZeroThreshold: 0,
|
||||
}, []string{"organization_name", "template_name", "preset_name"}),
|
||||
jobQueueWait: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "coderd",
|
||||
Name: "provisioner_job_queue_wait_seconds",
|
||||
Help: "Time from job creation to acquisition by a provisioner daemon.",
|
||||
Buckets: []float64{
|
||||
0.1, // 100ms
|
||||
0.5, // 500ms
|
||||
1, // 1s
|
||||
5, // 5s
|
||||
10, // 10s
|
||||
30, // 30s
|
||||
60, // 1m
|
||||
120, // 2m
|
||||
300, // 5m
|
||||
600, // 10m
|
||||
900, // 15m
|
||||
1800, // 30m
|
||||
},
|
||||
NativeHistogramBucketFactor: 1.1,
|
||||
NativeHistogramMaxBucketNumber: 100,
|
||||
NativeHistogramMinResetDuration: time.Hour,
|
||||
NativeHistogramZeroThreshold: 0,
|
||||
NativeHistogramMaxZeroThreshold: 0,
|
||||
}, []string{"provisioner_type", "job_type", "transition", "build_reason"}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +128,10 @@ func (m *Metrics) Register(reg prometheus.Registerer) error {
|
||||
if err := reg.Register(m.workspaceCreationTimings); err != nil {
|
||||
return err
|
||||
}
|
||||
return reg.Register(m.workspaceClaimTimings)
|
||||
if err := reg.Register(m.workspaceClaimTimings); err != nil {
|
||||
return err
|
||||
}
|
||||
return reg.Register(m.jobQueueWait)
|
||||
}
|
||||
|
||||
// IsTrackable returns true if the workspace build should be tracked in metrics.
|
||||
@@ -162,3 +196,9 @@ func (m *Metrics) UpdateWorkspaceTimingsMetrics(
|
||||
// Not a trackable build type (e.g. restart, stop, subsequent builds)
|
||||
}
|
||||
}
|
||||
|
||||
// ObserveJobQueueWait records the time a provisioner job spent waiting in the queue.
|
||||
// For non-workspace-build jobs, transition and buildReason should be empty strings.
|
||||
func (m *Metrics) ObserveJobQueueWait(provisionerType, jobType, transition, buildReason string, waitSeconds float64) {
|
||||
m.jobQueueWait.WithLabelValues(provisionerType, jobType, transition, buildReason).Observe(waitSeconds)
|
||||
}
|
||||
|
||||
@@ -478,6 +478,10 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
|
||||
TraceMetadata: jobTraceMetadata,
|
||||
}
|
||||
|
||||
// jobTransition and jobBuildReason are used for metrics; only set for workspace builds.
|
||||
var jobTransition string
|
||||
var jobBuildReason string
|
||||
|
||||
switch job.Type {
|
||||
case database.ProvisionerJobTypeWorkspaceBuild:
|
||||
var input WorkspaceProvisionJob
|
||||
@@ -584,6 +588,15 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
|
||||
if err != nil {
|
||||
return nil, failJob(fmt.Sprintf("convert workspace transition: %s", err))
|
||||
}
|
||||
jobTransition = string(workspaceBuild.Transition)
|
||||
// Prebuilds use BuildReasonInitiator in the database but we want to
|
||||
// track them separately in metrics. Check the initiator ID to detect
|
||||
// prebuild jobs.
|
||||
if job.InitiatorID == database.PrebuildsSystemUserID {
|
||||
jobBuildReason = BuildReasonPrebuild
|
||||
} else {
|
||||
jobBuildReason = string(workspaceBuild.Reason)
|
||||
}
|
||||
|
||||
// A previous workspace build exists
|
||||
var lastWorkspaceBuildParameters []database.WorkspaceBuildParameter
|
||||
@@ -825,6 +838,12 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
|
||||
return nil, failJob(fmt.Sprintf("payload was too big: %d > %d", protobuf.Size(protoJob), drpcsdk.MaxMessageSize))
|
||||
}
|
||||
|
||||
// Record the time the job spent waiting in the queue.
|
||||
if s.metrics != nil && job.StartedAt.Valid && job.Provisioner.Valid() {
|
||||
queueWaitSeconds := job.StartedAt.Time.Sub(job.CreatedAt).Seconds()
|
||||
s.metrics.ObserveJobQueueWait(string(job.Provisioner), string(job.Type), jobTransition, jobBuildReason, queueWaitSeconds)
|
||||
}
|
||||
|
||||
return protoJob, err
|
||||
}
|
||||
|
||||
|
||||
@@ -385,6 +385,7 @@ func AIBridgeInterceptions(ctx context.Context, db database.Store, query string,
|
||||
filter.InitiatorID = parseUser(ctx, db, parser, values, "initiator", actorID)
|
||||
filter.Provider = parser.String(values, "", "provider")
|
||||
filter.Model = parser.String(values, "", "model")
|
||||
filter.Client = parser.String(values, "", "client")
|
||||
|
||||
// Time must be between started_after and started_before.
|
||||
filter.StartedAfter = parser.Time3339Nano(values, time.Time{}, "started_after")
|
||||
|
||||
@@ -376,7 +376,7 @@ func TestTelemetry(t *testing.T) {
|
||||
|
||||
require.Equal(t, snapshot1.Provider, aiBridgeInterception1.Provider)
|
||||
require.Equal(t, snapshot1.Model, aiBridgeInterception1.Model)
|
||||
require.Equal(t, snapshot1.Client, "unknown") // no client info yet
|
||||
require.Equal(t, snapshot1.Client, "Unknown") // no client info yet
|
||||
require.EqualValues(t, snapshot1.InterceptionCount, 2)
|
||||
require.EqualValues(t, snapshot1.InterceptionsByRoute, map[string]int64{}) // no route info yet
|
||||
require.EqualValues(t, snapshot1.InterceptionDurationMillis.P50, 90_000)
|
||||
@@ -396,7 +396,7 @@ func TestTelemetry(t *testing.T) {
|
||||
|
||||
require.Equal(t, snapshot2.Provider, aiBridgeInterception3.Provider)
|
||||
require.Equal(t, snapshot2.Model, aiBridgeInterception3.Model)
|
||||
require.Equal(t, snapshot2.Client, "unknown") // no client info yet
|
||||
require.Equal(t, snapshot2.Client, "Unknown") // no client info yet
|
||||
require.EqualValues(t, snapshot2.InterceptionCount, 1)
|
||||
require.EqualValues(t, snapshot2.InterceptionsByRoute, map[string]int64{}) // no route info yet
|
||||
require.EqualValues(t, snapshot2.InterceptionDurationMillis.P50, 180_000)
|
||||
|
||||
@@ -1131,6 +1131,7 @@ func (api *API) convertTemplate(
|
||||
RequireActiveVersion: templateAccessControl.RequireActiveVersion,
|
||||
Deprecated: templateAccessControl.IsDeprecated(),
|
||||
DeprecationMessage: templateAccessControl.Deprecated,
|
||||
Deleted: template.Deleted,
|
||||
MaxPortShareLevel: maxPortShareLevel,
|
||||
UseClassicParameterFlow: template.UseClassicParameterFlow,
|
||||
CORSBehavior: codersdk.CORSBehavior(template.CorsBehavior),
|
||||
|
||||
@@ -1801,6 +1801,49 @@ func TestDeleteTemplate(t *testing.T) {
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("DeletedIsSet", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Verify the deleted field is exposed in the SDK and set to false for active templates
|
||||
got, err := client.Template(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, got.Deleted)
|
||||
})
|
||||
|
||||
t.Run("DeletedIsTrue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
err := client.DeleteTemplate(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the deleted field is set to true by listing templates with
|
||||
// deleted:true filter.
|
||||
templates, err := client.Templates(ctx, codersdk.TemplateFilter{
|
||||
OrganizationID: user.OrganizationID,
|
||||
SearchQuery: "deleted:true",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, templates, 1)
|
||||
require.Equal(t, template.ID, templates[0].ID)
|
||||
require.True(t, templates[0].Deleted)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTemplateMetrics(t *testing.T) {
|
||||
|
||||
@@ -349,7 +349,7 @@ func TestDeleteUser(t *testing.T) {
|
||||
err := client.DeleteUser(context.Background(), firstUser.UserID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
t.Run("HasWorkspaces", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -1010,7 +1010,7 @@ func TestUpdateUserProfile(t *testing.T) {
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
// Right now, we are raising a BAD request error because we don't support a
|
||||
// user accessing other users info
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("ConflictingUsername", func(t *testing.T) {
|
||||
@@ -2602,7 +2602,7 @@ func TestUserAutofillParameters(t *testing.T) {
|
||||
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
|
||||
// u1 should be able to read u2's parameters as u1 is site admin.
|
||||
_, err = client1.UserAutofillParameters(
|
||||
|
||||
@@ -68,27 +68,30 @@ func SubdomainAppSessionTokenCookie(hostname string) string {
|
||||
// the wrong value.
|
||||
//
|
||||
// We use different cookie names for:
|
||||
// - path apps on primary access URL: coder_session_token
|
||||
// - path apps on proxies: coder_path_app_session_token
|
||||
// - path apps: coder_path_app_session_token
|
||||
// - subdomain apps: coder_subdomain_app_session_token_{unique_hash}
|
||||
//
|
||||
// First we try the default function to get a token from request, which supports
|
||||
// query parameters, the Coder-Session-Token header and the coder_session_token
|
||||
// cookie.
|
||||
//
|
||||
// Then we try the specific cookie name for the access method.
|
||||
// We prefer the access-method-specific cookie first, then fall back to standard
|
||||
// Coder token extraction (query parameters, Coder-Session-Token header, etc.).
|
||||
func (c AppCookies) TokenFromRequest(r *http.Request, accessMethod AccessMethod) string {
|
||||
// Try the default function first.
|
||||
token := httpmw.APITokenFromRequest(r)
|
||||
if token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
// Then try the specific cookie name for the access method.
|
||||
// Prefer the access-method-specific cookie first.
|
||||
//
|
||||
// Workspace app requests commonly include an `Authorization` header intended
|
||||
// for the upstream app (e.g. API calls). `httpmw.APITokenFromRequest` supports
|
||||
// RFC 6750 bearer tokens, so if we consult it first we'd incorrectly treat
|
||||
// that upstream header as a Coder session token and ignore the app session
|
||||
// cookie, breaking token renewal for subdomain apps.
|
||||
cookie, err := r.Cookie(c.CookieNameForAccessMethod(accessMethod))
|
||||
if err == nil && cookie.Value != "" {
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
// Fall back to standard Coder token extraction (session cookie, query param,
|
||||
// Coder-Session-Token header, and then Authorization: Bearer).
|
||||
token := httpmw.APITokenFromRequest(r)
|
||||
if token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package workspaceapps_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -32,3 +34,19 @@ func TestAppCookies(t *testing.T) {
|
||||
newCookies := workspaceapps.NewAppCookies("different.com")
|
||||
require.NotEqual(t, cookies.SubdomainAppSessionToken, newCookies.SubdomainAppSessionToken)
|
||||
}
|
||||
|
||||
func TestAppCookies_TokenFromRequest_PrefersAppCookieOverAuthorizationBearer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cookies := workspaceapps.NewAppCookies("apps.example.com")
|
||||
|
||||
req := httptest.NewRequest("GET", "https://8081--agent--workspace--user.apps.example.com/", nil)
|
||||
req.Header.Set("Authorization", "Bearer whatever")
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: cookies.CookieNameForAccessMethod(workspaceapps.AccessMethodSubdomain),
|
||||
Value: "subdomain-session-token",
|
||||
})
|
||||
|
||||
got := cookies.TokenFromRequest(req, workspaceapps.AccessMethodSubdomain)
|
||||
require.Equal(t, "subdomain-session-token", got)
|
||||
}
|
||||
|
||||
@@ -382,7 +382,8 @@ func (api *API) postWorkspaceBuildsInternal(
|
||||
LogLevel(string(createBuild.LogLevel)).
|
||||
DeploymentValues(api.Options.DeploymentValues).
|
||||
Experiments(api.Experiments).
|
||||
TemplateVersionPresetID(createBuild.TemplateVersionPresetID)
|
||||
TemplateVersionPresetID(createBuild.TemplateVersionPresetID).
|
||||
BuildMetrics(api.WorkspaceBuilderMetrics)
|
||||
|
||||
if (transition == database.WorkspaceTransitionStart || transition == database.WorkspaceTransitionStop) && createBuild.Reason != "" {
|
||||
builder = builder.Reason(database.BuildReason(createBuild.Reason))
|
||||
|
||||
@@ -787,7 +787,8 @@ func createWorkspace(
|
||||
ActiveVersion().
|
||||
Experiments(api.Experiments).
|
||||
DeploymentValues(api.DeploymentValues).
|
||||
RichParameterValues(req.RichParameterValues)
|
||||
RichParameterValues(req.RichParameterValues).
|
||||
BuildMetrics(api.WorkspaceBuilderMetrics)
|
||||
if req.TemplateVersionID != uuid.Nil {
|
||||
builder = builder.VersionID(req.TemplateVersionID)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -21,7 +22,9 @@ import (
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/autobuild"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest/promhelp"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
@@ -30,6 +33,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
|
||||
"github.com/coder/coder/v2/coderd/provisionerdserver"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/render"
|
||||
@@ -37,6 +41,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/schedule/cron"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/coderd/wsbuilder"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
@@ -5901,3 +5906,135 @@ func TestWorkspaceCreateWithImplicitPreset(t *testing.T) {
|
||||
require.Equal(t, preset2ID, *ws2.LatestBuild.TemplateVersionPresetID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProvisionerJobQueueWaitMetric(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := testutil.Logger(t)
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := provisionerdserver.NewMetrics(logger)
|
||||
err := metrics.Register(reg)
|
||||
require.NoError(t, err)
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
ProvisionerdServerMetrics: metrics,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a template version - this triggers a template_version_import job.
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
// Check that the queue wait metric was recorded for the template_version_import job.
|
||||
importMetric := promhelp.MetricValue(t, reg, "coderd_provisioner_job_queue_wait_seconds", prometheus.Labels{
|
||||
"provisioner_type": string(database.ProvisionerTypeEcho),
|
||||
"job_type": string(database.ProvisionerJobTypeTemplateVersionImport),
|
||||
"transition": "",
|
||||
"build_reason": "",
|
||||
})
|
||||
require.NotNil(t, importMetric, "import job metric should be recorded")
|
||||
importHistogram := importMetric.GetHistogram()
|
||||
require.NotNil(t, importHistogram)
|
||||
require.Equal(t, uint64(1), importHistogram.GetSampleCount(), "import job should have 1 sample")
|
||||
require.Greater(t, importHistogram.GetSampleSum(), 0.0, "import job queue wait should be non-zero")
|
||||
|
||||
// Create a template and workspace - this triggers a workspace_build job.
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// Check that the queue wait metric was recorded for the workspace_build job.
|
||||
buildMetric := promhelp.MetricValue(t, reg, "coderd_provisioner_job_queue_wait_seconds", prometheus.Labels{
|
||||
"provisioner_type": string(database.ProvisionerTypeEcho),
|
||||
"job_type": string(database.ProvisionerJobTypeWorkspaceBuild),
|
||||
"transition": string(database.WorkspaceTransitionStart),
|
||||
"build_reason": string(database.BuildReasonInitiator),
|
||||
})
|
||||
require.NotNil(t, buildMetric, "workspace build job metric should be recorded")
|
||||
buildHistogram := buildMetric.GetHistogram()
|
||||
require.NotNil(t, buildHistogram)
|
||||
require.Equal(t, uint64(1), buildHistogram.GetSampleCount(), "workspace build job should have 1 sample")
|
||||
require.Greater(t, buildHistogram.GetSampleSum(), 0.0, "workspace build job queue wait should be non-zero")
|
||||
}
|
||||
|
||||
func TestWorkspaceBuildsEnqueuedMetric(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
logger = testutil.Logger(t)
|
||||
reg = prometheus.NewRegistry()
|
||||
metrics = provisionerdserver.NewMetrics(logger)
|
||||
|
||||
sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *")
|
||||
tickCh = make(chan time.Time)
|
||||
statsCh = make(chan autobuild.Stats)
|
||||
)
|
||||
|
||||
err := metrics.Register(reg)
|
||||
require.NoError(t, err)
|
||||
|
||||
wsBuilderMetrics, err := wsbuilder.NewMetrics(reg)
|
||||
require.NoError(t, err)
|
||||
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
ProvisionerdServerMetrics: metrics,
|
||||
WorkspaceBuilderMetrics: wsBuilderMetrics,
|
||||
AutobuildTicker: tickCh,
|
||||
AutobuildStats: statsCh,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a template and workspace with autostart schedule.
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
cwr.AutostartSchedule = ptr.Ref(sched.String())
|
||||
})
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// Stop the workspace to prepare for autostart.
|
||||
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
|
||||
|
||||
// Trigger an autostart build via the autobuild ticker. This verifies that
|
||||
// autostart builds are recorded with build_reason="autostart".
|
||||
p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, map[string]string{})
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
tickTime := sched.Next(workspace.LatestBuild.CreatedAt)
|
||||
coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime)
|
||||
tickCh <- tickTime
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Wait for the autostart to complete.
|
||||
stats := <-statsCh
|
||||
require.Len(t, stats.Errors, 0)
|
||||
require.Len(t, stats.Transitions, 1)
|
||||
require.Contains(t, stats.Transitions, workspace.ID)
|
||||
require.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID])
|
||||
|
||||
// Verify the workspace was autostarted.
|
||||
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
require.Equal(t, codersdk.BuildReasonAutostart, workspace.LatestBuild.Reason)
|
||||
|
||||
// Now check the autostart metric was recorded.
|
||||
autostartCount := promhelp.CounterValue(t, reg, "coderd_workspace_builds_enqueued_total", prometheus.Labels{
|
||||
"provisioner_type": string(database.ProvisionerTypeEcho),
|
||||
"build_reason": string(database.BuildReasonAutostart),
|
||||
"transition": string(database.WorkspaceTransitionStart),
|
||||
"status": wsbuilder.BuildStatusSuccess,
|
||||
})
|
||||
require.Equal(t, 1, autostartCount, "autostart should record 1 enqueue with build_reason=autostart")
|
||||
}
|
||||
|
||||
func mustSchedule(t *testing.T, s string) *cron.Schedule {
|
||||
t.Helper()
|
||||
sched, err := cron.Weekly(s)
|
||||
require.NoError(t, err)
|
||||
return sched
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package wsbuilder
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
// Metrics holds metrics related to workspace build creation.
|
||||
type Metrics struct {
|
||||
workspaceBuildsEnqueued *prometheus.CounterVec
|
||||
}
|
||||
|
||||
// Metric label values for build status.
|
||||
const (
|
||||
BuildStatusSuccess = "success"
|
||||
BuildStatusFailed = "failed"
|
||||
)
|
||||
|
||||
func NewMetrics(reg prometheus.Registerer) (*Metrics, error) {
|
||||
m := &Metrics{
|
||||
workspaceBuildsEnqueued: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "coderd",
|
||||
Name: "workspace_builds_enqueued_total",
|
||||
Help: "Total number of workspace build enqueue attempts.",
|
||||
}, []string{"provisioner_type", "build_reason", "transition", "status"}),
|
||||
}
|
||||
|
||||
if reg != nil {
|
||||
if err := reg.Register(m.workspaceBuildsEnqueued); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// RecordBuildEnqueued records a workspace build enqueue attempt. It determines
|
||||
// the status based on whether an error occurred and increments the counter.
|
||||
func (m *Metrics) RecordBuildEnqueued(provisionerType, buildReason, transition string, err error) {
|
||||
status := BuildStatusSuccess
|
||||
if err != nil {
|
||||
status = BuildStatusFailed
|
||||
}
|
||||
m.workspaceBuildsEnqueued.WithLabelValues(provisionerType, buildReason, transition, status).Inc()
|
||||
}
|
||||
@@ -90,6 +90,8 @@ type Builder struct {
|
||||
|
||||
prebuiltWorkspaceBuildStage sdkproto.PrebuiltWorkspaceBuildStage
|
||||
verifyNoLegacyParametersOnce bool
|
||||
|
||||
buildMetrics *Metrics
|
||||
}
|
||||
|
||||
type UsageChecker interface {
|
||||
@@ -253,6 +255,12 @@ func (b Builder) TemplateVersionPresetID(id uuid.UUID) Builder {
|
||||
return b
|
||||
}
|
||||
|
||||
func (b Builder) BuildMetrics(m *Metrics) Builder {
|
||||
// nolint: revive
|
||||
b.buildMetrics = m
|
||||
return b
|
||||
}
|
||||
|
||||
type BuildError struct {
|
||||
// Status is a suitable HTTP status code
|
||||
Status int
|
||||
@@ -313,11 +321,34 @@ func (b *Builder) Build(
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
b.recordBuildMetrics(provisionerJob, err)
|
||||
return nil, nil, nil, xerrors.Errorf("build tx: %w", err)
|
||||
}
|
||||
b.recordBuildMetrics(provisionerJob, nil)
|
||||
return workspaceBuild, provisionerJob, provisionerDaemons, nil
|
||||
}
|
||||
|
||||
// recordBuildMetrics records the workspace build enqueue metric if metrics are
|
||||
// configured. It determines the appropriate build reason label, using "prebuild"
|
||||
// for prebuild operations instead of the database reason.
|
||||
func (b *Builder) recordBuildMetrics(job *database.ProvisionerJob, err error) {
|
||||
if b.buildMetrics == nil {
|
||||
return
|
||||
}
|
||||
if job == nil || !job.Provisioner.Valid() {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the build reason for metrics. Prebuilds use BuildReasonInitiator
|
||||
// in the database but we want to track them separately in metrics.
|
||||
buildReason := string(b.reason)
|
||||
if b.prebuiltWorkspaceBuildStage == sdkproto.PrebuiltWorkspaceBuildStage_CREATE {
|
||||
buildReason = provisionerdserver.BuildReasonPrebuild
|
||||
}
|
||||
|
||||
b.buildMetrics.RecordBuildEnqueued(string(job.Provisioner), buildReason, string(b.trans), err)
|
||||
}
|
||||
|
||||
// buildTx contains the business logic of computing a new build. Attributes of the new database objects are computed
|
||||
// in a functional style, rather than imperative, to emphasize the logic of how they are defined. A simple cache
|
||||
// of database-fetched objects is stored on the struct to ensure we only fetch things once, even if they are used in
|
||||
|
||||
@@ -17,6 +17,7 @@ type AIBridgeInterception struct {
|
||||
Initiator MinimalUser `json:"initiator"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Client *string `json:"client"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
StartedAt time.Time `json:"started_at" format:"date-time"`
|
||||
EndedAt *time.Time `json:"ended_at" format:"date-time"`
|
||||
@@ -75,6 +76,7 @@ type AIBridgeListInterceptionsFilter struct {
|
||||
StartedAfter time.Time `json:"started_after,omitempty" format:"date-time"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Client string `json:"client,omitempty"`
|
||||
|
||||
FilterQuery string `json:"q,omitempty"`
|
||||
}
|
||||
@@ -101,6 +103,9 @@ func (f AIBridgeListInterceptionsFilter) asRequestOption() RequestOption {
|
||||
if f.Model != "" {
|
||||
params = append(params, fmt.Sprintf("model:%q", f.Model))
|
||||
}
|
||||
if f.Client != "" {
|
||||
params = append(params, fmt.Sprintf("client:%q", f.Client))
|
||||
}
|
||||
if f.FilterQuery != "" {
|
||||
// If custom stuff is added, just add it on here.
|
||||
params = append(params, f.FilterQuery)
|
||||
|
||||
@@ -354,6 +354,29 @@ func (c *Client) PauseTask(ctx context.Context, user string, id uuid.UUID) (Paus
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ResumeTaskResponse represents the response from resuming a task.
|
||||
type ResumeTaskResponse struct {
|
||||
WorkspaceBuild *WorkspaceBuild `json:"workspace_build"`
|
||||
}
|
||||
|
||||
func (c *Client) ResumeTask(ctx context.Context, user string, id uuid.UUID) (ResumeTaskResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s/%s/resume", user, id.String()), nil)
|
||||
if err != nil {
|
||||
return ResumeTaskResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusAccepted {
|
||||
return ResumeTaskResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var resp ResumeTaskResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
return ResumeTaskResponse{}, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// TaskLogType indicates the source of a task log entry.
|
||||
type TaskLogType string
|
||||
|
||||
|
||||
@@ -171,6 +171,20 @@ func (c *Client) DeleteAPIKey(ctx context.Context, userID string, id string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpireAPIKey expires an API key by id, setting its expiry to now.
|
||||
// This preserves the API key record for audit purposes rather than deleting it.
|
||||
func (c *Client) ExpireAPIKey(ctx context.Context, userID string, id string) error {
|
||||
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/keys/%s/expire", userID, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode > http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTokenConfig returns deployment options related to token management
|
||||
func (c *Client) GetTokenConfig(ctx context.Context, userID string) (TokenConfig, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens/tokenconfig", userID), nil)
|
||||
|
||||
+48
-48
@@ -1431,7 +1431,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
}
|
||||
emailHello := serpent.Option{
|
||||
Name: "Email: Hello",
|
||||
Description: "The hostname identifying this client to the SMTP server.",
|
||||
Description: "The hostname identifying the SMTP server.",
|
||||
Flag: "email-hello",
|
||||
Env: "CODER_EMAIL_HELLO",
|
||||
Default: "localhost",
|
||||
@@ -1523,7 +1523,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
}
|
||||
emailTLSCertFile := serpent.Option{
|
||||
Name: "Email TLS: Certificate File",
|
||||
Description: "Client certificate file for mutual TLS authentication.",
|
||||
Description: "Certificate file to use.",
|
||||
Flag: "email-tls-cert-file",
|
||||
Env: "CODER_EMAIL_TLS_CERTFILE",
|
||||
Value: &c.Notifications.SMTP.TLS.CertFile,
|
||||
@@ -1532,7 +1532,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
}
|
||||
emailTLSCertKeyFile := serpent.Option{
|
||||
Name: "Email TLS: Certificate Key File",
|
||||
Description: "Private key file for the client certificate.",
|
||||
Description: "Certificate key file to use.",
|
||||
Flag: "email-tls-cert-key-file",
|
||||
Env: "CODER_EMAIL_TLS_CERTKEYFILE",
|
||||
Value: &c.Notifications.SMTP.TLS.KeyFile,
|
||||
@@ -1551,7 +1551,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
}
|
||||
workspaceHostnameSuffix := serpent.Option{
|
||||
Name: "Workspace Hostname Suffix",
|
||||
Description: "Workspace hostnames use this suffix for SSH connections and Coder Connect. By default it is coder, resulting in hostnames like agent.workspace.owner.coder.",
|
||||
Description: "Workspace hostnames use this suffix in SSH config and Coder Connect on Coder Desktop. By default it is coder, resulting in names like myworkspace.coder.",
|
||||
Flag: "workspace-hostname-suffix",
|
||||
Env: "CODER_WORKSPACE_HOSTNAME_SUFFIX",
|
||||
YAML: "workspaceHostnameSuffix",
|
||||
@@ -1680,7 +1680,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "TLS Client CA Files",
|
||||
Description: "PEM-encoded Certificate Authority file used for checking the authenticity of the client.",
|
||||
Description: "PEM-encoded Certificate Authority file used for checking the authenticity of client.",
|
||||
Flag: "tls-client-ca-file",
|
||||
Env: "CODER_TLS_CLIENT_CA_FILE",
|
||||
Value: &c.TLS.ClientCAFile,
|
||||
@@ -1742,7 +1742,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "TLS Ciphers",
|
||||
Description: "Specify specific TLS ciphers that are allowed to be used. See https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L53-L75.",
|
||||
Description: "Specify specific TLS ciphers that allowed to be used. See https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L53-L75.",
|
||||
Flag: "tls-ciphers",
|
||||
Env: "CODER_TLS_CIPHERS",
|
||||
Default: "",
|
||||
@@ -1800,7 +1800,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "DERP Server Region Name",
|
||||
Description: "Region name to use for the embedded DERP server.",
|
||||
Description: "Region name that for the embedded DERP server.",
|
||||
Flag: "derp-server-region-name",
|
||||
Env: "CODER_DERP_SERVER_REGION_NAME",
|
||||
Default: "Coder Embedded Relay",
|
||||
@@ -1811,7 +1811,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "DERP Server STUN Addresses",
|
||||
Description: "Addresses for STUN servers to establish P2P connections. It's recommended to have at least two STUN servers to give users the best chance of connecting P2P to workspaces. Each STUN server will get its own DERP region, with region IDs starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn off STUN completely.",
|
||||
Description: "Addresses for STUN servers to establish P2P connections. It's recommended to have at least two STUN servers to give users the best chance of connecting P2P to workspaces. Each STUN server will get it's own DERP region, with region IDs starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn off STUN completely.",
|
||||
Flag: "derp-server-stun-addresses",
|
||||
Env: "CODER_DERP_SERVER_STUN_ADDRESSES",
|
||||
Default: "stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302",
|
||||
@@ -1833,7 +1833,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "Block Direct Connections",
|
||||
Description: "Block peer-to-peer (aka. direct) workspace connections. All workspace connections from the CLI will be proxied through Coder (or custom configured DERP servers) and will never be peer-to-peer when enabled. Workspace agents may still reach out to STUN servers to discover their address until they are restarted, but all new connections will be proxied regardless.",
|
||||
Description: "Block peer-to-peer (aka. direct) workspace connections. All workspace connections from the CLI will be proxied through Coder (or custom configured DERP servers) and will never be peer-to-peer when enabled. Workspaces may still reach out to STUN servers to get their address until they are restarted after this change has been made, but new connections will still be proxied regardless.",
|
||||
// This cannot be called `disable-direct-connections` because that's
|
||||
// already a global CLI flag for CLI connections. This is a
|
||||
// deployment-wide flag.
|
||||
@@ -1884,7 +1884,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
// Prometheus settings
|
||||
{
|
||||
Name: "Prometheus Enable",
|
||||
Description: "Serve Prometheus metrics on the address defined by prometheus address.",
|
||||
Description: "Serve prometheus metrics on the address defined by prometheus address.",
|
||||
Flag: "prometheus-enable",
|
||||
Env: "CODER_PROMETHEUS_ENABLE",
|
||||
Value: &c.Prometheus.Enable,
|
||||
@@ -1894,7 +1894,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "Prometheus Address",
|
||||
Description: "The bind address to serve Prometheus metrics.",
|
||||
Description: "The bind address to serve prometheus metrics.",
|
||||
Flag: "prometheus-address",
|
||||
Env: "CODER_PROMETHEUS_ADDRESS",
|
||||
Default: "127.0.0.1:2112",
|
||||
@@ -1945,7 +1945,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
// Pprof settings
|
||||
{
|
||||
Name: "pprof Enable",
|
||||
Description: "Serve pprof profiling endpoints on the address defined by pprof address.",
|
||||
Description: "Serve pprof metrics on the address defined by pprof address.",
|
||||
Flag: "pprof-enable",
|
||||
Env: "CODER_PPROF_ENABLE",
|
||||
Value: &c.Pprof.Enable,
|
||||
@@ -2032,7 +2032,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "OAuth2 GitHub Allow Everyone",
|
||||
Description: "Allow all GitHub users to authenticate. When enabled, allowed orgs and teams must be empty.",
|
||||
Description: "Allow all logins, setting this option means allowed orgs and teams must be empty.",
|
||||
Flag: "oauth2-github-allow-everyone",
|
||||
Env: "CODER_OAUTH2_GITHUB_ALLOW_EVERYONE",
|
||||
Value: &c.OAuth2.Github.AllowEveryone,
|
||||
@@ -2079,8 +2079,8 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "OIDC Client Key File",
|
||||
Description: "PEM encoded RSA private key to use for OAuth2 PKI/JWT authorization. " +
|
||||
"This can be used instead of oidc-client-secret if your IdP supports it.",
|
||||
Description: "Pem encoded RSA private key to use for oauth2 PKI/JWT authorization. " +
|
||||
"This can be used instead of oidc-client-secret if your IDP supports it.",
|
||||
Flag: "oidc-client-key-file",
|
||||
Env: "CODER_OIDC_CLIENT_KEY_FILE",
|
||||
YAML: "oidcClientKeyFile",
|
||||
@@ -2089,8 +2089,8 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "OIDC Client Cert File",
|
||||
Description: "PEM encoded certificate file to use for OAuth2 PKI/JWT authorization. " +
|
||||
"The public certificate that accompanies oidc-client-key-file. A standard X.509 certificate is expected.",
|
||||
Description: "Pem encoded certificate file to use for oauth2 PKI/JWT authorization. " +
|
||||
"The public certificate that accompanies oidc-client-key-file. A standard x509 certificate is expected.",
|
||||
Flag: "oidc-client-cert-file",
|
||||
Env: "CODER_OIDC_CLIENT_CERT_FILE",
|
||||
YAML: "oidcClientCertFile",
|
||||
@@ -2242,7 +2242,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "OIDC Group Field",
|
||||
Description: "OIDC claim field to use as the user's groups. This field must be set if using the group sync feature and the scope name is not 'groups'.",
|
||||
Description: "This field must be set if using the group sync feature and the scope name is not 'groups'. Set to the claim to be used for groups.",
|
||||
Flag: "oidc-group-field",
|
||||
Env: "CODER_OIDC_GROUP_FIELD",
|
||||
// This value is intentionally blank. If this is empty, then OIDC group
|
||||
@@ -2257,7 +2257,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "OIDC Group Mapping",
|
||||
Description: "A map of OIDC group IDs and the groups in Coder they should map to. This is useful when OIDC providers only return group IDs.",
|
||||
Description: "A map of OIDC group IDs and the group in Coder it should map to. This is useful for when OIDC providers only return group IDs.",
|
||||
Flag: "oidc-group-mapping",
|
||||
Env: "CODER_OIDC_GROUP_MAPPING",
|
||||
Default: "{}",
|
||||
@@ -2277,7 +2277,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "OIDC Regex Group Filter",
|
||||
Description: "If provided, any group name not matching the regex is ignored. This allows filtering out groups that are not needed. This filter is applied after the OIDC Group Mapping step.",
|
||||
Description: "If provided any group name not matching the regex is ignored. This allows for filtering out groups that are not needed. This filter is applied after the group mapping.",
|
||||
Flag: "oidc-group-regex-filter",
|
||||
Env: "CODER_OIDC_GROUP_REGEX_FILTER",
|
||||
Default: ".*",
|
||||
@@ -2287,7 +2287,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "OIDC Allowed Groups",
|
||||
Description: "If provided, only users with at least one group in this list will be allowed to authenticate. This restricts access to a specific set of groups. This check is applied before any group mapping or filtering.",
|
||||
Description: "If provided any group name not in the list will not be allowed to authenticate. This allows for restricting access to a specific set of groups. This filter is applied after the group mapping and before the regex filter.",
|
||||
Flag: "oidc-allowed-groups",
|
||||
Env: "CODER_OIDC_ALLOWED_GROUPS",
|
||||
Default: "",
|
||||
@@ -2309,7 +2309,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "OIDC User Role Mapping",
|
||||
Description: "A map of OIDC user role names to Coder role names. This is useful if the role names do not match between systems. If mapped to the empty string, the role will be ignored.",
|
||||
Description: "A map of the OIDC passed in user roles and the groups in Coder it should map to. This is useful if the group names do not match. If mapped to the empty string, the role will ignored.",
|
||||
Flag: "oidc-user-role-mapping",
|
||||
Env: "CODER_OIDC_USER_ROLE_MAPPING",
|
||||
Default: "{}",
|
||||
@@ -2319,7 +2319,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "OIDC User Role Default",
|
||||
Description: "If user role sync is enabled, these roles are always included for all authenticated users in addition to synced roles. The 'member' role is always assigned regardless of this setting.",
|
||||
Description: "If user role sync is enabled, these roles are always included for all authenticated users. The 'member' role is always assigned.",
|
||||
Flag: "oidc-user-role-default",
|
||||
Env: "CODER_OIDC_USER_ROLE_DEFAULT",
|
||||
Default: "",
|
||||
@@ -2339,7 +2339,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "OpenID connect icon URL",
|
||||
Description: "URL of the icon to use on the OpenID Connect login button.",
|
||||
Description: "URL pointing to the icon to use on the OpenID Connect login button.",
|
||||
Flag: "oidc-icon-url",
|
||||
Env: "CODER_OIDC_ICON_URL",
|
||||
Value: &c.OIDC.IconURL,
|
||||
@@ -2348,7 +2348,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "Signups disabled text",
|
||||
Description: "Custom text to show on the error page when OIDC signups are disabled. Markdown format is supported.",
|
||||
Description: "The custom text to show on the error page informing about disabled OIDC signups. Markdown format is supported.",
|
||||
Flag: "oidc-signups-disabled-text",
|
||||
Env: "CODER_OIDC_SIGNUPS_DISABLED_TEXT",
|
||||
Value: &c.OIDC.SignupsDisabledText,
|
||||
@@ -2807,7 +2807,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
},
|
||||
{
|
||||
Name: "SameSite Auth Cookie",
|
||||
Description: "Controls if the 'SameSite' property is set on browser session cookies.",
|
||||
Description: "Controls the 'SameSite' property is set on browser session cookies.",
|
||||
Flag: "samesite-auth-cookie",
|
||||
Env: "CODER_SAMESITE_AUTH_COOKIE",
|
||||
// Do not allow "strict" same-site cookies. That would potentially break workspace apps.
|
||||
@@ -3000,7 +3000,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
{
|
||||
Name: "SSH Config Options",
|
||||
Description: "These SSH config options will override the default SSH config options. " +
|
||||
"Provide options in \"key=value\" or \"key value\" format separated by commas. " +
|
||||
"Provide options in \"key=value\" or \"key value\" format separated by commas." +
|
||||
"Using this incorrectly can break SSH to your deployment, use cautiously.",
|
||||
Flag: "ssh-config-options",
|
||||
Env: "CODER_SSH_CONFIG_OPTIONS",
|
||||
@@ -3041,7 +3041,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
{
|
||||
// Env handling is done in cli.ReadGitAuthFromEnvironment
|
||||
Name: "External Auth Providers",
|
||||
Description: "Configure external authentication providers for Git and other services.",
|
||||
Description: "External Authentication providers.",
|
||||
YAML: "externalAuthProviders",
|
||||
Flag: "external-auth-providers",
|
||||
Value: &c.ExternalAuthConfigs,
|
||||
@@ -3059,7 +3059,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "Proxy Health Check Interval",
|
||||
Description: "The interval at which coderd checks the status of workspace proxies.",
|
||||
Description: "The interval in which coderd should be checking the status of workspace proxies.",
|
||||
Flag: "proxy-health-interval",
|
||||
Env: "CODER_PROXY_HEALTH_INTERVAL",
|
||||
Default: (time.Minute).String(),
|
||||
@@ -3080,7 +3080,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "Allow Custom Quiet Hours",
|
||||
Description: "Allow users to set their own quiet hours schedule for when workspaces are stopped (depending on template autostop requirement settings). If false, users can't change their quiet hours schedule and the site default is always used.",
|
||||
Description: "Allow users to set their own quiet hours schedule for workspaces to stop in (depending on template autostop requirement settings). If false, users can't change their quiet hours schedule and the site default is always used.",
|
||||
Flag: "allow-custom-quiet-hours",
|
||||
Env: "CODER_ALLOW_CUSTOM_QUIET_HOURS",
|
||||
Default: "true",
|
||||
@@ -3192,7 +3192,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "Notifications: Email: Hello",
|
||||
Description: "The hostname identifying this client to the SMTP server.",
|
||||
Description: "The hostname identifying the SMTP server.",
|
||||
Flag: "notifications-email-hello",
|
||||
Env: "CODER_NOTIFICATIONS_EMAIL_HELLO",
|
||||
Value: &c.Notifications.SMTP.Hello,
|
||||
@@ -3355,7 +3355,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
Name: "Notifications: Store Sync Interval",
|
||||
Description: "The notifications system buffers message updates in memory to ease pressure on the database. " +
|
||||
"This option controls how often it synchronizes its state with the database. The shorter this value the " +
|
||||
"lower the chance of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
|
||||
"lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
|
||||
"database. It is recommended to keep this option at its default value.",
|
||||
Flag: "notifications-store-sync-interval",
|
||||
Env: "CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL",
|
||||
@@ -3370,7 +3370,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
Name: "Notifications: Store Sync Buffer Size",
|
||||
Description: "The notifications system buffers message updates in memory to ease pressure on the database. " +
|
||||
"This option controls how many updates are kept in memory. The lower this value the " +
|
||||
"lower the chance of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
|
||||
"lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
|
||||
"database. It is recommended to keep this option at its default value.",
|
||||
Flag: "notifications-store-sync-buffer-size",
|
||||
Env: "CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE",
|
||||
@@ -3434,7 +3434,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "Reconciliation Backoff Interval",
|
||||
Description: "Amount of time to add to the reconciliation backoff delay after each prebuild failure, before the next retry attempt is made.",
|
||||
Description: "Interval to increase reconciliation backoff by when prebuilds fail, after which a retry attempt is made.",
|
||||
Flag: "workspace-prebuilds-reconciliation-backoff-interval",
|
||||
Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_BACKOFF_INTERVAL",
|
||||
Value: &c.Prebuilds.ReconciliationBackoffInterval,
|
||||
@@ -3446,7 +3446,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "Reconciliation Backoff Lookback Period",
|
||||
Description: "Time period to look back when counting failed prebuilds to calculate the backoff delay.",
|
||||
Description: "Interval to look back to determine number of failed prebuilds, which influences backoff.",
|
||||
Flag: "workspace-prebuilds-reconciliation-backoff-lookback-period",
|
||||
Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_BACKOFF_LOOKBACK_PERIOD",
|
||||
Value: &c.Prebuilds.ReconciliationBackoffLookback,
|
||||
@@ -3458,7 +3458,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "Failure Hard Limit",
|
||||
Description: "Maximum number of consecutive failed prebuilds before a preset is considered hard-limited and stops automatic prebuild creation. Disabled when set to zero.",
|
||||
Description: "Maximum number of consecutive failed prebuilds before a preset hits the hard limit; disabled when set to zero.",
|
||||
Flag: "workspace-prebuilds-failure-hard-limit",
|
||||
Env: "CODER_WORKSPACE_PREBUILDS_FAILURE_HARD_LIMIT",
|
||||
Value: &c.Prebuilds.FailureHardLimit,
|
||||
@@ -3481,7 +3481,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
// AI Bridge Options
|
||||
{
|
||||
Name: "AI Bridge Enabled",
|
||||
Description: "Enable the embedded AI Bridge service to intercept and record AI provider requests.",
|
||||
Description: "Whether to start an in-memory aibridged instance.",
|
||||
Flag: "aibridge-enabled",
|
||||
Env: "CODER_AIBRIDGE_ENABLED",
|
||||
Value: &c.AI.BridgeConfig.Enabled,
|
||||
@@ -3501,7 +3501,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge OpenAI Key",
|
||||
Description: "API key for authenticating with the OpenAI API.",
|
||||
Description: "The key to authenticate against the OpenAI API.",
|
||||
Flag: "aibridge-openai-key",
|
||||
Env: "CODER_AIBRIDGE_OPENAI_KEY",
|
||||
Value: &c.AI.BridgeConfig.OpenAI.Key,
|
||||
@@ -3521,7 +3521,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Anthropic Key",
|
||||
Description: "API key for authenticating with the Anthropic API.",
|
||||
Description: "The key to authenticate against the Anthropic API.",
|
||||
Flag: "aibridge-anthropic-key",
|
||||
Env: "CODER_AIBRIDGE_ANTHROPIC_KEY",
|
||||
Value: &c.AI.BridgeConfig.Anthropic.Key,
|
||||
@@ -3553,7 +3553,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Bedrock Access Key",
|
||||
Description: "AWS access key for authenticating with the AWS Bedrock API.",
|
||||
Description: "The access key to authenticate against the AWS Bedrock API.",
|
||||
Flag: "aibridge-bedrock-access-key",
|
||||
Env: "CODER_AIBRIDGE_BEDROCK_ACCESS_KEY",
|
||||
Value: &c.AI.BridgeConfig.Bedrock.AccessKey,
|
||||
@@ -3563,7 +3563,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Bedrock Access Key Secret",
|
||||
Description: "AWS secret access key for authenticating with the AWS Bedrock API.",
|
||||
Description: "The access key secret to use with the access key to authenticate against the AWS Bedrock API.",
|
||||
Flag: "aibridge-bedrock-access-key-secret",
|
||||
Env: "CODER_AIBRIDGE_BEDROCK_ACCESS_KEY_SECRET",
|
||||
Value: &c.AI.BridgeConfig.Bedrock.AccessKeySecret,
|
||||
@@ -3593,7 +3593,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Inject Coder MCP tools",
|
||||
Description: "Enable injection of Coder's MCP tools into intercepted AI Bridge requests. Requires the 'oauth2' and 'mcp-server-http' experiments.",
|
||||
Description: "Whether to inject Coder's MCP tools into intercepted AI Bridge requests (requires the \"oauth2\" and \"mcp-server-http\" experiments to be enabled).",
|
||||
Flag: "aibridge-inject-coder-mcp-tools",
|
||||
Env: "CODER_AIBRIDGE_INJECT_CODER_MCP_TOOLS",
|
||||
Value: &c.AI.BridgeConfig.InjectCoderMCPTools,
|
||||
@@ -3603,7 +3603,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Data Retention Duration",
|
||||
Description: "How long to retain AI Bridge data including interceptions, tokens, prompts, and tool usage records.",
|
||||
Description: "Length of time to retain data such as interceptions and all related records (token, prompt, tool use).",
|
||||
Flag: "aibridge-retention",
|
||||
Env: "CODER_AIBRIDGE_RETENTION",
|
||||
Value: &c.AI.BridgeConfig.Retention,
|
||||
@@ -3656,7 +3656,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Circuit Breaker Enabled",
|
||||
Description: "Enable the circuit breaker to protect against cascading failures from upstream AI provider rate limits and overload errors (HTTP 429, 503, 529).",
|
||||
Description: "Enable the circuit breaker to protect against cascading failures from upstream AI provider rate limits (429, 503, 529 overloaded).",
|
||||
Flag: "aibridge-circuit-breaker-enabled",
|
||||
Env: "CODER_AIBRIDGE_CIRCUIT_BREAKER_ENABLED",
|
||||
Value: &c.AI.BridgeConfig.CircuitBreakerEnabled,
|
||||
@@ -3666,7 +3666,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Circuit Breaker Failure Threshold",
|
||||
Description: "Number of consecutive failures that trigger the circuit breaker to open.",
|
||||
Description: "Number of consecutive failures that triggers the circuit breaker to open.",
|
||||
Flag: "aibridge-circuit-breaker-failure-threshold",
|
||||
Env: "CODER_AIBRIDGE_CIRCUIT_BREAKER_FAILURE_THRESHOLD",
|
||||
Value: serpent.Validate(&c.AI.BridgeConfig.CircuitBreakerFailureThreshold, func(value *serpent.Int64) error {
|
||||
@@ -3682,7 +3682,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Circuit Breaker Interval",
|
||||
Description: "Time window for counting failures before resetting the failure count in the closed state.",
|
||||
Description: "Cyclic period of the closed state for clearing internal failure counts.",
|
||||
Flag: "aibridge-circuit-breaker-interval",
|
||||
Env: "CODER_AIBRIDGE_CIRCUIT_BREAKER_INTERVAL",
|
||||
Value: &c.AI.BridgeConfig.CircuitBreakerInterval,
|
||||
@@ -3830,7 +3830,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "Workspace Agent Logs Retention",
|
||||
Description: "How long workspace agent logs are retained. Logs from non-latest builds are deleted if the agent hasn't connected within this period. Logs from the latest build for each workspace are always retained. Set to 0 to disable automatic deletion.",
|
||||
Description: "How long workspace agent logs are retained. Logs from non-latest builds are deleted if the agent hasn't connected within this period. Logs from the latest build are always retained. Set to 0 to disable automatic deletion.",
|
||||
Flag: "workspace-agent-logs-retention",
|
||||
Env: "CODER_WORKSPACE_AGENT_LOGS_RETENTION",
|
||||
Value: &c.Retention.WorkspaceAgentLogs,
|
||||
@@ -3841,7 +3841,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "Enable Authorization Recordings",
|
||||
Description: "All API requests will have a header including all authorization calls made during the request. " +
|
||||
Description: "All api requests will have a header including all authorization calls made during the request. " +
|
||||
"This is used for debugging purposes and only available for dev builds.",
|
||||
Required: false,
|
||||
Flag: "enable-authz-recordings",
|
||||
|
||||
@@ -32,6 +32,7 @@ type Template struct {
|
||||
Description string `json:"description"`
|
||||
Deprecated bool `json:"deprecated"`
|
||||
DeprecationMessage string `json:"deprecation_message"`
|
||||
Deleted bool `json:"deleted"`
|
||||
Icon string `json:"icon"`
|
||||
DefaultTTLMillis int64 `json:"default_ttl_ms"`
|
||||
ActivityBumpMillis int64 `json:"activity_bump_ms"`
|
||||
|
||||
@@ -59,6 +59,15 @@ const (
|
||||
BuildReasonVSCodeConnection BuildReason = "vscode_connection"
|
||||
// BuildReasonJetbrainsConnection "jetbrains_connection" is used when a build to start a workspace is triggered by a JetBrains connection.
|
||||
BuildReasonJetbrainsConnection BuildReason = "jetbrains_connection"
|
||||
// BuildReasonTaskAutoPause "task_auto_pause" is used when a build to stop
|
||||
// a task workspace is triggered by the lifecycle executor.
|
||||
BuildReasonTaskAutoPause BuildReason = "task_auto_pause"
|
||||
// BuildReasonTaskManualPause "task_manual_pause" is used when a build to
|
||||
// stop a task workspace is triggered by a user.
|
||||
BuildReasonTaskManualPause BuildReason = "task_manual_pause"
|
||||
// BuildReasonTaskResume "task_resume" is used when a build to
|
||||
// start a task workspace is triggered by a user.
|
||||
BuildReasonTaskResume BuildReason = "task_resume"
|
||||
)
|
||||
|
||||
// WorkspaceBuild is an at-point representation of a workspace state.
|
||||
|
||||
@@ -110,6 +110,7 @@ const (
|
||||
CreateWorkspaceBuildReasonVSCodeConnection CreateWorkspaceBuildReason = "vscode_connection"
|
||||
CreateWorkspaceBuildReasonJetbrainsConnection CreateWorkspaceBuildReason = "jetbrains_connection"
|
||||
CreateWorkspaceBuildReasonTaskManualPause CreateWorkspaceBuildReason = "task_manual_pause"
|
||||
CreateWorkspaceBuildReasonTaskResume CreateWorkspaceBuildReason = "task_resume"
|
||||
)
|
||||
|
||||
// CreateWorkspaceBuildRequest provides options to update the latest workspace build.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Dev Containers
|
||||
|
||||
Dev containers allow developers to define their development environment
|
||||
Dev Containers allow developers to define their development environment
|
||||
as code using the [Dev Container specification](https://containers.dev/).
|
||||
Configuration lives in a `devcontainer.json` file alongside source code,
|
||||
enabling consistent, reproducible environments.
|
||||
|
||||
By adopting dev containers, organizations can:
|
||||
By adopting Dev Containers, organizations can:
|
||||
|
||||
- **Standardize environments**: Eliminate "works on my machine" issues while
|
||||
still allowing developers to customize their tools within approved boundaries.
|
||||
@@ -14,36 +14,129 @@ By adopting dev containers, organizations can:
|
||||
- **Improve security**: Use hardened base images and controlled package
|
||||
registries to enforce security policies while enabling developer self-service.
|
||||
|
||||
Coder supports two approaches for running dev containers. Choose based on your
|
||||
infrastructure and workflow requirements.
|
||||
Coder supports multiple approaches for running Dev Containers. Choose based on
|
||||
your infrastructure and workflow requirements.
|
||||
|
||||
## Dev Containers Integration
|
||||
## Comparison
|
||||
|
||||
The Dev Containers Integration uses the standard `@devcontainers/cli` and Docker
|
||||
to run containers inside your workspace. This is the recommended approach for
|
||||
most use cases.
|
||||
| Method | Dev Container CLI | Envbuilder | CI/CD Pre-built |
|
||||
|-------------------------------------------|--------------------------------------------------------|---------------------------------------|-----------------------------------------------------------|
|
||||
| **Standard Dev Container implementation** | ✅ Yes | ❌ No | ✅ Yes |
|
||||
| **Full Dev Container Spec Support** | ✅ All options | ❌ Limited options | \~ Most options |
|
||||
| **Startup Time** | Build at runtime, faster with caching | Build at runtime, faster with caching | Fast (pre-built) |
|
||||
| **Docker Required** | ❌ Yes | ✅ No | ✅ No |
|
||||
| **Caching** | More difficult | ✅ Yes | ✅ Yes |
|
||||
| **Repo Discovery** | ✅ Yes | ❌ No | ❌ No |
|
||||
| **Custom Apps in-spec** | ✅ Via spec args | ❌ No | ❌ No |
|
||||
| **Debugging** | Easy | Very difficult | Moderate |
|
||||
| **Versioning** | \~ Via spec, or template | \~ Via spec, or template | ✅ Image tags |
|
||||
| **Testing Pipeline** | \~ Via CLI in CI/CD | \~ Via CLI in CI/CD | ✅ Yes, via the same pipeline |
|
||||
| **Feedback Loop** | ✅ Fast | ✅ Fast | Slow (build, and then test) |
|
||||
| **Maintenance Status** | ✅ Active | ⚠️ Maintenance mode | ✅ Active |
|
||||
| **Best For** | Dev flexibility, rapid iteration, feature completeness | Restricted environments | Controlled and centralized releases, less dev flexibility |
|
||||
|
||||
## Dev Container CLI
|
||||
|
||||
The Dev Container CLI integration uses the standard `@devcontainers/cli` and Docker to run
|
||||
Dev Containers inside your workspace. This is the recommended approach for most use
|
||||
cases and provides the most complete Dev Container experience.
|
||||
|
||||
Uses the
|
||||
[devcontainers-cli module](https://registry.coder.com/modules/devcontainers-cli),
|
||||
the `coder_devcontainer` Terraform resource, and
|
||||
`CODER_AGENT_DEVCONTAINERS_ENABLE=true`.
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Standard Dev Container implementation via Microsoft's official `@devcontainers/cli` package.
|
||||
- Supports all Dev Container configuration options.
|
||||
- Supports custom arguments in the Dev Container spec for defining custom apps
|
||||
without needing template changes.
|
||||
- Supports discovery of repos with Dev Containers in them.
|
||||
- Easier to debug, since you have access to the outer container.
|
||||
|
||||
**Cons / Requirements:**
|
||||
|
||||
- Requires Docker in workspaces. This does not necessarily mean Docker-in-Docker
|
||||
or a specific Kubernetes runtime — you could use Rootless Podman or a
|
||||
privileged sidecar.
|
||||
- Caching is more difficult than with Envbuilder or CI/CD pre-built approaches.
|
||||
|
||||
**Best for:**
|
||||
|
||||
- Workspaces with Docker available (Docker-in-Docker or mounted socket)
|
||||
- Dev container management in the Coder dashboard (discovery, status, rebuild)
|
||||
- Multiple dev containers per workspace
|
||||
- Dev flexibility, rapid iteration, and feature completeness.
|
||||
- Workspaces with Docker available (Docker-in-Docker or mounted socket).
|
||||
- Dev Container management in the Coder dashboard (discovery, status, rebuild).
|
||||
- Multiple Dev Containers per workspace.
|
||||
|
||||
[Configure Dev Containers Integration](./integration.md)
|
||||
See the [Dev Containers Integration](./integration.md) page for instructions.
|
||||
|
||||
For user documentation, see the
|
||||
[Dev Containers user guide](../../../user-guides/devcontainers/index.md).
|
||||
|
||||
## Envbuilder
|
||||
|
||||
Envbuilder transforms the workspace image itself from a `devcontainer.json`,
|
||||
rather than running containers inside the workspace. It does not require
|
||||
a Docker daemon.
|
||||
Envbuilder transforms the workspace environment itself from a Dev Container spec (i.e `devcontainer.json`),
|
||||
rather than running containers inside the workspace. It does not require a Docker
|
||||
daemon.
|
||||
|
||||
> [!NOTE]
|
||||
> Envbuilder is in **maintenance mode**. No new features are planned to be
|
||||
> implemented. For most use cases, the
|
||||
> [Dev Container CLI](#dev-container-cli) or [CI/CD Pre-built](#cicd-pre-built)
|
||||
> approaches are recommended.
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Does not require Docker in workspaces.
|
||||
- Easier caching.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Very complicated to debug, since Envbuilder replaces the filesystem of the
|
||||
container. You can't access that environment within Coder easily if it fails,
|
||||
and you won't have many debug tools.
|
||||
- Does not support all of the Dev Container configuration options.
|
||||
- Does not support discovery of repos with Dev Containers in them.
|
||||
- Less flexible and more complex in general.
|
||||
|
||||
**Best for:**
|
||||
|
||||
- Environments where Docker is unavailable or restricted
|
||||
- Infrastructure-level control over image builds, caching, and security scanning
|
||||
- Kubernetes-native deployments without privileged containers
|
||||
- Environments where Docker is unavailable or restricted.
|
||||
- Infrastructure-level control over image builds, caching, and security scanning.
|
||||
- Kubernetes-native deployments without privileged containers.
|
||||
|
||||
[Configure Envbuilder](./envbuilder/index.md)
|
||||
See the [Envbuilder](./envbuilder/index.md) page for instructions.
|
||||
|
||||
## CI/CD Pre-built
|
||||
|
||||
Build the Dev Container image from CI/CD and pull it from within Terraform. This
|
||||
approach separates the image build step from the workspace startup, resulting in
|
||||
fast startup times and a generic template that doesn't have any
|
||||
Dev Container-specific configuration items.
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Standard Dev Container implementation via Microsoft's official `@devcontainers/cli` package.
|
||||
- Faster startup time — no need for a specific caching setup.
|
||||
- The template is generic and doesn't have any Dev Container-specific
|
||||
configuration items.
|
||||
- Versioned via image tags.
|
||||
- Testable pipeline.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Adds a build step.
|
||||
- Does not support all of the runtime options, but still supports more options
|
||||
than Envbuilder.
|
||||
- Does not support discovery of repos with Dev Containers.
|
||||
- Slow feedback loop (build, then test).
|
||||
|
||||
**Best for:**
|
||||
|
||||
- Controlled and centralized releases with less dev flexibility.
|
||||
- Teams that already have CI/CD pipelines for building images.
|
||||
- Environments that need fast, predictable startup times.
|
||||
|
||||
For an example workflow, see the
|
||||
[uwu/basic-env CI/CD workflow](https://github.com/uwu/basic-env/blob/main/.github/workflows/_build-and-push.yml).
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Configure a template for Dev Containers
|
||||
|
||||
This guide covers the Dev Containers Integration, which uses Docker. For
|
||||
This guide covers the Dev Containers CLI Integration, which uses Docker. For
|
||||
environments without Docker, see [Envbuilder](./envbuilder/index.md) as an
|
||||
alternative.
|
||||
|
||||
To enable Dev Containers in workspaces, configure your template with the Dev Containers
|
||||
modules and configurations outlined in this doc.
|
||||
To enable Dev Containers in workspaces, configure your template with the [`devcontainers-cli`](https://registry.coder.com/modules/coder/devcontainers-cli)
|
||||
module and configurations outlined in this doc.
|
||||
|
||||
Dev Containers are currently not supported in Windows or macOS workspaces.
|
||||
> [!WARNING]
|
||||
> Dev Containers are currently not supported in Windows or macOS workspaces.
|
||||
|
||||
## Configuration Modes
|
||||
|
||||
@@ -16,7 +17,7 @@ There are two approaches to configuring Dev Containers in Coder:
|
||||
### Manual Configuration
|
||||
|
||||
Use the [`coder_devcontainer`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/devcontainer) Terraform resource to explicitly define which Dev
|
||||
Containers should be started in your workspace. This approach provides:
|
||||
Container(s) should be started in your workspace. This approach provides:
|
||||
|
||||
- Predictable behavior and explicit control
|
||||
- Clear template configuration
|
||||
@@ -38,7 +39,7 @@ or work with many projects, as it reduces template maintenance overhead.
|
||||
|
||||
Use the
|
||||
[devcontainers-cli](https://registry.coder.com/modules/devcontainers-cli) module
|
||||
to ensure the `@devcontainers/cli` is installed in your workspace:
|
||||
to ensure that the `@devcontainers/cli` NPM package is installed in your workspace:
|
||||
|
||||
```terraform
|
||||
module "devcontainers-cli" {
|
||||
@@ -54,7 +55,7 @@ Alternatively, install the devcontainer CLI manually in your base image.
|
||||
|
||||
The
|
||||
[`coder_devcontainer`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/devcontainer)
|
||||
resource automatically starts a Dev Container in your workspace, ensuring it's
|
||||
resource automatically starts a specific Dev Container in your workspace, ensuring it's
|
||||
ready when you access the workspace:
|
||||
|
||||
```terraform
|
||||
@@ -74,9 +75,9 @@ For multi-repo workspaces, define multiple `coder_devcontainer` resources, each
|
||||
pointing to a different repository. Each one runs as a separate sub-agent with
|
||||
its own terminal and apps in the dashboard.
|
||||
|
||||
## Enable Dev Containers Integration
|
||||
## Enable Dev Containers CLI Integration
|
||||
|
||||
Dev Containers integration is **enabled by default** in Coder 2.24.0 and later.
|
||||
The Dev Containers CLI Integration is **enabled by default** in Coder 2.24.0 and later.
|
||||
You don't need to set any environment variables unless you want to change the
|
||||
default behavior.
|
||||
|
||||
@@ -110,7 +111,7 @@ the feature.
|
||||
|
||||
**Default: `true`** • **Added in: v2.24.0**
|
||||
|
||||
Enables the Dev Containers integration in the Coder agent.
|
||||
Enables the Dev Containers CLI Integration in the Coder agent.
|
||||
|
||||
The Dev Containers feature is enabled by default. You can explicitly disable it
|
||||
by setting this to `false`.
|
||||
@@ -160,6 +161,8 @@ always auto-start regardless of this setting.
|
||||
> [dev container features](../../../user-guides/devcontainers/working-with-dev-containers.md#dev-container-features).
|
||||
> For Coder-specific apps, use the
|
||||
> [`apps` customization](../../../user-guides/devcontainers/customizing-dev-containers.md#custom-apps).
|
||||
>
|
||||
> If you really need modules, look into [other Dev Container options](./index.md#comparison)
|
||||
|
||||
Developers can customize individual dev containers using the `customizations.coder`
|
||||
block in their `devcontainer.json` file. Available options include:
|
||||
@@ -174,7 +177,7 @@ block in their `devcontainer.json` file. Available options include:
|
||||
For the full reference, see
|
||||
[Customizing dev containers](../../../user-guides/devcontainers/customizing-dev-containers.md).
|
||||
|
||||
## Complete Template Example
|
||||
## Simplified Template Example
|
||||
|
||||
Here's a simplified template example that uses Dev Containers with manual
|
||||
configuration:
|
||||
@@ -216,7 +219,9 @@ resource "coder_devcontainer" "my-repository" {
|
||||
### Alternative: Project Discovery with Autostart
|
||||
|
||||
By default, discovered containers appear in the dashboard but developers must
|
||||
manually start them. To have them start automatically, enable autostart:
|
||||
manually start them. To have them start automatically, enable autostart by setting the `CODER_AGENT_DEVCONTAINERS_DISCOVERY_AUTOSTART_ENABLE` environment to `true` within the workspace.
|
||||
|
||||
For example, with a Docker-based template:
|
||||
|
||||
```terraform
|
||||
resource "docker_container" "workspace" {
|
||||
@@ -236,24 +241,24 @@ With autostart enabled:
|
||||
|
||||
- Discovered containers automatically build and start during workspace
|
||||
initialization
|
||||
- The `coder_devcontainer` resource is not required
|
||||
- The [`coder_devcontainer`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/devcontainer) resource is not required
|
||||
- Developers can work with multiple projects seamlessly
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> When using project discovery, you still need to install the devcontainers CLI
|
||||
> When using project discovery, you still need to install the `devcontainer` CLI
|
||||
> using the module or in your base image.
|
||||
|
||||
## Example Template
|
||||
|
||||
The [Docker (Dev Containers)](https://github.com/coder/coder/tree/main/examples/templates/docker-devcontainer)
|
||||
starter template demonstrates Dev Containers integration using Docker-in-Docker.
|
||||
It includes the `devcontainers-cli` module, `git-clone` module, and the
|
||||
`coder_devcontainer` resource.
|
||||
starter template demonstrates the Dev Containers CLI Integration using Docker-in-Docker.
|
||||
It includes the [`devcontainers-cli`](https://registry.coder.com/modules/coder/devcontainers-cli) module, [`git-clone`](https://registry.coder.com/modules/git-clone) module, and the
|
||||
[`coder_devcontainer`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/devcontainer) resource.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Dev Containers Integration](../../../user-guides/devcontainers/index.md)
|
||||
- [Dev Containers CLI Integration user guide](../../../user-guides/devcontainers/index.md)
|
||||
- [Customizing Dev Containers](../../../user-guides/devcontainers/customizing-dev-containers.md)
|
||||
- [Working with Dev Containers](../../../user-guides/devcontainers/working-with-dev-containers.md)
|
||||
- [Troubleshooting Dev Containers](../../../user-guides/devcontainers/troubleshooting-dev-containers.md)
|
||||
|
||||
@@ -104,105 +104,170 @@ deployment. They will always be available from the agent.
|
||||
|
||||
<!-- Code generated by 'make docs/admin/integrations/prometheus.md'. DO NOT EDIT -->
|
||||
|
||||
| Name | Type | Description | Labels |
|
||||
|---------------------------------------------------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
|
||||
| `agent_scripts_executed_total` | counter | Total number of scripts executed by the Coder agent. Includes cron scheduled scripts. | `agent_name` `success` `template_name` `username` `workspace_name` |
|
||||
| `coder_aibridged_injected_tool_invocations_total` | counter | The number of times an injected MCP tool was invoked by aibridge. | `model` `name` `provider` `server` |
|
||||
| `coder_aibridged_interceptions_duration_seconds` | histogram | The total duration of intercepted requests, in seconds. The majority of this time will be the upstream processing of the request. aibridge has no control over upstream processing time, so it's just an illustrative metric. | `model` `provider` |
|
||||
| `coder_aibridged_interceptions_inflight` | gauge | The number of intercepted requests which are being processed. | `model` `provider` `route` |
|
||||
| `coder_aibridged_interceptions_total` | counter | The count of intercepted requests. | `initiator_id` `method` `model` `provider` `route` `status` |
|
||||
| `coder_aibridged_non_injected_tool_selections_total` | counter | The number of times an AI model selected a tool to be invoked by the client. | `model` `name` `provider` |
|
||||
| `coder_aibridged_prompts_total` | counter | The number of prompts issued by users (initiators). | `initiator_id` `model` `provider` |
|
||||
| `coder_aibridged_tokens_total` | counter | The number of tokens used by intercepted requests. | `initiator_id` `model` `provider` `type` |
|
||||
| `coderd_agentapi_metadata_batch_size` | histogram | Total number of metadata entries in each batch, updated before flushes. | |
|
||||
| `coderd_agentapi_metadata_batch_utilization` | histogram | Number of metadata keys per agent in each batch, updated before flushes. | |
|
||||
| `coderd_agentapi_metadata_batches_total` | counter | Total number of metadata batches flushed. | `reason` |
|
||||
| `coderd_agentapi_metadata_dropped_keys_total` | counter | Total number of metadata keys dropped due to capacity limits. | |
|
||||
| `coderd_agentapi_metadata_flush_duration_seconds` | histogram | Time taken to flush metadata batch to database and pubsub. | `reason` |
|
||||
| `coderd_agentapi_metadata_flushed_total` | counter | Total number of unique metadatas flushed. | |
|
||||
| `coderd_agentapi_metadata_publish_errors_total` | counter | Total number of metadata batch pubsub publish calls that have resulted in an error. | |
|
||||
| `coderd_agents_apps` | gauge | Agent applications with statuses. | `agent_name` `app_name` `health` `username` `workspace_name` |
|
||||
| `coderd_agents_connection_latencies_seconds` | gauge | Agent connection latencies in seconds. | `agent_name` `derp_region` `preferred` `username` `workspace_name` |
|
||||
| `coderd_agents_connections` | gauge | Agent connections with statuses. | `agent_name` `lifecycle_state` `status` `tailnet_node` `username` `workspace_name` |
|
||||
| `coderd_agents_up` | gauge | The number of active agents per workspace. | `template_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_connection_count` | gauge | The number of established connections by agent | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_connection_median_latency_seconds` | gauge | The median agent connection latency | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_currently_reachable_peers` | gauge | The number of peers (e.g. clients) that are currently reachable over the encrypted network. | `agent_name` `connection_type` `template_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_rx_bytes` | gauge | Agent Rx bytes | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_session_count_jetbrains` | gauge | The number of session established by JetBrains | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_session_count_reconnecting_pty` | gauge | The number of session established by reconnecting PTY | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_session_count_ssh` | gauge | The number of session established by SSH | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_session_count_vscode` | gauge | The number of session established by VSCode | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_startup_script_seconds` | gauge | The number of seconds the startup script took to execute. | `agent_name` `success` `template_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_tx_bytes` | gauge | Agent Tx bytes | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_api_active_users_duration_hour` | gauge | The number of users that have been active within the last hour. | |
|
||||
| `coderd_api_concurrent_requests` | gauge | The number of concurrent API requests. | |
|
||||
| `coderd_api_concurrent_websockets` | gauge | The total number of concurrent API websockets. | |
|
||||
| `coderd_api_request_latencies_seconds` | histogram | Latency distribution of requests in seconds. | `method` `path` |
|
||||
| `coderd_api_requests_processed_total` | counter | The total number of processed API requests | `code` `method` `path` |
|
||||
| `coderd_api_websocket_durations_seconds` | histogram | Websocket duration distribution of requests in seconds. | `path` |
|
||||
| `coderd_api_workspace_latest_build` | gauge | The latest workspace builds with a status. | `status` |
|
||||
| `coderd_insights_applications_usage_seconds` | gauge | The application usage per template. | `application_name` `slug` `template_name` |
|
||||
| `coderd_insights_parameters` | gauge | The parameter usage per template. | `parameter_name` `parameter_type` `parameter_value` `template_name` |
|
||||
| `coderd_insights_templates_active_users` | gauge | The number of active users of the template. | `template_name` |
|
||||
| `coderd_license_active_users` | gauge | The number of active users. | |
|
||||
| `coderd_license_errors` | gauge | The number of active license errors. | |
|
||||
| `coderd_license_limit_users` | gauge | The user seats limit based on the active Coder license. | |
|
||||
| `coderd_license_user_limit_enabled` | gauge | Returns 1 if the current license enforces the user limit. | |
|
||||
| `coderd_license_warnings` | gauge | The number of active license warnings. | |
|
||||
| `coderd_metrics_collector_agents_execution_seconds` | histogram | Histogram for duration of agents metrics collection in seconds. | |
|
||||
| `coderd_oauth2_external_requests_rate_limit` | gauge | The total number of allowed requests per interval. | `name` `resource` |
|
||||
| `coderd_oauth2_external_requests_rate_limit_next_reset_unix` | gauge | Unix timestamp of the next interval | `name` `resource` |
|
||||
| `coderd_oauth2_external_requests_rate_limit_remaining` | gauge | The remaining number of allowed requests in this interval. | `name` `resource` |
|
||||
| `coderd_oauth2_external_requests_rate_limit_reset_in_seconds` | gauge | Seconds until the next interval | `name` `resource` |
|
||||
| `coderd_oauth2_external_requests_rate_limit_used` | gauge | The number of requests made in this interval. | `name` `resource` |
|
||||
| `coderd_oauth2_external_requests_total` | counter | The total number of api calls made to external oauth2 providers. 'status_code' will be 0 if the request failed with no response. | `name` `source` `status_code` |
|
||||
| `coderd_prebuilt_workspace_claim_duration_seconds` | histogram | Time to claim a prebuilt workspace by organization, template, and preset. | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_provisionerd_job_timings_seconds` | histogram | The provisioner job time duration in seconds. | `provisioner` `status` |
|
||||
| `coderd_provisionerd_jobs_current` | gauge | The number of currently running provisioner jobs. | `provisioner` |
|
||||
| `coderd_provisionerd_num_daemons` | gauge | The number of provisioner daemons. | |
|
||||
| `coderd_provisionerd_workspace_build_timings_seconds` | histogram | The time taken for a workspace to build. | `status` `template_name` `template_version` `workspace_transition` |
|
||||
| `coderd_template_workspace_build_duration_seconds` | histogram | Duration from workspace build creation to agent ready, by template. | `is_prebuild` `organization_name` `status` `template_name` `transition` |
|
||||
| `coderd_workspace_builds_total` | counter | The number of workspaces started, updated, or deleted. | `action` `owner_email` `status` `template_name` `template_version` `workspace_name` |
|
||||
| `coderd_workspace_creation_duration_seconds` | histogram | Time to create a workspace by organization, template, preset, and type (regular or prebuild). | `organization_name` `preset_name` `template_name` `type` |
|
||||
| `coderd_workspace_creation_total` | counter | Total regular (non-prebuilt) workspace creations by organization, template, and preset. | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_workspace_latest_build_status` | gauge | The current workspace statuses by template, transition, and owner. | `status` `template_name` `template_version` `workspace_owner` `workspace_transition` |
|
||||
| `go_gc_duration_seconds` | summary | A summary of the pause duration of garbage collection cycles. | |
|
||||
| `go_goroutines` | gauge | Number of goroutines that currently exist. | |
|
||||
| `go_info` | gauge | Information about the Go environment. | `version` |
|
||||
| `go_memstats_alloc_bytes` | gauge | Number of bytes allocated and still in use. | |
|
||||
| `go_memstats_alloc_bytes_total` | counter | Total number of bytes allocated, even if freed. | |
|
||||
| `go_memstats_buck_hash_sys_bytes` | gauge | Number of bytes used by the profiling bucket hash table. | |
|
||||
| `go_memstats_frees_total` | counter | Total number of frees. | |
|
||||
| `go_memstats_gc_sys_bytes` | gauge | Number of bytes used for garbage collection system metadata. | |
|
||||
| `go_memstats_heap_alloc_bytes` | gauge | Number of heap bytes allocated and still in use. | |
|
||||
| `go_memstats_heap_idle_bytes` | gauge | Number of heap bytes waiting to be used. | |
|
||||
| `go_memstats_heap_inuse_bytes` | gauge | Number of heap bytes that are in use. | |
|
||||
| `go_memstats_heap_objects` | gauge | Number of allocated objects. | |
|
||||
| `go_memstats_heap_released_bytes` | gauge | Number of heap bytes released to OS. | |
|
||||
| `go_memstats_heap_sys_bytes` | gauge | Number of heap bytes obtained from system. | |
|
||||
| `go_memstats_last_gc_time_seconds` | gauge | Number of seconds since 1970 of last garbage collection. | |
|
||||
| `go_memstats_lookups_total` | counter | Total number of pointer lookups. | |
|
||||
| `go_memstats_mallocs_total` | counter | Total number of mallocs. | |
|
||||
| `go_memstats_mcache_inuse_bytes` | gauge | Number of bytes in use by mcache structures. | |
|
||||
| `go_memstats_mcache_sys_bytes` | gauge | Number of bytes used for mcache structures obtained from system. | |
|
||||
| `go_memstats_mspan_inuse_bytes` | gauge | Number of bytes in use by mspan structures. | |
|
||||
| `go_memstats_mspan_sys_bytes` | gauge | Number of bytes used for mspan structures obtained from system. | |
|
||||
| `go_memstats_next_gc_bytes` | gauge | Number of heap bytes when next garbage collection will take place. | |
|
||||
| `go_memstats_other_sys_bytes` | gauge | Number of bytes used for other system allocations. | |
|
||||
| `go_memstats_stack_inuse_bytes` | gauge | Number of bytes in use by the stack allocator. | |
|
||||
| `go_memstats_stack_sys_bytes` | gauge | Number of bytes obtained from system for stack allocator. | |
|
||||
| `go_memstats_sys_bytes` | gauge | Number of bytes obtained from system. | |
|
||||
| `go_threads` | gauge | Number of OS threads created. | |
|
||||
| `process_cpu_seconds_total` | counter | Total user and system CPU time spent in seconds. | |
|
||||
| `process_max_fds` | gauge | Maximum number of open file descriptors. | |
|
||||
| `process_open_fds` | gauge | Number of open file descriptors. | |
|
||||
| `process_resident_memory_bytes` | gauge | Resident memory size in bytes. | |
|
||||
| `process_start_time_seconds` | gauge | Start time of the process since unix epoch in seconds. | |
|
||||
| `process_virtual_memory_bytes` | gauge | Virtual memory size in bytes. | |
|
||||
| `process_virtual_memory_max_bytes` | gauge | Maximum amount of virtual memory available in bytes. | |
|
||||
| `promhttp_metric_handler_requests_in_flight` | gauge | Current number of scrapes being served. | |
|
||||
| `promhttp_metric_handler_requests_total` | counter | Total number of scrapes by HTTP status code. | `code` |
|
||||
| Name | Type | Description | Labels |
|
||||
|-------------------------------------------------------------------------|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------|
|
||||
| `agent_scripts_executed_total` | counter | Total number of scripts executed by the Coder agent. Includes cron scheduled scripts. | `agent_name` `success` `template_name` `username` `workspace_name` |
|
||||
| `coder_aibridged_circuit_breaker_rejects_total` | counter | Total number of requests rejected due to open circuit breaker. | `endpoint` `model` `provider` |
|
||||
| `coder_aibridged_circuit_breaker_state` | gauge | Current state of the circuit breaker (0=closed, 0.5=half-open, 1=open). | `endpoint` `model` `provider` |
|
||||
| `coder_aibridged_circuit_breaker_trips_total` | counter | Total number of times the circuit breaker transitioned to open state. | `endpoint` `model` `provider` |
|
||||
| `coder_aibridged_injected_tool_invocations_total` | counter | The number of times an injected MCP tool was invoked by aibridge. | `model` `name` `provider` `server` |
|
||||
| `coder_aibridged_interceptions_duration_seconds` | histogram | The total duration of intercepted requests, in seconds. The majority of this time will be the upstream processing of the request. aibridge has no control over upstream processing time, so it's just an illustrative metric. | `model` `provider` |
|
||||
| `coder_aibridged_interceptions_inflight` | gauge | The number of intercepted requests which are being processed. | `model` `provider` `route` |
|
||||
| `coder_aibridged_interceptions_total` | counter | The count of intercepted requests. | `initiator_id` `method` `model` `provider` `route` `status` |
|
||||
| `coder_aibridged_non_injected_tool_selections_total` | counter | The number of times an AI model selected a tool to be invoked by the client. | `model` `name` `provider` |
|
||||
| `coder_aibridged_passthrough_total` | counter | The count of requests which were not intercepted but passed through to the upstream. | `method` `provider` `route` |
|
||||
| `coder_aibridged_prompts_total` | counter | The number of prompts issued by users (initiators). | `initiator_id` `model` `provider` |
|
||||
| `coder_aibridged_tokens_total` | counter | The number of tokens used by intercepted requests. | `initiator_id` `model` `provider` `type` |
|
||||
| `coder_aibridgeproxyd_connect_sessions_total` | counter | Total number of CONNECT sessions established. | `type` |
|
||||
| `coder_aibridgeproxyd_inflight_mitm_requests` | gauge | Number of MITM requests currently being processed. | `provider` |
|
||||
| `coder_aibridgeproxyd_mitm_requests_total` | counter | Total number of MITM requests handled by the proxy. | `provider` |
|
||||
| `coder_aibridgeproxyd_mitm_responses_total` | counter | Total number of MITM responses by HTTP status code class. | `code` `provider` |
|
||||
| `coder_pubsub_connected` | gauge | Whether we are connected (1) or not connected (0) to postgres | |
|
||||
| `coder_pubsub_current_events` | gauge | The current number of pubsub event channels listened for | |
|
||||
| `coder_pubsub_current_subscribers` | gauge | The current number of active pubsub subscribers | |
|
||||
| `coder_pubsub_disconnections_total` | counter | Total number of times we disconnected unexpectedly from postgres | |
|
||||
| `coder_pubsub_latency_measure_errs_total` | counter | The number of pubsub latency measurement failures | |
|
||||
| `coder_pubsub_latency_measures_total` | counter | The number of pubsub latency measurements | |
|
||||
| `coder_pubsub_messages_total` | counter | Total number of messages received from postgres | `size` |
|
||||
| `coder_pubsub_published_bytes_total` | counter | Total number of bytes successfully published across all publishes | |
|
||||
| `coder_pubsub_publishes_total` | counter | Total number of calls to Publish | `success` |
|
||||
| `coder_pubsub_receive_latency_seconds` | gauge | The time taken to receive a message from a pubsub event channel | |
|
||||
| `coder_pubsub_received_bytes_total` | counter | Total number of bytes received across all messages | |
|
||||
| `coder_pubsub_send_latency_seconds` | gauge | The time taken to send a message into a pubsub event channel | |
|
||||
| `coder_pubsub_subscribes_total` | counter | Total number of calls to Subscribe/SubscribeWithErr | `success` |
|
||||
| `coder_servertailnet_connections_total` | counter | Total number of TCP connections made to workspace agents. | `network` |
|
||||
| `coder_servertailnet_open_connections` | gauge | Total number of TCP connections currently open to workspace agents. | `network` |
|
||||
| `coderd_agentapi_metadata_batch_size` | histogram | Total number of metadata entries in each batch, updated before flushes. | |
|
||||
| `coderd_agentapi_metadata_batch_utilization` | histogram | Number of metadata keys per agent in each batch, updated before flushes. | |
|
||||
| `coderd_agentapi_metadata_batches_total` | counter | Total number of metadata batches flushed. | `reason` |
|
||||
| `coderd_agentapi_metadata_dropped_keys_total` | counter | Total number of metadata keys dropped due to capacity limits. | |
|
||||
| `coderd_agentapi_metadata_flush_duration_seconds` | histogram | Time taken to flush metadata batch to database and pubsub. | `reason` |
|
||||
| `coderd_agentapi_metadata_flushed_total` | counter | Total number of unique metadatas flushed. | |
|
||||
| `coderd_agentapi_metadata_publish_errors_total` | counter | Total number of metadata batch pubsub publish calls that have resulted in an error. | |
|
||||
| `coderd_agents_apps` | gauge | Agent applications with statuses. | `agent_name` `app_name` `health` `username` `workspace_name` |
|
||||
| `coderd_agents_connection_latencies_seconds` | gauge | Agent connection latencies in seconds. | `agent_name` `derp_region` `preferred` `username` `workspace_name` |
|
||||
| `coderd_agents_connections` | gauge | Agent connections with statuses. | `agent_name` `lifecycle_state` `status` `tailnet_node` `username` `workspace_name` |
|
||||
| `coderd_agents_up` | gauge | The number of active agents per workspace. | `template_name` `template_version` `username` `workspace_name` |
|
||||
| `coderd_agentstats_connection_count` | gauge | The number of established connections by agent | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_connection_median_latency_seconds` | gauge | The median agent connection latency | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_currently_reachable_peers` | gauge | The number of peers (e.g. clients) that are currently reachable over the encrypted network. | `agent_name` `connection_type` `template_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_rx_bytes` | gauge | Agent Rx bytes | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_session_count_jetbrains` | gauge | The number of session established by JetBrains | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_session_count_reconnecting_pty` | gauge | The number of session established by reconnecting PTY | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_session_count_ssh` | gauge | The number of session established by SSH | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_session_count_vscode` | gauge | The number of session established by VSCode | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_startup_script_seconds` | gauge | The number of seconds the startup script took to execute. | `agent_name` `success` `template_name` `username` `workspace_name` |
|
||||
| `coderd_agentstats_tx_bytes` | gauge | Agent Tx bytes | `agent_name` `username` `workspace_name` |
|
||||
| `coderd_api_active_users_duration_hour` | gauge | The number of users that have been active within the last hour. | |
|
||||
| `coderd_api_concurrent_requests` | gauge | The number of concurrent API requests. | `method` `path` |
|
||||
| `coderd_api_concurrent_websockets` | gauge | The total number of concurrent API websockets. | `path` |
|
||||
| `coderd_api_request_latencies_seconds` | histogram | Latency distribution of requests in seconds. | `method` `path` |
|
||||
| `coderd_api_requests_processed_total` | counter | The total number of processed API requests | `code` `method` `path` |
|
||||
| `coderd_api_total_user_count` | gauge | The total number of registered users, partitioned by status. | `status` |
|
||||
| `coderd_api_websocket_durations_seconds` | histogram | Websocket duration distribution of requests in seconds. | `path` |
|
||||
| `coderd_api_workspace_latest_build` | gauge | The current number of workspace builds by status for all non-deleted workspaces. | `status` |
|
||||
| `coderd_authz_authorize_duration_seconds` | histogram | Duration of the 'Authorize' call in seconds. Only counts calls that succeed. | `allowed` |
|
||||
| `coderd_authz_prepare_authorize_duration_seconds` | histogram | Duration of the 'PrepareAuthorize' call in seconds. | |
|
||||
| `coderd_db_query_counts_total` | counter | Total number of queries labelled by HTTP route, method, and query name. | `method` `query` `route` |
|
||||
| `coderd_db_query_latencies_seconds` | histogram | Latency distribution of queries in seconds. | `query` |
|
||||
| `coderd_db_tx_duration_seconds` | histogram | Duration of transactions in seconds. | `success` `tx_id` |
|
||||
| `coderd_db_tx_executions_count` | counter | Total count of transactions executed. 'retries' is expected to be 0 for a successful transaction. | `retries` `success` `tx_id` |
|
||||
| `coderd_dbpurge_iteration_duration_seconds` | histogram | Duration of each dbpurge iteration in seconds. | `success` |
|
||||
| `coderd_dbpurge_records_purged_total` | counter | Total number of records purged by type. | `record_type` |
|
||||
| `coderd_experiments` | gauge | Indicates whether each experiment is enabled (1) or not (0) | `experiment` |
|
||||
| `coderd_insights_applications_usage_seconds` | gauge | The application usage per template. | `application_name` `slug` `template_name` |
|
||||
| `coderd_insights_parameters` | gauge | The parameter usage per template. | `parameter_name` `parameter_type` `parameter_value` `template_name` |
|
||||
| `coderd_insights_templates_active_users` | gauge | The number of active users of the template. | `template_name` |
|
||||
| `coderd_license_active_users` | gauge | The number of active users. | |
|
||||
| `coderd_license_errors` | gauge | The number of active license errors. | |
|
||||
| `coderd_license_limit_users` | gauge | The user seats limit based on the active Coder license. | |
|
||||
| `coderd_license_user_limit_enabled` | gauge | Returns 1 if the current license enforces the user limit. | |
|
||||
| `coderd_license_warnings` | gauge | The number of active license warnings. | |
|
||||
| `coderd_lifecycle_autobuild_execution_duration_seconds` | histogram | Duration of each autobuild execution. | |
|
||||
| `coderd_notifications_dispatcher_send_seconds` | histogram | The time taken to dispatch notifications. | `method` |
|
||||
| `coderd_notifications_inflight_dispatches` | gauge | The number of dispatch attempts which are currently in progress. | `method` `notification_template_id` |
|
||||
| `coderd_notifications_pending_updates` | gauge | The number of dispatch attempt results waiting to be flushed to the store. | |
|
||||
| `coderd_notifications_queued_seconds` | histogram | The time elapsed between a notification being enqueued in the store and retrieved for dispatching (measures the latency of the notifications system). This should generally be within CODER_NOTIFICATIONS_FETCH_INTERVAL seconds; higher values for a sustained period indicates delayed processing and CODER_NOTIFICATIONS_LEASE_COUNT can be increased to accommodate this. | `method` |
|
||||
| `coderd_notifications_retry_count` | counter | The count of notification dispatch retry attempts. | `method` `notification_template_id` |
|
||||
| `coderd_notifications_synced_updates_total` | counter | The number of dispatch attempt results flushed to the store. | |
|
||||
| `coderd_oauth2_external_requests_rate_limit` | gauge | The total number of allowed requests per interval. | `name` `resource` |
|
||||
| `coderd_oauth2_external_requests_rate_limit_next_reset_unix` | gauge | Unix timestamp for when the next interval starts | `name` `resource` |
|
||||
| `coderd_oauth2_external_requests_rate_limit_remaining` | gauge | The remaining number of allowed requests in this interval. | `name` `resource` |
|
||||
| `coderd_oauth2_external_requests_rate_limit_reset_in_seconds` | gauge | Seconds until the next interval | `name` `resource` |
|
||||
| `coderd_oauth2_external_requests_rate_limit_used` | gauge | The number of requests made in this interval. | `name` `resource` |
|
||||
| `coderd_oauth2_external_requests_total` | counter | The total number of api calls made to external oauth2 providers. 'status_code' will be 0 if the request failed with no response. | `name` `source` `status_code` |
|
||||
| `coderd_open_file_refs_current` | gauge | The count of file references currently open in the file cache. Multiple references can be held for the same file. | |
|
||||
| `coderd_open_file_refs_total` | counter | The total number of file references ever opened in the file cache. The 'hit' label indicates if the file was loaded from the cache. | `hit` |
|
||||
| `coderd_open_files_current` | gauge | The count of unique files currently open in the file cache. | |
|
||||
| `coderd_open_files_size_bytes_current` | gauge | The current amount of memory of all files currently open in the file cache. | |
|
||||
| `coderd_open_files_size_bytes_total` | counter | The total amount of memory ever opened in the file cache. This number never decrements. | |
|
||||
| `coderd_open_files_total` | counter | The total count of unique files ever opened in the file cache. | |
|
||||
| `coderd_prebuilds_reconciliation_duration_seconds` | histogram | Duration of each prebuilds reconciliation cycle. | |
|
||||
| `coderd_prebuilt_workspace_claim_duration_seconds` | histogram | Time to claim a prebuilt workspace by organization, template, and preset. | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_prebuilt_workspaces_claimed_total` | counter | Total number of prebuilt workspaces which were claimed by users. Claiming refers to creating a workspace with a preset selected for which eligible prebuilt workspaces are available and one is reassigned to a user. | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_prebuilt_workspaces_created_total` | counter | Total number of prebuilt workspaces that have been created to meet the desired instance count of each template preset. | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_prebuilt_workspaces_desired` | gauge | Target number of prebuilt workspaces that should be available for each template preset. | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_prebuilt_workspaces_eligible` | gauge | Current number of prebuilt workspaces that are eligible to be claimed by users. These are workspaces that have completed their build process with their agent reporting 'ready' status. | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_prebuilt_workspaces_failed_total` | counter | Total number of prebuilt workspaces that failed to build. | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_prebuilt_workspaces_metrics_last_updated` | gauge | The unix timestamp when the metrics related to prebuilt workspaces were last updated; these metrics are cached. | |
|
||||
| `coderd_prebuilt_workspaces_preset_hard_limited` | gauge | Indicates whether a given preset has reached the hard failure limit (1 = hard-limited). Metric is omitted otherwise. | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_prebuilt_workspaces_reconciliation_paused` | gauge | Indicates whether prebuilds reconciliation is currently paused (1 = paused, 0 = not paused). | |
|
||||
| `coderd_prebuilt_workspaces_resource_replacements_total` | counter | Total number of prebuilt workspaces whose resource(s) got replaced upon being claimed. In Terraform, drift on immutable attributes results in resource replacement. This represents a worst-case scenario for prebuilt workspaces because the pre-provisioned resource would have been recreated when claiming, thus obviating the point of pre-provisioning. See https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_prebuilt_workspaces_running` | gauge | Current number of prebuilt workspaces that are in a running state. These workspaces have started successfully but may not yet be claimable by users (see coderd_prebuilt_workspaces_eligible). | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_prometheusmetrics_agents_execution_seconds` | histogram | Histogram for duration of agents metrics collection in seconds. | |
|
||||
| `coderd_prometheusmetrics_agentstats_execution_seconds` | histogram | Histogram for duration of agent stats metrics collection in seconds. | |
|
||||
| `coderd_prometheusmetrics_metrics_aggregator_execution_cleanup_seconds` | histogram | Histogram for duration of metrics aggregator cleanup in seconds. | |
|
||||
| `coderd_prometheusmetrics_metrics_aggregator_execution_update_seconds` | histogram | Histogram for duration of metrics aggregator update in seconds. | |
|
||||
| `coderd_prometheusmetrics_metrics_aggregator_store_size` | gauge | The number of metrics stored in the aggregator | |
|
||||
| `coderd_provisioner_job_queue_wait_seconds` | histogram | Time from job creation to acquisition by a provisioner daemon. | `build_reason` `job_type` `provisioner_type` `transition` |
|
||||
| `coderd_provisionerd_job_timings_seconds` | histogram | The provisioner job time duration in seconds. | `provisioner` `status` |
|
||||
| `coderd_provisionerd_jobs_current` | gauge | The number of currently running provisioner jobs. | `provisioner` |
|
||||
| `coderd_provisionerd_num_daemons` | gauge | The number of provisioner daemons. | |
|
||||
| `coderd_provisionerd_workspace_build_timings_seconds` | histogram | The time taken for a workspace to build. | `status` `template_name` `template_version` `workspace_transition` |
|
||||
| `coderd_proxyhealth_health_check_duration_seconds` | histogram | Histogram for duration of proxy health collection in seconds. | |
|
||||
| `coderd_proxyhealth_health_check_results` | gauge | This endpoint returns a number to indicate the health status. -3 (unknown), -2 (Unreachable), -1 (Unhealthy), 0 (Unregistered), 1 (Healthy) | `proxy_id` |
|
||||
| `coderd_template_workspace_build_duration_seconds` | histogram | Duration from workspace build creation to agent ready, by template. | `is_prebuild` `organization_name` `status` `template_name` `transition` |
|
||||
| `coderd_workspace_builds_enqueued_total` | counter | Total number of workspace build enqueue attempts. | `build_reason` `provisioner_type` `status` `transition` |
|
||||
| `coderd_workspace_builds_total` | counter | The number of workspaces started, updated, or deleted. | `status` `template_name` `template_version` `workspace_name` `workspace_owner` `workspace_transition` |
|
||||
| `coderd_workspace_creation_duration_seconds` | histogram | Time to create a workspace by organization, template, preset, and type (regular or prebuild). | `organization_name` `preset_name` `template_name` `type` |
|
||||
| `coderd_workspace_creation_total` | counter | Total regular (non-prebuilt) workspace creations by organization, template, and preset. | `organization_name` `preset_name` `template_name` |
|
||||
| `coderd_workspace_latest_build_status` | gauge | The current workspace statuses by template, transition, and owner for all non-deleted workspaces. | `status` `template_name` `template_version` `workspace_owner` `workspace_transition` |
|
||||
| `go_gc_duration_seconds` | summary | A summary of the pause duration of garbage collection cycles. | |
|
||||
| `go_goroutines` | gauge | Number of goroutines that currently exist. | |
|
||||
| `go_info` | gauge | Information about the Go environment. | `version` |
|
||||
| `go_memstats_alloc_bytes` | gauge | Number of bytes allocated and still in use. | |
|
||||
| `go_memstats_alloc_bytes_total` | counter | Total number of bytes allocated, even if freed. | |
|
||||
| `go_memstats_buck_hash_sys_bytes` | gauge | Number of bytes used by the profiling bucket hash table. | |
|
||||
| `go_memstats_frees_total` | counter | Total number of frees. | |
|
||||
| `go_memstats_gc_sys_bytes` | gauge | Number of bytes used for garbage collection system metadata. | |
|
||||
| `go_memstats_heap_alloc_bytes` | gauge | Number of heap bytes allocated and still in use. | |
|
||||
| `go_memstats_heap_idle_bytes` | gauge | Number of heap bytes waiting to be used. | |
|
||||
| `go_memstats_heap_inuse_bytes` | gauge | Number of heap bytes that are in use. | |
|
||||
| `go_memstats_heap_objects` | gauge | Number of allocated objects. | |
|
||||
| `go_memstats_heap_released_bytes` | gauge | Number of heap bytes released to OS. | |
|
||||
| `go_memstats_heap_sys_bytes` | gauge | Number of heap bytes obtained from system. | |
|
||||
| `go_memstats_last_gc_time_seconds` | gauge | Number of seconds since 1970 of last garbage collection. | |
|
||||
| `go_memstats_lookups_total` | counter | Total number of pointer lookups. | |
|
||||
| `go_memstats_mallocs_total` | counter | Total number of mallocs. | |
|
||||
| `go_memstats_mcache_inuse_bytes` | gauge | Number of bytes in use by mcache structures. | |
|
||||
| `go_memstats_mcache_sys_bytes` | gauge | Number of bytes used for mcache structures obtained from system. | |
|
||||
| `go_memstats_mspan_inuse_bytes` | gauge | Number of bytes in use by mspan structures. | |
|
||||
| `go_memstats_mspan_sys_bytes` | gauge | Number of bytes used for mspan structures obtained from system. | |
|
||||
| `go_memstats_next_gc_bytes` | gauge | Number of heap bytes when next garbage collection will take place. | |
|
||||
| `go_memstats_other_sys_bytes` | gauge | Number of bytes used for other system allocations. | |
|
||||
| `go_memstats_stack_inuse_bytes` | gauge | Number of bytes in use by the stack allocator. | |
|
||||
| `go_memstats_stack_sys_bytes` | gauge | Number of bytes obtained from system for stack allocator. | |
|
||||
| `go_memstats_sys_bytes` | gauge | Number of bytes obtained from system. | |
|
||||
| `go_threads` | gauge | Number of OS threads created. | |
|
||||
| `process_cpu_seconds_total` | counter | Total user and system CPU time spent in seconds. | |
|
||||
| `process_max_fds` | gauge | Maximum number of open file descriptors. | |
|
||||
| `process_open_fds` | gauge | Number of open file descriptors. | |
|
||||
| `process_resident_memory_bytes` | gauge | Resident memory size in bytes. | |
|
||||
| `process_start_time_seconds` | gauge | Start time of the process since unix epoch in seconds. | |
|
||||
| `process_virtual_memory_bytes` | gauge | Virtual memory size in bytes. | |
|
||||
| `process_virtual_memory_max_bytes` | gauge | Maximum amount of virtual memory available in bytes. | |
|
||||
| `promhttp_metric_handler_requests_in_flight` | gauge | Current number of scrapes being served. | |
|
||||
| `promhttp_metric_handler_requests_total` | counter | Total number of scrapes by HTTP status code. | `code` |
|
||||
|
||||
<!-- End generated by 'make docs/admin/integrations/prometheus.md'. -->
|
||||
|
||||
|
||||
@@ -13,32 +13,32 @@ We track the following resources:
|
||||
|
||||
<!-- Code generated by 'make docs/admin/security/audit-logs.md'. DO NOT EDIT -->
|
||||
|
||||
| <b>Resource<b> | | |
|
||||
|----------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>allow_list</td><td>false</td></tr><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scopes</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
|
||||
| AuditableOrganizationMember<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>roles</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| CustomRole<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_system</td><td>false</td></tr><tr><td>member_permissions</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>org_permissions</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>site_permissions</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_permissions</td><td>true</td></tr></tbody></table> |
|
||||
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| GroupSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>auto_create_missing_groups</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>legacy_group_name_mapping</td><td>false</td></tr><tr><td>mapping</td><td>true</td></tr><tr><td>regex_filter</td><td>true</td></tr></tbody></table> |
|
||||
| HealthSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>dismissed_healthchecks</td><td>true</td></tr><tr><td>id</td><td>false</td></tr></tbody></table> |
|
||||
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
|
||||
| NotificationTemplate<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>actions</td><td>true</td></tr><tr><td>body_template</td><td>true</td></tr><tr><td>enabled_by_default</td><td>true</td></tr><tr><td>group</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>kind</td><td>true</td></tr><tr><td>method</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>title_template</td><td>true</td></tr></tbody></table> |
|
||||
| NotificationsSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>id</td><td>false</td></tr><tr><td>notifier_paused</td><td>true</td></tr></tbody></table> |
|
||||
| OAuth2ProviderApp<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>callback_url</td><td>true</td></tr><tr><td>client_id_issued_at</td><td>false</td></tr><tr><td>client_secret_expires_at</td><td>true</td></tr><tr><td>client_type</td><td>true</td></tr><tr><td>client_uri</td><td>true</td></tr><tr><td>contacts</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>dynamically_registered</td><td>true</td></tr><tr><td>grant_types</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwks</td><td>true</td></tr><tr><td>jwks_uri</td><td>true</td></tr><tr><td>logo_uri</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>policy_uri</td><td>true</td></tr><tr><td>redirect_uris</td><td>true</td></tr><tr><td>registration_access_token</td><td>true</td></tr><tr><td>registration_client_uri</td><td>true</td></tr><tr><td>response_types</td><td>true</td></tr><tr><td>scope</td><td>true</td></tr><tr><td>software_id</td><td>true</td></tr><tr><td>software_version</td><td>true</td></tr><tr><td>token_endpoint_auth_method</td><td>true</td></tr><tr><td>tos_uri</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| OAuth2ProviderAppSecret<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>app_id</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>display_secret</td><td>false</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>secret_prefix</td><td>false</td></tr></tbody></table> |
|
||||
| Organization<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_default</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>workspace_sharing_disabled</td><td>true</td></tr></tbody></table> |
|
||||
| OrganizationSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>assign_default</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
|
||||
| PrebuildsSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>id</td><td>false</td></tr><tr><td>reconciliation_paused</td><td>true</td></tr></tbody></table> |
|
||||
| RoleSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
|
||||
| TaskTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>deleted_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>prompt</td><td>true</td></tr><tr><td>template_parameters</td><td>true</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>workspace_id</td><td>true</td></tr></tbody></table> |
|
||||
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>active_version_id</td><td>true</td></tr><tr><td>activity_bump</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>cors_behavior</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>disable_module_cache</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_port_sharing_level</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_display_name</td><td>false</td></tr><tr><td>organization_icon</td><td>false</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>organization_name</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>use_classic_parameter_flow</td><td>true</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_system</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_name</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>template_version_preset_id</td><td>false</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
|
||||
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
|
||||
| WorkspaceTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>favorite</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>next_start_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
| <b>Resource<b> | | |
|
||||
|-----------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| APIKey<br><i>login, logout, register, create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>allow_list</td><td>false</td></tr><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scopes</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
|
||||
| AuditableOrganizationMember<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>roles</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| CustomRole<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_system</td><td>false</td></tr><tr><td>member_permissions</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>org_permissions</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>site_permissions</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_permissions</td><td>true</td></tr></tbody></table> |
|
||||
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| GroupSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>auto_create_missing_groups</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>legacy_group_name_mapping</td><td>false</td></tr><tr><td>mapping</td><td>true</td></tr><tr><td>regex_filter</td><td>true</td></tr></tbody></table> |
|
||||
| HealthSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>dismissed_healthchecks</td><td>true</td></tr><tr><td>id</td><td>false</td></tr></tbody></table> |
|
||||
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
|
||||
| NotificationTemplate<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>actions</td><td>true</td></tr><tr><td>body_template</td><td>true</td></tr><tr><td>enabled_by_default</td><td>true</td></tr><tr><td>group</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>kind</td><td>true</td></tr><tr><td>method</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>title_template</td><td>true</td></tr></tbody></table> |
|
||||
| NotificationsSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>id</td><td>false</td></tr><tr><td>notifier_paused</td><td>true</td></tr></tbody></table> |
|
||||
| OAuth2ProviderApp<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>callback_url</td><td>true</td></tr><tr><td>client_id_issued_at</td><td>false</td></tr><tr><td>client_secret_expires_at</td><td>true</td></tr><tr><td>client_type</td><td>true</td></tr><tr><td>client_uri</td><td>true</td></tr><tr><td>contacts</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>dynamically_registered</td><td>true</td></tr><tr><td>grant_types</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwks</td><td>true</td></tr><tr><td>jwks_uri</td><td>true</td></tr><tr><td>logo_uri</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>policy_uri</td><td>true</td></tr><tr><td>redirect_uris</td><td>true</td></tr><tr><td>registration_access_token</td><td>true</td></tr><tr><td>registration_client_uri</td><td>true</td></tr><tr><td>response_types</td><td>true</td></tr><tr><td>scope</td><td>true</td></tr><tr><td>software_id</td><td>true</td></tr><tr><td>software_version</td><td>true</td></tr><tr><td>token_endpoint_auth_method</td><td>true</td></tr><tr><td>tos_uri</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| OAuth2ProviderAppSecret<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>app_id</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>display_secret</td><td>false</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>secret_prefix</td><td>false</td></tr></tbody></table> |
|
||||
| Organization<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_default</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>workspace_sharing_disabled</td><td>true</td></tr></tbody></table> |
|
||||
| OrganizationSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>assign_default</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
|
||||
| PrebuildsSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>id</td><td>false</td></tr><tr><td>reconciliation_paused</td><td>true</td></tr></tbody></table> |
|
||||
| RoleSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
|
||||
| TaskTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>deleted_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>prompt</td><td>true</td></tr><tr><td>template_parameters</td><td>true</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>workspace_id</td><td>true</td></tr></tbody></table> |
|
||||
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>active_version_id</td><td>true</td></tr><tr><td>activity_bump</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>cors_behavior</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>disable_module_cache</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_port_sharing_level</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_display_name</td><td>false</td></tr><tr><td>organization_icon</td><td>false</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>organization_name</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>use_classic_parameter_flow</td><td>true</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_system</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_name</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>template_version_preset_id</td><td>false</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
|
||||
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
|
||||
| WorkspaceTable<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>favorite</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>next_start_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
|
||||
<!-- End generated by 'make docs/admin/security/audit-logs.md'. -->
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ Dev containers extend your template with containerized development environments,
|
||||
allowing developers to work in consistent, reproducible setups defined by
|
||||
`devcontainer.json` files.
|
||||
|
||||
Coder's Dev Containers Integration uses the standard `@devcontainers/cli` and
|
||||
Coder's main Dev Containers Integration uses the standard `@devcontainers/cli` and
|
||||
Docker to run containers inside workspaces.
|
||||
|
||||
For setup instructions, see
|
||||
[Dev Containers Integration](../../integrations/devcontainers/integration.md).
|
||||
|
||||
For an alternative approach that doesn't require Docker, see
|
||||
[Envbuilder](../../integrations/devcontainers/envbuilder/index.md).
|
||||
For alternative approaches and comparisons between them, see the
|
||||
[Dev Containers](../../integrations/devcontainers/index.md) page.
|
||||
|
||||
@@ -54,21 +54,31 @@ For a full list of available modules please check
|
||||
|
||||
## Offline installations
|
||||
|
||||
In offline and restricted deployments, there are two ways to fetch modules.
|
||||
In offline and restricted deployments, there are three ways to fetch modules.
|
||||
|
||||
1. Artifactory
|
||||
2. Private git repository
|
||||
1. Artifactory Remote Terraform Repository (Recommended)
|
||||
2. Artifactory Local Repository (manual publishing)
|
||||
3. Private git repository
|
||||
|
||||
### Artifactory
|
||||
### Artifactory Remote Terraform Repository (Recommended)
|
||||
|
||||
Air gapped users can clone the [coder/registry](https://github.com/coder/registry/)
|
||||
Configure Artifactory as a **Remote Terraform Repository** that proxies and
|
||||
caches the Coder registry. This approach provides automatic updates and
|
||||
requires no manual synchronization.
|
||||
|
||||
See [Mirror the Coder Registry with JFrog Artifactory](../../../install/registry-mirror-artifactory.md)
|
||||
for complete setup instructions.
|
||||
|
||||
### Artifactory Local Repository
|
||||
|
||||
Air-gapped users can clone the [coder/registry](https://github.com/coder/registry/)
|
||||
repo and publish a
|
||||
[local terraform module repository](https://jfrog.com/help/r/jfrog-artifactory-documentation/set-up-a-terraform-module/provider-registry)
|
||||
to resolve modules via [Artifactory](https://jfrog.com/artifactory/).
|
||||
|
||||
1. Create a local-terraform-repository with name `coder-modules-local`
|
||||
2. Create a virtual repository with name `tf`
|
||||
3. Follow the below instructions to publish coder modules to Artifactory
|
||||
1. Create a virtual repository with name `tf`
|
||||
1. Follow the below instructions to publish coder modules to Artifactory
|
||||
|
||||
```shell
|
||||
git clone https://github.com/coder/registry
|
||||
@@ -77,9 +87,9 @@ to resolve modules via [Artifactory](https://jfrog.com/artifactory/).
|
||||
jf tf p --namespace="coder" --provider="coder" --tag="1.0.0"
|
||||
```
|
||||
|
||||
4. Generate a token with access to the `tf` repo and set an `ENV` variable
|
||||
1. Generate a token with access to the `tf` repo and set an `ENV` variable
|
||||
`TF_TOKEN_example.jfrog.io="XXXXXXXXXXXXXXX"` on the Coder provisioner.
|
||||
5. Create a file `.terraformrc` with following content and mount at
|
||||
1. Create a file `.terraformrc` with following content and mount at
|
||||
`/home/coder/.terraformrc` within the Coder provisioner.
|
||||
|
||||
```tf
|
||||
@@ -93,7 +103,7 @@ to resolve modules via [Artifactory](https://jfrog.com/artifactory/).
|
||||
}
|
||||
```
|
||||
|
||||
6. Update module source as:
|
||||
1. Update module source as:
|
||||
|
||||
```tf
|
||||
module "module-name" {
|
||||
|
||||
@@ -115,6 +115,25 @@ specified in your template in the `disable_params` search params list
|
||||
[](https://YOUR_ACCESS_URL/templates/YOUR_TEMPLATE/workspace?disable_params=first_parameter,second_parameter)
|
||||
```
|
||||
|
||||
### Security: consent dialog for automatic creation
|
||||
|
||||
When using `mode=auto` with prefilled `param.*` values, Coder displays a
|
||||
security consent dialog before creating the workspace. This protects users
|
||||
from malicious links that could provision workspaces with untrusted
|
||||
configurations, such as dotfiles or startup scripts from unknown sources.
|
||||
|
||||
The dialog shows:
|
||||
|
||||
- A warning that a workspace is about to be created automatically from a link
|
||||
- All prefilled `param.*` values from the URL
|
||||
- **Confirm and Create** and **Cancel** buttons
|
||||
|
||||
The workspace is only created if the user explicitly clicks **Confirm and
|
||||
Create**. Clicking **Cancel** falls back to the standard creation form where
|
||||
all parameters can be reviewed manually.
|
||||
|
||||

|
||||
|
||||
### Example: Kubernetes
|
||||
|
||||
For a full example of the Open in Coder flow in Kubernetes, check out
|
||||
|
||||
@@ -96,6 +96,24 @@ You can use the
|
||||
server flag to set the maximum duration for long-lived tokens in your
|
||||
deployment.
|
||||
|
||||
### Remove or expire a token
|
||||
|
||||
You can remove a token using the CLI or the API. By default, `coder tokens remove`
|
||||
expires the token, (soft-delete):
|
||||
|
||||
```console
|
||||
coder tokens remove <name|id>
|
||||
```
|
||||
|
||||
Expired tokens can no longer be used for authentication but remain visible in
|
||||
token listings.
|
||||
|
||||
To hard-delete a token, use the `--delete` flag:
|
||||
|
||||
```console
|
||||
coder tokens remove --delete <name|id>
|
||||
```
|
||||
|
||||
## API Key Scopes
|
||||
|
||||
API key scopes allow you to limit the permissions of a token to specific operations. By default, tokens are created with the `all` scope, granting full access to all actions the user can perform. For improved security, you can create tokens with limited scopes that restrict access to only the operations needed.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user