Compare commits
319 Commits
release/2.11
...
v2.13.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a027b0774 | |||
| bddf0bf85a | |||
| d4f8a6481a | |||
| 48c4859942 | |||
| 5b120203b2 | |||
| 5edfccf30d | |||
| 56bf386b15 | |||
| c620bffbb3 | |||
| 84d992062b | |||
| 6daf330d3a | |||
| 3cc86cf62d | |||
| 1a877716ca | |||
| 0a221e8d5b | |||
| 4213560b7a | |||
| 2a21b0d144 | |||
| 86ee75b672 | |||
| 0b8b227dcf | |||
| bda94bfc77 | |||
| 8b615f4522 | |||
| 093ec3d05b | |||
| 5a0afd8b7e | |||
| 22f2c6da4f | |||
| ce7f13c6c3 | |||
| 089f06886b | |||
| c94b5188bd | |||
| 5b59f2880f | |||
| c4f1676055 | |||
| 30c4b4db5c | |||
| 08e728bcb2 | |||
| 20e59e0797 | |||
| d5d8b918d7 | |||
| 8a3592582b | |||
| 87ad560aff | |||
| 58325dfd14 | |||
| fed668b432 | |||
| d7eadee4d7 | |||
| 9c1a6a29f2 | |||
| 46e1c36c42 | |||
| 0d2f14606b | |||
| 136900268e | |||
| 313d4e02d2 | |||
| 65b9f9bfd6 | |||
| 94639730f8 | |||
| 34c67e8428 | |||
| e4333c0433 | |||
| 8ccdf05bbc | |||
| 218f429336 | |||
| 6f4a9b6b51 | |||
| 3dec6ff32f | |||
| b9d83c75de | |||
| 7e20b56352 | |||
| 3d6c9799e3 | |||
| b4a5c7ffa9 | |||
| 7cb8bfb133 | |||
| 2cfadad023 | |||
| 9abaa94599 | |||
| 54e8f30002 | |||
| 5177f366f5 | |||
| 0e933f0537 | |||
| 3ef12ac284 | |||
| 75e7213ac2 | |||
| cbdaa63b68 | |||
| 714f2ef83c | |||
| 73a25c3bc5 | |||
| 819bfd3170 | |||
| 66a604d779 | |||
| 2ef2f97388 | |||
| 889daf200e | |||
| c4656d77cc | |||
| 495eea452f | |||
| 43e45f4ab7 | |||
| 57b38e5bb8 | |||
| 0793a4b35b | |||
| a1db6d809e | |||
| a1ec8ad6e9 | |||
| 8e06ad46d0 | |||
| 4699adee5e | |||
| 8923ce5216 | |||
| 02ffff11dd | |||
| 7049d7a881 | |||
| 84cdcac8ad | |||
| e987ad1d89 | |||
| 3a1fa04590 | |||
| d0b2f6196c | |||
| 1de023a121 | |||
| 1d3642d0be | |||
| 8c1bd32c33 | |||
| 07cd9acb2c | |||
| eed9794516 | |||
| 808e1c0d89 | |||
| 44d69139d5 | |||
| 87820a29d7 | |||
| c01d6fdf46 | |||
| fe240add86 | |||
| d04959cea8 | |||
| 3d30c8dc68 | |||
| 7d51515f9d | |||
| 87a172fb14 | |||
| c587af7c0e | |||
| 5d3f3c08cd | |||
| 0268c7a659 | |||
| 4b0b9b08d5 | |||
| 88eb6ce378 | |||
| fc09077b7b | |||
| d0fc81a51c | |||
| bbe23edc7d | |||
| de9e6889bb | |||
| 1ca5dc0328 | |||
| 28228f1bcb | |||
| 58bf0ec1c6 | |||
| ba7d1835e5 | |||
| 0c627a4cb9 | |||
| a11f8b003b | |||
| dd99897bb2 | |||
| 5ccf5084e8 | |||
| c9cca9d56e | |||
| 7958c52918 | |||
| 1f9bdc36bf | |||
| dd243686e4 | |||
| e7bea17e70 | |||
| 363dbad3a3 | |||
| 5b9a65e5c1 | |||
| c7e7312cb0 | |||
| e96652ebbc | |||
| 8326a3a675 | |||
| 7c081dcd6f | |||
| 0d65143301 | |||
| 056a697eff | |||
| 48ecee1025 | |||
| 7c3b8b6224 | |||
| e2b330fcba | |||
| 1adc19b41f | |||
| 4dfa901990 | |||
| a8a81a61cd | |||
| 44a70a5bc2 | |||
| 1131772e79 | |||
| e743588843 | |||
| 37676c46d5 | |||
| 7995d7c3d6 | |||
| f1b42a15fa | |||
| 8f62311f00 | |||
| fade8ba759 | |||
| 775fc3f5e9 | |||
| ffcfbb6c55 | |||
| 9c3fd5dd26 | |||
| 42324b386a | |||
| a4bba520a2 | |||
| 9a757f8e74 | |||
| 83ac386533 | |||
| 0ea89a3d41 | |||
| 213848e2e3 | |||
| 8435b70bea | |||
| 3b7f9534fb | |||
| e3206612e1 | |||
| 168d2d6ba0 | |||
| cd32c42699 | |||
| e527bc6242 | |||
| a51076a4cd | |||
| 78b8264a90 | |||
| c7233eccec | |||
| 40390ecc30 | |||
| 2806752c7d | |||
| e4ac691468 | |||
| 43ef00401c | |||
| 27f26910b6 | |||
| 0b019cad77 | |||
| 9d00a26a90 | |||
| 8cdd468107 | |||
| cb94dfb1f6 | |||
| 79fd736387 | |||
| 973cc2b875 | |||
| 24ba81930b | |||
| bf98b0dfe4 | |||
| b723da9e91 | |||
| b248f125e1 | |||
| de8149fbfd | |||
| 19530c6b44 | |||
| 4758952ebc | |||
| bee4ece1b9 | |||
| 7569cccc51 | |||
| 59ab5053b1 | |||
| e176867d77 | |||
| 7cc96f5d40 | |||
| 7a7bef0dab | |||
| a1671a633c | |||
| 6730c24c58 | |||
| 5aea80381c | |||
| 9eb797eb5a | |||
| 5fb231774c | |||
| 9ae825ebae | |||
| 374f0a0fd1 | |||
| bc8126fa45 | |||
| 5789ea5397 | |||
| afd9d3b35f | |||
| b69f6358f0 | |||
| cca3cb1c55 | |||
| b7edf5bbc7 | |||
| 84b3121777 | |||
| a551aa51ab | |||
| ec78f54941 | |||
| ef4ed64a29 | |||
| 02c36868b2 | |||
| 7ea510e091 | |||
| 18692058a9 | |||
| 5a8a254c93 | |||
| 00f6cfe3cf | |||
| 9299e9f6ba | |||
| e5d848f19d | |||
| 1edd46dd5f | |||
| 762cb84f4a | |||
| 6293c33746 | |||
| 5b78ec97b6 | |||
| 79d73f77f5 | |||
| a1d3b82dd1 | |||
| 47f8f5d963 | |||
| 60224fa216 | |||
| 87dd878779 | |||
| ff617cc545 | |||
| a0962ba089 | |||
| e5bb0a7a00 | |||
| 1b4ca00428 | |||
| d748c6d718 | |||
| 98fa823c79 | |||
| b43344b672 | |||
| c67eba10d5 | |||
| c2837a62e4 | |||
| fa9edc1f42 | |||
| a40e954afc | |||
| 3364abecdd | |||
| ed6ee9aaa8 | |||
| 390ff9ac05 | |||
| 7ea4a89a20 | |||
| 78deaba481 | |||
| f27f5c0002 | |||
| 3f1e9c038a | |||
| 0a86d6d176 | |||
| c61b64be61 | |||
| 8e78b9495d | |||
| 273209432d | |||
| b8b80fe6d2 | |||
| 45b45f1107 | |||
| a63d427efd | |||
| 4af0f093ee | |||
| d8bb5a05db | |||
| f176ff532f | |||
| f23d4802b5 | |||
| f66d0445da | |||
| 0998cedb5c | |||
| 92c5dfa266 | |||
| 80538c079d | |||
| ad8c314130 | |||
| 85de0e966d | |||
| cf91eff7cf | |||
| 194be12133 | |||
| a0fce363cd | |||
| 63e06853eb | |||
| 114fb31fbb | |||
| fc6f18aa96 | |||
| 1f5788feff | |||
| cb6b5e8fbd | |||
| f14927955d | |||
| a8a0be98b8 | |||
| 721ab2a1b4 | |||
| 2b29559984 | |||
| 9ced001570 | |||
| ebee9288ae | |||
| a5a64948cd | |||
| 8412450ae3 | |||
| c41d0efff9 | |||
| 7358c1b1ac | |||
| 4e7381341f | |||
| 228b99d9c2 | |||
| f13b1c9af6 | |||
| 5ddbeddf85 | |||
| 3d707cbe5a | |||
| ee817b4d80 | |||
| c557c25b3d | |||
| 82c1562f82 | |||
| 8c9560ddb8 | |||
| 7eb228e3ff | |||
| 6182ee90f0 | |||
| 989575c5b6 | |||
| 4671ebb330 | |||
| e14f8fb64b | |||
| 679099373b | |||
| d8e0be6ee6 | |||
| a4bd50c985 | |||
| 1832a755e1 | |||
| 35cb572888 | |||
| 24448e79fe | |||
| c73d5a2617 | |||
| 06dd656e08 | |||
| b7a921a2bf | |||
| 30227dae97 | |||
| 96f2cec541 | |||
| 3905e2c541 | |||
| 421c0d1242 | |||
| 677be9aab2 | |||
| 72f2efe048 | |||
| 5e8f97d8c3 | |||
| b56c9c438f | |||
| 6f5c183c80 | |||
| 3e3118794f | |||
| 05facc971b | |||
| e7c87a806b | |||
| dfd27f559e | |||
| deee9492e3 | |||
| 619ec927e9 | |||
| e76b595052 | |||
| d51c6912a7 | |||
| 2efb46a10e | |||
| 7c3ec51997 | |||
| 3e77f5b512 | |||
| d956af0a3a | |||
| 886a97b425 | |||
| 13dd526f11 | |||
| b20c63c185 | |||
| 060f023174 | |||
| 205c43da99 |
@@ -4,12 +4,12 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.21.9"
|
||||
default: "1.22.4"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Go
|
||||
uses: buildjet/setup-go@v5
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ inputs.version }}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ runs:
|
||||
with:
|
||||
version: 8
|
||||
- name: Setup Node
|
||||
uses: buildjet/setup-node@v4.0.1
|
||||
uses: actions/setup-node@v4.0.1
|
||||
with:
|
||||
node-version: 18.19.0
|
||||
# See https://github.com/actions/setup-node#caching-global-packages-data
|
||||
|
||||
@@ -7,5 +7,5 @@ runs:
|
||||
- name: Install Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: 1.5.7
|
||||
terraform_version: 1.8.4
|
||||
terraform_wrapper: false
|
||||
|
||||
+3
-45
@@ -61,7 +61,9 @@ updates:
|
||||
- dependency-name: "terraform"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/site/"
|
||||
directories:
|
||||
- "/site"
|
||||
- "/offlinedocs"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
time: "06:00"
|
||||
@@ -82,47 +84,3 @@ updates:
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
open-pull-requests-limit: 15
|
||||
groups:
|
||||
site:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/offlinedocs/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
reviewers:
|
||||
- "coder/ts"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels: []
|
||||
ignore:
|
||||
# Ignore patch updates for all dependencies
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- version-update:semver-patch
|
||||
# Ignore major updates to Node.js types, because they need to
|
||||
# correspond to the Node.js engine version
|
||||
- dependency-name: "@types/node"
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
groups:
|
||||
offlinedocs:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# Update dogfood.
|
||||
- package-ecosystem: "terraform"
|
||||
directory: "/dogfood/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels: []
|
||||
ignore:
|
||||
# We likely want to update this ourselves.
|
||||
- dependency-name: "coder/coder"
|
||||
|
||||
@@ -86,6 +86,7 @@ provider "kubernetes" {
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
resource "coder_agent" "main" {
|
||||
os = "linux"
|
||||
@@ -175,21 +176,21 @@ resource "coder_app" "code-server" {
|
||||
|
||||
resource "kubernetes_persistent_volume_claim" "home" {
|
||||
metadata {
|
||||
name = "coder-${lower(data.coder_workspace.me.owner)}-${lower(data.coder_workspace.me.name)}-home"
|
||||
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-home"
|
||||
namespace = var.namespace
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-pvc"
|
||||
"app.kubernetes.io/instance" = "coder-pvc-${lower(data.coder_workspace.me.owner)}-${lower(data.coder_workspace.me.name)}"
|
||||
"app.kubernetes.io/instance" = "coder-pvc-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
//Coder-specific labels.
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace.me.owner_id
|
||||
"com.coder.user.username" = data.coder_workspace.me.owner
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
annotations = {
|
||||
"com.coder.user.email" = data.coder_workspace.me.owner_email
|
||||
"com.coder.user.email" = data.coder_workspace_owner.me.email
|
||||
}
|
||||
}
|
||||
wait_until_bound = false
|
||||
@@ -210,20 +211,20 @@ resource "kubernetes_deployment" "main" {
|
||||
]
|
||||
wait_for_rollout = false
|
||||
metadata {
|
||||
name = "coder-${lower(data.coder_workspace.me.owner)}-${lower(data.coder_workspace.me.name)}"
|
||||
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
|
||||
namespace = var.namespace
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${lower(data.coder_workspace.me.owner)}-${lower(data.coder_workspace.me.name)}"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
|
||||
"app.kubernetes.io/part-of" = "coder"
|
||||
"com.coder.resource" = "true"
|
||||
"com.coder.workspace.id" = data.coder_workspace.me.id
|
||||
"com.coder.workspace.name" = data.coder_workspace.me.name
|
||||
"com.coder.user.id" = data.coder_workspace.me.owner_id
|
||||
"com.coder.user.username" = data.coder_workspace.me.owner
|
||||
"com.coder.user.id" = data.coder_workspace_owner.me.id
|
||||
"com.coder.user.username" = data.coder_workspace_owner.me.name
|
||||
}
|
||||
annotations = {
|
||||
"com.coder.user.email" = data.coder_workspace.me.owner_email
|
||||
"com.coder.user.email" = data.coder_workspace_owner.me.email
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+127
-25
@@ -37,8 +37,10 @@ jobs:
|
||||
k8s: ${{ steps.filter.outputs.k8s }}
|
||||
ci: ${{ steps.filter.outputs.ci }}
|
||||
db: ${{ steps.filter.outputs.db }}
|
||||
gomod: ${{ steps.filter.outputs.gomod }}
|
||||
offlinedocs-only: ${{ steps.filter.outputs.offlinedocs_count == steps.filter.outputs.all_count }}
|
||||
offlinedocs: ${{ steps.filter.outputs.offlinedocs }}
|
||||
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -90,6 +92,9 @@ jobs:
|
||||
- "scaletest/**"
|
||||
- "tailnet/**"
|
||||
- "testutil/**"
|
||||
gomod:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
ts:
|
||||
- "site/**"
|
||||
- "Makefile"
|
||||
@@ -103,15 +108,38 @@ jobs:
|
||||
- ".github/workflows/ci.yaml"
|
||||
offlinedocs:
|
||||
- "offlinedocs/**"
|
||||
tailnet-integration:
|
||||
- "tailnet/**"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
|
||||
- id: debug
|
||||
run: |
|
||||
echo "${{ toJSON(steps.filter )}}"
|
||||
|
||||
update-flake:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.gomod == 'true'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Update Nix Flake SRI Hash
|
||||
run: ./scripts/update-flake.sh
|
||||
|
||||
- name: Ensure No Changes
|
||||
run: git diff --exit-code
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.offlinedocs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -132,7 +160,7 @@ jobs:
|
||||
echo "LINT_CACHE_DIR=$dir" >> $GITHUB_ENV
|
||||
|
||||
- name: golangci-lint cache
|
||||
uses: buildjet/cache@v4
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.LINT_CACHE_DIR }}
|
||||
@@ -142,7 +170,7 @@ jobs:
|
||||
|
||||
# Check for any typos
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.20.10
|
||||
uses: crate-ci/typos@v1.22.9
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
|
||||
@@ -163,9 +191,15 @@ jobs:
|
||||
run: |
|
||||
make --output-sync=line -j lint
|
||||
|
||||
- name: Check workflow files
|
||||
run: |
|
||||
bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 1.6.22
|
||||
./actionlint -color -shellcheck= -ignore "set-output"
|
||||
shell: bash
|
||||
|
||||
gen:
|
||||
timeout-minutes: 8
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
@@ -183,6 +217,9 @@ jobs:
|
||||
- name: Setup sqlc
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: go install tools
|
||||
run: |
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30
|
||||
@@ -212,7 +249,7 @@ jobs:
|
||||
fmt:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.offlinedocs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 7
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -223,12 +260,9 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
# Use default Go version
|
||||
- name: Setup Go
|
||||
uses: buildjet/setup-go@v5
|
||||
with:
|
||||
# This doesn't need caching. It's super fast anyways!
|
||||
cache: false
|
||||
go-version: 1.21.9
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Install shfmt
|
||||
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0
|
||||
@@ -242,7 +276,7 @@ jobs:
|
||||
run: ./scripts/check_unstaged.sh
|
||||
|
||||
test-go:
|
||||
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'buildjet-4vcpu-ubuntu-2204' || matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'macos-latest-xlarge' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-16-cores' || matrix.os }}
|
||||
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'macos-latest-xlarge' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-16-cores' || matrix.os }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 20
|
||||
@@ -298,7 +332,7 @@ jobs:
|
||||
api-key: ${{ secrets.DATADOG_API_KEY }}
|
||||
|
||||
test-go-pg:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs:
|
||||
- changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
@@ -320,8 +354,50 @@ jobs:
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
env:
|
||||
POSTGRES_VERSION: "13"
|
||||
TS_DEBUG_DISCO: "true"
|
||||
run: |
|
||||
make test-postgres
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
timeout-minutes: 1
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/upload-datadog
|
||||
if: success() || failure()
|
||||
with:
|
||||
api-key: ${{ secrets.DATADOG_API_KEY }}
|
||||
|
||||
# NOTE: this could instead be defined as a matrix strategy, but we want to
|
||||
# only block merging if tests on postgres 13 fail. Using a matrix strategy
|
||||
# here makes the check in the above `required` job rather complicated.
|
||||
test-go-pg-16:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs:
|
||||
- changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
# This timeout must be greater than the timeout set by `go test` in
|
||||
# `make test-postgres` to ensure we receive a trace of running
|
||||
# goroutines. Setting this to the timeout +5m should work quite well
|
||||
# even if some of the preceding steps are slow.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
env:
|
||||
POSTGRES_VERSION: "16"
|
||||
TS_DEBUG_DISCO: "true"
|
||||
run: |
|
||||
export TS_DEBUG_DISCO=true
|
||||
make test-postgres
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
@@ -333,7 +409,7 @@ jobs:
|
||||
api-key: ${{ secrets.DATADOG_API_KEY }}
|
||||
|
||||
test-go-race:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 25
|
||||
@@ -361,8 +437,36 @@ jobs:
|
||||
with:
|
||||
api-key: ${{ secrets.DATADOG_API_KEY }}
|
||||
|
||||
# Tailnet integration tests only run when the `tailnet` directory or `go.sum`
|
||||
# and `go.mod` are changed. These tests are to ensure we don't add regressions
|
||||
# to tailnet, either due to our code or due to updating dependencies.
|
||||
#
|
||||
# These tests are skipped in the main go test jobs because they require root
|
||||
# and mess with networking.
|
||||
test-go-tailnet-integration:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
# Unnecessary to run on main for now
|
||||
if: needs.changes.outputs.tailnet-integration == 'true' || needs.changes.outputs.ci == 'true'
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
# Used by some integration tests.
|
||||
- name: Install Nginx
|
||||
run: sudo apt-get update && sudo apt-get install -y nginx
|
||||
|
||||
- name: Run Tests
|
||||
run: make test-tailnet-integration
|
||||
|
||||
test-js:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 20
|
||||
@@ -379,7 +483,7 @@ jobs:
|
||||
working-directory: site
|
||||
|
||||
test-e2e:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-16vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 20
|
||||
@@ -523,7 +627,7 @@ jobs:
|
||||
offlinedocs:
|
||||
name: offlinedocs
|
||||
needs: changes
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
if: needs.changes.outputs.offlinedocs == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.docs == 'true'
|
||||
|
||||
steps:
|
||||
@@ -621,11 +725,10 @@ jobs:
|
||||
|
||||
build:
|
||||
# This builds and publishes ghcr.io/coder/coder-preview:main for each commit
|
||||
# to main branch. We are only building this for amd64 platform. (>95% pulls
|
||||
# are for amd64)
|
||||
# to main branch.
|
||||
needs: changes
|
||||
if: github.ref == 'refs/heads/main' && needs.changes.outputs.docs-only == 'false' && !github.event.pull_request.head.repo.fork
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
outputs:
|
||||
@@ -831,7 +934,7 @@ jobs:
|
||||
# runs sqlc-vet to ensure all queries are valid. This catches any mistakes
|
||||
# in migrations or sqlc queries that makes a query unable to be prepared.
|
||||
sqlc-vet:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.db == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
@@ -854,17 +957,16 @@ jobs:
|
||||
# This action is not intended to do a vulnerability check since that is handled by a separate action.
|
||||
dependency-license-review:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref != 'refs/heads/main'
|
||||
if: github.ref != 'refs/heads/main' && github.actor != 'dependabot[bot]'
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@v4
|
||||
- name: "Dependency Review"
|
||||
id: review
|
||||
# TODO: Replace this with the latest release once https://github.com/actions/dependency-review-action/pull/761 is merged.
|
||||
uses: actions/dependency-review-action@49fbbe0acb033b7824f26d00b005d7d598d76301
|
||||
uses: actions/dependency-review-action@v4.3.2
|
||||
with:
|
||||
allow-licenses: Apache-2.0, BSD-2-Clause, BSD-3-Clause, CC0-1.0, ISC, MIT, MIT-0, MPL-2.0
|
||||
allow-dependencies-licenses: "pkg:golang/github.com/pelletier/go-toml/v2"
|
||||
allow-dependencies-licenses: "pkg:golang/github.com/coder/wgtunnel@0.1.13-0.20240522110300-ade90dfb2da0, pkg:npm/pako@1.0.11"
|
||||
license-check: true
|
||||
vulnerability-check: false
|
||||
- name: "Report"
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
steps:
|
||||
- name: cla
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.3.2
|
||||
uses: contributor-assistant/github-action@v2.4.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||
|
||||
@@ -8,6 +8,11 @@ on:
|
||||
- scripts/Dockerfile.base
|
||||
- scripts/Dockerfile
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- scripts/Dockerfile.base
|
||||
- .github/workflows/docker-base.yaml
|
||||
|
||||
schedule:
|
||||
# Run every week at 09:43 on Monday, Wednesday and Friday. We build this
|
||||
# frequently to ensure that packages are up-to-date.
|
||||
@@ -57,11 +62,12 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
pull: true
|
||||
no-cache: true
|
||||
push: true
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
ghcr.io/coder/coder-base:latest
|
||||
|
||||
- name: Verify that images are pushed properly
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
# retry 10 times with a 5 second delay as the images may not be
|
||||
# available immediately
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
# While GitHub's toaster runners are likelier to flake, we want consistency
|
||||
# between this environment and the regular test environment for DataDog
|
||||
# statistics and to only show real workflow threats.
|
||||
runs-on: "buildjet-8vcpu-ubuntu-2204"
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
# This runner costs 0.016 USD per minute,
|
||||
# so 0.016 * 240 = 3.84 USD per run.
|
||||
timeout-minutes: 240
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
go-timing:
|
||||
# We run these tests with p=1 so we don't need a lot of compute.
|
||||
runs-on: "buildjet-2vcpu-ubuntu-2204"
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -14,4 +14,4 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Assign author
|
||||
uses: toshimaru/auto-author-assign@v2.1.0
|
||||
uses: toshimaru/auto-author-assign@v2.1.1
|
||||
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
needs: get_info
|
||||
# Run build job only if there are changes in the files that we care about or if the workflow is manually triggered with --build flag
|
||||
if: needs.get_info.outputs.BUILD == 'true'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
# This concurrency only cancels build jobs if a new build is triggred. It will avoid cancelling the current deployemtn in case of docs chnages.
|
||||
concurrency:
|
||||
group: build-${{ github.workflow }}-${{ github.ref }}-${{ needs.get_info.outputs.BUILD }}
|
||||
|
||||
@@ -39,7 +39,7 @@ env:
|
||||
jobs:
|
||||
release:
|
||||
name: Build and publish
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
env:
|
||||
# Necessary for Docker manifest
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
|
||||
- name: Test migrations from current ref to main
|
||||
run: |
|
||||
make test-migrations
|
||||
POSTGRES_VERSION=13 make test-migrations
|
||||
|
||||
# Setup GCloud for signing Windows binaries.
|
||||
- name: Authenticate to Google Cloud
|
||||
@@ -297,7 +297,7 @@ jobs:
|
||||
|
||||
# build Docker images for each architecture
|
||||
version="$(./scripts/version.sh)"
|
||||
make -j build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
|
||||
make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
|
||||
|
||||
# we can't build multi-arch if the images aren't pushed, so quit now
|
||||
# if dry-running
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
|
||||
# build and push multi-arch manifest, this depends on the other images
|
||||
# being pushed so will automatically push them.
|
||||
make -j push/build/coder_"$version"_linux.tag
|
||||
make push/build/coder_"$version"_linux.tag
|
||||
|
||||
# if the current version is equal to the highest (according to semver)
|
||||
# version in the repo, also create a multi-arch image as ":latest" and
|
||||
|
||||
@@ -23,7 +23,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
|
||||
|
||||
trivy:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@d710430a6722f083d3b36b8339ff66b32f22ee55
|
||||
uses: aquasecurity/trivy-action@7c2007bcb556501da015201bcba5aa14069b74e2
|
||||
with:
|
||||
image-ref: ${{ steps.build.outputs.image }}
|
||||
format: sarif
|
||||
|
||||
@@ -33,4 +33,5 @@ extend-exclude = [
|
||||
"**/pnpm-lock.yaml",
|
||||
"tailnet/testdata/**",
|
||||
"site/src/pages/SetupPage/countries.tsx",
|
||||
"provisioner/terraform/testdata/**",
|
||||
]
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
file-path: "./README.md"
|
||||
|
||||
- name: Send Slack notification
|
||||
if: failure() && github.event_name != 'workflow_dispatch'
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
run: |
|
||||
curl -X POST -H 'Content-type: application/json' -d '{"msg":"Broken links found in the documentation. Please check the logs at ${{ env.LOGS_URL }}"}' ${{ secrets.DOCS_LINK_SLACK_WEBHOOK }}
|
||||
echo "Sent Slack notification"
|
||||
|
||||
@@ -68,3 +68,6 @@ result
|
||||
|
||||
# Filebrowser.db
|
||||
**/filebrowser.db
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
||||
@@ -71,6 +71,9 @@ result
|
||||
|
||||
# Filebrowser.db
|
||||
**/filebrowser.db
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
# .prettierignore.include:
|
||||
# Helm templates contain variables that are invalid YAML and can't be formatted
|
||||
# by Prettier.
|
||||
|
||||
Vendored
+3
-2
@@ -195,7 +195,6 @@
|
||||
"**.pb.go": true,
|
||||
"**/*.gen.json": true,
|
||||
"**/testdata/*": true,
|
||||
"**Generated.ts": true,
|
||||
"coderd/apidoc/**": true,
|
||||
"docs/api/*.md": true,
|
||||
"docs/templates/*.md": true,
|
||||
@@ -222,5 +221,7 @@
|
||||
"go.testFlags": ["-short", "-coverpkg=./..."],
|
||||
// We often use a version of TypeScript that's ahead of the version shipped
|
||||
// with VS Code.
|
||||
"typescript.tsdk": "./site/node_modules/typescript/lib"
|
||||
"typescript.tsdk": "./site/node_modules/typescript/lib",
|
||||
// Playwright tests in VSCode will open a browser to live "view" the test.
|
||||
"playwright.reuseBrowser": true
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ GOOS := $(shell go env GOOS)
|
||||
GOARCH := $(shell go env GOARCH)
|
||||
GOOS_BIN_EXT := $(if $(filter windows, $(GOOS)),.exe,)
|
||||
VERSION := $(shell ./scripts/version.sh)
|
||||
POSTGRES_VERSION ?= 16
|
||||
|
||||
# Use the highest ZSTD compression level in CI.
|
||||
ifdef CI
|
||||
@@ -56,6 +57,9 @@ GO_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go' -not -nam
|
||||
# All the shell files in the repo, excluding ignored files.
|
||||
SHELL_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
|
||||
|
||||
# Ensure we don't use the user's git configs which might cause side-effects
|
||||
GIT_FLAGS = GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
|
||||
|
||||
# All ${OS}_${ARCH} combos we build for. Windows binaries have the .exe suffix.
|
||||
OS_ARCHES := \
|
||||
linux_amd64 linux_arm64 linux_armv7 \
|
||||
@@ -483,12 +487,14 @@ gen: \
|
||||
$(DB_GEN_FILES) \
|
||||
site/src/api/typesGenerated.ts \
|
||||
coderd/rbac/object_gen.go \
|
||||
codersdk/rbacresources_gen.go \
|
||||
docs/admin/prometheus.md \
|
||||
docs/cli.md \
|
||||
docs/admin/audit-logs.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
.prettierignore.include \
|
||||
.prettierignore \
|
||||
provisioner/terraform/testdata/version \
|
||||
site/.prettierrc.yaml \
|
||||
site/.prettierignore \
|
||||
site/.eslintignore \
|
||||
@@ -554,6 +560,9 @@ coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $
|
||||
coderd/database/dbmock/dbmock.go: coderd/database/db.go coderd/database/querier.go
|
||||
go generate ./coderd/database/dbmock/
|
||||
|
||||
coderd/database/pubsub/psmock/psmock.go: coderd/database/pubsub/pubsub.go
|
||||
go generate ./coderd/database/pubsub/psmock
|
||||
|
||||
tailnet/tailnettest/coordinatormock.go tailnet/tailnettest/multiagentmock.go tailnet/tailnettest/coordinateemock.go: tailnet/coordinator.go tailnet/multiagent.go
|
||||
go generate ./tailnet/tailnettest/
|
||||
|
||||
@@ -607,8 +616,11 @@ site/src/theme/icons.json: $(wildcard scripts/gensite/*) $(wildcard site/static/
|
||||
examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates)
|
||||
go run ./scripts/examplegen/main.go > examples/examples.gen.json
|
||||
|
||||
coderd/rbac/object_gen.go: scripts/rbacgen/main.go coderd/rbac/object.go
|
||||
go run scripts/rbacgen/main.go ./coderd/rbac > coderd/rbac/object_gen.go
|
||||
coderd/rbac/object_gen.go: scripts/rbacgen/rbacobject.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go
|
||||
go run scripts/rbacgen/main.go rbac > coderd/rbac/object_gen.go
|
||||
|
||||
codersdk/rbacresources_gen.go: scripts/rbacgen/codersdk.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go
|
||||
go run scripts/rbacgen/main.go codersdk > codersdk/rbacresources_gen.go
|
||||
|
||||
docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
|
||||
go run scripts/metricsdocgen/main.go
|
||||
@@ -674,6 +686,12 @@ provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/tes
|
||||
go test ./provisioner/terraform -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
|
||||
provisioner/terraform/testdata/version:
|
||||
if [[ "$(shell cat provisioner/terraform/testdata/version.txt)" != "$(shell terraform version -json | jq -r '.terraform_version')" ]]; then
|
||||
./provisioner/terraform/testdata/generate.sh
|
||||
fi
|
||||
.PHONY: provisioner/terraform/testdata/version
|
||||
|
||||
scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go)
|
||||
go test ./scripts/ci-report -run=TestOutputMatchesGoldenFile -update
|
||||
touch "$@"
|
||||
@@ -739,7 +757,7 @@ site/.eslintignore site/.prettierignore: .prettierignore Makefile
|
||||
done < "$<"
|
||||
|
||||
test:
|
||||
gotestsum --format standard-quiet -- -v -short -count=1 ./...
|
||||
$(GIT_FLAGS) gotestsum --format standard-quiet -- -v -short -count=1 ./...
|
||||
.PHONY: test
|
||||
|
||||
# sqlc-cloud-is-setup will fail if no SQLc auth token is set. Use this as a
|
||||
@@ -775,7 +793,7 @@ sqlc-vet: test-postgres-docker
|
||||
test-postgres: test-postgres-docker
|
||||
# The postgres test is prone to failure, so we limit parallelism for
|
||||
# more consistent execution.
|
||||
DB=ci DB_FROM=$(shell go run scripts/migrate-ci/main.go) gotestsum \
|
||||
$(GIT_FLAGS) DB=ci DB_FROM=$(shell go run scripts/migrate-ci/main.go) gotestsum \
|
||||
--junitfile="gotests.xml" \
|
||||
--jsonfile="gotests.json" \
|
||||
--packages="./..." -- \
|
||||
@@ -787,14 +805,17 @@ test-postgres: test-postgres-docker
|
||||
test-migrations: test-postgres-docker
|
||||
echo "--- test migrations"
|
||||
set -euo pipefail
|
||||
COMMIT_FROM=$(shell git rev-parse --short HEAD)
|
||||
COMMIT_TO=$(shell git rev-parse --short main)
|
||||
COMMIT_FROM=$(shell git log -1 --format='%h' HEAD)
|
||||
echo "COMMIT_FROM=$${COMMIT_FROM}"
|
||||
COMMIT_TO=$(shell git log -1 --format='%h' origin/main)
|
||||
echo "COMMIT_TO=$${COMMIT_TO}"
|
||||
if [[ "$${COMMIT_FROM}" == "$${COMMIT_TO}" ]]; then echo "Nothing to do!"; exit 0; fi
|
||||
echo "DROP DATABASE IF EXISTS migrate_test_$${COMMIT_FROM}; CREATE DATABASE migrate_test_$${COMMIT_FROM};" | psql 'postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable'
|
||||
go run ./scripts/migrate-test/main.go --from="$$COMMIT_FROM" --to="$$COMMIT_TO" --postgres-url="postgresql://postgres:postgres@localhost:5432/migrate_test_$${COMMIT_FROM}?sslmode=disable"
|
||||
|
||||
# NOTE: we set --memory to the same size as a GitHub runner.
|
||||
test-postgres-docker:
|
||||
docker rm -f test-postgres-docker || true
|
||||
docker rm -f test-postgres-docker-${POSTGRES_VERSION} || true
|
||||
docker run \
|
||||
--env POSTGRES_PASSWORD=postgres \
|
||||
--env POSTGRES_USER=postgres \
|
||||
@@ -802,11 +823,11 @@ test-postgres-docker:
|
||||
--env PGDATA=/tmp \
|
||||
--tmpfs /tmp \
|
||||
--publish 5432:5432 \
|
||||
--name test-postgres-docker \
|
||||
--name test-postgres-docker-${POSTGRES_VERSION} \
|
||||
--restart no \
|
||||
--detach \
|
||||
--memory 16GB \
|
||||
gcr.io/coder-dev-1/postgres:13 \
|
||||
gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION} \
|
||||
-c shared_buffers=1GB \
|
||||
-c work_mem=1GB \
|
||||
-c effective_cache_size=1GB \
|
||||
@@ -824,12 +845,28 @@ test-postgres-docker:
|
||||
|
||||
# Make sure to keep this in sync with test-go-race from .github/workflows/ci.yaml.
|
||||
test-race:
|
||||
gotestsum --junitfile="gotests.xml" -- -race -count=1 ./...
|
||||
$(GIT_FLAGS) gotestsum --junitfile="gotests.xml" -- -race -count=1 ./...
|
||||
.PHONY: test-race
|
||||
|
||||
test-tailnet-integration:
|
||||
env \
|
||||
CODER_TAILNET_TESTS=true \
|
||||
CODER_MAGICSOCK_DEBUG_LOGGING=true \
|
||||
TS_DEBUG_NETCHECK=true \
|
||||
GOTRACEBACK=single \
|
||||
go test \
|
||||
-exec "sudo -E" \
|
||||
-timeout=5m \
|
||||
-count=1 \
|
||||
./tailnet/test/integration
|
||||
|
||||
# Note: we used to add this to the test target, but it's not necessary and we can
|
||||
# achieve the desired result by specifying -count=1 in the go test invocation
|
||||
# instead. Keeping it here for convenience.
|
||||
test-clean:
|
||||
go clean -testcache
|
||||
.PHONY: test-clean
|
||||
|
||||
.PHONY: test-e2e
|
||||
test-e2e:
|
||||
cd ./site && DEBUG=pw:api pnpm playwright:test --forbid-only --workers 1
|
||||
|
||||
@@ -20,17 +20,17 @@
|
||||
<br>
|
||||
<br>
|
||||
|
||||
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Enterprise](https://coder.com/docs/v2/latest/enterprise)
|
||||
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Enterprise](https://coder.com/docs/enterprise)
|
||||
|
||||
[](https://discord.gg/coder)
|
||||
[](https://github.com/coder/coder/releases/latest)
|
||||
[](https://pkg.go.dev/github.com/coder/coder)
|
||||
[](https://goreportcard.com/report/github.com/coder/coder)
|
||||
[](https://goreportcard.com/report/github.com/coder/coder/v2)
|
||||
[](./LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
[Coder](https://coder.com) enables organizations to set up development environments in their public or private cloud infrastructure. Cloud development environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and are automatically shut down when not in use to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads that are most beneficial to them.
|
||||
[Coder](https://coder.com) enables organizations to set up development environments in their public or private cloud infrastructure. Cloud development environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and automatically shut down when not used to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads most beneficial to them.
|
||||
|
||||
- Define cloud development environments in Terraform
|
||||
- EC2 VMs, Kubernetes Pods, Docker Containers, etc.
|
||||
@@ -53,7 +53,7 @@ curl -L https://coder.com/install.sh | sh
|
||||
coder server
|
||||
|
||||
# Navigate to http://localhost:3000 to create your initial user,
|
||||
# create a Docker template, and provision a workspace
|
||||
# create a Docker template and provision a workspace
|
||||
```
|
||||
|
||||
## Install
|
||||
@@ -69,7 +69,7 @@ curl -L https://coder.com/install.sh | sh
|
||||
|
||||
You can run the install script with `--dry-run` to see the commands that will be used to install without executing them. Run the install script with `--help` for additional flags.
|
||||
|
||||
> See [install](https://coder.com/docs/v2/latest/install) for additional methods.
|
||||
> See [install](https://coder.com/docs/install) for additional methods.
|
||||
|
||||
Once installed, you can start a production deployment with a single command:
|
||||
|
||||
@@ -81,27 +81,27 @@ coder server
|
||||
coder server --postgres-url <url> --access-url <url>
|
||||
```
|
||||
|
||||
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/v2/latest/install) for a full walkthrough.
|
||||
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/install) for a complete walkthrough.
|
||||
|
||||
## Documentation
|
||||
|
||||
Browse our docs [here](https://coder.com/docs/v2) or visit a specific section below:
|
||||
Browse our docs [here](https://coder.com/docs) or visit a specific section below:
|
||||
|
||||
- [**Templates**](https://coder.com/docs/v2/latest/templates): Templates are written in Terraform and describe the infrastructure for workspaces
|
||||
- [**Workspaces**](https://coder.com/docs/v2/latest/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
|
||||
- [**IDEs**](https://coder.com/docs/v2/latest/ides): Connect your existing editor to a workspace
|
||||
- [**Administration**](https://coder.com/docs/v2/latest/admin): Learn how to operate Coder
|
||||
- [**Enterprise**](https://coder.com/docs/v2/latest/enterprise): Learn about our paid features built for large teams
|
||||
- [**Templates**](https://coder.com/docs/templates): Templates are written in Terraform and describe the infrastructure for workspaces
|
||||
- [**Workspaces**](https://coder.com/docs/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
|
||||
- [**IDEs**](https://coder.com/docs/ides): Connect your existing editor to a workspace
|
||||
- [**Administration**](https://coder.com/docs/admin): Learn how to operate Coder
|
||||
- [**Enterprise**](https://coder.com/docs/enterprise): Learn about our paid features built for large teams
|
||||
|
||||
## Support
|
||||
|
||||
Feel free to [open an issue](https://github.com/coder/coder/issues/new) if you have questions, run into bugs, or have a feature request.
|
||||
|
||||
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features, and chat with the community using Coder!
|
||||
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features and chat with the community using Coder!
|
||||
|
||||
## Integrations
|
||||
|
||||
We are always working on new integrations. Feel free to open an issue to request an integration. Contributions are welcome in any official or community repositories.
|
||||
We are always working on new integrations. Please feel free to open an issue and ask for an integration. Contributions are welcome in any official or community repositories.
|
||||
|
||||
### Official
|
||||
|
||||
@@ -120,5 +120,9 @@ We are always working on new integrations. Feel free to open an issue to request
|
||||
## Contributing
|
||||
|
||||
We are always happy to see new contributors to Coder. If you are new to the Coder codebase, we have
|
||||
[a guide on how to get started](https://coder.com/docs/v2/latest/CONTRIBUTING). We'd love to see your
|
||||
[a guide on how to get started](https://coder.com/docs/CONTRIBUTING). We'd love to see your
|
||||
contributions!
|
||||
|
||||
## Hiring
|
||||
|
||||
Apply [here](https://jobs.ashbyhq.com/coder?utm_source=github&utm_medium=readme&utm_campaign=unknown) if you're interested in joining our team.
|
||||
|
||||
+96
-87
@@ -91,6 +91,7 @@ type Options struct {
|
||||
ModifiedProcesses chan []*agentproc.Process
|
||||
// ProcessManagementTick is used for testing process priority management.
|
||||
ProcessManagementTick <-chan time.Time
|
||||
BlockFileTransfer bool
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
@@ -155,35 +156,36 @@ func New(options Options) Agent {
|
||||
hardCtx, hardCancel := context.WithCancel(context.Background())
|
||||
gracefulCtx, gracefulCancel := context.WithCancel(hardCtx)
|
||||
a := &agent{
|
||||
tailnetListenPort: options.TailnetListenPort,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
gracefulCtx: gracefulCtx,
|
||||
gracefulCancel: gracefulCancel,
|
||||
hardCtx: hardCtx,
|
||||
hardCancel: hardCancel,
|
||||
coordDisconnected: make(chan struct{}),
|
||||
environmentVariables: options.EnvironmentVariables,
|
||||
client: options.Client,
|
||||
exchangeToken: options.ExchangeToken,
|
||||
filesystem: options.Filesystem,
|
||||
logDir: options.LogDir,
|
||||
tempDir: options.TempDir,
|
||||
scriptDataDir: options.ScriptDataDir,
|
||||
lifecycleUpdate: make(chan struct{}, 1),
|
||||
lifecycleReported: make(chan codersdk.WorkspaceAgentLifecycle, 1),
|
||||
lifecycleStates: []agentsdk.PostLifecycleRequest{{State: codersdk.WorkspaceAgentLifecycleCreated}},
|
||||
ignorePorts: options.IgnorePorts,
|
||||
portCacheDuration: options.PortCacheDuration,
|
||||
reportMetadataInterval: options.ReportMetadataInterval,
|
||||
serviceBannerRefreshInterval: options.ServiceBannerRefreshInterval,
|
||||
sshMaxTimeout: options.SSHMaxTimeout,
|
||||
subsystems: options.Subsystems,
|
||||
addresses: options.Addresses,
|
||||
syscaller: options.Syscaller,
|
||||
modifiedProcs: options.ModifiedProcesses,
|
||||
processManagementTick: options.ProcessManagementTick,
|
||||
logSender: agentsdk.NewLogSender(options.Logger),
|
||||
tailnetListenPort: options.TailnetListenPort,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
gracefulCtx: gracefulCtx,
|
||||
gracefulCancel: gracefulCancel,
|
||||
hardCtx: hardCtx,
|
||||
hardCancel: hardCancel,
|
||||
coordDisconnected: make(chan struct{}),
|
||||
environmentVariables: options.EnvironmentVariables,
|
||||
client: options.Client,
|
||||
exchangeToken: options.ExchangeToken,
|
||||
filesystem: options.Filesystem,
|
||||
logDir: options.LogDir,
|
||||
tempDir: options.TempDir,
|
||||
scriptDataDir: options.ScriptDataDir,
|
||||
lifecycleUpdate: make(chan struct{}, 1),
|
||||
lifecycleReported: make(chan codersdk.WorkspaceAgentLifecycle, 1),
|
||||
lifecycleStates: []agentsdk.PostLifecycleRequest{{State: codersdk.WorkspaceAgentLifecycleCreated}},
|
||||
ignorePorts: options.IgnorePorts,
|
||||
portCacheDuration: options.PortCacheDuration,
|
||||
reportMetadataInterval: options.ReportMetadataInterval,
|
||||
announcementBannersRefreshInterval: options.ServiceBannerRefreshInterval,
|
||||
sshMaxTimeout: options.SSHMaxTimeout,
|
||||
subsystems: options.Subsystems,
|
||||
addresses: options.Addresses,
|
||||
syscaller: options.Syscaller,
|
||||
modifiedProcs: options.ModifiedProcesses,
|
||||
processManagementTick: options.ProcessManagementTick,
|
||||
logSender: agentsdk.NewLogSender(options.Logger),
|
||||
blockFileTransfer: options.BlockFileTransfer,
|
||||
|
||||
prometheusRegistry: prometheusRegistry,
|
||||
metrics: newAgentMetrics(prometheusRegistry),
|
||||
@@ -193,7 +195,7 @@ func New(options Options) Agent {
|
||||
// that gets closed on disconnection. This is used to wait for graceful disconnection from the
|
||||
// coordinator during shut down.
|
||||
close(a.coordDisconnected)
|
||||
a.serviceBanner.Store(new(codersdk.ServiceBannerConfig))
|
||||
a.announcementBanners.Store(new([]codersdk.BannerConfig))
|
||||
a.sessionToken.Store(new(string))
|
||||
a.init()
|
||||
return a
|
||||
@@ -231,14 +233,15 @@ type agent struct {
|
||||
|
||||
environmentVariables map[string]string
|
||||
|
||||
manifest atomic.Pointer[agentsdk.Manifest] // manifest is atomic because values can change after reconnection.
|
||||
reportMetadataInterval time.Duration
|
||||
scriptRunner *agentscripts.Runner
|
||||
serviceBanner atomic.Pointer[codersdk.ServiceBannerConfig] // serviceBanner is atomic because it is periodically updated.
|
||||
serviceBannerRefreshInterval time.Duration
|
||||
sessionToken atomic.Pointer[string]
|
||||
sshServer *agentssh.Server
|
||||
sshMaxTimeout time.Duration
|
||||
manifest atomic.Pointer[agentsdk.Manifest] // manifest is atomic because values can change after reconnection.
|
||||
reportMetadataInterval time.Duration
|
||||
scriptRunner *agentscripts.Runner
|
||||
announcementBanners atomic.Pointer[[]codersdk.BannerConfig] // announcementBanners is atomic because it is periodically updated.
|
||||
announcementBannersRefreshInterval time.Duration
|
||||
sessionToken atomic.Pointer[string]
|
||||
sshServer *agentssh.Server
|
||||
sshMaxTimeout time.Duration
|
||||
blockFileTransfer bool
|
||||
|
||||
lifecycleUpdate chan struct{}
|
||||
lifecycleReported chan codersdk.WorkspaceAgentLifecycle
|
||||
@@ -272,11 +275,12 @@ func (a *agent) TailnetConn() *tailnet.Conn {
|
||||
func (a *agent) init() {
|
||||
// pass the "hard" context because we explicitly close the SSH server as part of graceful shutdown.
|
||||
sshSrv, err := agentssh.NewServer(a.hardCtx, a.logger.Named("ssh-server"), a.prometheusRegistry, a.filesystem, &agentssh.Config{
|
||||
MaxTimeout: a.sshMaxTimeout,
|
||||
MOTDFile: func() string { return a.manifest.Load().MOTDFile },
|
||||
ServiceBanner: func() *codersdk.ServiceBannerConfig { return a.serviceBanner.Load() },
|
||||
UpdateEnv: a.updateCommandEnv,
|
||||
WorkingDirectory: func() string { return a.manifest.Load().Directory },
|
||||
MaxTimeout: a.sshMaxTimeout,
|
||||
MOTDFile: func() string { return a.manifest.Load().MOTDFile },
|
||||
AnnouncementBanners: func() *[]codersdk.BannerConfig { return a.announcementBanners.Load() },
|
||||
UpdateEnv: a.updateCommandEnv,
|
||||
WorkingDirectory: func() string { return a.manifest.Load().Directory },
|
||||
BlockFileTransfer: a.blockFileTransfer,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -709,23 +713,26 @@ func (a *agent) setLifecycle(state codersdk.WorkspaceAgentLifecycle) {
|
||||
// (and must be done before the session actually starts).
|
||||
func (a *agent) fetchServiceBannerLoop(ctx context.Context, conn drpc.Conn) error {
|
||||
aAPI := proto.NewDRPCAgentClient(conn)
|
||||
ticker := time.NewTicker(a.serviceBannerRefreshInterval)
|
||||
ticker := time.NewTicker(a.announcementBannersRefreshInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
sbp, err := aAPI.GetServiceBanner(ctx, &proto.GetServiceBannerRequest{})
|
||||
bannersProto, err := aAPI.GetAnnouncementBanners(ctx, &proto.GetAnnouncementBannersRequest{})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
a.logger.Error(ctx, "failed to update service banner", slog.Error(err))
|
||||
a.logger.Error(ctx, "failed to update notification banners", slog.Error(err))
|
||||
return err
|
||||
}
|
||||
serviceBanner := agentsdk.ServiceBannerFromProto(sbp)
|
||||
a.serviceBanner.Store(&serviceBanner)
|
||||
banners := make([]codersdk.BannerConfig, 0, len(bannersProto.AnnouncementBanners))
|
||||
for _, bannerProto := range bannersProto.AnnouncementBanners {
|
||||
banners = append(banners, agentsdk.BannerConfigFromProto(bannerProto))
|
||||
}
|
||||
a.announcementBanners.Store(&banners)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -757,15 +764,18 @@ func (a *agent) run() (retErr error) {
|
||||
// redial the coder server and retry.
|
||||
connMan := newAPIConnRoutineManager(a.gracefulCtx, a.hardCtx, a.logger, conn)
|
||||
|
||||
connMan.start("init service banner", gracefulShutdownBehaviorStop,
|
||||
connMan.start("init notification banners", gracefulShutdownBehaviorStop,
|
||||
func(ctx context.Context, conn drpc.Conn) error {
|
||||
aAPI := proto.NewDRPCAgentClient(conn)
|
||||
sbp, err := aAPI.GetServiceBanner(ctx, &proto.GetServiceBannerRequest{})
|
||||
bannersProto, err := aAPI.GetAnnouncementBanners(ctx, &proto.GetAnnouncementBannersRequest{})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch service banner: %w", err)
|
||||
}
|
||||
serviceBanner := agentsdk.ServiceBannerFromProto(sbp)
|
||||
a.serviceBanner.Store(&serviceBanner)
|
||||
banners := make([]codersdk.BannerConfig, 0, len(bannersProto.AnnouncementBanners))
|
||||
for _, bannerProto := range bannersProto.AnnouncementBanners {
|
||||
banners = append(banners, agentsdk.BannerConfigFromProto(bannerProto))
|
||||
}
|
||||
a.announcementBanners.Store(&banners)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -807,23 +817,21 @@ func (a *agent) run() (retErr error) {
|
||||
// coordination <--------------------------+
|
||||
// derp map subscriber <----------------+
|
||||
// stats report loop <---------------+
|
||||
networkOK := make(chan struct{})
|
||||
manifestOK := make(chan struct{})
|
||||
networkOK := newCheckpoint(a.logger)
|
||||
manifestOK := newCheckpoint(a.logger)
|
||||
|
||||
connMan.start("handle manifest", gracefulShutdownBehaviorStop, a.handleManifest(manifestOK))
|
||||
|
||||
connMan.start("app health reporter", gracefulShutdownBehaviorStop,
|
||||
func(ctx context.Context, conn drpc.Conn) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-manifestOK:
|
||||
manifest := a.manifest.Load()
|
||||
NewWorkspaceAppHealthReporter(
|
||||
a.logger, manifest.Apps, agentsdk.AppHealthPoster(proto.NewDRPCAgentClient(conn)),
|
||||
)(ctx)
|
||||
return nil
|
||||
if err := manifestOK.wait(ctx); err != nil {
|
||||
return xerrors.Errorf("no manifest: %w", err)
|
||||
}
|
||||
manifest := a.manifest.Load()
|
||||
NewWorkspaceAppHealthReporter(
|
||||
a.logger, manifest.Apps, agentsdk.AppHealthPoster(proto.NewDRPCAgentClient(conn)),
|
||||
)(ctx)
|
||||
return nil
|
||||
})
|
||||
|
||||
connMan.start("create or update network", gracefulShutdownBehaviorStop,
|
||||
@@ -831,10 +839,8 @@ func (a *agent) run() (retErr error) {
|
||||
|
||||
connMan.start("coordination", gracefulShutdownBehaviorStop,
|
||||
func(ctx context.Context, conn drpc.Conn) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-networkOK:
|
||||
if err := networkOK.wait(ctx); err != nil {
|
||||
return xerrors.Errorf("no network: %w", err)
|
||||
}
|
||||
return a.runCoordinator(ctx, conn, a.network)
|
||||
},
|
||||
@@ -842,10 +848,8 @@ func (a *agent) run() (retErr error) {
|
||||
|
||||
connMan.start("derp map subscriber", gracefulShutdownBehaviorStop,
|
||||
func(ctx context.Context, conn drpc.Conn) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-networkOK:
|
||||
if err := networkOK.wait(ctx); err != nil {
|
||||
return xerrors.Errorf("no network: %w", err)
|
||||
}
|
||||
return a.runDERPMapSubscriber(ctx, conn, a.network)
|
||||
})
|
||||
@@ -853,10 +857,8 @@ func (a *agent) run() (retErr error) {
|
||||
connMan.start("fetch service banner loop", gracefulShutdownBehaviorStop, a.fetchServiceBannerLoop)
|
||||
|
||||
connMan.start("stats report loop", gracefulShutdownBehaviorStop, func(ctx context.Context, conn drpc.Conn) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-networkOK:
|
||||
if err := networkOK.wait(ctx); err != nil {
|
||||
return xerrors.Errorf("no network: %w", err)
|
||||
}
|
||||
return a.statsReporter.reportLoop(ctx, proto.NewDRPCAgentClient(conn))
|
||||
})
|
||||
@@ -865,8 +867,17 @@ func (a *agent) run() (retErr error) {
|
||||
}
|
||||
|
||||
// handleManifest returns a function that fetches and processes the manifest
|
||||
func (a *agent) handleManifest(manifestOK chan<- struct{}) func(ctx context.Context, conn drpc.Conn) error {
|
||||
func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, conn drpc.Conn) error {
|
||||
return func(ctx context.Context, conn drpc.Conn) error {
|
||||
var (
|
||||
sentResult = false
|
||||
err error
|
||||
)
|
||||
defer func() {
|
||||
if !sentResult {
|
||||
manifestOK.complete(err)
|
||||
}
|
||||
}()
|
||||
aAPI := proto.NewDRPCAgentClient(conn)
|
||||
mp, err := aAPI.GetManifest(ctx, &proto.GetManifestRequest{})
|
||||
if err != nil {
|
||||
@@ -903,14 +914,12 @@ func (a *agent) handleManifest(manifestOK chan<- struct{}) func(ctx context.Cont
|
||||
Subsystems: subsys,
|
||||
}})
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("update workspace agent startup: %w", err)
|
||||
}
|
||||
|
||||
oldManifest := a.manifest.Swap(&manifest)
|
||||
close(manifestOK)
|
||||
manifestOK.complete(nil)
|
||||
sentResult = true
|
||||
|
||||
// The startup script should only execute on the first run!
|
||||
if oldManifest == nil {
|
||||
@@ -971,14 +980,15 @@ func (a *agent) handleManifest(manifestOK chan<- struct{}) func(ctx context.Cont
|
||||
|
||||
// createOrUpdateNetwork waits for the manifest to be set using manifestOK, then creates or updates
|
||||
// the tailnet using the information in the manifest
|
||||
func (a *agent) createOrUpdateNetwork(manifestOK <-chan struct{}, networkOK chan<- struct{}) func(context.Context, drpc.Conn) error {
|
||||
return func(ctx context.Context, _ drpc.Conn) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-manifestOK:
|
||||
func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(context.Context, drpc.Conn) error {
|
||||
return func(ctx context.Context, _ drpc.Conn) (retErr error) {
|
||||
if err := manifestOK.wait(ctx); err != nil {
|
||||
return xerrors.Errorf("no manifest: %w", err)
|
||||
}
|
||||
var err error
|
||||
defer func() {
|
||||
networkOK.complete(retErr)
|
||||
}()
|
||||
manifest := a.manifest.Load()
|
||||
a.closeMutex.Lock()
|
||||
network := a.network
|
||||
@@ -1014,7 +1024,6 @@ func (a *agent) createOrUpdateNetwork(manifestOK <-chan struct{}, networkOK chan
|
||||
network.SetDERPForceWebSockets(manifest.DERPForceWebSockets)
|
||||
network.SetBlockEndpoints(manifest.DisableDirectConnections)
|
||||
}
|
||||
close(networkOK)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
+98
-5
@@ -614,12 +614,12 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) {
|
||||
// Set new banner func and wait for the agent to call it to update the
|
||||
// banner.
|
||||
ready := make(chan struct{}, 2)
|
||||
client.SetServiceBannerFunc(func() (codersdk.ServiceBannerConfig, error) {
|
||||
client.SetAnnouncementBannersFunc(func() ([]codersdk.BannerConfig, error) {
|
||||
select {
|
||||
case ready <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return test.banner, nil
|
||||
return []codersdk.BannerConfig{test.banner}, nil
|
||||
})
|
||||
<-ready
|
||||
<-ready // Wait for two updates to ensure the value has propagated.
|
||||
@@ -970,6 +970,99 @@ func TestAgent_SCP(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAgent_FileTransferBlocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertFileTransferBlocked := func(t *testing.T, errorMessage string) {
|
||||
// NOTE: Checking content of the error message is flaky. Most likely there is a race condition, which results
|
||||
// in stopping the client in different phases, and returning different errors:
|
||||
// - client read the full error message: File transfer has been disabled.
|
||||
// - client's stream was terminated before reading the error message: EOF
|
||||
// - client just read the error code (Windows): Process exited with status 65
|
||||
isErr := strings.Contains(errorMessage, agentssh.BlockedFileTransferErrorMessage) ||
|
||||
strings.Contains(errorMessage, "EOF") ||
|
||||
strings.Contains(errorMessage, "Process exited with status 65")
|
||||
require.True(t, isErr, fmt.Sprintf("Message: "+errorMessage))
|
||||
}
|
||||
|
||||
t.Run("SFTP", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.BlockFileTransfer = true
|
||||
})
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
_, err = sftp.NewClient(sshClient)
|
||||
require.Error(t, err)
|
||||
assertFileTransferBlocked(t, err.Error())
|
||||
})
|
||||
|
||||
t.Run("SCP with go-scp package", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.BlockFileTransfer = true
|
||||
})
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
scpClient, err := scp.NewClientBySSH(sshClient)
|
||||
require.NoError(t, err)
|
||||
defer scpClient.Close()
|
||||
tempFile := filepath.Join(t.TempDir(), "scp")
|
||||
err = scpClient.CopyFile(context.Background(), strings.NewReader("hello world"), tempFile, "0755")
|
||||
require.Error(t, err)
|
||||
assertFileTransferBlocked(t, err.Error())
|
||||
})
|
||||
|
||||
t.Run("Forbidden commands", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, c := range agentssh.BlockedFileTransferCommands {
|
||||
t.Run(c, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.BlockFileTransfer = true
|
||||
})
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:govet // we don't need `c := c` in Go 1.22
|
||||
err = session.Start(c)
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
msg, err := io.ReadAll(stdout)
|
||||
require.NoError(t, err)
|
||||
assertFileTransferBlocked(t, string(msg))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgent_EnvironmentVariables(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := "EXAMPLE"
|
||||
@@ -2193,15 +2286,15 @@ func setupAgentSSHClient(ctx context.Context, t *testing.T) *ssh.Client {
|
||||
func setupSSHSession(
|
||||
t *testing.T,
|
||||
manifest agentsdk.Manifest,
|
||||
serviceBanner codersdk.ServiceBannerConfig,
|
||||
banner codersdk.BannerConfig,
|
||||
prepareFS func(fs afero.Fs),
|
||||
opts ...func(*agenttest.Client, *agent.Options),
|
||||
) *ssh.Session {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
opts = append(opts, func(c *agenttest.Client, o *agent.Options) {
|
||||
c.SetServiceBannerFunc(func() (codersdk.ServiceBannerConfig, error) {
|
||||
return serviceBanner, nil
|
||||
c.SetAnnouncementBannersFunc(func() ([]codersdk.BannerConfig, error) {
|
||||
return []codersdk.BannerConfig{banner}, nil
|
||||
})
|
||||
})
|
||||
//nolint:dogsled
|
||||
|
||||
+67
-11
@@ -52,8 +52,16 @@ const (
|
||||
// MagicProcessCmdlineJetBrains is a string in a process's command line that
|
||||
// uniquely identifies it as JetBrains software.
|
||||
MagicProcessCmdlineJetBrains = "idea.vendor.name=JetBrains"
|
||||
|
||||
// BlockedFileTransferErrorCode indicates that SSH server restricted the raw command from performing
|
||||
// the file transfer.
|
||||
BlockedFileTransferErrorCode = 65 // Error code: host not allowed to connect
|
||||
BlockedFileTransferErrorMessage = "File transfer has been disabled."
|
||||
)
|
||||
|
||||
// BlockedFileTransferCommands contains a list of restricted file transfer commands.
|
||||
var BlockedFileTransferCommands = []string{"nc", "rsync", "scp", "sftp"}
|
||||
|
||||
// Config sets configuration parameters for the agent SSH server.
|
||||
type Config struct {
|
||||
// MaxTimeout sets the absolute connection timeout, none if empty. If set to
|
||||
@@ -63,7 +71,7 @@ type Config struct {
|
||||
// file will be displayed to the user upon login.
|
||||
MOTDFile func() string
|
||||
// ServiceBanner returns the configuration for the Coder service banner.
|
||||
ServiceBanner func() *codersdk.ServiceBannerConfig
|
||||
AnnouncementBanners func() *[]codersdk.BannerConfig
|
||||
// UpdateEnv updates the environment variables for the command to be
|
||||
// executed. It can be used to add, modify or replace environment variables.
|
||||
UpdateEnv func(current []string) (updated []string, err error)
|
||||
@@ -74,6 +82,8 @@ type Config struct {
|
||||
// X11SocketDir is the directory where X11 sockets are created. Default is
|
||||
// /tmp/.X11-unix.
|
||||
X11SocketDir string
|
||||
// BlockFileTransfer restricts use of file transfer applications.
|
||||
BlockFileTransfer bool
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -123,8 +133,8 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
if config.MOTDFile == nil {
|
||||
config.MOTDFile = func() string { return "" }
|
||||
}
|
||||
if config.ServiceBanner == nil {
|
||||
config.ServiceBanner = func() *codersdk.ServiceBannerConfig { return &codersdk.ServiceBannerConfig{} }
|
||||
if config.AnnouncementBanners == nil {
|
||||
config.AnnouncementBanners = func() *[]codersdk.BannerConfig { return &[]codersdk.BannerConfig{} }
|
||||
}
|
||||
if config.WorkingDirectory == nil {
|
||||
config.WorkingDirectory = func() string {
|
||||
@@ -272,6 +282,18 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
extraEnv = append(extraEnv, fmt.Sprintf("DISPLAY=:%d.0", x11.ScreenNumber))
|
||||
}
|
||||
|
||||
if s.fileTransferBlocked(session) {
|
||||
s.logger.Warn(ctx, "file transfer blocked", slog.F("session_subsystem", session.Subsystem()), slog.F("raw_command", session.RawCommand()))
|
||||
|
||||
if session.Subsystem() == "" { // sftp does not expect error, otherwise it fails with "package too long"
|
||||
// Response format: <status_code><message body>\n
|
||||
errorMessage := fmt.Sprintf("\x02%s\n", BlockedFileTransferErrorMessage)
|
||||
_, _ = session.Write([]byte(errorMessage))
|
||||
}
|
||||
_ = session.Exit(BlockedFileTransferErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
switch ss := session.Subsystem(); ss {
|
||||
case "":
|
||||
case "sftp":
|
||||
@@ -322,6 +344,37 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
_ = session.Exit(0)
|
||||
}
|
||||
|
||||
// fileTransferBlocked method checks if the file transfer commands should be blocked.
|
||||
//
|
||||
// Warning: consider this mechanism as "Do not trespass" sign, as a violator can still ssh to the host,
|
||||
// smuggle the `scp` binary, or just manually send files outside with `curl` or `ftp`.
|
||||
// If a user needs a more sophisticated and battle-proof solution, consider full endpoint security.
|
||||
func (s *Server) fileTransferBlocked(session ssh.Session) bool {
|
||||
if !s.config.BlockFileTransfer {
|
||||
return false // file transfers are permitted
|
||||
}
|
||||
// File transfers are restricted.
|
||||
|
||||
if session.Subsystem() == "sftp" {
|
||||
return true
|
||||
}
|
||||
|
||||
cmd := session.Command()
|
||||
if len(cmd) == 0 {
|
||||
return false // no command?
|
||||
}
|
||||
|
||||
c := cmd[0]
|
||||
c = filepath.Base(c) // in case the binary is absolute path, /usr/sbin/scp
|
||||
|
||||
for _, cmd := range BlockedFileTransferCommands {
|
||||
if cmd == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, extraEnv []string) (retErr error) {
|
||||
ctx := session.Context()
|
||||
env := append(session.Environ(), extraEnv...)
|
||||
@@ -441,12 +494,15 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy
|
||||
session.DisablePTYEmulation()
|
||||
|
||||
if isLoginShell(session.RawCommand()) {
|
||||
serviceBanner := s.config.ServiceBanner()
|
||||
if serviceBanner != nil {
|
||||
err := showServiceBanner(session, serviceBanner)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "agent failed to show service banner", slog.Error(err))
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "service_banner").Add(1)
|
||||
banners := s.config.AnnouncementBanners()
|
||||
if banners != nil {
|
||||
for _, banner := range *banners {
|
||||
err := showAnnouncementBanner(session, banner)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "agent failed to show announcement banner", slog.Error(err))
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "announcement_banner").Add(1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -891,9 +947,9 @@ func isQuietLogin(fs afero.Fs, rawCommand string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// showServiceBanner will write the service banner if enabled and not blank
|
||||
// showAnnouncementBanner will write the service banner if enabled and not blank
|
||||
// along with a blank line for spacing.
|
||||
func showServiceBanner(session io.Writer, banner *codersdk.ServiceBannerConfig) error {
|
||||
func showAnnouncementBanner(session io.Writer, banner codersdk.BannerConfig) error {
|
||||
if banner.Enabled && banner.Message != "" {
|
||||
// The banner supports Markdown so we might want to parse it but Markdown is
|
||||
// still fairly readable in its raw form.
|
||||
|
||||
+21
-13
@@ -138,8 +138,8 @@ func (c *Client) GetStartupLogs() []agentsdk.Log {
|
||||
return c.logs
|
||||
}
|
||||
|
||||
func (c *Client) SetServiceBannerFunc(f func() (codersdk.ServiceBannerConfig, error)) {
|
||||
c.fakeAgentAPI.SetServiceBannerFunc(f)
|
||||
func (c *Client) SetAnnouncementBannersFunc(f func() ([]codersdk.BannerConfig, error)) {
|
||||
c.fakeAgentAPI.SetAnnouncementBannersFunc(f)
|
||||
}
|
||||
|
||||
func (c *Client) PushDERPMapUpdate(update *tailcfg.DERPMap) error {
|
||||
@@ -171,31 +171,39 @@ type FakeAgentAPI struct {
|
||||
lifecycleStates []codersdk.WorkspaceAgentLifecycle
|
||||
metadata map[string]agentsdk.Metadata
|
||||
|
||||
getServiceBannerFunc func() (codersdk.ServiceBannerConfig, error)
|
||||
getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error)
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) GetManifest(context.Context, *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
|
||||
return f.manifest, nil
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) SetServiceBannerFunc(fn func() (codersdk.ServiceBannerConfig, error)) {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
f.getServiceBannerFunc = fn
|
||||
f.logger.Info(context.Background(), "updated ServiceBannerFunc")
|
||||
func (*FakeAgentAPI) GetServiceBanner(context.Context, *agentproto.GetServiceBannerRequest) (*agentproto.ServiceBanner, error) {
|
||||
return &agentproto.ServiceBanner{}, nil
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) GetServiceBanner(context.Context, *agentproto.GetServiceBannerRequest) (*agentproto.ServiceBanner, error) {
|
||||
func (f *FakeAgentAPI) SetAnnouncementBannersFunc(fn func() ([]codersdk.BannerConfig, error)) {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
if f.getServiceBannerFunc == nil {
|
||||
return &agentproto.ServiceBanner{}, nil
|
||||
f.getAnnouncementBannersFunc = fn
|
||||
f.logger.Info(context.Background(), "updated notification banners")
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) GetAnnouncementBanners(context.Context, *agentproto.GetAnnouncementBannersRequest) (*agentproto.GetAnnouncementBannersResponse, error) {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
if f.getAnnouncementBannersFunc == nil {
|
||||
return &agentproto.GetAnnouncementBannersResponse{AnnouncementBanners: []*agentproto.BannerConfig{}}, nil
|
||||
}
|
||||
sb, err := f.getServiceBannerFunc()
|
||||
banners, err := f.getAnnouncementBannersFunc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return agentsdk.ProtoFromServiceBanner(sb), nil
|
||||
bannersProto := make([]*agentproto.BannerConfig, 0, len(banners))
|
||||
for _, banner := range banners {
|
||||
bannersProto = append(bannersProto, agentsdk.ProtoFromBannerConfig(banner))
|
||||
}
|
||||
return &agentproto.GetAnnouncementBannersResponse{AnnouncementBanners: bannersProto}, nil
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error) {
|
||||
|
||||
+53
-59
@@ -10,14 +10,11 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/clock"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
// WorkspaceAgentApps fetches the workspace apps.
|
||||
type WorkspaceAgentApps func(context.Context) ([]codersdk.WorkspaceApp, error)
|
||||
|
||||
// PostWorkspaceAgentAppHealth updates the workspace app health.
|
||||
type PostWorkspaceAgentAppHealth func(context.Context, agentsdk.PostAppHealthsRequest) error
|
||||
|
||||
@@ -26,15 +23,26 @@ type WorkspaceAppHealthReporter func(ctx context.Context)
|
||||
|
||||
// NewWorkspaceAppHealthReporter creates a WorkspaceAppHealthReporter that reports app health to coderd.
|
||||
func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.WorkspaceApp, postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth) WorkspaceAppHealthReporter {
|
||||
return NewAppHealthReporterWithClock(logger, apps, postWorkspaceAgentAppHealth, clock.NewReal())
|
||||
}
|
||||
|
||||
// NewAppHealthReporterWithClock is only called directly by test code. Product code should call
|
||||
// NewAppHealthReporter.
|
||||
func NewAppHealthReporterWithClock(
|
||||
logger slog.Logger,
|
||||
apps []codersdk.WorkspaceApp,
|
||||
postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth,
|
||||
clk clock.Clock,
|
||||
) WorkspaceAppHealthReporter {
|
||||
logger = logger.Named("apphealth")
|
||||
|
||||
runHealthcheckLoop := func(ctx context.Context) error {
|
||||
return func(ctx context.Context) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// no need to run this loop if no apps for this workspace.
|
||||
if len(apps) == 0 {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
hasHealthchecksEnabled := false
|
||||
@@ -49,7 +57,7 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace
|
||||
|
||||
// no need to run this loop if no health checks are configured.
|
||||
if !hasHealthchecksEnabled {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
// run a ticker for each app health check.
|
||||
@@ -61,25 +69,29 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace
|
||||
}
|
||||
app := nextApp
|
||||
go func() {
|
||||
t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second)
|
||||
defer t.Stop()
|
||||
_ = clk.TickerFunc(ctx, time.Duration(app.Healthcheck.Interval)*time.Second, func() error {
|
||||
// We time out at the healthcheck interval to prevent getting too backed up, but
|
||||
// set it 1ms early so that it's not simultaneous with the next tick in testing,
|
||||
// which makes the test easier to understand.
|
||||
//
|
||||
// It would be idiomatic to use the http.Client.Timeout or a context.WithTimeout,
|
||||
// but we are passing this off to the native http library, which is not aware
|
||||
// of the clock library we are using. That means in testing, with a mock clock
|
||||
// it will compare mocked times with real times, and we will get strange results.
|
||||
// So, we just implement the timeout as a context we cancel with an AfterFunc
|
||||
reqCtx, reqCancel := context.WithCancel(ctx)
|
||||
timeout := clk.AfterFunc(
|
||||
time.Duration(app.Healthcheck.Interval)*time.Second-time.Millisecond,
|
||||
reqCancel,
|
||||
"timeout", app.Slug)
|
||||
defer timeout.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
// we set the http timeout to the healthcheck interval to prevent getting too backed up.
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(app.Healthcheck.Interval) * time.Second,
|
||||
}
|
||||
err := func() error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, app.Healthcheck.URL, nil)
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, app.Healthcheck.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -118,54 +130,36 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace
|
||||
mu.Unlock()
|
||||
logger.Debug(ctx, "workspace app healthy", slog.F("id", app.ID.String()), slog.F("slug", app.Slug))
|
||||
}
|
||||
|
||||
t.Reset(time.Duration(app.Healthcheck.Interval) * time.Second)
|
||||
}
|
||||
return nil
|
||||
}, "healthcheck", app.Slug)
|
||||
}()
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
lastHealth := copyHealth(health)
|
||||
mu.Unlock()
|
||||
reportTicker := time.NewTicker(time.Second)
|
||||
defer reportTicker.Stop()
|
||||
// every second we check if the health values of the apps have changed
|
||||
// and if there is a change we will report the new values.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
reportTicker := clk.TickerFunc(ctx, time.Second, func() error {
|
||||
mu.RLock()
|
||||
changed := healthChanged(lastHealth, health)
|
||||
mu.RUnlock()
|
||||
if !changed {
|
||||
return nil
|
||||
case <-reportTicker.C:
|
||||
mu.RLock()
|
||||
changed := healthChanged(lastHealth, health)
|
||||
mu.RUnlock()
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
lastHealth = copyHealth(health)
|
||||
mu.Unlock()
|
||||
err := postWorkspaceAgentAppHealth(ctx, agentsdk.PostAppHealthsRequest{
|
||||
Healths: lastHealth,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "failed to report workspace app health", slog.Error(err))
|
||||
} else {
|
||||
logger.Debug(ctx, "sent workspace app health", slog.F("health", lastHealth))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return func(ctx context.Context) {
|
||||
for r := retry.New(time.Second, 30*time.Second); r.Wait(ctx); {
|
||||
err := runHealthcheckLoop(ctx)
|
||||
if err == nil || xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
mu.Lock()
|
||||
lastHealth = copyHealth(health)
|
||||
mu.Unlock()
|
||||
err := postWorkspaceAgentAppHealth(ctx, agentsdk.PostAppHealthsRequest{
|
||||
Healths: lastHealth,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "failed to report workspace app health", slog.Error(err))
|
||||
} else {
|
||||
logger.Debug(ctx, "sent workspace app health", slog.F("health", lastHealth))
|
||||
}
|
||||
logger.Error(ctx, "failed running workspace app reporter", slog.Error(err))
|
||||
}
|
||||
return nil
|
||||
}, "report")
|
||||
_ = reportTicker.Wait() // only possible error is context done
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+154
-113
@@ -4,14 +4,12 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
@@ -19,6 +17,7 @@ import (
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/clock"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
@@ -27,15 +26,17 @@ import (
|
||||
|
||||
func TestAppHealth_Healthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
ID: uuid.UUID{1},
|
||||
Slug: "app1",
|
||||
Healthcheck: codersdk.Healthcheck{},
|
||||
Health: codersdk.WorkspaceAppHealthDisabled,
|
||||
},
|
||||
{
|
||||
ID: uuid.UUID{2},
|
||||
Slug: "app2",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
// URL: We don't set the URL for this test because the setup will
|
||||
@@ -46,6 +47,7 @@ func TestAppHealth_Healthy(t *testing.T) {
|
||||
Health: codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
{
|
||||
ID: uuid.UUID{3},
|
||||
Slug: "app3",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
Interval: 2,
|
||||
@@ -54,36 +56,71 @@ func TestAppHealth_Healthy(t *testing.T) {
|
||||
Health: codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
}
|
||||
checks2 := 0
|
||||
checks3 := 0
|
||||
handlers := []http.Handler{
|
||||
nil,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
checks2++
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, nil)
|
||||
}),
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
checks3++
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, nil)
|
||||
}),
|
||||
}
|
||||
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
apps, err := getApps(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, apps[0].Health)
|
||||
require.Eventually(t, func() bool {
|
||||
apps, err := getApps(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mClock := clock.NewMock(t)
|
||||
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
|
||||
defer healthcheckTrap.Close()
|
||||
reportTrap := mClock.Trap().TickerFunc("report")
|
||||
defer reportTrap.Close()
|
||||
|
||||
return apps[1].Health == codersdk.WorkspaceAppHealthHealthy && apps[2].Health == codersdk.WorkspaceAppHealthHealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
fakeAPI, closeFn := setupAppReporter(ctx, t, slices.Clone(apps), handlers, mClock)
|
||||
defer closeFn()
|
||||
healthchecksStarted := make([]string, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
c := healthcheckTrap.MustWait(ctx)
|
||||
c.Release()
|
||||
healthchecksStarted[i] = c.Tags[1]
|
||||
}
|
||||
slices.Sort(healthchecksStarted)
|
||||
require.Equal(t, []string{"app2", "app3"}, healthchecksStarted)
|
||||
|
||||
// advance the clock 1ms before the report ticker starts, so that it's not
|
||||
// simultaneous with the checks.
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx)
|
||||
reportTrap.MustWait(ctx).Release()
|
||||
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // app2 is now healthy
|
||||
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered
|
||||
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 2)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[1].Health)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthInitializing, apps[2].Health)
|
||||
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // app3 is now healthy
|
||||
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered
|
||||
update = testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 2)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[1].Health)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[2].Health)
|
||||
|
||||
// ensure we aren't spamming
|
||||
require.Equal(t, 2, checks2)
|
||||
require.Equal(t, 1, checks3)
|
||||
}
|
||||
|
||||
func TestAppHealth_500(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
ID: uuid.UUID{2},
|
||||
Slug: "app2",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
// URL: We don't set the URL for this test because the setup will
|
||||
@@ -99,59 +136,40 @@ func TestAppHealth_500(t *testing.T) {
|
||||
httpapi.Write(r.Context(), w, http.StatusInternalServerError, nil)
|
||||
}),
|
||||
}
|
||||
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
require.Eventually(t, func() bool {
|
||||
apps, err := getApps(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
mClock := clock.NewMock(t)
|
||||
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
|
||||
defer healthcheckTrap.Close()
|
||||
reportTrap := mClock.Trap().TickerFunc("report")
|
||||
defer reportTrap.Close()
|
||||
|
||||
fakeAPI, closeFn := setupAppReporter(ctx, t, slices.Clone(apps), handlers, mClock)
|
||||
defer closeFn()
|
||||
healthcheckTrap.MustWait(ctx).Release()
|
||||
// advance the clock 1ms before the report ticker starts, so that it's not
|
||||
// simultaneous with the checks.
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx)
|
||||
reportTrap.MustWait(ctx).Release()
|
||||
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // check gets triggered
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered, but unsent since we are at the threshold
|
||||
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // 2nd check, crosses threshold
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx) // 2nd report, sends update
|
||||
|
||||
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 1)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthUnhealthy, apps[0].Health)
|
||||
}
|
||||
|
||||
func TestAppHealth_Timeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
Slug: "app2",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
// URL: We don't set the URL for this test because the setup will
|
||||
// create a httptest server for us and set it for us.
|
||||
Interval: 1,
|
||||
Threshold: 1,
|
||||
},
|
||||
Health: codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
}
|
||||
handlers := []http.Handler{
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// sleep longer than the interval to cause the health check to time out
|
||||
time.Sleep(2 * time.Second)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, nil)
|
||||
}),
|
||||
}
|
||||
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
require.Eventually(t, func() bool {
|
||||
apps, err := getApps(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
}
|
||||
|
||||
func TestAppHealth_NotSpamming(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
ID: uuid.UUID{2},
|
||||
Slug: "app2",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
// URL: We don't set the URL for this test because the setup will
|
||||
@@ -163,27 +181,65 @@ func TestAppHealth_NotSpamming(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
counter := new(int32)
|
||||
handlers := []http.Handler{
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(counter, 1)
|
||||
http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
// allow the request to time out
|
||||
<-r.Context().Done()
|
||||
}),
|
||||
}
|
||||
_, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
mClock := clock.NewMock(t)
|
||||
start := mClock.Now()
|
||||
|
||||
// for this test, it's easier to think in the number of milliseconds elapsed
|
||||
// since start.
|
||||
ms := func(n int) time.Time {
|
||||
return start.Add(time.Duration(n) * time.Millisecond)
|
||||
}
|
||||
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
|
||||
defer healthcheckTrap.Close()
|
||||
reportTrap := mClock.Trap().TickerFunc("report")
|
||||
defer reportTrap.Close()
|
||||
timeoutTrap := mClock.Trap().AfterFunc("timeout")
|
||||
defer timeoutTrap.Close()
|
||||
|
||||
fakeAPI, closeFn := setupAppReporter(ctx, t, apps, handlers, mClock)
|
||||
defer closeFn()
|
||||
// Ensure we haven't made more than 2 (expected 1 + 1 for buffer) requests in the last second.
|
||||
// if there is a bug where we are spamming the healthcheck route this will catch it.
|
||||
time.Sleep(time.Second)
|
||||
require.LessOrEqual(t, atomic.LoadInt32(counter), int32(2))
|
||||
healthcheckTrap.MustWait(ctx).Release()
|
||||
// advance the clock 1ms before the report ticker starts, so that it's not
|
||||
// simultaneous with the checks.
|
||||
mClock.Set(ms(1)).MustWait(ctx)
|
||||
reportTrap.MustWait(ctx).Release()
|
||||
|
||||
w := mClock.Set(ms(1000)) // 1st check starts
|
||||
timeoutTrap.MustWait(ctx).Release()
|
||||
mClock.Set(ms(1001)).MustWait(ctx) // report tick, no change
|
||||
mClock.Set(ms(1999)) // timeout pops
|
||||
w.MustWait(ctx) // 1st check finished
|
||||
w = mClock.Set(ms(2000)) // 2nd check starts
|
||||
timeoutTrap.MustWait(ctx).Release()
|
||||
mClock.Set(ms(2001)).MustWait(ctx) // report tick, no change
|
||||
mClock.Set(ms(2999)) // timeout pops
|
||||
w.MustWait(ctx) // 2nd check finished
|
||||
// app is now unhealthy after 2 timeouts
|
||||
mClock.Set(ms(3000)) // 3rd check starts
|
||||
timeoutTrap.MustWait(ctx).Release()
|
||||
mClock.Set(ms(3001)).MustWait(ctx) // report tick, sends changes
|
||||
|
||||
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 1)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthUnhealthy, apps[0].Health)
|
||||
}
|
||||
|
||||
func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.WorkspaceApp, handlers []http.Handler) (agent.WorkspaceAgentApps, func()) {
|
||||
func setupAppReporter(
|
||||
ctx context.Context, t *testing.T,
|
||||
apps []codersdk.WorkspaceApp,
|
||||
handlers []http.Handler,
|
||||
clk clock.Clock,
|
||||
) (*agenttest.FakeAgentAPI, func()) {
|
||||
closers := []func(){}
|
||||
for i, app := range apps {
|
||||
if app.ID == uuid.Nil {
|
||||
app.ID = uuid.New()
|
||||
apps[i] = app
|
||||
}
|
||||
for _, app := range apps {
|
||||
require.NotEqual(t, uuid.Nil, app.ID, "all apps must have ID set")
|
||||
}
|
||||
for i, handler := range handlers {
|
||||
if handler == nil {
|
||||
@@ -196,14 +252,6 @@ func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.Workspa
|
||||
closers = append(closers, ts.Close)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
workspaceAgentApps := func(context.Context) ([]codersdk.WorkspaceApp, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var newApps []codersdk.WorkspaceApp
|
||||
return append(newApps, apps...), nil
|
||||
}
|
||||
|
||||
// We don't care about manifest or stats in this test since it's not using
|
||||
// a full agent and these RPCs won't get called.
|
||||
//
|
||||
@@ -212,38 +260,31 @@ func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.Workspa
|
||||
// post function.
|
||||
fakeAAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil)
|
||||
|
||||
// Process events from the channel and update the health of the apps.
|
||||
go func() {
|
||||
appHealthCh := fakeAAPI.AppHealthCh()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case req := <-appHealthCh:
|
||||
mu.Lock()
|
||||
for _, update := range req.Updates {
|
||||
updateID, err := uuid.FromBytes(update.Id)
|
||||
assert.NoError(t, err)
|
||||
updateHealth := codersdk.WorkspaceAppHealth(strings.ToLower(proto.AppHealth_name[int32(update.Health)]))
|
||||
go agent.NewAppHealthReporterWithClock(
|
||||
slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
apps, agentsdk.AppHealthPoster(fakeAAPI), clk,
|
||||
)(ctx)
|
||||
|
||||
for i, app := range apps {
|
||||
if app.ID != updateID {
|
||||
continue
|
||||
}
|
||||
app.Health = updateHealth
|
||||
apps[i] = app
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go agent.NewWorkspaceAppHealthReporter(slogtest.Make(t, nil).Leveled(slog.LevelDebug), apps, agentsdk.AppHealthPoster(fakeAAPI))(ctx)
|
||||
|
||||
return workspaceAgentApps, func() {
|
||||
return fakeAAPI, func() {
|
||||
for _, closeFn := range closers {
|
||||
closeFn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyUpdate(t *testing.T, apps []codersdk.WorkspaceApp, req *proto.BatchUpdateAppHealthRequest) {
|
||||
t.Helper()
|
||||
for _, update := range req.Updates {
|
||||
updateID, err := uuid.FromBytes(update.Id)
|
||||
require.NoError(t, err)
|
||||
updateHealth := codersdk.WorkspaceAppHealth(strings.ToLower(proto.AppHealth_name[int32(update.Health)]))
|
||||
|
||||
for i, app := range apps {
|
||||
if app.ID != updateID {
|
||||
continue
|
||||
}
|
||||
app.Health = updateHealth
|
||||
apps[i] = app
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
// checkpoint allows a goroutine to communicate when it is OK to proceed beyond some async condition
|
||||
// to other dependent goroutines.
|
||||
type checkpoint struct {
|
||||
logger slog.Logger
|
||||
mu sync.Mutex
|
||||
called bool
|
||||
done chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
// complete the checkpoint. Pass nil to indicate the checkpoint was ok. It is an error to call this
|
||||
// more than once.
|
||||
func (c *checkpoint) complete(err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.called {
|
||||
b := make([]byte, 2048)
|
||||
n := runtime.Stack(b, false)
|
||||
c.logger.Critical(context.Background(), "checkpoint complete called more than once", slog.F("stacktrace", b[:n]))
|
||||
return
|
||||
}
|
||||
c.called = true
|
||||
c.err = err
|
||||
close(c.done)
|
||||
}
|
||||
|
||||
func (c *checkpoint) wait(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-c.done:
|
||||
return c.err
|
||||
}
|
||||
}
|
||||
|
||||
func newCheckpoint(logger slog.Logger) *checkpoint {
|
||||
return &checkpoint{
|
||||
logger: logger,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestCheckpoint_CompleteWait(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger := slogtest.Make(t, nil)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
uut := newCheckpoint(logger)
|
||||
err := xerrors.New("test")
|
||||
uut.complete(err)
|
||||
got := uut.wait(ctx)
|
||||
require.Equal(t, err, got)
|
||||
}
|
||||
|
||||
func TestCheckpoint_CompleteTwice(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
uut := newCheckpoint(logger)
|
||||
err := xerrors.New("test")
|
||||
uut.complete(err)
|
||||
uut.complete(nil) // drops CRITICAL log
|
||||
got := uut.wait(ctx)
|
||||
require.Equal(t, err, got)
|
||||
}
|
||||
|
||||
func TestCheckpoint_WaitComplete(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger := slogtest.Make(t, nil)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
uut := newCheckpoint(logger)
|
||||
err := xerrors.New("test")
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- uut.wait(ctx)
|
||||
}()
|
||||
uut.complete(err)
|
||||
got := testutil.RequireRecvCtx(ctx, t, errCh)
|
||||
require.Equal(t, err, got)
|
||||
}
|
||||
+342
-129
@@ -1859,6 +1859,154 @@ func (x *BatchCreateLogsResponse) GetLogLimitExceeded() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type GetAnnouncementBannersRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *GetAnnouncementBannersRequest) Reset() {
|
||||
*x = GetAnnouncementBannersRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[22]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *GetAnnouncementBannersRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetAnnouncementBannersRequest) ProtoMessage() {}
|
||||
|
||||
func (x *GetAnnouncementBannersRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[22]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetAnnouncementBannersRequest.ProtoReflect.Descriptor instead.
|
||||
func (*GetAnnouncementBannersRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_proto_agent_proto_rawDescGZIP(), []int{22}
|
||||
}
|
||||
|
||||
type GetAnnouncementBannersResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
AnnouncementBanners []*BannerConfig `protobuf:"bytes,1,rep,name=announcement_banners,json=announcementBanners,proto3" json:"announcement_banners,omitempty"`
|
||||
}
|
||||
|
||||
func (x *GetAnnouncementBannersResponse) Reset() {
|
||||
*x = GetAnnouncementBannersResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[23]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *GetAnnouncementBannersResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetAnnouncementBannersResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetAnnouncementBannersResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[23]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetAnnouncementBannersResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetAnnouncementBannersResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_proto_agent_proto_rawDescGZIP(), []int{23}
|
||||
}
|
||||
|
||||
func (x *GetAnnouncementBannersResponse) GetAnnouncementBanners() []*BannerConfig {
|
||||
if x != nil {
|
||||
return x.AnnouncementBanners
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type BannerConfig struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"`
|
||||
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
|
||||
BackgroundColor string `protobuf:"bytes,3,opt,name=background_color,json=backgroundColor,proto3" json:"background_color,omitempty"`
|
||||
}
|
||||
|
||||
func (x *BannerConfig) Reset() {
|
||||
*x = BannerConfig{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[24]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *BannerConfig) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*BannerConfig) ProtoMessage() {}
|
||||
|
||||
func (x *BannerConfig) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[24]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use BannerConfig.ProtoReflect.Descriptor instead.
|
||||
func (*BannerConfig) Descriptor() ([]byte, []int) {
|
||||
return file_agent_proto_agent_proto_rawDescGZIP(), []int{24}
|
||||
}
|
||||
|
||||
func (x *BannerConfig) GetEnabled() bool {
|
||||
if x != nil {
|
||||
return x.Enabled
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *BannerConfig) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *BannerConfig) GetBackgroundColor() string {
|
||||
if x != nil {
|
||||
return x.BackgroundColor
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type WorkspaceApp_Healthcheck struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -1872,7 +2020,7 @@ type WorkspaceApp_Healthcheck struct {
|
||||
func (x *WorkspaceApp_Healthcheck) Reset() {
|
||||
*x = WorkspaceApp_Healthcheck{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[22]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[25]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1885,7 +2033,7 @@ func (x *WorkspaceApp_Healthcheck) String() string {
|
||||
func (*WorkspaceApp_Healthcheck) ProtoMessage() {}
|
||||
|
||||
func (x *WorkspaceApp_Healthcheck) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[22]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[25]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1936,7 +2084,7 @@ type WorkspaceAgentMetadata_Result struct {
|
||||
func (x *WorkspaceAgentMetadata_Result) Reset() {
|
||||
*x = WorkspaceAgentMetadata_Result{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[23]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[26]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1949,7 +2097,7 @@ func (x *WorkspaceAgentMetadata_Result) String() string {
|
||||
func (*WorkspaceAgentMetadata_Result) ProtoMessage() {}
|
||||
|
||||
func (x *WorkspaceAgentMetadata_Result) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[23]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[26]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2008,7 +2156,7 @@ type WorkspaceAgentMetadata_Description struct {
|
||||
func (x *WorkspaceAgentMetadata_Description) Reset() {
|
||||
*x = WorkspaceAgentMetadata_Description{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[24]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[27]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -2021,7 +2169,7 @@ func (x *WorkspaceAgentMetadata_Description) String() string {
|
||||
func (*WorkspaceAgentMetadata_Description) ProtoMessage() {}
|
||||
|
||||
func (x *WorkspaceAgentMetadata_Description) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[24]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[27]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2086,7 +2234,7 @@ type Stats_Metric struct {
|
||||
func (x *Stats_Metric) Reset() {
|
||||
*x = Stats_Metric{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[27]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[30]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -2099,7 +2247,7 @@ func (x *Stats_Metric) String() string {
|
||||
func (*Stats_Metric) ProtoMessage() {}
|
||||
|
||||
func (x *Stats_Metric) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[27]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[30]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2155,7 +2303,7 @@ type Stats_Metric_Label struct {
|
||||
func (x *Stats_Metric_Label) Reset() {
|
||||
*x = Stats_Metric_Label{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[28]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[31]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -2168,7 +2316,7 @@ func (x *Stats_Metric_Label) String() string {
|
||||
func (*Stats_Metric_Label) ProtoMessage() {}
|
||||
|
||||
func (x *Stats_Metric_Label) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[28]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[31]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2210,7 +2358,7 @@ type BatchUpdateAppHealthRequest_HealthUpdate struct {
|
||||
func (x *BatchUpdateAppHealthRequest_HealthUpdate) Reset() {
|
||||
*x = BatchUpdateAppHealthRequest_HealthUpdate{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[29]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[32]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -2223,7 +2371,7 @@ func (x *BatchUpdateAppHealthRequest_HealthUpdate) String() string {
|
||||
func (*BatchUpdateAppHealthRequest_HealthUpdate) ProtoMessage() {}
|
||||
|
||||
func (x *BatchUpdateAppHealthRequest_HealthUpdate) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[29]
|
||||
mi := &file_agent_proto_agent_proto_msgTypes[32]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2594,64 +2742,87 @@ var file_agent_proto_agent_proto_rawDesc = []byte{
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x6c, 0x6f, 0x67, 0x5f, 0x6c, 0x69,
|
||||
0x6d, 0x69, 0x74, 0x5f, 0x65, 0x78, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x08, 0x52, 0x10, 0x6c, 0x6f, 0x67, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x45, 0x78, 0x63, 0x65,
|
||||
0x65, 0x64, 0x65, 0x64, 0x2a, 0x63, 0x0a, 0x09, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74,
|
||||
0x68, 0x12, 0x1a, 0x0a, 0x16, 0x41, 0x50, 0x50, 0x5f, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x5f,
|
||||
0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a,
|
||||
0x08, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x49,
|
||||
0x4e, 0x49, 0x54, 0x49, 0x41, 0x4c, 0x49, 0x5a, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x0b, 0x0a,
|
||||
0x07, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x03, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e,
|
||||
0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x04, 0x32, 0xf6, 0x05, 0x0a, 0x05, 0x41, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x12, 0x4b, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65,
|
||||
0x73, 0x74, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
|
||||
0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74,
|
||||
0x12, 0x5a, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61,
|
||||
0x6e, 0x6e, 0x65, 0x72, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
|
||||
0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e,
|
||||
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53,
|
||||
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x56, 0x0a, 0x0b,
|
||||
0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x22, 0x2e, 0x63, 0x6f,
|
||||
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64,
|
||||
0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||
0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
|
||||
0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69,
|
||||
0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c,
|
||||
0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||
0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
|
||||
0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x72, 0x0a, 0x15, 0x42, 0x61,
|
||||
0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c,
|
||||
0x74, 0x68, 0x73, 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
|
||||
0x65, 0x64, 0x65, 0x64, 0x22, 0x1f, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75,
|
||||
0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x71, 0x0a, 0x1e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f,
|
||||
0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x14, 0x61, 0x6e, 0x6e, 0x6f, 0x75,
|
||||
0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x62, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x18,
|
||||
0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x43, 0x6f, 0x6e,
|
||||
0x66, 0x69, 0x67, 0x52, 0x13, 0x61, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x22, 0x6d, 0x0a, 0x0c, 0x42, 0x61, 0x6e, 0x6e,
|
||||
0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62,
|
||||
0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c,
|
||||
0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x29, 0x0a, 0x10,
|
||||
0x62, 0x61, 0x63, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x63, 0x6f, 0x6c, 0x6f, 0x72,
|
||||
0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x62, 0x61, 0x63, 0x6b, 0x67, 0x72, 0x6f, 0x75,
|
||||
0x6e, 0x64, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x2a, 0x63, 0x0a, 0x09, 0x41, 0x70, 0x70, 0x48, 0x65,
|
||||
0x61, 0x6c, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x16, 0x41, 0x50, 0x50, 0x5f, 0x48, 0x45, 0x41, 0x4c,
|
||||
0x54, 0x48, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00,
|
||||
0x12, 0x0c, 0x0a, 0x08, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x01, 0x12, 0x10,
|
||||
0x0a, 0x0c, 0x49, 0x4e, 0x49, 0x54, 0x49, 0x41, 0x4c, 0x49, 0x5a, 0x49, 0x4e, 0x47, 0x10, 0x02,
|
||||
0x12, 0x0b, 0x0a, 0x07, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x03, 0x12, 0x0d, 0x0a,
|
||||
0x09, 0x55, 0x4e, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x04, 0x32, 0xef, 0x06, 0x0a,
|
||||
0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x4b, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e,
|
||||
0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65,
|
||||
0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65,
|
||||
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66,
|
||||
0x65, 0x73, 0x74, 0x12, 0x5a, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||
0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76,
|
||||
0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x1d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76,
|
||||
0x32, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12,
|
||||
0x56, 0x0a, 0x0b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x22,
|
||||
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e,
|
||||
0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
|
||||
0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74,
|
||||
0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64,
|
||||
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61,
|
||||
0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
|
||||
0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x72, 0x0a,
|
||||
0x15, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48,
|
||||
0x65, 0x61, 0x6c, 0x74, 0x68, 0x73, 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64,
|
||||
0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
|
||||
0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76,
|
||||
0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70,
|
||||
0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e,
|
||||
0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12,
|
||||
0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
|
||||
0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12, 0x6e,
|
||||
0x0a, 0x13, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74,
|
||||
0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61,
|
||||
0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
|
||||
0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65,
|
||||
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62,
|
||||
0x0a, 0x0f, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67,
|
||||
0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
|
||||
0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f,
|
||||
0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65,
|
||||
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68,
|
||||
0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
|
||||
0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x33,
|
||||
0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x4e, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74,
|
||||
0x75, 0x70, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
|
||||
0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75,
|
||||
0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
|
||||
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75,
|
||||
0x70, 0x12, 0x6e, 0x0a, 0x13, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
|
||||
0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
|
||||
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55,
|
||||
0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74,
|
||||
0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x62, 0x0a, 0x0f, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
|
||||
0x4c, 0x6f, 0x67, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74,
|
||||
0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63,
|
||||
0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61,
|
||||
0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x77, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f,
|
||||
0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x12,
|
||||
0x2d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
|
||||
0x2e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74,
|
||||
0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e,
|
||||
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e,
|
||||
0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42,
|
||||
0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x27,
|
||||
0x5a, 0x25, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64,
|
||||
0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e,
|
||||
0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -2667,7 +2838,7 @@ func file_agent_proto_agent_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_agent_proto_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 7)
|
||||
var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 30)
|
||||
var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 33)
|
||||
var file_agent_proto_agent_proto_goTypes = []interface{}{
|
||||
(AppHealth)(0), // 0: coder.agent.v2.AppHealth
|
||||
(WorkspaceApp_SharingLevel)(0), // 1: coder.agent.v2.WorkspaceApp.SharingLevel
|
||||
@@ -2698,73 +2869,79 @@ var file_agent_proto_agent_proto_goTypes = []interface{}{
|
||||
(*Log)(nil), // 26: coder.agent.v2.Log
|
||||
(*BatchCreateLogsRequest)(nil), // 27: coder.agent.v2.BatchCreateLogsRequest
|
||||
(*BatchCreateLogsResponse)(nil), // 28: coder.agent.v2.BatchCreateLogsResponse
|
||||
(*WorkspaceApp_Healthcheck)(nil), // 29: coder.agent.v2.WorkspaceApp.Healthcheck
|
||||
(*WorkspaceAgentMetadata_Result)(nil), // 30: coder.agent.v2.WorkspaceAgentMetadata.Result
|
||||
(*WorkspaceAgentMetadata_Description)(nil), // 31: coder.agent.v2.WorkspaceAgentMetadata.Description
|
||||
nil, // 32: coder.agent.v2.Manifest.EnvironmentVariablesEntry
|
||||
nil, // 33: coder.agent.v2.Stats.ConnectionsByProtoEntry
|
||||
(*Stats_Metric)(nil), // 34: coder.agent.v2.Stats.Metric
|
||||
(*Stats_Metric_Label)(nil), // 35: coder.agent.v2.Stats.Metric.Label
|
||||
(*BatchUpdateAppHealthRequest_HealthUpdate)(nil), // 36: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate
|
||||
(*durationpb.Duration)(nil), // 37: google.protobuf.Duration
|
||||
(*proto.DERPMap)(nil), // 38: coder.tailnet.v2.DERPMap
|
||||
(*timestamppb.Timestamp)(nil), // 39: google.protobuf.Timestamp
|
||||
(*GetAnnouncementBannersRequest)(nil), // 29: coder.agent.v2.GetAnnouncementBannersRequest
|
||||
(*GetAnnouncementBannersResponse)(nil), // 30: coder.agent.v2.GetAnnouncementBannersResponse
|
||||
(*BannerConfig)(nil), // 31: coder.agent.v2.BannerConfig
|
||||
(*WorkspaceApp_Healthcheck)(nil), // 32: coder.agent.v2.WorkspaceApp.Healthcheck
|
||||
(*WorkspaceAgentMetadata_Result)(nil), // 33: coder.agent.v2.WorkspaceAgentMetadata.Result
|
||||
(*WorkspaceAgentMetadata_Description)(nil), // 34: coder.agent.v2.WorkspaceAgentMetadata.Description
|
||||
nil, // 35: coder.agent.v2.Manifest.EnvironmentVariablesEntry
|
||||
nil, // 36: coder.agent.v2.Stats.ConnectionsByProtoEntry
|
||||
(*Stats_Metric)(nil), // 37: coder.agent.v2.Stats.Metric
|
||||
(*Stats_Metric_Label)(nil), // 38: coder.agent.v2.Stats.Metric.Label
|
||||
(*BatchUpdateAppHealthRequest_HealthUpdate)(nil), // 39: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate
|
||||
(*durationpb.Duration)(nil), // 40: google.protobuf.Duration
|
||||
(*proto.DERPMap)(nil), // 41: coder.tailnet.v2.DERPMap
|
||||
(*timestamppb.Timestamp)(nil), // 42: google.protobuf.Timestamp
|
||||
}
|
||||
var file_agent_proto_agent_proto_depIdxs = []int32{
|
||||
1, // 0: coder.agent.v2.WorkspaceApp.sharing_level:type_name -> coder.agent.v2.WorkspaceApp.SharingLevel
|
||||
29, // 1: coder.agent.v2.WorkspaceApp.healthcheck:type_name -> coder.agent.v2.WorkspaceApp.Healthcheck
|
||||
32, // 1: coder.agent.v2.WorkspaceApp.healthcheck:type_name -> coder.agent.v2.WorkspaceApp.Healthcheck
|
||||
2, // 2: coder.agent.v2.WorkspaceApp.health:type_name -> coder.agent.v2.WorkspaceApp.Health
|
||||
37, // 3: coder.agent.v2.WorkspaceAgentScript.timeout:type_name -> google.protobuf.Duration
|
||||
30, // 4: coder.agent.v2.WorkspaceAgentMetadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result
|
||||
31, // 5: coder.agent.v2.WorkspaceAgentMetadata.description:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description
|
||||
32, // 6: coder.agent.v2.Manifest.environment_variables:type_name -> coder.agent.v2.Manifest.EnvironmentVariablesEntry
|
||||
38, // 7: coder.agent.v2.Manifest.derp_map:type_name -> coder.tailnet.v2.DERPMap
|
||||
40, // 3: coder.agent.v2.WorkspaceAgentScript.timeout:type_name -> google.protobuf.Duration
|
||||
33, // 4: coder.agent.v2.WorkspaceAgentMetadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result
|
||||
34, // 5: coder.agent.v2.WorkspaceAgentMetadata.description:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description
|
||||
35, // 6: coder.agent.v2.Manifest.environment_variables:type_name -> coder.agent.v2.Manifest.EnvironmentVariablesEntry
|
||||
41, // 7: coder.agent.v2.Manifest.derp_map:type_name -> coder.tailnet.v2.DERPMap
|
||||
8, // 8: coder.agent.v2.Manifest.scripts:type_name -> coder.agent.v2.WorkspaceAgentScript
|
||||
7, // 9: coder.agent.v2.Manifest.apps:type_name -> coder.agent.v2.WorkspaceApp
|
||||
31, // 10: coder.agent.v2.Manifest.metadata:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description
|
||||
33, // 11: coder.agent.v2.Stats.connections_by_proto:type_name -> coder.agent.v2.Stats.ConnectionsByProtoEntry
|
||||
34, // 12: coder.agent.v2.Stats.metrics:type_name -> coder.agent.v2.Stats.Metric
|
||||
34, // 10: coder.agent.v2.Manifest.metadata:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description
|
||||
36, // 11: coder.agent.v2.Stats.connections_by_proto:type_name -> coder.agent.v2.Stats.ConnectionsByProtoEntry
|
||||
37, // 12: coder.agent.v2.Stats.metrics:type_name -> coder.agent.v2.Stats.Metric
|
||||
14, // 13: coder.agent.v2.UpdateStatsRequest.stats:type_name -> coder.agent.v2.Stats
|
||||
37, // 14: coder.agent.v2.UpdateStatsResponse.report_interval:type_name -> google.protobuf.Duration
|
||||
40, // 14: coder.agent.v2.UpdateStatsResponse.report_interval:type_name -> google.protobuf.Duration
|
||||
4, // 15: coder.agent.v2.Lifecycle.state:type_name -> coder.agent.v2.Lifecycle.State
|
||||
39, // 16: coder.agent.v2.Lifecycle.changed_at:type_name -> google.protobuf.Timestamp
|
||||
42, // 16: coder.agent.v2.Lifecycle.changed_at:type_name -> google.protobuf.Timestamp
|
||||
17, // 17: coder.agent.v2.UpdateLifecycleRequest.lifecycle:type_name -> coder.agent.v2.Lifecycle
|
||||
36, // 18: coder.agent.v2.BatchUpdateAppHealthRequest.updates:type_name -> coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate
|
||||
39, // 18: coder.agent.v2.BatchUpdateAppHealthRequest.updates:type_name -> coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate
|
||||
5, // 19: coder.agent.v2.Startup.subsystems:type_name -> coder.agent.v2.Startup.Subsystem
|
||||
21, // 20: coder.agent.v2.UpdateStartupRequest.startup:type_name -> coder.agent.v2.Startup
|
||||
30, // 21: coder.agent.v2.Metadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result
|
||||
33, // 21: coder.agent.v2.Metadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result
|
||||
23, // 22: coder.agent.v2.BatchUpdateMetadataRequest.metadata:type_name -> coder.agent.v2.Metadata
|
||||
39, // 23: coder.agent.v2.Log.created_at:type_name -> google.protobuf.Timestamp
|
||||
42, // 23: coder.agent.v2.Log.created_at:type_name -> google.protobuf.Timestamp
|
||||
6, // 24: coder.agent.v2.Log.level:type_name -> coder.agent.v2.Log.Level
|
||||
26, // 25: coder.agent.v2.BatchCreateLogsRequest.logs:type_name -> coder.agent.v2.Log
|
||||
37, // 26: coder.agent.v2.WorkspaceApp.Healthcheck.interval:type_name -> google.protobuf.Duration
|
||||
39, // 27: coder.agent.v2.WorkspaceAgentMetadata.Result.collected_at:type_name -> google.protobuf.Timestamp
|
||||
37, // 28: coder.agent.v2.WorkspaceAgentMetadata.Description.interval:type_name -> google.protobuf.Duration
|
||||
37, // 29: coder.agent.v2.WorkspaceAgentMetadata.Description.timeout:type_name -> google.protobuf.Duration
|
||||
3, // 30: coder.agent.v2.Stats.Metric.type:type_name -> coder.agent.v2.Stats.Metric.Type
|
||||
35, // 31: coder.agent.v2.Stats.Metric.labels:type_name -> coder.agent.v2.Stats.Metric.Label
|
||||
0, // 32: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate.health:type_name -> coder.agent.v2.AppHealth
|
||||
11, // 33: coder.agent.v2.Agent.GetManifest:input_type -> coder.agent.v2.GetManifestRequest
|
||||
13, // 34: coder.agent.v2.Agent.GetServiceBanner:input_type -> coder.agent.v2.GetServiceBannerRequest
|
||||
15, // 35: coder.agent.v2.Agent.UpdateStats:input_type -> coder.agent.v2.UpdateStatsRequest
|
||||
18, // 36: coder.agent.v2.Agent.UpdateLifecycle:input_type -> coder.agent.v2.UpdateLifecycleRequest
|
||||
19, // 37: coder.agent.v2.Agent.BatchUpdateAppHealths:input_type -> coder.agent.v2.BatchUpdateAppHealthRequest
|
||||
22, // 38: coder.agent.v2.Agent.UpdateStartup:input_type -> coder.agent.v2.UpdateStartupRequest
|
||||
24, // 39: coder.agent.v2.Agent.BatchUpdateMetadata:input_type -> coder.agent.v2.BatchUpdateMetadataRequest
|
||||
27, // 40: coder.agent.v2.Agent.BatchCreateLogs:input_type -> coder.agent.v2.BatchCreateLogsRequest
|
||||
10, // 41: coder.agent.v2.Agent.GetManifest:output_type -> coder.agent.v2.Manifest
|
||||
12, // 42: coder.agent.v2.Agent.GetServiceBanner:output_type -> coder.agent.v2.ServiceBanner
|
||||
16, // 43: coder.agent.v2.Agent.UpdateStats:output_type -> coder.agent.v2.UpdateStatsResponse
|
||||
17, // 44: coder.agent.v2.Agent.UpdateLifecycle:output_type -> coder.agent.v2.Lifecycle
|
||||
20, // 45: coder.agent.v2.Agent.BatchUpdateAppHealths:output_type -> coder.agent.v2.BatchUpdateAppHealthResponse
|
||||
21, // 46: coder.agent.v2.Agent.UpdateStartup:output_type -> coder.agent.v2.Startup
|
||||
25, // 47: coder.agent.v2.Agent.BatchUpdateMetadata:output_type -> coder.agent.v2.BatchUpdateMetadataResponse
|
||||
28, // 48: coder.agent.v2.Agent.BatchCreateLogs:output_type -> coder.agent.v2.BatchCreateLogsResponse
|
||||
41, // [41:49] is the sub-list for method output_type
|
||||
33, // [33:41] is the sub-list for method input_type
|
||||
33, // [33:33] is the sub-list for extension type_name
|
||||
33, // [33:33] is the sub-list for extension extendee
|
||||
0, // [0:33] is the sub-list for field type_name
|
||||
31, // 26: coder.agent.v2.GetAnnouncementBannersResponse.announcement_banners:type_name -> coder.agent.v2.BannerConfig
|
||||
40, // 27: coder.agent.v2.WorkspaceApp.Healthcheck.interval:type_name -> google.protobuf.Duration
|
||||
42, // 28: coder.agent.v2.WorkspaceAgentMetadata.Result.collected_at:type_name -> google.protobuf.Timestamp
|
||||
40, // 29: coder.agent.v2.WorkspaceAgentMetadata.Description.interval:type_name -> google.protobuf.Duration
|
||||
40, // 30: coder.agent.v2.WorkspaceAgentMetadata.Description.timeout:type_name -> google.protobuf.Duration
|
||||
3, // 31: coder.agent.v2.Stats.Metric.type:type_name -> coder.agent.v2.Stats.Metric.Type
|
||||
38, // 32: coder.agent.v2.Stats.Metric.labels:type_name -> coder.agent.v2.Stats.Metric.Label
|
||||
0, // 33: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate.health:type_name -> coder.agent.v2.AppHealth
|
||||
11, // 34: coder.agent.v2.Agent.GetManifest:input_type -> coder.agent.v2.GetManifestRequest
|
||||
13, // 35: coder.agent.v2.Agent.GetServiceBanner:input_type -> coder.agent.v2.GetServiceBannerRequest
|
||||
15, // 36: coder.agent.v2.Agent.UpdateStats:input_type -> coder.agent.v2.UpdateStatsRequest
|
||||
18, // 37: coder.agent.v2.Agent.UpdateLifecycle:input_type -> coder.agent.v2.UpdateLifecycleRequest
|
||||
19, // 38: coder.agent.v2.Agent.BatchUpdateAppHealths:input_type -> coder.agent.v2.BatchUpdateAppHealthRequest
|
||||
22, // 39: coder.agent.v2.Agent.UpdateStartup:input_type -> coder.agent.v2.UpdateStartupRequest
|
||||
24, // 40: coder.agent.v2.Agent.BatchUpdateMetadata:input_type -> coder.agent.v2.BatchUpdateMetadataRequest
|
||||
27, // 41: coder.agent.v2.Agent.BatchCreateLogs:input_type -> coder.agent.v2.BatchCreateLogsRequest
|
||||
29, // 42: coder.agent.v2.Agent.GetAnnouncementBanners:input_type -> coder.agent.v2.GetAnnouncementBannersRequest
|
||||
10, // 43: coder.agent.v2.Agent.GetManifest:output_type -> coder.agent.v2.Manifest
|
||||
12, // 44: coder.agent.v2.Agent.GetServiceBanner:output_type -> coder.agent.v2.ServiceBanner
|
||||
16, // 45: coder.agent.v2.Agent.UpdateStats:output_type -> coder.agent.v2.UpdateStatsResponse
|
||||
17, // 46: coder.agent.v2.Agent.UpdateLifecycle:output_type -> coder.agent.v2.Lifecycle
|
||||
20, // 47: coder.agent.v2.Agent.BatchUpdateAppHealths:output_type -> coder.agent.v2.BatchUpdateAppHealthResponse
|
||||
21, // 48: coder.agent.v2.Agent.UpdateStartup:output_type -> coder.agent.v2.Startup
|
||||
25, // 49: coder.agent.v2.Agent.BatchUpdateMetadata:output_type -> coder.agent.v2.BatchUpdateMetadataResponse
|
||||
28, // 50: coder.agent.v2.Agent.BatchCreateLogs:output_type -> coder.agent.v2.BatchCreateLogsResponse
|
||||
30, // 51: coder.agent.v2.Agent.GetAnnouncementBanners:output_type -> coder.agent.v2.GetAnnouncementBannersResponse
|
||||
43, // [43:52] is the sub-list for method output_type
|
||||
34, // [34:43] is the sub-list for method input_type
|
||||
34, // [34:34] is the sub-list for extension type_name
|
||||
34, // [34:34] is the sub-list for extension extendee
|
||||
0, // [0:34] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_agent_proto_agent_proto_init() }
|
||||
@@ -3038,7 +3215,7 @@ func file_agent_proto_agent_proto_init() {
|
||||
}
|
||||
}
|
||||
file_agent_proto_agent_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*WorkspaceApp_Healthcheck); i {
|
||||
switch v := v.(*GetAnnouncementBannersRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@@ -3050,7 +3227,7 @@ func file_agent_proto_agent_proto_init() {
|
||||
}
|
||||
}
|
||||
file_agent_proto_agent_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*WorkspaceAgentMetadata_Result); i {
|
||||
switch v := v.(*GetAnnouncementBannersResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@@ -3062,7 +3239,31 @@ func file_agent_proto_agent_proto_init() {
|
||||
}
|
||||
}
|
||||
file_agent_proto_agent_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*WorkspaceAgentMetadata_Description); i {
|
||||
switch v := v.(*BannerConfig); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_proto_agent_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*WorkspaceApp_Healthcheck); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_proto_agent_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*WorkspaceAgentMetadata_Result); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@@ -3074,6 +3275,18 @@ func file_agent_proto_agent_proto_init() {
|
||||
}
|
||||
}
|
||||
file_agent_proto_agent_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*WorkspaceAgentMetadata_Description); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_proto_agent_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*Stats_Metric); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
@@ -3085,7 +3298,7 @@ func file_agent_proto_agent_proto_init() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_proto_agent_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} {
|
||||
file_agent_proto_agent_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*Stats_Metric_Label); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
@@ -3097,7 +3310,7 @@ func file_agent_proto_agent_proto_init() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_proto_agent_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} {
|
||||
file_agent_proto_agent_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*BatchUpdateAppHealthRequest_HealthUpdate); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
@@ -3116,7 +3329,7 @@ func file_agent_proto_agent_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_agent_proto_agent_proto_rawDesc,
|
||||
NumEnums: 7,
|
||||
NumMessages: 30,
|
||||
NumMessages: 33,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -251,6 +251,18 @@ message BatchCreateLogsResponse {
|
||||
bool log_limit_exceeded = 1;
|
||||
}
|
||||
|
||||
message GetAnnouncementBannersRequest {}
|
||||
|
||||
message GetAnnouncementBannersResponse {
|
||||
repeated BannerConfig announcement_banners = 1;
|
||||
}
|
||||
|
||||
message BannerConfig {
|
||||
bool enabled = 1;
|
||||
string message = 2;
|
||||
string background_color = 3;
|
||||
}
|
||||
|
||||
service Agent {
|
||||
rpc GetManifest(GetManifestRequest) returns (Manifest);
|
||||
rpc GetServiceBanner(GetServiceBannerRequest) returns (ServiceBanner);
|
||||
@@ -260,4 +272,5 @@ service Agent {
|
||||
rpc UpdateStartup(UpdateStartupRequest) returns (Startup);
|
||||
rpc BatchUpdateMetadata(BatchUpdateMetadataRequest) returns (BatchUpdateMetadataResponse);
|
||||
rpc BatchCreateLogs(BatchCreateLogsRequest) returns (BatchCreateLogsResponse);
|
||||
rpc GetAnnouncementBanners(GetAnnouncementBannersRequest) returns (GetAnnouncementBannersResponse);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ type DRPCAgentClient interface {
|
||||
UpdateStartup(ctx context.Context, in *UpdateStartupRequest) (*Startup, error)
|
||||
BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
|
||||
BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
|
||||
GetAnnouncementBanners(ctx context.Context, in *GetAnnouncementBannersRequest) (*GetAnnouncementBannersResponse, error)
|
||||
}
|
||||
|
||||
type drpcAgentClient struct {
|
||||
@@ -130,6 +131,15 @@ func (c *drpcAgentClient) BatchCreateLogs(ctx context.Context, in *BatchCreateLo
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) GetAnnouncementBanners(ctx context.Context, in *GetAnnouncementBannersRequest) (*GetAnnouncementBannersResponse, error) {
|
||||
out := new(GetAnnouncementBannersResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/GetAnnouncementBanners", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type DRPCAgentServer interface {
|
||||
GetManifest(context.Context, *GetManifestRequest) (*Manifest, error)
|
||||
GetServiceBanner(context.Context, *GetServiceBannerRequest) (*ServiceBanner, error)
|
||||
@@ -139,6 +149,7 @@ type DRPCAgentServer interface {
|
||||
UpdateStartup(context.Context, *UpdateStartupRequest) (*Startup, error)
|
||||
BatchUpdateMetadata(context.Context, *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
|
||||
BatchCreateLogs(context.Context, *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
|
||||
GetAnnouncementBanners(context.Context, *GetAnnouncementBannersRequest) (*GetAnnouncementBannersResponse, error)
|
||||
}
|
||||
|
||||
type DRPCAgentUnimplementedServer struct{}
|
||||
@@ -175,9 +186,13 @@ func (s *DRPCAgentUnimplementedServer) BatchCreateLogs(context.Context, *BatchCr
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) GetAnnouncementBanners(context.Context, *GetAnnouncementBannersRequest) (*GetAnnouncementBannersResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
type DRPCAgentDescription struct{}
|
||||
|
||||
func (DRPCAgentDescription) NumMethods() int { return 8 }
|
||||
func (DRPCAgentDescription) NumMethods() int { return 9 }
|
||||
|
||||
func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
|
||||
switch n {
|
||||
@@ -253,6 +268,15 @@ func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver,
|
||||
in1.(*BatchCreateLogsRequest),
|
||||
)
|
||||
}, DRPCAgentServer.BatchCreateLogs, true
|
||||
case 8:
|
||||
return "/coder.agent.v2.Agent/GetAnnouncementBanners", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
GetAnnouncementBanners(
|
||||
ctx,
|
||||
in1.(*GetAnnouncementBannersRequest),
|
||||
)
|
||||
}, DRPCAgentServer.GetAnnouncementBanners, true
|
||||
default:
|
||||
return "", nil, nil, nil, false
|
||||
}
|
||||
@@ -389,3 +413,19 @@ func (x *drpcAgent_BatchCreateLogsStream) SendAndClose(m *BatchCreateLogsRespons
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_GetAnnouncementBannersStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*GetAnnouncementBannersResponse) error
|
||||
}
|
||||
|
||||
type drpcAgent_GetAnnouncementBannersStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_GetAnnouncementBannersStream) SendAndClose(m *GetAnnouncementBannersResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"cdr.dev/slog/sloggers/slogstackdriver"
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agentproc"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/reaper"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -48,6 +49,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
|
||||
slogHumanPath string
|
||||
slogJSONPath string
|
||||
slogStackdriverPath string
|
||||
blockFileTransfer bool
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "agent",
|
||||
@@ -314,6 +316,8 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
|
||||
// Intentionally set this to nil. It's mainly used
|
||||
// for testing.
|
||||
ModifiedProcesses: nil,
|
||||
|
||||
BlockFileTransfer: blockFileTransfer,
|
||||
})
|
||||
|
||||
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
|
||||
@@ -417,6 +421,13 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
|
||||
Default: "",
|
||||
Value: serpent.StringOf(&slogStackdriverPath),
|
||||
},
|
||||
{
|
||||
Flag: "block-file-transfer",
|
||||
Default: "false",
|
||||
Env: "CODER_AGENT_BLOCK_FILE_TRANSFER",
|
||||
Description: fmt.Sprintf("Block file transfer using known applications: %s.", strings.Join(agentssh.BlockedFileTransferCommands, ",")),
|
||||
Value: serpent.BoolOf(&blockFileTransfer),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -37,6 +37,9 @@ func ExternalAuth(ctx context.Context, writer io.Writer, opts ExternalAuthOption
|
||||
if auth.Authenticated {
|
||||
return nil
|
||||
}
|
||||
if auth.Optional {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(writer, "You must authenticate with %s to create a workspace with this template. Visit:\n\n\t%s\n\n", auth.DisplayName, auth.AuthenticateURL)
|
||||
|
||||
|
||||
+6
-1
@@ -7,6 +7,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/serpent"
|
||||
@@ -143,7 +144,11 @@ func (f *tableFormat) AttachOptions(opts *serpent.OptionSet) {
|
||||
|
||||
// Format implements OutputFormat.
|
||||
func (f *tableFormat) Format(_ context.Context, data any) (string, error) {
|
||||
return DisplayTable(data, f.sort, f.columns)
|
||||
headers := make(table.Row, len(f.allColumns))
|
||||
for i, header := range f.allColumns {
|
||||
headers[i] = header
|
||||
}
|
||||
return renderTable(data, f.sort, headers, f.columns)
|
||||
}
|
||||
|
||||
type jsonFormat struct{}
|
||||
|
||||
@@ -43,7 +43,10 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
|
||||
return "", err
|
||||
}
|
||||
|
||||
values, err := MultiSelect(inv, options)
|
||||
values, err := MultiSelect(inv, MultiSelectOptions{
|
||||
Options: options,
|
||||
Defaults: options,
|
||||
})
|
||||
if err == nil {
|
||||
v, err := json.Marshal(&values)
|
||||
if err != nil {
|
||||
|
||||
+13
-42
@@ -14,48 +14,11 @@ import (
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func init() {
|
||||
survey.SelectQuestionTemplate = `
|
||||
{{- define "option"}}
|
||||
{{- " " }}{{- if eq .SelectedIndex .CurrentIndex }}{{color "green" }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}}
|
||||
{{- .CurrentOpt.Value}}
|
||||
{{- color "reset"}}
|
||||
{{end}}
|
||||
|
||||
{{- if not .ShowAnswer }}
|
||||
{{- if .Config.Icons.Help.Text }}
|
||||
{{- if .FilterMessage }}{{ "Search:" }}{{ .FilterMessage }}
|
||||
{{- else }}
|
||||
{{- color "black+h"}}{{- "Type to search" }}{{color "reset"}}
|
||||
{{- end }}
|
||||
{{- "\n" }}
|
||||
{{- end }}
|
||||
{{- "\n" }}
|
||||
{{- range $ix, $option := .PageEntries}}
|
||||
{{- template "option" $.IterateOption $ix $option}}
|
||||
{{- end}}
|
||||
{{- end }}`
|
||||
|
||||
survey.MultiSelectQuestionTemplate = `
|
||||
{{- define "option"}}
|
||||
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
|
||||
{{- if index .Checked .CurrentOpt.Index }}{{color .Config.Icons.MarkedOption.Format }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color .Config.Icons.UnmarkedOption.Format }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}}
|
||||
{{- color "reset"}}
|
||||
{{- " "}}{{- .CurrentOpt.Value}}
|
||||
{{end}}
|
||||
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
|
||||
{{- if not .ShowAnswer }}
|
||||
{{- "\n"}}
|
||||
{{- range $ix, $option := .PageEntries}}
|
||||
{{- template "option" $.IterateOption $ix $option}}
|
||||
{{- end}}
|
||||
{{- end}}`
|
||||
}
|
||||
|
||||
type SelectOptions struct {
|
||||
Options []string
|
||||
// Default will be highlighted first if it's a valid option.
|
||||
Default string
|
||||
Message string
|
||||
Size int
|
||||
HideSearch bool
|
||||
}
|
||||
@@ -122,6 +85,7 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
|
||||
Options: opts.Options,
|
||||
Default: defaultOption,
|
||||
PageSize: opts.Size,
|
||||
Message: opts.Message,
|
||||
}, &value, survey.WithIcons(func(is *survey.IconSet) {
|
||||
is.Help.Text = "Type to search"
|
||||
if opts.HideSearch {
|
||||
@@ -138,15 +102,22 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
|
||||
return value, err
|
||||
}
|
||||
|
||||
func MultiSelect(inv *serpent.Invocation, items []string) ([]string, error) {
|
||||
type MultiSelectOptions struct {
|
||||
Message string
|
||||
Options []string
|
||||
Defaults []string
|
||||
}
|
||||
|
||||
func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) {
|
||||
// Similar hack is applied to Select()
|
||||
if flag.Lookup("test.v") != nil {
|
||||
return items, nil
|
||||
return opts.Defaults, nil
|
||||
}
|
||||
|
||||
prompt := &survey.MultiSelect{
|
||||
Options: items,
|
||||
Default: items,
|
||||
Options: opts.Options,
|
||||
Default: opts.Defaults,
|
||||
Message: opts.Message,
|
||||
}
|
||||
|
||||
var values []string
|
||||
|
||||
@@ -107,7 +107,10 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
|
||||
var values []string
|
||||
cmd := &serpent.Command{
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
selectedItems, err := cliui.MultiSelect(inv, items)
|
||||
selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
|
||||
Options: items,
|
||||
Defaults: items,
|
||||
})
|
||||
if err == nil {
|
||||
values = selectedItems
|
||||
}
|
||||
|
||||
+74
-18
@@ -22,6 +22,13 @@ func Table() table.Writer {
|
||||
return tableWriter
|
||||
}
|
||||
|
||||
// This type can be supplied as part of a slice to DisplayTable
|
||||
// or to a `TableFormat` `Format` call to render a separator.
|
||||
// Leading separators are not supported and trailing separators
|
||||
// are ignored by the table formatter.
|
||||
// e.g. `[]any{someRow, TableSeparator, someRow}`
|
||||
type TableSeparator struct{}
|
||||
|
||||
// filterTableColumns returns configurations to hide columns
|
||||
// that are not provided in the array. If the array is empty,
|
||||
// no filtering will occur!
|
||||
@@ -47,8 +54,12 @@ func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig
|
||||
return columnConfigs
|
||||
}
|
||||
|
||||
// DisplayTable renders a table as a string. The input argument must be a slice
|
||||
// of structs. At least one field in the struct must have a `table:""` tag
|
||||
// DisplayTable renders a table as a string. The input argument can be:
|
||||
// - a struct slice.
|
||||
// - an interface slice, where the first element is a struct,
|
||||
// and all other elements are of the same type, or a TableSeparator.
|
||||
//
|
||||
// At least one field in the struct must have a `table:""` tag
|
||||
// containing the name of the column in the outputted table.
|
||||
//
|
||||
// If `sort` is not specified, the field with the `table:"$NAME,default_sort"`
|
||||
@@ -66,11 +77,20 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
v := reflect.Indirect(reflect.ValueOf(out))
|
||||
|
||||
if v.Kind() != reflect.Slice {
|
||||
return "", xerrors.Errorf("DisplayTable called with a non-slice type")
|
||||
return "", xerrors.New("DisplayTable called with a non-slice type")
|
||||
}
|
||||
var tableType reflect.Type
|
||||
if v.Type().Elem().Kind() == reflect.Interface {
|
||||
if v.Len() == 0 {
|
||||
return "", xerrors.New("DisplayTable called with empty interface slice")
|
||||
}
|
||||
tableType = reflect.Indirect(reflect.ValueOf(v.Index(0).Interface())).Type()
|
||||
} else {
|
||||
tableType = v.Type().Elem()
|
||||
}
|
||||
|
||||
// Get the list of table column headers.
|
||||
headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem(), true)
|
||||
headersRaw, defaultSort, err := typeToTableHeaders(tableType, true)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
|
||||
}
|
||||
@@ -82,9 +102,8 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
}
|
||||
headers := make(table.Row, len(headersRaw))
|
||||
for i, header := range headersRaw {
|
||||
headers[i] = header
|
||||
headers[i] = strings.ReplaceAll(header, "_", " ")
|
||||
}
|
||||
|
||||
// Verify that the given sort column and filter columns are valid.
|
||||
if sort != "" || len(filterColumns) != 0 {
|
||||
headersMap := make(map[string]string, len(headersRaw))
|
||||
@@ -130,6 +149,11 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
|
||||
}
|
||||
}
|
||||
return renderTable(out, sort, headers, filterColumns)
|
||||
}
|
||||
|
||||
func renderTable(out any, sort string, headers table.Row, filterColumns []string) (string, error) {
|
||||
v := reflect.Indirect(reflect.ValueOf(out))
|
||||
|
||||
// Setup the table formatter.
|
||||
tw := Table()
|
||||
@@ -143,15 +167,22 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
|
||||
// Write each struct to the table.
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
cur := v.Index(i).Interface()
|
||||
_, ok := cur.(TableSeparator)
|
||||
if ok {
|
||||
tw.AppendSeparator()
|
||||
continue
|
||||
}
|
||||
// Format the row as a slice.
|
||||
rowMap, err := valueToTableMap(v.Index(i))
|
||||
// ValueToTableMap does what `reflect.Indirect` does
|
||||
rowMap, err := valueToTableMap(reflect.ValueOf(cur))
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get table row map %v: %w", i, err)
|
||||
}
|
||||
|
||||
rowSlice := make([]any, len(headers))
|
||||
for i, h := range headersRaw {
|
||||
v, ok := rowMap[h]
|
||||
for i, h := range headers {
|
||||
v, ok := rowMap[h.(string)]
|
||||
if !ok {
|
||||
v = nil
|
||||
}
|
||||
@@ -174,6 +205,24 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against nil dereferences
|
||||
if v != nil {
|
||||
rt := reflect.TypeOf(v)
|
||||
switch rt.Kind() {
|
||||
case reflect.Slice:
|
||||
// By default, the behavior is '%v', which just returns a string like
|
||||
// '[a b c]'. This will add commas in between each value.
|
||||
strs := make([]string, 0)
|
||||
vt := reflect.ValueOf(v)
|
||||
for i := 0; i < vt.Len(); i++ {
|
||||
strs = append(strs, fmt.Sprintf("%v", vt.Index(i).Interface()))
|
||||
}
|
||||
v = "[" + strings.Join(strs, ", ") + "]"
|
||||
default:
|
||||
// Leave it as it is
|
||||
}
|
||||
}
|
||||
|
||||
rowSlice[i] = v
|
||||
}
|
||||
|
||||
@@ -188,25 +237,28 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
// returned. If the table tag is malformed, an error is returned.
|
||||
//
|
||||
// The returned name is transformed from "snake_case" to "normal text".
|
||||
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, skipParentName bool, err error) {
|
||||
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName bool, err error) {
|
||||
tags, err := structtag.Parse(string(field.Tag))
|
||||
if err != nil {
|
||||
return "", false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
return "", false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
}
|
||||
|
||||
tag, err := tags.Get("table")
|
||||
if err != nil || tag.Name == "-" {
|
||||
// tags.Get only returns an error if the tag is not found.
|
||||
return "", false, false, false, nil
|
||||
return "", false, false, false, false, nil
|
||||
}
|
||||
|
||||
defaultSortOpt := false
|
||||
noSortOpt = false
|
||||
recursiveOpt := false
|
||||
skipParentNameOpt := false
|
||||
for _, opt := range tag.Options {
|
||||
switch opt {
|
||||
case "default_sort":
|
||||
defaultSortOpt = true
|
||||
case "nosort":
|
||||
noSortOpt = true
|
||||
case "recursive":
|
||||
recursiveOpt = true
|
||||
case "recursive_inline":
|
||||
@@ -216,11 +268,11 @@ func parseTableStructTag(field reflect.StructField) (name string, defaultSort, r
|
||||
recursiveOpt = true
|
||||
skipParentNameOpt = true
|
||||
default:
|
||||
return "", false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
return "", false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, skipParentNameOpt, nil
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, nil
|
||||
}
|
||||
|
||||
func isStructOrStructPointer(t reflect.Type) bool {
|
||||
@@ -244,12 +296,16 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
|
||||
|
||||
headers := []string{}
|
||||
defaultSortName := ""
|
||||
noSortOpt := false
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
name, defaultSort, recursive, skip, err := parseTableStructTag(field)
|
||||
name, defaultSort, noSort, recursive, skip, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
|
||||
}
|
||||
if requireDefault && noSort {
|
||||
noSortOpt = true
|
||||
}
|
||||
|
||||
if name == "" && (recursive && skip) {
|
||||
return nil, "", xerrors.Errorf("a name is required for the field %q. "+
|
||||
@@ -292,8 +348,8 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
|
||||
headers = append(headers, name)
|
||||
}
|
||||
|
||||
if defaultSortName == "" && requireDefault {
|
||||
return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String())
|
||||
if defaultSortName == "" && requireDefault && !noSortOpt {
|
||||
return nil, "", xerrors.Errorf("no field marked as default_sort or nosort in type %q", t.String())
|
||||
}
|
||||
|
||||
return headers, defaultSortName, nil
|
||||
@@ -320,7 +376,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
fieldVal := val.Field(i)
|
||||
name, _, recursive, skip, err := parseTableStructTag(field)
|
||||
name, _, _, recursive, skip, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
|
||||
}
|
||||
|
||||
+44
-16
@@ -138,10 +138,10 @@ func Test_DisplayTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||
foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
`
|
||||
|
||||
// Test with non-pointer values.
|
||||
@@ -165,10 +165,10 @@ foo 10 [a b c] foo1 11 foo2 12 foo3
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "age", nil)
|
||||
@@ -218,6 +218,42 @@ Alice 25
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
// This test ensures we can display dynamically typed slices
|
||||
t.Run("Interfaces", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []any{tableTest1{}}
|
||||
out, err := cliui.DisplayTable(in, "", nil)
|
||||
t.Log("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
other := []tableTest1{{}}
|
||||
expected, err := cliui.DisplayTable(other, "", nil)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
t.Run("WithSeparator", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||
---------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||
---------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
`
|
||||
|
||||
var inlineIn []any
|
||||
for _, v := range in {
|
||||
inlineIn = append(inlineIn, v)
|
||||
inlineIn = append(inlineIn, cliui.TableSeparator{})
|
||||
}
|
||||
out, err := cliui.DisplayTable(inlineIn, "", nil)
|
||||
t.Log("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
// This test ensures that safeties against invalid use of `table` tags
|
||||
// causes errors (even without data).
|
||||
t.Run("Errors", func(t *testing.T) {
|
||||
@@ -255,14 +291,6 @@ Alice 25
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []any{tableTest1{}}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NotStruct", func(t *testing.T) {
|
||||
|
||||
+3
-3
@@ -230,12 +230,12 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
Annotations: workspaceCommand,
|
||||
Use: "config-ssh",
|
||||
Short: "Add an SSH Host entry for your workspaces \"ssh coder.workspace\"",
|
||||
Long: formatExamples(
|
||||
example{
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "You can use -o (or --ssh-option) so set SSH options to be used for all your workspaces",
|
||||
Command: "coder config-ssh -o ForwardAgent=yes",
|
||||
},
|
||||
example{
|
||||
Example{
|
||||
Description: "You can use --dry-run (or -n) to see the changes that would be made",
|
||||
Command: "coder config-ssh --dry-run",
|
||||
},
|
||||
|
||||
+2
-2
@@ -35,8 +35,8 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
Annotations: workspaceCommand,
|
||||
Use: "create [name]",
|
||||
Short: "Create a workspace",
|
||||
Long: formatExamples(
|
||||
example{
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Create a workspace for another user (if you have permission)",
|
||||
Command: "coder create <username>/<workspace_name>",
|
||||
},
|
||||
|
||||
+2
-2
@@ -28,8 +28,8 @@ func (r *RootCmd) dotfiles() *serpent.Command {
|
||||
Use: "dotfiles <git_repo_url>",
|
||||
Middleware: serpent.RequireNArgs(1),
|
||||
Short: "Personalize your workspace by applying a canonical dotfiles repository",
|
||||
Long: formatExamples(
|
||||
example{
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Check out and install a dotfiles repository without prompts",
|
||||
Command: "coder dotfiles --yes git@github.com:example/dotfiles.git",
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ func (r *RootCmd) expCmd() *serpent.Command {
|
||||
Children: []*serpent.Command{
|
||||
r.scaletestCmd(),
|
||||
r.errorExample(),
|
||||
r.promptExample(),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
|
||||
+3
-3
@@ -35,8 +35,8 @@ func (r *RootCmd) externalAuthAccessToken() *serpent.Command {
|
||||
Short: "Print auth for an external provider",
|
||||
Long: "Print an access-token for an external auth provider. " +
|
||||
"The access-token will be validated and sent to stdout with exit code 0. " +
|
||||
"If a valid access-token cannot be obtained, the URL to authenticate will be sent to stdout with exit code 1\n" + formatExamples(
|
||||
example{
|
||||
"If a valid access-token cannot be obtained, the URL to authenticate will be sent to stdout with exit code 1\n" + FormatExamples(
|
||||
Example{
|
||||
Description: "Ensure that the user is authenticated with GitHub before cloning.",
|
||||
Command: `#!/usr/bin/env sh
|
||||
|
||||
@@ -49,7 +49,7 @@ else
|
||||
fi
|
||||
`,
|
||||
},
|
||||
example{
|
||||
Example{
|
||||
Description: "Obtain an extra property of an access token for additional metadata.",
|
||||
Command: "coder external-auth access-token slack --extra \"authed_user.id\"",
|
||||
},
|
||||
|
||||
+38
-2
@@ -58,6 +58,21 @@ func promptFirstUsername(inv *serpent.Invocation) (string, error) {
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func promptFirstName(inv *serpent.Invocation) (string, error) {
|
||||
name, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "(Optional) What " + pretty.Sprint(cliui.DefaultStyles.Field, "name") + " would you like?",
|
||||
Default: "",
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func promptFirstPassword(inv *serpent.Invocation) (string, error) {
|
||||
retry:
|
||||
password, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
@@ -130,6 +145,7 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
var (
|
||||
email string
|
||||
username string
|
||||
name string
|
||||
password string
|
||||
trial bool
|
||||
useTokenForSession bool
|
||||
@@ -191,6 +207,7 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Attempting to authenticate with %s URL: '%s'\n", urlSource, serverURL)
|
||||
|
||||
// nolint: nestif
|
||||
if !hasFirstUser {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Your Coder deployment hasn't been set up!\n")
|
||||
|
||||
@@ -212,6 +229,10 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err = promptFirstName(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
@@ -239,7 +260,7 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
|
||||
if !inv.ParsedFlags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" {
|
||||
v, _ := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Start a 30-day trial of Enterprise?",
|
||||
Text: "Start a trial of Enterprise?",
|
||||
IsConfirm: true,
|
||||
Default: "yes",
|
||||
})
|
||||
@@ -249,6 +270,7 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Name: name,
|
||||
Password: password,
|
||||
Trial: trial,
|
||||
})
|
||||
@@ -287,7 +309,8 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
}
|
||||
|
||||
sessionToken, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Paste your token here:",
|
||||
Text: "Paste your token here:",
|
||||
Secret: true,
|
||||
Validate: func(token string) error {
|
||||
client.SetSessionToken(token)
|
||||
_, err := client.User(ctx, codersdk.Me)
|
||||
@@ -335,6 +358,13 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
return xerrors.Errorf("write server url: %w", err)
|
||||
}
|
||||
|
||||
// If the current organization cannot be fetched, then reset the organization context.
|
||||
// Otherwise, organization cli commands will fail.
|
||||
_, err = CurrentOrganization(r, inv, client)
|
||||
if err != nil {
|
||||
_ = config.Organization().Delete()
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Welcome to Coder, %s! You're authenticated.\n", pretty.Sprint(cliui.DefaultStyles.Keyword, resp.Username))
|
||||
return nil
|
||||
},
|
||||
@@ -352,6 +382,12 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
Description: "Specifies a username to use if creating the first user for the deployment.",
|
||||
Value: serpent.StringOf(&username),
|
||||
},
|
||||
{
|
||||
Flag: "first-user-full-name",
|
||||
Env: "CODER_FIRST_USER_FULL_NAME",
|
||||
Description: "Specifies a human-readable name for the first user of the deployment.",
|
||||
Value: serpent.StringOf(&name),
|
||||
},
|
||||
{
|
||||
Flag: "first-user-password",
|
||||
Env: "CODER_FIRST_USER_PASSWORD",
|
||||
|
||||
+179
-16
@@ -5,9 +5,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -18,6 +20,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
@@ -89,10 +92,11 @@ func TestLogin(t *testing.T) {
|
||||
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "SomeSecurePassword!",
|
||||
"password", "SomeSecurePassword!", // Confirm.
|
||||
"username", coderdtest.FirstUserParams.Username,
|
||||
"name", coderdtest.FirstUserParams.Name,
|
||||
"email", coderdtest.FirstUserParams.Email,
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", coderdtest.FirstUserParams.Password, // confirm
|
||||
"trial", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
@@ -103,6 +107,64 @@ func TestLogin(t *testing.T) {
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: coderdtest.FirstUserParams.Email,
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
|
||||
})
|
||||
|
||||
t.Run("InitialUserTTYNameOptional", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
// The --force-tty flag is required on Windows, because the `isatty` library does not
|
||||
// accurately detect Windows ptys when they are not attached to a process:
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
|
||||
pty := ptytest.New(t).Attach(root)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", coderdtest.FirstUserParams.Username,
|
||||
"name", "",
|
||||
"email", coderdtest.FirstUserParams.Email,
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", coderdtest.FirstUserParams.Password, // confirm
|
||||
"trial", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: coderdtest.FirstUserParams.Email,
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
|
||||
assert.Empty(t, me.Name)
|
||||
})
|
||||
|
||||
t.Run("InitialUserTTYFlag", func(t *testing.T) {
|
||||
@@ -119,10 +181,11 @@ func TestLogin(t *testing.T) {
|
||||
pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with flag URL: '%s'", client.URL.String()))
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "SomeSecurePassword!",
|
||||
"password", "SomeSecurePassword!", // Confirm.
|
||||
"username", coderdtest.FirstUserParams.Username,
|
||||
"name", coderdtest.FirstUserParams.Name,
|
||||
"email", coderdtest.FirstUserParams.Email,
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", coderdtest.FirstUserParams.Password, // confirm
|
||||
"trial", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
@@ -132,6 +195,18 @@ func TestLogin(t *testing.T) {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: coderdtest.FirstUserParams.Email,
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
|
||||
})
|
||||
|
||||
t.Run("InitialUserFlags", func(t *testing.T) {
|
||||
@@ -139,13 +214,56 @@ func TestLogin(t *testing.T) {
|
||||
client := coderdtest.New(t, nil)
|
||||
inv, _ := clitest.New(
|
||||
t, "login", client.URL.String(),
|
||||
"--first-user-username", "testuser", "--first-user-email", "user@coder.com",
|
||||
"--first-user-password", "SomeSecurePassword!", "--first-user-trial",
|
||||
"--first-user-username", coderdtest.FirstUserParams.Username,
|
||||
"--first-user-full-name", coderdtest.FirstUserParams.Name,
|
||||
"--first-user-email", coderdtest.FirstUserParams.Email,
|
||||
"--first-user-password", coderdtest.FirstUserParams.Password,
|
||||
"--first-user-trial",
|
||||
)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
w.RequireSuccess()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: coderdtest.FirstUserParams.Email,
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
|
||||
})
|
||||
|
||||
t.Run("InitialUserFlagsNameOptional", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
inv, _ := clitest.New(
|
||||
t, "login", client.URL.String(),
|
||||
"--first-user-username", coderdtest.FirstUserParams.Username,
|
||||
"--first-user-email", coderdtest.FirstUserParams.Email,
|
||||
"--first-user-password", coderdtest.FirstUserParams.Password,
|
||||
"--first-user-trial",
|
||||
)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
w.RequireSuccess()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: coderdtest.FirstUserParams.Email,
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
|
||||
assert.Empty(t, me.Name)
|
||||
})
|
||||
|
||||
t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) {
|
||||
@@ -167,10 +285,11 @@ func TestLogin(t *testing.T) {
|
||||
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "MyFirstSecurePassword!",
|
||||
"password", "MyNonMatchingSecurePassword!", // Confirm.
|
||||
"username", coderdtest.FirstUserParams.Username,
|
||||
"name", coderdtest.FirstUserParams.Name,
|
||||
"email", coderdtest.FirstUserParams.Email,
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", "something completely different",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
@@ -183,9 +302,9 @@ func TestLogin(t *testing.T) {
|
||||
pty.ExpectMatch("Passwords do not match")
|
||||
pty.ExpectMatch("Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password"))
|
||||
|
||||
pty.WriteLine("SomeSecurePassword!")
|
||||
pty.WriteLine(coderdtest.FirstUserParams.Password)
|
||||
pty.ExpectMatch("Confirm")
|
||||
pty.WriteLine("SomeSecurePassword!")
|
||||
pty.WriteLine(coderdtest.FirstUserParams.Password)
|
||||
pty.ExpectMatch("trial")
|
||||
pty.WriteLine("yes")
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
@@ -304,4 +423,48 @@ func TestLogin(t *testing.T) {
|
||||
// This **should not be equal** to the token we passed in.
|
||||
require.NotEqual(t, client.SessionToken(), sessionFile)
|
||||
})
|
||||
|
||||
// Login should reset the configured organization if the user is not a member
|
||||
t.Run("ResetOrganization", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
root, cfg := clitest.New(t, "login", client.URL.String(), "--token", client.SessionToken())
|
||||
|
||||
notRealOrg := uuid.NewString()
|
||||
err := cfg.Organization().Write(notRealOrg)
|
||||
require.NoError(t, err, "write bad org to config")
|
||||
|
||||
err = root.Run()
|
||||
require.NoError(t, err)
|
||||
sessionFile, err := cfg.Session().Read()
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, client.SessionToken(), sessionFile)
|
||||
|
||||
// Organization config should be deleted since the org does not exist
|
||||
selected, err := cfg.Organization().Read()
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
require.NotEqual(t, selected, notRealOrg)
|
||||
})
|
||||
|
||||
t.Run("KeepOrganizationContext", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
root, cfg := clitest.New(t, "login", client.URL.String(), "--token", client.SessionToken())
|
||||
|
||||
err := cfg.Organization().Write(first.OrganizationID.String())
|
||||
require.NoError(t, err, "write bad org to config")
|
||||
|
||||
err = root.Run()
|
||||
require.NoError(t, err)
|
||||
sessionFile, err := cfg.Session().Read()
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, client.SessionToken(), sessionFile)
|
||||
|
||||
// Organization config should be deleted since the org does not exist
|
||||
selected, err := cfg.Organization().Read()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, selected, first.OrganizationID.String())
|
||||
})
|
||||
}
|
||||
|
||||
+13
-2
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/coderd/healthcheck/derphealth"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
@@ -34,11 +35,21 @@ func (r *RootCmd) netcheck() *serpent.Command {
|
||||
|
||||
_, _ = fmt.Fprint(inv.Stderr, "Gathering a network report. This may take a few seconds...\n\n")
|
||||
|
||||
var report derphealth.Report
|
||||
report.Run(ctx, &derphealth.ReportOptions{
|
||||
var derpReport derphealth.Report
|
||||
derpReport.Run(ctx, &derphealth.ReportOptions{
|
||||
DERPMap: connInfo.DERPMap,
|
||||
})
|
||||
|
||||
ifReport, err := healthsdk.RunInterfacesReport()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to run interfaces report: %w", err)
|
||||
}
|
||||
|
||||
report := healthsdk.ClientNetcheckReport{
|
||||
DERP: healthsdk.DERPHealthReport(derpReport),
|
||||
Interfaces: ifReport,
|
||||
}
|
||||
|
||||
raw, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
@@ -27,12 +26,13 @@ func TestNetcheck(t *testing.T) {
|
||||
|
||||
b := out.Bytes()
|
||||
t.Log(string(b))
|
||||
var report healthsdk.DERPHealthReport
|
||||
var report healthsdk.ClientNetcheckReport
|
||||
require.NoError(t, json.Unmarshal(b, &report))
|
||||
|
||||
assert.True(t, report.Healthy)
|
||||
require.Len(t, report.Regions, 1+1) // 1 built-in region + 1 test-managed STUN region
|
||||
for _, v := range report.Regions {
|
||||
// We do not assert that the report is healthy, just that
|
||||
// it has the expected number of reports per region.
|
||||
require.Len(t, report.DERP.Regions, 1+1) // 1 built-in region + 1 test-managed STUN region
|
||||
for _, v := range report.DERP.Regions {
|
||||
require.Len(t, v.NodeReports, len(v.Region.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
+9
-8
@@ -18,11 +18,10 @@ import (
|
||||
|
||||
func (r *RootCmd) organizations() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "organizations [subcommand]",
|
||||
Short: "Organization related commands",
|
||||
Aliases: []string{"organization", "org", "orgs"},
|
||||
Hidden: true, // Hidden until these commands are complete.
|
||||
Use: "organizations [subcommand]",
|
||||
Short: "Organization related commands",
|
||||
Aliases: []string{"organization", "org", "orgs"},
|
||||
Hidden: true, // Hidden until these commands are complete.
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
@@ -30,6 +29,8 @@ func (r *RootCmd) organizations() *serpent.Command {
|
||||
r.currentOrganization(),
|
||||
r.switchOrganization(),
|
||||
r.createOrganization(),
|
||||
r.organizationMembers(),
|
||||
r.organizationRoles(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,12 +44,12 @@ func (r *RootCmd) switchOrganization() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "set <organization name | ID>",
|
||||
Short: "set the organization used by the CLI. Pass an empty string to reset to the default organization.",
|
||||
Long: "set the organization used by the CLI. Pass an empty string to reset to the default organization.\n" + formatExamples(
|
||||
example{
|
||||
Long: "set the organization used by the CLI. Pass an empty string to reset to the default organization.\n" + FormatExamples(
|
||||
Example{
|
||||
Description: "Remove the current organization and defer to the default.",
|
||||
Command: "coder organizations set ''",
|
||||
},
|
||||
example{
|
||||
Example{
|
||||
Description: "Switch to a custom organization.",
|
||||
Command: "coder organizations set my-org",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) organizationMembers() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "members",
|
||||
Aliases: []string{"member"},
|
||||
Short: "Manage organization members",
|
||||
Children: []*serpent.Command{
|
||||
r.listOrganizationMembers(),
|
||||
r.assignOrganizationRoles(),
|
||||
r.addOrganizationMember(),
|
||||
r.removeOrganizationMember(),
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) removeOrganizationMember() *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "remove <username | user_id>",
|
||||
Short: "Remove a new member to the current organization",
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
organization, err := CurrentOrganization(r, inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := inv.Args[0]
|
||||
|
||||
err = client.DeleteOrganizationMember(ctx, organization.ID, user)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not remove member from organization %q: %w", organization.HumanName(), err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Organization member removed from %q\n", organization.HumanName())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) addOrganizationMember() *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "add <username | user_id>",
|
||||
Short: "Add a new member to the current organization",
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
organization, err := CurrentOrganization(r, inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := inv.Args[0]
|
||||
|
||||
_, err = client.PostOrganizationMember(ctx, organization.ID, user)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not add member to organization %q: %w", organization.HumanName(), err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Organization member added to %q\n", organization.HumanName())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) assignOrganizationRoles() *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "edit-roles <username | user_id> [roles...]",
|
||||
Aliases: []string{"edit-role"},
|
||||
Short: "Edit organization member's roles",
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
organization, err := CurrentOrganization(r, inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(inv.Args) < 1 {
|
||||
return xerrors.Errorf("user_id or username is required as the first argument")
|
||||
}
|
||||
userIdentifier := inv.Args[0]
|
||||
roles := inv.Args[1:]
|
||||
|
||||
member, err := client.UpdateOrganizationMemberRoles(ctx, organization.ID, userIdentifier, codersdk.UpdateRoles{
|
||||
Roles: roles,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update member roles: %w", err)
|
||||
}
|
||||
|
||||
updatedTo := make([]string, 0)
|
||||
for _, role := range member.Roles {
|
||||
updatedTo = append(updatedTo, role.String())
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Member roles updated to [%s]\n", strings.Join(updatedTo, ", "))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) listOrganizationMembers() *serpent.Command {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.TableFormat([]codersdk.OrganizationMemberWithName{}, []string{"username", "organization_roles"}),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "list",
|
||||
Short: "List all organization members",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
organization, err := CurrentOrganization(r, inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := client.OrganizationMembers(ctx, organization.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch members: %w", err)
|
||||
}
|
||||
|
||||
out, err := formatter.Format(inv.Context(), res)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(inv.Stdout, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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/coderd/rbac"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestListOrganizationMembers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient := coderdtest.New(t, &coderdtest.Options{})
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
inv, root := clitest.New(t, "organization", "members", "list", "-c", "user_id,username,roles")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, buf.String(), user.Username)
|
||||
require.Contains(t, buf.String(), owner.UserID.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddOrganizationMembers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient := coderdtest.New(t, &coderdtest.Options{})
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
_, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
//nolint:gocritic // must be an owner, only owners can create orgs
|
||||
otherOrg, err := ownerClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
|
||||
Name: "Other",
|
||||
DisplayName: "",
|
||||
Description: "",
|
||||
Icon: "",
|
||||
})
|
||||
require.NoError(t, err, "create another organization")
|
||||
|
||||
inv, root := clitest.New(t, "organization", "members", "add", "--organization", otherOrg.ID.String(), user.Username)
|
||||
//nolint:gocritic // must be an owner
|
||||
clitest.SetupConfig(t, ownerClient, root)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gocritic // must be an owner
|
||||
members, err := ownerClient.OrganizationMembers(ctx, otherOrg.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, members, 2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveOrganizationMembers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient := coderdtest.New(t, &coderdtest.Options{})
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
|
||||
_, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
inv, root := clitest.New(t, "organization", "members", "remove", "--organization", owner.OrganizationID.String(), user.Username)
|
||||
clitest.SetupConfig(t, orgAdminClient, root)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
members, err := orgAdminClient.OrganizationMembers(ctx, owner.OrganizationID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, members, 2)
|
||||
})
|
||||
|
||||
t.Run("UserNotExists", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient := coderdtest.New(t, &coderdtest.Options{})
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
inv, root := clitest.New(t, "organization", "members", "remove", "--organization", owner.OrganizationID.String(), "random_name")
|
||||
clitest.SetupConfig(t, orgAdminClient, root)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, "must be an existing uuid or username")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) organizationRoles() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "roles",
|
||||
Short: "Manage organization roles.",
|
||||
Aliases: []string{"role"},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Hidden: true,
|
||||
Children: []*serpent.Command{
|
||||
r.showOrganizationRoles(),
|
||||
r.editOrganizationRole(),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) showOrganizationRoles() *serpent.Command {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.ChangeFormatterData(
|
||||
cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "organization_permissions", "user_permissions"}),
|
||||
func(data any) (any, error) {
|
||||
inputs, ok := data.([]codersdk.AssignableRoles)
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("expected []codersdk.AssignableRoles got %T", data)
|
||||
}
|
||||
|
||||
tableRows := make([]roleTableRow, 0)
|
||||
for _, input := range inputs {
|
||||
tableRows = append(tableRows, roleToTableView(input.Role))
|
||||
}
|
||||
|
||||
return tableRows, nil
|
||||
},
|
||||
),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "show [role_names ...]",
|
||||
Short: "Show role(s)",
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
org, err := CurrentOrganization(r, inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
roles, err := client.ListOrganizationRoles(ctx, org.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listing roles: %w", err)
|
||||
}
|
||||
|
||||
if len(inv.Args) > 0 {
|
||||
// filter roles
|
||||
filtered := make([]codersdk.AssignableRoles, 0)
|
||||
for _, role := range roles {
|
||||
if slices.ContainsFunc(inv.Args, func(s string) bool {
|
||||
return strings.EqualFold(s, role.Name)
|
||||
}) {
|
||||
filtered = append(filtered, role)
|
||||
}
|
||||
}
|
||||
roles = filtered
|
||||
}
|
||||
|
||||
out, err := formatter.Format(inv.Context(), roles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(inv.Stdout, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) editOrganizationRole() *serpent.Command {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.ChangeFormatterData(
|
||||
cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "organization_permissions", "user_permissions"}),
|
||||
func(data any) (any, error) {
|
||||
typed, _ := data.(codersdk.Role)
|
||||
return []roleTableRow{roleToTableView(typed)}, nil
|
||||
},
|
||||
),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
|
||||
var (
|
||||
dryRun bool
|
||||
jsonInput bool
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "edit <role_name>",
|
||||
Short: "Edit an organization custom role",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Run with an input.json file",
|
||||
Command: "coder roles edit --stdin < role.json",
|
||||
},
|
||||
),
|
||||
Options: []serpent.Option{
|
||||
cliui.SkipPromptOption(),
|
||||
{
|
||||
Name: "dry-run",
|
||||
Description: "Does all the work, but does not submit the final updated role.",
|
||||
Flag: "dry-run",
|
||||
Value: serpent.BoolOf(&dryRun),
|
||||
},
|
||||
{
|
||||
Name: "stdin",
|
||||
Description: "Reads stdin for the json role definition to upload.",
|
||||
Flag: "stdin",
|
||||
Value: serpent.BoolOf(&jsonInput),
|
||||
},
|
||||
},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireRangeArgs(0, 1),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
org, err := CurrentOrganization(r, inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var customRole codersdk.Role
|
||||
if jsonInput {
|
||||
// JSON Upload mode
|
||||
bytes, err := io.ReadAll(inv.Stdin)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading stdin: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(bytes, &customRole)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parsing stdin json: %w", err)
|
||||
}
|
||||
|
||||
if customRole.Name == "" {
|
||||
arr := make([]json.RawMessage, 0)
|
||||
err = json.Unmarshal(bytes, &arr)
|
||||
if err == nil && len(arr) > 0 {
|
||||
return xerrors.Errorf("the input appears to be an array, only 1 role can be sent at a time")
|
||||
}
|
||||
return xerrors.Errorf("json input does not appear to be a valid role")
|
||||
}
|
||||
} else {
|
||||
if len(inv.Args) == 0 {
|
||||
return xerrors.Errorf("missing role name argument, usage: \"coder organizations roles edit <role_name>\"")
|
||||
}
|
||||
|
||||
interactiveRole, err := interactiveOrgRoleEdit(inv, org.ID, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("editing role: %w", err)
|
||||
}
|
||||
|
||||
customRole = *interactiveRole
|
||||
|
||||
preview := fmt.Sprintf("permissions: %d site, %d org, %d user",
|
||||
len(customRole.SitePermissions), len(customRole.OrganizationPermissions), len(customRole.UserPermissions))
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Are you sure you wish to update the role? " + preview,
|
||||
Default: "yes",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("abort: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var updated codersdk.Role
|
||||
if dryRun {
|
||||
// Do not actually post
|
||||
updated = customRole
|
||||
} else {
|
||||
updated, err = client.PatchOrganizationRole(ctx, org.ID, customRole)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("patch role: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
output, err := formatter.Format(ctx, updated)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("formatting: %w", err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(inv.Stdout, output)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func interactiveOrgRoleEdit(inv *serpent.Invocation, orgID uuid.UUID, client *codersdk.Client) (*codersdk.Role, error) {
|
||||
ctx := inv.Context()
|
||||
roles, err := client.ListOrganizationRoles(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listing roles: %w", err)
|
||||
}
|
||||
|
||||
// Make sure the role actually exists first
|
||||
var originalRole codersdk.AssignableRoles
|
||||
for _, r := range roles {
|
||||
if strings.EqualFold(inv.Args[0], r.Name) {
|
||||
originalRole = r
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if originalRole.Name == "" {
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "No organization role exists with that name, do you want to create one?",
|
||||
Default: "yes",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("abort: %w", err)
|
||||
}
|
||||
|
||||
originalRole.Role = codersdk.Role{
|
||||
Name: inv.Args[0],
|
||||
OrganizationID: orgID.String(),
|
||||
}
|
||||
}
|
||||
|
||||
// Some checks since interactive mode is limited in what it currently sees
|
||||
if len(originalRole.SitePermissions) > 0 {
|
||||
return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains site wide permissions")
|
||||
}
|
||||
|
||||
if len(originalRole.UserPermissions) > 0 {
|
||||
return nil, xerrors.Errorf("unable to edit role in interactive mode, it contains user permissions")
|
||||
}
|
||||
|
||||
role := &originalRole.Role
|
||||
allowedResources := []codersdk.RBACResource{
|
||||
codersdk.ResourceTemplate,
|
||||
codersdk.ResourceWorkspace,
|
||||
codersdk.ResourceUser,
|
||||
codersdk.ResourceGroup,
|
||||
}
|
||||
|
||||
const done = "Finish and submit changes"
|
||||
const abort = "Cancel changes"
|
||||
|
||||
// Now starts the role editing "game".
|
||||
customRoleLoop:
|
||||
for {
|
||||
selected, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Message: "Select which resources to edit permissions",
|
||||
Options: append(permissionPreviews(role, allowedResources), done, abort),
|
||||
})
|
||||
if err != nil {
|
||||
return role, xerrors.Errorf("selecting resource: %w", err)
|
||||
}
|
||||
switch selected {
|
||||
case done:
|
||||
break customRoleLoop
|
||||
case abort:
|
||||
return role, xerrors.Errorf("edit role %q aborted", role.Name)
|
||||
default:
|
||||
strs := strings.Split(selected, "::")
|
||||
resource := strings.TrimSpace(strs[0])
|
||||
|
||||
actions, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
|
||||
Message: fmt.Sprintf("Select actions to allow across the whole deployment for resources=%q", resource),
|
||||
Options: slice.ToStrings(codersdk.RBACResourceActions[codersdk.RBACResource(resource)]),
|
||||
Defaults: defaultActions(role, resource),
|
||||
})
|
||||
if err != nil {
|
||||
return role, xerrors.Errorf("selecting actions for resource %q: %w", resource, err)
|
||||
}
|
||||
applyOrgResourceActions(role, resource, actions)
|
||||
// back to resources!
|
||||
}
|
||||
}
|
||||
// This println is required because the prompt ends us on the same line as some text.
|
||||
_, _ = fmt.Println()
|
||||
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func applyOrgResourceActions(role *codersdk.Role, resource string, actions []string) {
|
||||
if role.OrganizationPermissions == nil {
|
||||
role.OrganizationPermissions = make([]codersdk.Permission, 0)
|
||||
}
|
||||
|
||||
// Construct new site perms with only new perms for the resource
|
||||
keep := make([]codersdk.Permission, 0)
|
||||
for _, perm := range role.OrganizationPermissions {
|
||||
perm := perm
|
||||
if string(perm.ResourceType) != resource {
|
||||
keep = append(keep, perm)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new perms
|
||||
for _, action := range actions {
|
||||
keep = append(keep, codersdk.Permission{
|
||||
Negate: false,
|
||||
ResourceType: codersdk.RBACResource(resource),
|
||||
Action: codersdk.RBACAction(action),
|
||||
})
|
||||
}
|
||||
|
||||
role.OrganizationPermissions = keep
|
||||
}
|
||||
|
||||
func defaultActions(role *codersdk.Role, resource string) []string {
|
||||
if role.OrganizationPermissions == nil {
|
||||
role.OrganizationPermissions = []codersdk.Permission{}
|
||||
}
|
||||
|
||||
defaults := make([]string, 0)
|
||||
for _, perm := range role.OrganizationPermissions {
|
||||
if string(perm.ResourceType) == resource {
|
||||
defaults = append(defaults, string(perm.Action))
|
||||
}
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
func permissionPreviews(role *codersdk.Role, resources []codersdk.RBACResource) []string {
|
||||
previews := make([]string, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
previews = append(previews, permissionPreview(role, resource))
|
||||
}
|
||||
return previews
|
||||
}
|
||||
|
||||
func permissionPreview(role *codersdk.Role, resource codersdk.RBACResource) string {
|
||||
if role.OrganizationPermissions == nil {
|
||||
role.OrganizationPermissions = []codersdk.Permission{}
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, perm := range role.OrganizationPermissions {
|
||||
if perm.ResourceType == resource {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s :: %d permissions", resource, count)
|
||||
}
|
||||
|
||||
func roleToTableView(role codersdk.Role) roleTableRow {
|
||||
return roleTableRow{
|
||||
Name: role.Name,
|
||||
DisplayName: role.DisplayName,
|
||||
OrganizationID: role.OrganizationID,
|
||||
SitePermissions: fmt.Sprintf("%d permissions", len(role.SitePermissions)),
|
||||
OrganizationPermissions: fmt.Sprintf("%d permissions", len(role.OrganizationPermissions)),
|
||||
UserPermissions: fmt.Sprintf("%d permissions", len(role.UserPermissions)),
|
||||
}
|
||||
}
|
||||
|
||||
type roleTableRow struct {
|
||||
Name string `table:"name,default_sort"`
|
||||
DisplayName string `table:"display_name"`
|
||||
OrganizationID string `table:"organization_id"`
|
||||
SitePermissions string ` table:"site_permissions"`
|
||||
// map[<org_id>] -> Permissions
|
||||
OrganizationPermissions string `table:"organization_permissions"`
|
||||
UserPermissions string `table:"user_permissions"`
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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/coderd/rbac"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestShowOrganizationRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{})
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
|
||||
|
||||
const expectedRole = "test-role"
|
||||
dbgen.CustomRole(t, db, database.CustomRole{
|
||||
Name: expectedRole,
|
||||
DisplayName: "Expected",
|
||||
SitePermissions: nil,
|
||||
OrgPermissions: nil,
|
||||
UserPermissions: nil,
|
||||
OrganizationID: uuid.NullUUID{
|
||||
UUID: owner.OrganizationID,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
inv, root := clitest.New(t, "organization", "roles", "show")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, buf.String(), expectedRole)
|
||||
})
|
||||
}
|
||||
+5
-7
@@ -48,19 +48,17 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := inv.Logger
|
||||
opts := &workspacesdk.DialAgentOptions{}
|
||||
|
||||
if r.verbose {
|
||||
logger = logger.AppendSinks(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
|
||||
opts.Logger = inv.Logger.AppendSinks(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
if r.disableDirect {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
|
||||
opts.BlockEndpoints = true
|
||||
}
|
||||
conn, err := workspacesdk.New(client).
|
||||
DialAgent(ctx, workspaceAgent.ID, &workspacesdk.DialAgentOptions{
|
||||
Logger: logger,
|
||||
BlockEndpoints: r.disableDirect,
|
||||
})
|
||||
conn, err := workspacesdk.New(client).DialAgent(ctx, workspaceAgent.ID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+11
-12
@@ -35,24 +35,24 @@ func (r *RootCmd) portForward() *serpent.Command {
|
||||
Use: "port-forward <workspace>",
|
||||
Short: `Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R".`,
|
||||
Aliases: []string{"tunnel"},
|
||||
Long: formatExamples(
|
||||
example{
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Port forward a single TCP port from 1234 in the workspace to port 5678 on your local machine",
|
||||
Command: "coder port-forward <workspace> --tcp 5678:1234",
|
||||
},
|
||||
example{
|
||||
Example{
|
||||
Description: "Port forward a single UDP port from port 9000 to port 9000 on your local machine",
|
||||
Command: "coder port-forward <workspace> --udp 9000",
|
||||
},
|
||||
example{
|
||||
Example{
|
||||
Description: "Port forward multiple TCP ports and a UDP port",
|
||||
Command: "coder port-forward <workspace> --tcp 8080:8080 --tcp 9000:3000 --udp 5353:53",
|
||||
},
|
||||
example{
|
||||
Example{
|
||||
Description: "Port forward multiple ports (TCP or UDP) in condensed syntax",
|
||||
Command: "coder port-forward <workspace> --tcp 8080,9000:3000,9090-9092,10000-10002:10010-10012",
|
||||
},
|
||||
example{
|
||||
Example{
|
||||
Description: "Port forward specifying the local address to bind to",
|
||||
Command: "coder port-forward <workspace> --tcp 1.2.3.4:8080:8080",
|
||||
},
|
||||
@@ -95,19 +95,18 @@ func (r *RootCmd) portForward() *serpent.Command {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
|
||||
opts := &workspacesdk.DialAgentOptions{}
|
||||
|
||||
logger := inv.Logger
|
||||
if r.verbose {
|
||||
logger = logger.AppendSinks(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
|
||||
opts.Logger = logger.AppendSinks(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
if r.disableDirect {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
|
||||
opts.BlockEndpoints = true
|
||||
}
|
||||
conn, err := workspacesdk.New(client).
|
||||
DialAgent(ctx, workspaceAgent.ID, &workspacesdk.DialAgentOptions{
|
||||
Logger: logger,
|
||||
BlockEndpoints: r.disableDirect,
|
||||
})
|
||||
conn, err := workspacesdk.New(client).DialAgent(ctx, workspaceAgent.ID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (RootCmd) promptExample() *serpent.Command {
|
||||
promptCmd := func(use string, prompt func(inv *serpent.Invocation) error, options ...serpent.Option) *serpent.Command {
|
||||
return &serpent.Command{
|
||||
Use: use,
|
||||
Options: options,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return prompt(inv)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var useSearch bool
|
||||
useSearchOption := serpent.Option{
|
||||
Name: "search",
|
||||
Description: "Show the search.",
|
||||
Required: false,
|
||||
Flag: "search",
|
||||
Value: serpent.BoolOf(&useSearch),
|
||||
}
|
||||
cmd := &serpent.Command{
|
||||
Use: "prompt-example",
|
||||
Short: "Example of various prompt types used within coder cli.",
|
||||
Long: "Example of various prompt types used within coder cli. " +
|
||||
"This command exists to aid in adjusting visuals of command prompts.",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Children: []*serpent.Command{
|
||||
promptCmd("confirm", func(inv *serpent.Invocation) error {
|
||||
value, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Basic confirmation prompt.",
|
||||
Default: "yes",
|
||||
IsConfirm: true,
|
||||
})
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", value)
|
||||
return err
|
||||
}),
|
||||
promptCmd("validation", func(inv *serpent.Invocation) error {
|
||||
value, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Input a string that starts with a capital letter.",
|
||||
Default: "",
|
||||
Secret: false,
|
||||
IsConfirm: false,
|
||||
Validate: func(s string) error {
|
||||
if len(s) == 0 {
|
||||
return xerrors.Errorf("an input string is required")
|
||||
}
|
||||
if strings.ToUpper(string(s[0])) != string(s[0]) {
|
||||
return xerrors.Errorf("input string must start with a capital letter")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", value)
|
||||
return err
|
||||
}),
|
||||
promptCmd("secret", func(inv *serpent.Invocation) error {
|
||||
value, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Input a secret",
|
||||
Default: "",
|
||||
Secret: true,
|
||||
IsConfirm: false,
|
||||
Validate: func(s string) error {
|
||||
if len(s) == 0 {
|
||||
return xerrors.Errorf("an input string is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Your secret of length %d is safe with me\n", len(value))
|
||||
return err
|
||||
}),
|
||||
promptCmd("select", func(inv *serpent.Invocation) error {
|
||||
value, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Options: []string{
|
||||
"Blue", "Green", "Yellow", "Red", "Something else",
|
||||
},
|
||||
Default: "",
|
||||
Message: "Select your favorite color:",
|
||||
Size: 5,
|
||||
HideSearch: !useSearch,
|
||||
})
|
||||
if value == "Something else" {
|
||||
_, _ = fmt.Fprint(inv.Stdout, "I would have picked blue.\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s is a nice color.\n", value)
|
||||
}
|
||||
return err
|
||||
}, useSearchOption),
|
||||
promptCmd("multi-select", func(inv *serpent.Invocation) error {
|
||||
values, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
|
||||
Message: "Select some things:",
|
||||
Options: []string{
|
||||
"Code", "Chair", "Whale", "Diamond", "Carrot",
|
||||
},
|
||||
Defaults: []string{"Code"},
|
||||
})
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(values, ", "))
|
||||
return err
|
||||
}),
|
||||
promptCmd("rich-parameter", func(inv *serpent.Invocation) error {
|
||||
value, err := cliui.RichSelect(inv, cliui.RichSelectOptions{
|
||||
Options: []codersdk.TemplateVersionParameterOption{
|
||||
{
|
||||
Name: "Blue",
|
||||
Description: "Like the ocean.",
|
||||
Value: "blue",
|
||||
Icon: "/logo/blue.png",
|
||||
},
|
||||
{
|
||||
Name: "Red",
|
||||
Description: "Like a clown's nose.",
|
||||
Value: "red",
|
||||
Icon: "/logo/red.png",
|
||||
},
|
||||
{
|
||||
Name: "Yellow",
|
||||
Description: "Like a bumblebee. ",
|
||||
Value: "yellow",
|
||||
Icon: "/logo/yellow.png",
|
||||
},
|
||||
},
|
||||
Default: "blue",
|
||||
Size: 5,
|
||||
HideSearch: useSearch,
|
||||
})
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s is a good choice.\n", value.Name)
|
||||
return err
|
||||
}, useSearchOption),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
+9
-9
@@ -181,12 +181,12 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
|
||||
`
|
||||
cmd := &serpent.Command{
|
||||
Use: "coder [global-flags] <subcommand>",
|
||||
Long: fmt.Sprintf(fmtLong, buildinfo.Version()) + formatExamples(
|
||||
example{
|
||||
Long: fmt.Sprintf(fmtLong, buildinfo.Version()) + FormatExamples(
|
||||
Example{
|
||||
Description: "Start a Coder server",
|
||||
Command: "coder server",
|
||||
},
|
||||
example{
|
||||
Example{
|
||||
Description: "Get started by creating a template from an example",
|
||||
Command: "coder templates init",
|
||||
},
|
||||
@@ -657,7 +657,7 @@ func CurrentOrganization(r *RootCmd, inv *serpent.Invocation, client *codersdk.C
|
||||
})
|
||||
|
||||
if index < 0 {
|
||||
return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization?", selected)
|
||||
return codersdk.Organization{}, xerrors.Errorf("organization %q not found, are you sure you are a member of this organization? If unsure, run 'coder organizations set \"\" ' to reset your current context.", selected)
|
||||
}
|
||||
return orgs[index], nil
|
||||
}
|
||||
@@ -753,16 +753,16 @@ func isTTYWriter(inv *serpent.Invocation, writer io.Writer) bool {
|
||||
return isatty.IsTerminal(file.Fd())
|
||||
}
|
||||
|
||||
// example represents a standard example for command usage, to be used
|
||||
// with formatExamples.
|
||||
type example struct {
|
||||
// Example represents a standard example for command usage, to be used
|
||||
// with FormatExamples.
|
||||
type Example struct {
|
||||
Description string
|
||||
Command string
|
||||
}
|
||||
|
||||
// formatExamples formats the examples as width wrapped bulletpoint
|
||||
// FormatExamples formats the examples as width wrapped bulletpoint
|
||||
// descriptions with the command underneath.
|
||||
func formatExamples(examples ...example) string {
|
||||
func FormatExamples(examples ...Example) string {
|
||||
var sb strings.Builder
|
||||
|
||||
padStyle := cliui.DefaultStyles.Wrap.With(pretty.XPad(4, 0))
|
||||
|
||||
@@ -45,7 +45,7 @@ func Test_formatExamples(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
examples []example
|
||||
examples []Example
|
||||
wantMatches []string
|
||||
}{
|
||||
{
|
||||
@@ -55,7 +55,7 @@ func Test_formatExamples(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Output examples",
|
||||
examples: []example{
|
||||
examples: []Example{
|
||||
{
|
||||
Description: "Hello world.",
|
||||
Command: "echo hello",
|
||||
@@ -72,7 +72,7 @@ func Test_formatExamples(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "No description outputs commands",
|
||||
examples: []example{
|
||||
examples: []Example{
|
||||
{
|
||||
Command: "echo hello",
|
||||
},
|
||||
@@ -87,7 +87,7 @@ func Test_formatExamples(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := formatExamples(tt.examples...)
|
||||
got := FormatExamples(tt.examples...)
|
||||
if len(tt.wantMatches) == 0 {
|
||||
require.Empty(t, got)
|
||||
} else {
|
||||
|
||||
+6
-6
@@ -140,8 +140,8 @@ func (r *RootCmd) scheduleStart() *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "start <workspace-name> { <start-time> [day-of-week] [location] | manual }",
|
||||
Long: scheduleStartDescriptionLong + "\n" + formatExamples(
|
||||
example{
|
||||
Long: scheduleStartDescriptionLong + "\n" + FormatExamples(
|
||||
Example{
|
||||
Description: "Set the workspace to start at 9:30am (in Dublin) from Monday to Friday",
|
||||
Command: "coder schedule start my-workspace 9:30AM Mon-Fri Europe/Dublin",
|
||||
},
|
||||
@@ -189,8 +189,8 @@ func (r *RootCmd) scheduleStop() *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
return &serpent.Command{
|
||||
Use: "stop <workspace-name> { <duration> | manual }",
|
||||
Long: scheduleStopDescriptionLong + "\n" + formatExamples(
|
||||
example{
|
||||
Long: scheduleStopDescriptionLong + "\n" + FormatExamples(
|
||||
Example{
|
||||
Command: "coder schedule stop my-workspace 2h30m",
|
||||
},
|
||||
),
|
||||
@@ -234,8 +234,8 @@ func (r *RootCmd) scheduleOverride() *serpent.Command {
|
||||
overrideCmd := &serpent.Command{
|
||||
Use: "override-stop <workspace-name> <duration from now>",
|
||||
Short: "Override the stop time of a currently running workspace instance.",
|
||||
Long: scheduleOverrideDescriptionLong + "\n" + formatExamples(
|
||||
example{
|
||||
Long: scheduleOverrideDescriptionLong + "\n" + FormatExamples(
|
||||
Example{
|
||||
Command: "coder schedule override-stop my-workspace 90m",
|
||||
},
|
||||
),
|
||||
|
||||
+18
-31
@@ -62,7 +62,6 @@ import (
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/autobuild"
|
||||
"github.com/coder/coder/v2/coderd/batchstats"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/awsiamrds"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmem"
|
||||
@@ -87,7 +86,7 @@ import (
|
||||
stringutil "github.com/coder/coder/v2/coderd/util/strings"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
"github.com/coder/coder/v2/coderd/workspaceusage"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/drpc"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
@@ -169,6 +168,7 @@ func createOIDCConfig(ctx context.Context, vals *codersdk.DeploymentValues) (*co
|
||||
EmailDomain: vals.OIDC.EmailDomain,
|
||||
AllowSignups: vals.OIDC.AllowSignups.Value(),
|
||||
UsernameField: vals.OIDC.UsernameField.String(),
|
||||
NameField: vals.OIDC.NameField.String(),
|
||||
EmailField: vals.OIDC.EmailField.String(),
|
||||
AuthURLParams: vals.OIDC.AuthURLParams.Value,
|
||||
IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(),
|
||||
@@ -796,31 +796,18 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
cliui.Infof(inv.Stdout, "\n==> Logs will stream in below (press ctrl+c to gracefully exit):")
|
||||
|
||||
if vals.Telemetry.Enable {
|
||||
gitAuth := make([]telemetry.GitAuth, 0)
|
||||
// TODO:
|
||||
var gitAuthConfigs []codersdk.ExternalAuthConfig
|
||||
for _, cfg := range gitAuthConfigs {
|
||||
gitAuth = append(gitAuth, telemetry.GitAuth{
|
||||
Type: cfg.Type,
|
||||
})
|
||||
vals, err := vals.WithoutSecrets()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("remove secrets from deployment values: %w", err)
|
||||
}
|
||||
|
||||
options.Telemetry, err = telemetry.New(telemetry.Options{
|
||||
BuiltinPostgres: builtinPostgres,
|
||||
DeploymentID: deploymentID,
|
||||
Database: options.Database,
|
||||
Logger: logger.Named("telemetry"),
|
||||
URL: vals.Telemetry.URL.Value(),
|
||||
Wildcard: vals.WildcardAccessURL.String() != "",
|
||||
DERPServerRelayURL: vals.DERP.Server.RelayURL.String(),
|
||||
GitAuth: gitAuth,
|
||||
GitHubOAuth: vals.OAuth2.Github.ClientID != "",
|
||||
OIDCAuth: vals.OIDC.ClientID != "",
|
||||
OIDCIssuerURL: vals.OIDC.IssuerURL.String(),
|
||||
Prometheus: vals.Prometheus.Enable.Value(),
|
||||
STUN: len(vals.DERP.Server.STUNAddresses) != 0,
|
||||
Tunnel: tunnel != nil,
|
||||
Experiments: vals.Experiments.Value(),
|
||||
BuiltinPostgres: builtinPostgres,
|
||||
DeploymentID: deploymentID,
|
||||
Database: options.Database,
|
||||
Logger: logger.Named("telemetry"),
|
||||
URL: vals.Telemetry.URL.Value(),
|
||||
Tunnel: tunnel != nil,
|
||||
DeploymentConfig: vals,
|
||||
ParseLicenseJWT: func(lic *telemetry.License) error {
|
||||
// This will be nil when running in AGPL-only mode.
|
||||
if options.ParseLicenseClaims == nil {
|
||||
@@ -869,9 +856,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
options.SwaggerEndpoint = vals.Swagger.Enable.Value()
|
||||
}
|
||||
|
||||
batcher, closeBatcher, err := batchstats.New(ctx,
|
||||
batchstats.WithLogger(options.Logger.Named("batchstats")),
|
||||
batchstats.WithStore(options.Database),
|
||||
batcher, closeBatcher, err := workspacestats.NewBatcher(ctx,
|
||||
workspacestats.BatcherWithLogger(options.Logger.Named("batchstats")),
|
||||
workspacestats.BatcherWithStore(options.Database),
|
||||
)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to create agent stats batcher: %w", err)
|
||||
@@ -976,8 +963,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
defer purger.Close()
|
||||
|
||||
// Updates workspace usage
|
||||
tracker := workspaceusage.New(options.Database,
|
||||
workspaceusage.WithLogger(logger.Named("workspace_usage_tracker")),
|
||||
tracker := workspacestats.NewTracker(options.Database,
|
||||
workspacestats.TrackerWithLogger(logger.Named("workspace_usage_tracker")),
|
||||
)
|
||||
options.WorkspaceUsageTracker = tracker
|
||||
defer tracker.Close()
|
||||
@@ -1441,7 +1428,7 @@ func newProvisionerDaemon(
|
||||
|
||||
connector[string(database.ProvisionerTypeTerraform)] = sdkproto.NewDRPCProvisionerClient(terraformClient)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown provisioner type %q", provisionerType)
|
||||
return nil, xerrors.Errorf("unknown provisioner type %q", provisionerType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
|
||||
// Use the validator tags so we match the API's validation.
|
||||
req := codersdk.CreateUserRequest{
|
||||
Username: "username",
|
||||
Name: "Admin User",
|
||||
Email: "email@coder.com",
|
||||
Password: "ValidPa$$word123!",
|
||||
OrganizationID: uuid.New(),
|
||||
@@ -116,6 +117,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if newUserEmail == "" {
|
||||
newUserEmail, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Email",
|
||||
@@ -189,10 +191,11 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
|
||||
ID: uuid.New(),
|
||||
Email: newUserEmail,
|
||||
Username: newUserUsername,
|
||||
Name: "Admin User",
|
||||
HashedPassword: []byte(hashedPassword),
|
||||
CreatedAt: dbtime.Now(),
|
||||
UpdatedAt: dbtime.Now(),
|
||||
RBACRoles: []string{rbac.RoleOwner()},
|
||||
RBACRoles: []string{rbac.RoleOwner().String()},
|
||||
LoginType: database.LoginTypePassword,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -222,7 +225,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
|
||||
UserID: newUser.ID,
|
||||
CreatedAt: dbtime.Now(),
|
||||
UpdatedAt: dbtime.Now(),
|
||||
Roles: []string{rbac.RoleOrgAdmin(org.ID)},
|
||||
Roles: []string{rbac.RoleOrgAdmin()},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert organization member: %w", err)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/userpassword"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -56,7 +57,7 @@ func TestServerCreateAdminUser(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "password does not match")
|
||||
|
||||
require.EqualValues(t, []string{rbac.RoleOwner()}, user.RBACRoles, "user does not have owner role")
|
||||
require.EqualValues(t, []string{codersdk.RoleOwner}, user.RBACRoles, "user does not have owner role")
|
||||
|
||||
// Check that user is admin in every org.
|
||||
orgs, err := db.GetOrganizations(ctx)
|
||||
@@ -66,12 +67,12 @@ func TestServerCreateAdminUser(t *testing.T) {
|
||||
orgIDs[org.ID] = struct{}{}
|
||||
}
|
||||
|
||||
orgMemberships, err := db.GetOrganizationMembershipsByUserID(ctx, user.ID)
|
||||
orgMemberships, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{UserID: user.ID})
|
||||
require.NoError(t, err)
|
||||
orgIDs2 := make(map[uuid.UUID]struct{}, len(orgMemberships))
|
||||
for _, membership := range orgMemberships {
|
||||
orgIDs2[membership.OrganizationID] = struct{}{}
|
||||
assert.Equal(t, []string{rbac.RoleOrgAdmin(membership.OrganizationID)}, membership.Roles, "user is not org admin")
|
||||
orgIDs2[membership.OrganizationMember.OrganizationID] = struct{}{}
|
||||
assert.Equal(t, []string{rbac.RoleOrgAdmin()}, membership.OrganizationMember.Roles, "user is not org admin")
|
||||
}
|
||||
|
||||
require.Equal(t, orgIDs, orgIDs2, "user is not in all orgs")
|
||||
|
||||
@@ -141,8 +141,8 @@ func Test_configureCipherSuites(t *testing.T) {
|
||||
name: "TLSUnsupported",
|
||||
minTLS: tls.VersionTLS10,
|
||||
maxTLS: tls.VersionTLS13,
|
||||
// TLS_RSA_WITH_AES_128_GCM_SHA256 only supports tls 1.2
|
||||
inputCiphers: []string{"TLS_RSA_WITH_AES_128_GCM_SHA256"},
|
||||
// TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 only supports tls 1.2
|
||||
inputCiphers: []string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"},
|
||||
wantErr: "no tls ciphers supported for tls versions",
|
||||
},
|
||||
{
|
||||
|
||||
+42
-31
@@ -967,26 +967,32 @@ func TestServer(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
// nolint:bodyclose
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
return err == nil
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
defer res.Body.Close()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(res.Body)
|
||||
hasActiveUsers := false
|
||||
for scanner.Scan() {
|
||||
// This metric is manually registered to be tracked in the server. That's
|
||||
// why we test it's tracked here.
|
||||
if strings.HasPrefix(scanner.Text(), "coderd_api_active_users_duration_hour") {
|
||||
hasActiveUsers = true
|
||||
continue
|
||||
scanner := bufio.NewScanner(res.Body)
|
||||
hasActiveUsers := false
|
||||
for scanner.Scan() {
|
||||
// This metric is manually registered to be tracked in the server. That's
|
||||
// why we test it's tracked here.
|
||||
if strings.HasPrefix(scanner.Text(), "coderd_api_active_users_duration_hour") {
|
||||
hasActiveUsers = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") {
|
||||
t.Fatal("db metrics should not be tracked when --prometheus-collect-db-metrics is not enabled")
|
||||
}
|
||||
t.Logf("scanned %s", scanner.Text())
|
||||
}
|
||||
if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") {
|
||||
t.Fatal("db metrics should not be tracked when --prometheus-collect-db-metrics is not enabled")
|
||||
if scanner.Err() != nil {
|
||||
t.Logf("scanner err: %s", scanner.Err().Error())
|
||||
return false
|
||||
}
|
||||
t.Logf("scanned %s", scanner.Text())
|
||||
}
|
||||
require.NoError(t, scanner.Err())
|
||||
require.True(t, hasActiveUsers)
|
||||
|
||||
return hasActiveUsers
|
||||
}, testutil.WaitShort, testutil.IntervalFast, "didn't find coderd_api_active_users_duration_hour in time")
|
||||
})
|
||||
|
||||
t.Run("DBMetricsEnabled", func(t *testing.T) {
|
||||
@@ -1017,20 +1023,25 @@ func TestServer(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
// nolint:bodyclose
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
return err == nil
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
defer res.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(res.Body)
|
||||
hasDBMetrics := false
|
||||
for scanner.Scan() {
|
||||
if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") {
|
||||
hasDBMetrics = true
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
t.Logf("scanned %s", scanner.Text())
|
||||
}
|
||||
require.NoError(t, scanner.Err())
|
||||
require.True(t, hasDBMetrics)
|
||||
defer res.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(res.Body)
|
||||
hasDBMetrics := false
|
||||
for scanner.Scan() {
|
||||
if strings.HasPrefix(scanner.Text(), "coderd_db_query_latencies_seconds") {
|
||||
hasDBMetrics = true
|
||||
}
|
||||
t.Logf("scanned %s", scanner.Text())
|
||||
}
|
||||
if scanner.Err() != nil {
|
||||
t.Logf("scanner err: %s", scanner.Err().Error())
|
||||
return false
|
||||
}
|
||||
return hasDBMetrics
|
||||
}, testutil.WaitShort, testutil.IntervalFast, "didn't find coderd_db_query_latencies_seconds in time")
|
||||
})
|
||||
})
|
||||
t.Run("GitHubOAuth", func(t *testing.T) {
|
||||
@@ -1347,7 +1358,7 @@ func TestServer(t *testing.T) {
|
||||
}
|
||||
return lastStat.Size() > 0
|
||||
},
|
||||
testutil.WaitShort,
|
||||
dur, //nolint:gocritic
|
||||
testutil.IntervalFast,
|
||||
"file at %s should exist, last stat: %+v",
|
||||
fiName, lastStat,
|
||||
|
||||
+68
-20
@@ -6,7 +6,6 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"golang.org/x/xerrors"
|
||||
tsspeedtest "tailscale.com/net/speedtest"
|
||||
"tailscale.com/wgengine/capture"
|
||||
@@ -19,12 +18,51 @@ import (
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
type SpeedtestResult struct {
|
||||
Overall SpeedtestResultInterval `json:"overall"`
|
||||
Intervals []SpeedtestResultInterval `json:"intervals"`
|
||||
}
|
||||
|
||||
type SpeedtestResultInterval struct {
|
||||
StartTimeSeconds float64 `json:"start_time_seconds"`
|
||||
EndTimeSeconds float64 `json:"end_time_seconds"`
|
||||
ThroughputMbits float64 `json:"throughput_mbits"`
|
||||
}
|
||||
|
||||
type speedtestTableItem struct {
|
||||
Interval string `table:"Interval,nosort"`
|
||||
Throughput string `table:"Throughput"`
|
||||
}
|
||||
|
||||
func (r *RootCmd) speedtest() *serpent.Command {
|
||||
var (
|
||||
direct bool
|
||||
duration time.Duration
|
||||
direction string
|
||||
pcapFile string
|
||||
formatter = cliui.NewOutputFormatter(
|
||||
cliui.ChangeFormatterData(cliui.TableFormat([]speedtestTableItem{}, []string{"Interval", "Throughput"}), func(data any) (any, error) {
|
||||
res, ok := data.(SpeedtestResult)
|
||||
if !ok {
|
||||
// This should never happen
|
||||
return "", xerrors.Errorf("expected speedtestResult, got %T", data)
|
||||
}
|
||||
tableRows := make([]any, len(res.Intervals)+2)
|
||||
for i, r := range res.Intervals {
|
||||
tableRows[i] = speedtestTableItem{
|
||||
Interval: fmt.Sprintf("%.2f-%.2f sec", r.StartTimeSeconds, r.EndTimeSeconds),
|
||||
Throughput: fmt.Sprintf("%.4f Mbits/sec", r.ThroughputMbits),
|
||||
}
|
||||
}
|
||||
tableRows[len(res.Intervals)] = cliui.TableSeparator{}
|
||||
tableRows[len(res.Intervals)+1] = speedtestTableItem{
|
||||
Interval: fmt.Sprintf("%.2f-%.2f sec", res.Overall.StartTimeSeconds, res.Overall.EndTimeSeconds),
|
||||
Throughput: fmt.Sprintf("%.4f Mbits/sec", res.Overall.ThroughputMbits),
|
||||
}
|
||||
return tableRows, nil
|
||||
}),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
@@ -39,6 +77,10 @@ func (r *RootCmd) speedtest() *serpent.Command {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
|
||||
if direct && r.disableDirect {
|
||||
return xerrors.Errorf("--direct (-d) is incompatible with --%s", varDisableDirect)
|
||||
}
|
||||
|
||||
_, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, false, inv.Args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -52,16 +94,13 @@ func (r *RootCmd) speedtest() *serpent.Command {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
|
||||
logger := inv.Logger.AppendSinks(sloghuman.Sink(inv.Stderr))
|
||||
opts := &workspacesdk.DialAgentOptions{}
|
||||
if r.verbose {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
opts.Logger = inv.Logger.AppendSinks(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
if r.disableDirect {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
|
||||
}
|
||||
opts := &workspacesdk.DialAgentOptions{
|
||||
Logger: logger,
|
||||
opts.BlockEndpoints = true
|
||||
}
|
||||
if pcapFile != "" {
|
||||
s := capture.New()
|
||||
@@ -100,14 +139,14 @@ func (r *RootCmd) speedtest() *serpent.Command {
|
||||
}
|
||||
peer := status.Peer[status.Peers()[0]]
|
||||
if !p2p && direct {
|
||||
cliui.Infof(inv.Stdout, "Waiting for a direct connection... (%dms via %s)", dur.Milliseconds(), peer.Relay)
|
||||
cliui.Infof(inv.Stderr, "Waiting for a direct connection... (%dms via %s)", dur.Milliseconds(), peer.Relay)
|
||||
continue
|
||||
}
|
||||
via := peer.Relay
|
||||
if via == "" {
|
||||
via = "direct"
|
||||
}
|
||||
cliui.Infof(inv.Stdout, "%dms via %s", dur.Milliseconds(), via)
|
||||
cliui.Infof(inv.Stderr, "%dms via %s", dur.Milliseconds(), via)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
@@ -123,24 +162,32 @@ func (r *RootCmd) speedtest() *serpent.Command {
|
||||
default:
|
||||
return xerrors.Errorf("invalid direction: %q", direction)
|
||||
}
|
||||
cliui.Infof(inv.Stdout, "Starting a %ds %s test...", int(duration.Seconds()), tsDir)
|
||||
cliui.Infof(inv.Stderr, "Starting a %ds %s test...", int(duration.Seconds()), tsDir)
|
||||
results, err := conn.Speedtest(ctx, tsDir, duration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableWriter := cliui.Table()
|
||||
tableWriter.AppendHeader(table.Row{"Interval", "Throughput"})
|
||||
var outputResult SpeedtestResult
|
||||
startTime := results[0].IntervalStart
|
||||
for _, r := range results {
|
||||
if r.Total {
|
||||
tableWriter.AppendSeparator()
|
||||
outputResult.Intervals = make([]SpeedtestResultInterval, len(results)-1)
|
||||
for i, r := range results {
|
||||
interval := SpeedtestResultInterval{
|
||||
StartTimeSeconds: r.IntervalStart.Sub(startTime).Seconds(),
|
||||
EndTimeSeconds: r.IntervalEnd.Sub(startTime).Seconds(),
|
||||
ThroughputMbits: r.MBitsPerSecond(),
|
||||
}
|
||||
if r.Total {
|
||||
interval.StartTimeSeconds = 0
|
||||
outputResult.Overall = interval
|
||||
} else {
|
||||
outputResult.Intervals[i] = interval
|
||||
}
|
||||
tableWriter.AppendRow(table.Row{
|
||||
fmt.Sprintf("%.2f-%.2f sec", r.IntervalStart.Sub(startTime).Seconds(), r.IntervalEnd.Sub(startTime).Seconds()),
|
||||
fmt.Sprintf("%.4f Mbits/sec", r.MBitsPerSecond()),
|
||||
})
|
||||
}
|
||||
_, err = fmt.Fprintln(inv.Stdout, tableWriter.Render())
|
||||
out, err := formatter.Format(inv.Context(), outputResult)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(inv.Stdout, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
@@ -172,5 +219,6 @@ func (r *RootCmd) speedtest() *serpent.Command {
|
||||
Value: serpent.StringOf(&pcapFile),
|
||||
},
|
||||
}
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/cli"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -56,3 +59,45 @@ func TestSpeedtest(t *testing.T) {
|
||||
})
|
||||
<-cmdDone
|
||||
}
|
||||
|
||||
func TestSpeedtestJson(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Skip("Potentially flaky test - see https://github.com/coder/coder/issues/6321")
|
||||
if testing.Short() {
|
||||
t.Skip("This test takes a minimum of 5ms per a hardcoded value in Tailscale!")
|
||||
}
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
ws, err := client.Workspace(ctx, workspace.ID)
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
a := ws.LatestBuild.Resources[0].Agents[0]
|
||||
return a.Status == codersdk.WorkspaceAgentConnected &&
|
||||
a.LifecycleState == codersdk.WorkspaceAgentLifecycleReady
|
||||
}, testutil.WaitLong, testutil.IntervalFast, "agent is not ready")
|
||||
|
||||
inv, root := clitest.New(t, "speedtest", "--output=json", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
out := bytes.NewBuffer(nil)
|
||||
inv.Stdout = out
|
||||
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
inv.Logger = slogtest.Make(t, nil).Named("speedtest").Leveled(slog.LevelDebug)
|
||||
cmdDone := tGo(t, func() {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
<-cmdDone
|
||||
|
||||
var result cli.SpeedtestResult
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &result))
|
||||
require.Len(t, result.Intervals, 5)
|
||||
}
|
||||
|
||||
+46
-2
@@ -6,11 +6,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -39,6 +41,10 @@ import (
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
const (
|
||||
disableUsageApp = "disable"
|
||||
)
|
||||
|
||||
var (
|
||||
workspacePollInterval = time.Minute
|
||||
autostopNotifyCountdown = []time.Duration{30 * time.Minute}
|
||||
@@ -56,6 +62,7 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
logDirPath string
|
||||
remoteForwards []string
|
||||
env []string
|
||||
usageApp string
|
||||
disableAutostart bool
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
@@ -79,6 +86,10 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Prevent unnecessary logs from the stdlib from messing up the TTY.
|
||||
// See: https://github.com/coder/coder/issues/13144
|
||||
log.SetOutput(io.Discard)
|
||||
|
||||
logger := inv.Logger
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
@@ -246,6 +257,15 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
|
||||
defer stopPolling()
|
||||
|
||||
usageAppName := getUsageAppName(usageApp)
|
||||
if usageAppName != "" {
|
||||
closeUsage := client.UpdateWorkspaceUsageWithBodyContext(ctx, workspace.ID, codersdk.PostWorkspaceUsageRequest{
|
||||
AgentID: workspaceAgent.ID,
|
||||
AppName: usageAppName,
|
||||
})
|
||||
defer closeUsage()
|
||||
}
|
||||
|
||||
if stdio {
|
||||
rawSSH, err := conn.SSH(ctx)
|
||||
if err != nil {
|
||||
@@ -504,6 +524,13 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
FlagShorthand: "e",
|
||||
Value: serpent.StringArrayOf(&env),
|
||||
},
|
||||
{
|
||||
Flag: "usage-app",
|
||||
Description: "Specifies the usage app to use for workspace activity tracking.",
|
||||
Env: "CODER_SSH_USAGE_APP",
|
||||
Value: serpent.StringOf(&usageApp),
|
||||
Hidden: true,
|
||||
},
|
||||
sshDisableAutostartOption(serpent.BoolOf(&disableAutostart)),
|
||||
}
|
||||
return cmd
|
||||
@@ -706,12 +733,12 @@ func tryPollWorkspaceAutostop(ctx context.Context, client *codersdk.Client, work
|
||||
lock := flock.New(filepath.Join(os.TempDir(), "coder-autostop-notify-"+workspace.ID.String()))
|
||||
conditionCtx, cancelCondition := context.WithCancel(ctx)
|
||||
condition := notifyCondition(conditionCtx, client, workspace.ID, lock)
|
||||
stopFunc := notify.Notify(condition, workspacePollInterval, autostopNotifyCountdown...)
|
||||
notifier := notify.New(condition, workspacePollInterval, autostopNotifyCountdown)
|
||||
return func() {
|
||||
// With many "ssh" processes running, `lock.TryLockContext` can be hanging until the context canceled.
|
||||
// Without this cancellation, a CLI process with failed remote-forward could be hanging indefinitely.
|
||||
cancelCondition()
|
||||
stopFunc()
|
||||
notifier.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,3 +1066,20 @@ func (r stdioErrLogReader) Read(_ []byte) (int, error) {
|
||||
r.l.Error(context.Background(), "reading from stdin in stdio mode is not allowed")
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func getUsageAppName(usageApp string) codersdk.UsageAppName {
|
||||
if usageApp == disableUsageApp {
|
||||
return ""
|
||||
}
|
||||
|
||||
allowedUsageApps := []string{
|
||||
string(codersdk.UsageAppNameSSH),
|
||||
string(codersdk.UsageAppNameVscode),
|
||||
string(codersdk.UsageAppNameJetbrains),
|
||||
}
|
||||
if slices.Contains(allowedUsageApps, usageApp) {
|
||||
return codersdk.UsageAppName(usageApp)
|
||||
}
|
||||
|
||||
return codersdk.UsageAppNameSSH
|
||||
}
|
||||
|
||||
+111
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
@@ -43,6 +44,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats/workspacestatstest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
@@ -1292,6 +1294,115 @@ func TestSSH(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ents, 1, "expected one file in logdir %s", logDir)
|
||||
})
|
||||
t.Run("UpdateUsage", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
experiment bool
|
||||
usageAppName string
|
||||
expectedCalls int
|
||||
expectedCountSSH int
|
||||
expectedCountJetbrains int
|
||||
expectedCountVscode int
|
||||
}
|
||||
tcs := []testCase{
|
||||
{
|
||||
name: "NoExperiment",
|
||||
},
|
||||
{
|
||||
name: "Empty",
|
||||
experiment: true,
|
||||
expectedCalls: 1,
|
||||
expectedCountSSH: 1,
|
||||
},
|
||||
{
|
||||
name: "SSH",
|
||||
experiment: true,
|
||||
usageAppName: "ssh",
|
||||
expectedCalls: 1,
|
||||
expectedCountSSH: 1,
|
||||
},
|
||||
{
|
||||
name: "Jetbrains",
|
||||
experiment: true,
|
||||
usageAppName: "jetbrains",
|
||||
expectedCalls: 1,
|
||||
expectedCountJetbrains: 1,
|
||||
},
|
||||
{
|
||||
name: "Vscode",
|
||||
experiment: true,
|
||||
usageAppName: "vscode",
|
||||
expectedCalls: 1,
|
||||
expectedCountVscode: 1,
|
||||
},
|
||||
{
|
||||
name: "InvalidDefaultsToSSH",
|
||||
experiment: true,
|
||||
usageAppName: "invalid",
|
||||
expectedCalls: 1,
|
||||
expectedCountSSH: 1,
|
||||
},
|
||||
{
|
||||
name: "Disable",
|
||||
experiment: true,
|
||||
usageAppName: "disable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
if tc.experiment {
|
||||
dv.Experiments = []string{string(codersdk.ExperimentWorkspaceUsage)}
|
||||
}
|
||||
batcher := &workspacestatstest.StatsBatcher{
|
||||
LastStats: &agentproto.Stats{},
|
||||
}
|
||||
admin, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
StatsBatcher: batcher,
|
||||
})
|
||||
admin.SetLogger(slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug))
|
||||
first := coderdtest.CreateFirstUser(t, admin)
|
||||
client, user := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID)
|
||||
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent().Do()
|
||||
workspace := r.Workspace
|
||||
agentToken := r.AgentToken
|
||||
inv, root := clitest.New(t, "ssh", workspace.Name, fmt.Sprintf("--usage-app=%s", tc.usageAppName))
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
cmdDone := tGo(t, func() {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
pty.ExpectMatch("Waiting")
|
||||
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
|
||||
pty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
|
||||
require.EqualValues(t, tc.expectedCalls, batcher.Called)
|
||||
require.EqualValues(t, tc.expectedCountSSH, batcher.LastStats.SessionCountSsh)
|
||||
require.EqualValues(t, tc.expectedCountJetbrains, batcher.LastStats.SessionCountJetbrains)
|
||||
require.EqualValues(t, tc.expectedCountVscode, batcher.LastStats.SessionCountVscode)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:paralleltest // This test uses t.Setenv, parent test MUST NOT be parallel.
|
||||
|
||||
@@ -254,6 +254,7 @@ func writeBundle(src *support.Bundle, dest *zip.Writer) error {
|
||||
"deployment/health.json": src.Deployment.HealthReport,
|
||||
"network/connection_info.json": src.Network.ConnectionInfo,
|
||||
"network/netcheck.json": src.Network.Netcheck,
|
||||
"network/interfaces.json": src.Network.Interfaces,
|
||||
"workspace/template.json": src.Workspace.Template,
|
||||
"workspace/template_version.json": src.Workspace.TemplateVersion,
|
||||
"workspace/parameters.json": src.Workspace.Parameters,
|
||||
|
||||
@@ -197,6 +197,10 @@ func assertBundleContents(t *testing.T, path string, wantWorkspace bool, wantAge
|
||||
var v derphealth.Report
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotEmpty(t, v, "netcheck should not be empty")
|
||||
case "network/interfaces.json":
|
||||
var v healthsdk.InterfacesReport
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
require.NotEmpty(t, v, "interfaces should not be empty")
|
||||
case "workspace/workspace.json":
|
||||
var v codersdk.Workspace
|
||||
decodeJSONFromZip(t, f, &v)
|
||||
|
||||
+12
-3
@@ -100,6 +100,16 @@ func (r *RootCmd) templatePush() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
// If user hasn't provided new provisioner tags, inherit ones from the active template version.
|
||||
if len(tags) == 0 && template.ActiveVersionID != uuid.Nil {
|
||||
templateVersion, err := client.TemplateVersion(inv.Context(), template.ActiveVersionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags = templateVersion.Job.Tags
|
||||
inv.Logger.Info(inv.Context(), "reusing existing provisioner tags", "tags", tags)
|
||||
}
|
||||
|
||||
userVariableValues, err := ParseUserVariableValues(
|
||||
varsFiles,
|
||||
variablesFile,
|
||||
@@ -407,9 +417,8 @@ func createValidTemplateVersion(inv *serpent.Invocation, args createValidTemplat
|
||||
if errors.As(err, &jobErr) && !codersdk.JobIsMissingParameterErrorCode(jobErr.Code) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
version, err = client.TemplateVersion(inv.Context(), version.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -403,6 +403,135 @@ func TestTemplatePush(t *testing.T) {
|
||||
assert.NotEqual(t, template.ActiveVersionID, templateVersions[1].ID)
|
||||
})
|
||||
|
||||
t.Run("ProvisionerTags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ChangeTags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Start the first provisioner
|
||||
client, provisionerDocker, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
ProvisionerDaemonTags: map[string]string{
|
||||
"docker": "true",
|
||||
},
|
||||
})
|
||||
defer provisionerDocker.Close()
|
||||
|
||||
// Start the second provisioner
|
||||
provisionerFoobar := coderdtest.NewTaggedProvisionerDaemon(t, api, "provisioner-foobar", map[string]string{
|
||||
"foobar": "foobaz",
|
||||
})
|
||||
defer provisionerFoobar.Close()
|
||||
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
// Create the template with initial tagged template version.
|
||||
templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) {
|
||||
ctvr.ProvisionerTags = map[string]string{
|
||||
"docker": "true",
|
||||
}
|
||||
})
|
||||
templateVersion = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID)
|
||||
|
||||
// Push new template version without provisioner tags. CLI should reuse tags from the previous version.
|
||||
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
})
|
||||
inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", template.Name,
|
||||
"--provisioner-tag", "foobar=foobaz")
|
||||
clitest.SetupConfig(t, templateAdmin, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- inv.Run()
|
||||
}()
|
||||
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Upload", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
|
||||
// Verify template version tags
|
||||
template, err := client.Template(context.Background(), template.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
templateVersion, err = client.TemplateVersion(context.Background(), template.ActiveVersionID)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, map[string]string{"foobar": "foobaz", "owner": "", "scope": "organization"}, templateVersion.Job.Tags)
|
||||
})
|
||||
|
||||
t.Run("DoNotChangeTags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Start the tagged provisioner
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
ProvisionerDaemonTags: map[string]string{
|
||||
"docker": "true",
|
||||
},
|
||||
})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
||||
// Create the template with initial tagged template version.
|
||||
templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) {
|
||||
ctvr.ProvisionerTags = map[string]string{
|
||||
"docker": "true",
|
||||
}
|
||||
})
|
||||
templateVersion = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID)
|
||||
|
||||
// Push new template version without provisioner tags. CLI should reuse tags from the previous version.
|
||||
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
})
|
||||
inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", template.Name)
|
||||
clitest.SetupConfig(t, templateAdmin, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- inv.Run()
|
||||
}()
|
||||
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Upload", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
|
||||
// Verify template version tags
|
||||
template, err := client.Template(context.Background(), template.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
templateVersion, err = client.TemplateVersion(context.Background(), template.ActiveVersionID)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, map[string]string{"docker": "true", "owner": "", "scope": "organization"}, templateVersion.Job.Tags)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Variables", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+3
-3
@@ -16,12 +16,12 @@ func (r *RootCmd) templates() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "templates",
|
||||
Short: "Manage templates",
|
||||
Long: "Templates are written in standard Terraform and describe the infrastructure for workspaces\n" + formatExamples(
|
||||
example{
|
||||
Long: "Templates are written in standard Terraform and describe the infrastructure for workspaces\n" + FormatExamples(
|
||||
Example{
|
||||
Description: "Make changes to your template, and plan the changes",
|
||||
Command: "coder templates plan my-template",
|
||||
},
|
||||
example{
|
||||
Example{
|
||||
Description: "Create or push an update to the template. Your developers can update their workspaces",
|
||||
Command: "coder templates push my-template",
|
||||
},
|
||||
|
||||
@@ -166,7 +166,7 @@ func (r *RootCmd) archiveTemplateVersions() *serpent.Command {
|
||||
inv.Stdout, fmt.Sprintf("Archived %d versions from "+pretty.Sprint(cliui.DefaultStyles.Keyword, template.Name)+" at "+cliui.Timestamp(time.Now()), len(resp.ArchivedIDs)),
|
||||
)
|
||||
|
||||
if ok, _ := inv.ParsedFlags().GetBool("verbose"); err == nil && ok {
|
||||
if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok {
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("marshal verbose response: %w", err)
|
||||
|
||||
@@ -19,8 +19,8 @@ func (r *RootCmd) templateVersions() *serpent.Command {
|
||||
Use: "versions",
|
||||
Short: "Manage different versions of the specified template",
|
||||
Aliases: []string{"version"},
|
||||
Long: formatExamples(
|
||||
example{
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "List versions of a specific template",
|
||||
Command: "coder templates versions list my-template",
|
||||
},
|
||||
|
||||
+3
@@ -18,6 +18,9 @@ OPTIONS:
|
||||
--auth string, $CODER_AGENT_AUTH (default: token)
|
||||
Specify the authentication type to use for the agent.
|
||||
|
||||
--block-file-transfer bool, $CODER_AGENT_BLOCK_FILE_TRANSFER (default: false)
|
||||
Block file transfer using known applications: nc,rsync,scp,sftp.
|
||||
|
||||
--debug-address string, $CODER_AGENT_DEBUG_ADDRESS (default: 127.0.0.1:2113)
|
||||
The bind address to serve a debug HTTP server.
|
||||
|
||||
|
||||
+3
@@ -10,6 +10,9 @@ OPTIONS:
|
||||
Specifies an email address to use if creating the first user for the
|
||||
deployment.
|
||||
|
||||
--first-user-full-name string, $CODER_FIRST_USER_FULL_NAME
|
||||
Specifies a human-readable name for the first user of the deployment.
|
||||
|
||||
--first-user-password string, $CODER_FIRST_USER_PASSWORD
|
||||
Specifies a password to use if creating the first user for the
|
||||
deployment.
|
||||
|
||||
+3
@@ -407,6 +407,9 @@ OIDC OPTIONS:
|
||||
--oidc-issuer-url string, $CODER_OIDC_ISSUER_URL
|
||||
Issuer URL to use for Login with OIDC.
|
||||
|
||||
--oidc-name-field string, $CODER_OIDC_NAME_FIELD (default: name)
|
||||
OIDC claim field to use as the name.
|
||||
|
||||
--oidc-group-regex-filter regexp, $CODER_OIDC_GROUP_REGEX_FILTER (default: .*)
|
||||
If provided any group name not matching the regex is ignored. This
|
||||
allows for filtering out groups that are not needed. This filter is
|
||||
|
||||
+7
@@ -6,6 +6,10 @@ USAGE:
|
||||
Run upload and download tests from your machine to a workspace
|
||||
|
||||
OPTIONS:
|
||||
-c, --column string-array (default: Interval,Throughput)
|
||||
Columns to display in table output. Available columns: Interval,
|
||||
Throughput.
|
||||
|
||||
-d, --direct bool
|
||||
Specifies whether to wait for a direct connection before testing
|
||||
speed.
|
||||
@@ -14,6 +18,9 @@ OPTIONS:
|
||||
Specifies whether to run in reverse mode where the client receives and
|
||||
the server sends.
|
||||
|
||||
-o, --output string (default: table)
|
||||
Output format. Available formats: table, json.
|
||||
|
||||
--pcap-file string
|
||||
Specifies a file to write a network capture to.
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ OPTIONS:
|
||||
-e, --email string
|
||||
Specifies an email address for the new user.
|
||||
|
||||
-n, --full-name string
|
||||
Specifies an optional human-readable name for the new user.
|
||||
|
||||
--login-type string
|
||||
Optionally specify the login type for the user. Valid values are:
|
||||
password, none, github, oidc. Using 'none' prevents the user from
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"id": "[first user ID]",
|
||||
"username": "testuser",
|
||||
"avatar_url": "",
|
||||
"name": "",
|
||||
"name": "Test User",
|
||||
"email": "testuser@coder.com",
|
||||
"created_at": "[timestamp]",
|
||||
"last_seen_at": "[timestamp]",
|
||||
|
||||
+3
@@ -306,6 +306,9 @@ oidc:
|
||||
# OIDC claim field to use as the username.
|
||||
# (default: preferred_username, type: string)
|
||||
usernameField: preferred_username
|
||||
# OIDC claim field to use as the name.
|
||||
# (default: name, type: string)
|
||||
nameField: name
|
||||
# OIDC claim field to use as the email.
|
||||
# (default: email, type: string)
|
||||
emailField: email
|
||||
|
||||
+4
-4
@@ -17,16 +17,16 @@ func (r *RootCmd) tokens() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "tokens",
|
||||
Short: "Manage personal access tokens",
|
||||
Long: "Tokens are used to authenticate automated clients to Coder.\n" + formatExamples(
|
||||
example{
|
||||
Long: "Tokens are used to authenticate automated clients to Coder.\n" + FormatExamples(
|
||||
Example{
|
||||
Description: "Create a token for automation",
|
||||
Command: "coder tokens create",
|
||||
},
|
||||
example{
|
||||
Example{
|
||||
Description: "List your tokens",
|
||||
Command: "coder tokens ls",
|
||||
},
|
||||
example{
|
||||
Example{
|
||||
Description: "Remove a token by ID",
|
||||
Command: "coder tokens rm WuoWs4ZsMX",
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
"github.com/coder/serpent"
|
||||
@@ -19,6 +20,7 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
var (
|
||||
email string
|
||||
username string
|
||||
name string
|
||||
password string
|
||||
disableLogin bool
|
||||
loginType string
|
||||
@@ -35,6 +37,9 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// We only prompt for the full name if both username and email have not
|
||||
// been set. This is to avoid breaking existing non-interactive usage.
|
||||
shouldPromptName := username == "" && email == ""
|
||||
if username == "" {
|
||||
username, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Username:",
|
||||
@@ -58,6 +63,18 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if name == "" && shouldPromptName {
|
||||
rawName, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Full name (optional):",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name = httpapi.NormalizeRealUsername(rawName)
|
||||
if !strings.EqualFold(rawName, name) {
|
||||
cliui.Warnf(inv.Stderr, "Normalized name to %q", name)
|
||||
}
|
||||
}
|
||||
userLoginType := codersdk.LoginTypePassword
|
||||
if disableLogin && loginType != "" {
|
||||
return xerrors.New("You cannot specify both --disable-login and --login-type")
|
||||
@@ -79,6 +96,7 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
_, err = client.CreateUser(inv.Context(), codersdk.CreateUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Name: name,
|
||||
Password: password,
|
||||
OrganizationID: organization.ID,
|
||||
UserLoginType: userLoginType,
|
||||
@@ -127,6 +145,12 @@ Create a workspace `+pretty.Sprint(cliui.DefaultStyles.Code, "coder create")+`!
|
||||
Description: "Specifies a username for the new user.",
|
||||
Value: serpent.StringOf(&username),
|
||||
},
|
||||
{
|
||||
Flag: "full-name",
|
||||
FlagShorthand: "n",
|
||||
Description: "Specifies an optional human-readable name for the new user.",
|
||||
Value: serpent.StringOf(&name),
|
||||
},
|
||||
{
|
||||
Flag: "password",
|
||||
FlagShorthand: "p",
|
||||
|
||||
+88
-1
@@ -4,16 +4,19 @@ import (
|
||||
"testing"
|
||||
|
||||
"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/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestUserCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Prompts", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
inv, root := clitest.New(t, "users", "create")
|
||||
@@ -28,6 +31,7 @@ func TestUserCreate(t *testing.T) {
|
||||
matches := []string{
|
||||
"Username", "dean",
|
||||
"Email", "dean@coder.com",
|
||||
"Full name (optional):", "Mr. Dean Deanington",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
@@ -35,6 +39,89 @@ func TestUserCreate(t *testing.T) {
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
_ = testutil.RequireRecvCtx(ctx, t, doneChan)
|
||||
created, err := client.User(ctx, matches[1])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, matches[1], created.Username)
|
||||
assert.Equal(t, matches[3], created.Email)
|
||||
assert.Equal(t, matches[5], created.Name)
|
||||
})
|
||||
|
||||
t.Run("PromptsNoName", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
inv, root := clitest.New(t, "users", "create")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
matches := []string{
|
||||
"Username", "noname",
|
||||
"Email", "noname@coder.com",
|
||||
"Full name (optional):", "",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
_ = testutil.RequireRecvCtx(ctx, t, doneChan)
|
||||
created, err := client.User(ctx, matches[1])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, matches[1], created.Username)
|
||||
assert.Equal(t, matches[3], created.Email)
|
||||
assert.Empty(t, created.Name)
|
||||
})
|
||||
|
||||
t.Run("Args", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
args := []string{
|
||||
"users", "create",
|
||||
"-e", "dean@coder.com",
|
||||
"-u", "dean",
|
||||
"-n", "Mr. Dean Deanington",
|
||||
"-p", "1n5ecureP4ssw0rd!",
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
created, err := client.User(ctx, "dean")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, args[3], created.Email)
|
||||
assert.Equal(t, args[5], created.Username)
|
||||
assert.Equal(t, args[7], created.Name)
|
||||
})
|
||||
|
||||
t.Run("ArgsNoName", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
args := []string{
|
||||
"users", "create",
|
||||
"-e", "dean@coder.com",
|
||||
"-u", "dean",
|
||||
"-p", "1n5ecureP4ssw0rd!",
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
created, err := client.User(ctx, args[5])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, args[3], created.Email)
|
||||
assert.Equal(t, args[5], created.Username)
|
||||
assert.Empty(t, created.Name)
|
||||
})
|
||||
}
|
||||
|
||||
+3
-2
@@ -57,8 +57,8 @@ func (r *RootCmd) userSingle() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "show <username|user_id|'me'>",
|
||||
Short: "Show a single user. Use 'me' to indicate the currently authenticated user.",
|
||||
Long: formatExamples(
|
||||
example{
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Command: "coder users show me",
|
||||
},
|
||||
),
|
||||
@@ -137,6 +137,7 @@ func (*userShowFormat) Format(_ context.Context, out interface{}) (string, error
|
||||
// Add rows for each of the user's fields.
|
||||
addRow("ID", user.ID.String())
|
||||
addRow("Username", user.Username)
|
||||
addRow("Full name", user.Name)
|
||||
addRow("Email", user.Email)
|
||||
addRow("Status", user.Status)
|
||||
addRow("Created At", user.CreatedAt.Format(time.Stamp))
|
||||
|
||||
@@ -57,7 +57,14 @@ func TestUserList(t *testing.T) {
|
||||
err := json.Unmarshal(buf.Bytes(), &users)
|
||||
require.NoError(t, err, "unmarshal JSON output")
|
||||
require.Len(t, users, 2)
|
||||
require.Contains(t, users[0].Email, "coder.com")
|
||||
for _, u := range users {
|
||||
assert.NotEmpty(t, u.ID)
|
||||
assert.NotEmpty(t, u.Email)
|
||||
assert.NotEmpty(t, u.Username)
|
||||
assert.NotEmpty(t, u.Name)
|
||||
assert.NotEmpty(t, u.CreatedAt)
|
||||
assert.NotEmpty(t, u.Status)
|
||||
}
|
||||
})
|
||||
t.Run("NoURLFileErrorHasHelperText", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -133,5 +140,6 @@ func TestUserShow(t *testing.T) {
|
||||
require.Equal(t, otherUser.ID, newUser.ID)
|
||||
require.Equal(t, otherUser.Username, newUser.Username)
|
||||
require.Equal(t, otherUser.Email, newUser.Email)
|
||||
require.Equal(t, otherUser.Name, newUser.Name)
|
||||
})
|
||||
}
|
||||
|
||||
+2
-2
@@ -40,8 +40,8 @@ func (r *RootCmd) createUserStatusCommand(sdkStatus codersdk.UserStatus) *serpen
|
||||
Use: fmt.Sprintf("%s <username|user_id>", verb),
|
||||
Short: short,
|
||||
Aliases: aliases,
|
||||
Long: formatExamples(
|
||||
example{
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Command: fmt.Sprintf("coder users %s example_user", verb),
|
||||
},
|
||||
),
|
||||
|
||||
+8
-1
@@ -110,7 +110,7 @@ func (r *RootCmd) vscodeSSH() *serpent.Command {
|
||||
// will call this command after the workspace is started.
|
||||
autostart := false
|
||||
|
||||
_, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, fmt.Sprintf("%s/%s", owner, name))
|
||||
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, fmt.Sprintf("%s/%s", owner, name))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("find workspace and agent: %w", err)
|
||||
}
|
||||
@@ -176,6 +176,13 @@ func (r *RootCmd) vscodeSSH() *serpent.Command {
|
||||
defer agentConn.Close()
|
||||
|
||||
agentConn.AwaitReachable(ctx)
|
||||
|
||||
closeUsage := client.UpdateWorkspaceUsageWithBodyContext(ctx, workspace.ID, codersdk.PostWorkspaceUsageRequest{
|
||||
AgentID: workspaceAgent.ID,
|
||||
AppName: codersdk.UsageAppNameVscode,
|
||||
})
|
||||
defer closeUsage()
|
||||
|
||||
rawSSH, err := agentConn.SSH(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+29
-1
@@ -9,9 +9,16 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"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/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats/workspacestatstest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -22,7 +29,25 @@ import (
|
||||
func TestVSCodeSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.Experiments = []string{string(codersdk.ExperimentWorkspaceUsage)}
|
||||
batcher := &workspacestatstest.StatsBatcher{
|
||||
LastStats: &agentproto.Stats{},
|
||||
}
|
||||
admin, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
StatsBatcher: batcher,
|
||||
})
|
||||
admin.SetLogger(slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug))
|
||||
first := coderdtest.CreateFirstUser(t, admin)
|
||||
client, user := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID)
|
||||
r := dbfake.WorkspaceBuild(t, store, database.Workspace{
|
||||
OrganizationID: first.OrganizationID,
|
||||
OwnerID: user.ID,
|
||||
}).WithAgent().Do()
|
||||
workspace := r.Workspace
|
||||
agentToken := r.AgentToken
|
||||
|
||||
user, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -65,4 +90,7 @@ func TestVSCodeSSH(t *testing.T) {
|
||||
if err := waiter.Wait(); err != nil {
|
||||
waiter.RequireIs(context.Canceled)
|
||||
}
|
||||
|
||||
require.EqualValues(t, 1, batcher.Called)
|
||||
require.EqualValues(t, 1, batcher.LastStats.SessionCountVscode)
|
||||
}
|
||||
|
||||
+635
@@ -0,0 +1,635 @@
|
||||
# Quartz
|
||||
|
||||
A Go time testing library for writing deterministic unit tests
|
||||
|
||||
_Note: Quartz is the name I'm targeting for the standalone open source project when we spin this
|
||||
out._
|
||||
|
||||
Our high level goal is to write unit tests that
|
||||
|
||||
1. execute quickly
|
||||
2. don't flake
|
||||
3. are straightforward to write and understand
|
||||
|
||||
For tests to execute quickly without flakes, we want to focus on _determinism_: the test should run
|
||||
the same each time, and it should be easy to force the system into a known state (no races) before
|
||||
executing test assertions. `time.Sleep`, `runtime.Gosched()`, and
|
||||
polling/[Eventually](https://pkg.go.dev/github.com/stretchr/testify/assert#Eventually) are all
|
||||
symptoms of an inability to do this easily.
|
||||
|
||||
## Usage
|
||||
|
||||
### `Clock` interface
|
||||
|
||||
In your application code, maintain a reference to a `quartz.Clock` instance to start timers and
|
||||
tickers, instead of the bare `time` standard library.
|
||||
|
||||
```go
|
||||
import "github.com/coder/quartz"
|
||||
|
||||
type Component struct {
|
||||
...
|
||||
|
||||
// for testing
|
||||
clock quartz.Clock
|
||||
}
|
||||
```
|
||||
|
||||
Whenever you would call into `time` to start a timer or ticker, call `Component`'s `clock` instead.
|
||||
|
||||
In production, set this clock to `quartz.NewReal()` to create a clock that just transparently passes
|
||||
through to the standard `time` library.
|
||||
|
||||
### Mocking
|
||||
|
||||
In your tests, you can use a `*Mock` to control the tickers and timers your code under test gets.
|
||||
|
||||
```go
|
||||
import (
|
||||
"testing"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
func TestComponent(t *testing.T) {
|
||||
mClock := quartz.NewMock(t)
|
||||
comp := &Component{
|
||||
...
|
||||
clock: mClock,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `*Mock` clock starts at Jan 1, 2024, 00:00 UTC by default, but you can set any start time you'd like prior to your test.
|
||||
|
||||
```go
|
||||
mClock := quartz.NewMock(t)
|
||||
mClock.Set(time.Date(2021, 6, 18, 12, 0, 0, 0, time.UTC)) // June 18, 2021 @ 12pm UTC
|
||||
```
|
||||
|
||||
#### Advancing the clock
|
||||
|
||||
Once you begin setting timers or tickers, you cannot change the time backward, only advance it
|
||||
forward. You may continue to use `Set()`, but it is often easier and clearer to use `Advance()`.
|
||||
|
||||
For example, with a timer:
|
||||
|
||||
```go
|
||||
fired := false
|
||||
|
||||
tmr := mClock.Afterfunc(time.Second, func() {
|
||||
fired = true
|
||||
})
|
||||
mClock.Advance(time.Second)
|
||||
```
|
||||
|
||||
When you call `Advance()` it immediately moves the clock forward the given amount, and triggers any
|
||||
tickers or timers that are scheduled to happen at that time. Any triggered events happen on separate
|
||||
goroutines, so _do not_ immediately assert the results:
|
||||
|
||||
```go
|
||||
fired := false
|
||||
|
||||
tmr := mClock.Afterfunc(time.Second, func() {
|
||||
fired = true
|
||||
})
|
||||
mClock.Advance(time.Second)
|
||||
|
||||
// RACE CONDITION, DO NOT DO THIS!
|
||||
if !fired {
|
||||
t.Fatal("didn't fire")
|
||||
}
|
||||
```
|
||||
|
||||
`Advance()` (and `Set()` for that matter) return an `AdvanceWaiter` object you can use to wait for
|
||||
all triggered events to complete.
|
||||
|
||||
```go
|
||||
fired := false
|
||||
// set a test timeout so we don't wait the default `go test` timeout for a failure
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
tmr := mClock.Afterfunc(time.Second, func() {
|
||||
fired = true
|
||||
})
|
||||
|
||||
w := mClock.Advance(time.Second)
|
||||
err := w.Wait(ctx)
|
||||
if err != nil {
|
||||
t.Fatal("AfterFunc f never completed")
|
||||
}
|
||||
if !fired {
|
||||
t.Fatal("didn't fire")
|
||||
}
|
||||
```
|
||||
|
||||
The construction of waiting for the triggered events and failing the test if they don't complete is
|
||||
very common, so there is a shorthand:
|
||||
|
||||
```go
|
||||
w := mClock.Advance(time.Second)
|
||||
err := w.Wait(ctx)
|
||||
if err != nil {
|
||||
t.Fatal("AfterFunc f never completed")
|
||||
}
|
||||
```
|
||||
|
||||
is equivalent to:
|
||||
|
||||
```go
|
||||
w := mClock.Advance(time.Second)
|
||||
w.MustWait(ctx)
|
||||
```
|
||||
|
||||
or even more briefly:
|
||||
|
||||
```go
|
||||
mClock.Advance(time.Second).MustWait(ctx)
|
||||
```
|
||||
|
||||
### Advance only to the next event
|
||||
|
||||
One important restriction on advancing the clock is that you may only advance forward to the next
|
||||
timer or ticker event and no further. The following will result in a test failure:
|
||||
|
||||
```go
|
||||
func TestAdvanceTooFar(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(10*time.Second)
|
||||
defer cancel()
|
||||
mClock := quartz.NewMock(t)
|
||||
var firedAt time.Time
|
||||
mClock.AfterFunc(time.Second, func() {
|
||||
firedAt := mClock.Now()
|
||||
})
|
||||
mClock.Advance(2*time.Second).MustWait(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
This is a deliberate design decision to allow `Advance()` to immediately and synchronously move the
|
||||
clock forward (even without calling `Wait()` on returned waiter). This helps meet Quartz's design
|
||||
goals of writing deterministic and easy to understand unit tests. It also allows the clock to be
|
||||
advanced, deterministically _during_ the execution of a tick or timer function, as explained in the
|
||||
next sections on Traps.
|
||||
|
||||
Advancing multiple events can be accomplished via looping. E.g. if you have a 1-second ticker
|
||||
|
||||
```go
|
||||
for i := 0; i < 10; i++ {
|
||||
mClock.Advance(time.Second).MustWait(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
will advance 10 ticks.
|
||||
|
||||
If you don't know or don't want to compute the time to the next event, you can use `AdvanceNext()`.
|
||||
|
||||
```go
|
||||
d, w := mClock.AdvanceNext()
|
||||
w.MustWait(ctx)
|
||||
// d contains the duration we advanced
|
||||
```
|
||||
|
||||
`d, ok := Peek()` returns the duration until the next event, if any (`ok` is `true`). You can use
|
||||
this to advance a specific time, regardless of the tickers and timer events:
|
||||
|
||||
```go
|
||||
desired := time.Minute // time to advance
|
||||
for desired > 0 {
|
||||
p, ok := mClock.Peek()
|
||||
if !ok || p > desired {
|
||||
mClock.Advance(desired).MustWait(ctx)
|
||||
break
|
||||
}
|
||||
mClock.Advance(p).MustWait(ctx)
|
||||
desired -= p
|
||||
}
|
||||
```
|
||||
|
||||
### Traps
|
||||
|
||||
A trap allows you to match specific calls into the library while mocking, block their return,
|
||||
inspect their arguments, then release them to allow them to return. They help you write
|
||||
deterministic unit tests even when the code under test executes asynchronously from the test.
|
||||
|
||||
You set your traps prior to executing code under test, and then wait for them to be triggered.
|
||||
|
||||
```go
|
||||
func TestTrap(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(10*time.Second)
|
||||
defer cancel()
|
||||
mClock := quartz.NewMock(t)
|
||||
trap := mClock.Trap().AfterFunc()
|
||||
defer trap.Close() // stop trapping AfterFunc calls
|
||||
|
||||
count := 0
|
||||
go mClock.AfterFunc(time.Hour, func(){
|
||||
count++
|
||||
})
|
||||
call := trap.MustWait(ctx)
|
||||
call.Release()
|
||||
if call.Duration != time.Hour {
|
||||
t.Fatal("wrong duration")
|
||||
}
|
||||
|
||||
// Now that the async call to AfterFunc has occurred, we can advance the clock to trigger it
|
||||
mClock.Advance(call.Duration).MustWait(ctx)
|
||||
if count != 1 {
|
||||
t.Fatal("wrong count")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this test, the trap serves 2 purposes. Firstly, it allows us to capture and assert the duration
|
||||
passed to the `AfterFunc` call. Secondly, it prevents a race between setting the timer and advancing
|
||||
it. Since these things happen on different goroutines, if `Advance()` completes before
|
||||
`AfterFunc()` is called, then the timer never pops in this test.
|
||||
|
||||
Any untrapped calls immediately complete using the current time, and calling `Close()` on a trap
|
||||
causes the mock clock to stop trapping those calls.
|
||||
|
||||
You may also `Advance()` the clock between trapping a call and releasing it. The call uses the
|
||||
current (mocked) time at the moment it is released.
|
||||
|
||||
```go
|
||||
func TestTrap2(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(10*time.Second)
|
||||
defer cancel()
|
||||
mClock := quartz.NewMock(t)
|
||||
trap := mClock.Trap().Now()
|
||||
defer trap.Close() // stop trapping AfterFunc calls
|
||||
|
||||
var logs []string
|
||||
done := make(chan struct{})
|
||||
go func(clk quartz.Clock){
|
||||
defer close(done)
|
||||
start := clk.Now()
|
||||
phase1()
|
||||
p1end := clk.Now()
|
||||
logs = append(fmt.Sprintf("Phase 1 took %s", p1end.Sub(start).String()))
|
||||
phase2()
|
||||
p2end := clk.Now()
|
||||
logs = append(fmt.Sprintf("Phase 2 took %s", p2end.Sub(p1end).String()))
|
||||
}(mClock)
|
||||
|
||||
// start
|
||||
trap.MustWait(ctx).Release()
|
||||
// phase 1
|
||||
call := trap.MustWait(ctx)
|
||||
mClock.Advance(3*time.Second).MustWait(ctx)
|
||||
call.Release()
|
||||
// phase 2
|
||||
call = trap.MustWait(ctx)
|
||||
mClock.Advance(5*time.Second).MustWait(ctx)
|
||||
call.Release()
|
||||
|
||||
<-done
|
||||
// Now logs contains []string{"Phase 1 took 3s", "Phase 2 took 5s"}
|
||||
}
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
When multiple goroutines in the code under test call into the Clock, you can use `tags` to
|
||||
distinguish them in your traps.
|
||||
|
||||
```go
|
||||
trap := mClock.Trap.Now("foo") // traps any calls that contain "foo"
|
||||
defer trap.Close()
|
||||
|
||||
foo := make(chan time.Time)
|
||||
go func(){
|
||||
foo <- mClock.Now("foo", "bar")
|
||||
}()
|
||||
baz := make(chan time.Time)
|
||||
go func(){
|
||||
baz <- mClock.Now("baz")
|
||||
}()
|
||||
call := trap.MustWait(ctx)
|
||||
mClock.Advance(time.Second).MustWait(ctx)
|
||||
call.Release()
|
||||
// call.Tags contains []string{"foo", "bar"}
|
||||
|
||||
gotFoo := <-foo // 1s after start
|
||||
gotBaz := <-baz // ?? never trapped, so races with Advance()
|
||||
```
|
||||
|
||||
Tags appear as an optional suffix on all `Clock` methods (type `...string`) and are ignored entirely
|
||||
by the real clock. They also appear on all methods on returned timers and tickers.
|
||||
|
||||
## Recommended Patterns
|
||||
|
||||
### Options
|
||||
|
||||
We use the Option pattern to inject the mock clock for testing, keeping the call signature in
|
||||
production clean. The option pattern is compatible with other optional fields as well.
|
||||
|
||||
```go
|
||||
type Option func(*Thing)
|
||||
|
||||
// WithTestClock is used in tests to inject a mock Clock
|
||||
func WithTestClock(clk quartz.Clock) Option {
|
||||
return func(t *Thing) {
|
||||
t.clock = clk
|
||||
}
|
||||
}
|
||||
|
||||
func NewThing(<required args>, opts ...Option) *Thing {
|
||||
t := &Thing{
|
||||
...
|
||||
clock: quartz.NewReal()
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(t)
|
||||
}
|
||||
return t
|
||||
}
|
||||
```
|
||||
|
||||
In tests, this becomes
|
||||
|
||||
```go
|
||||
func TestThing(t *testing.T) {
|
||||
mClock := quartz.NewMock(t)
|
||||
thing := NewThing(<required args>, WithTestClock(mClock))
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Tagging convention
|
||||
|
||||
Tag your `Clock` method calls as:
|
||||
|
||||
```go
|
||||
func (c *Component) Method() {
|
||||
now := c.clock.Now("Component", "Method")
|
||||
}
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```go
|
||||
func (c *Component) Method() {
|
||||
start := c.clock.Now("Component", "Method", "start")
|
||||
...
|
||||
end := c.clock.Now("Component", "Method", "end")
|
||||
}
|
||||
```
|
||||
|
||||
This makes it much less likely that code changes that introduce new components or methods will spoil
|
||||
existing unit tests.
|
||||
|
||||
## Why another time testing library?
|
||||
|
||||
Writing good unit tests for components and functions that use the `time` package is difficult, even
|
||||
though several open source libraries exist. In building Quartz, we took some inspiration from
|
||||
|
||||
- [github.com/benbjohnson/clock](https://github.com/benbjohnson/clock)
|
||||
- Tailscale's [tstest.Clock](https://github.com/coder/tailscale/blob/main/tstest/clock.go)
|
||||
- [github.com/aspenmesh/tock](https://github.com/aspenmesh/tock)
|
||||
|
||||
Quartz shares the high level design of a `Clock` interface that closely resembles the functions in
|
||||
the `time` standard library, and a "real" clock passes thru to the standard library in production,
|
||||
while a mock clock gives precise control in testing.
|
||||
|
||||
As mentioned in our introduction, our high level goal is to write unit tests that
|
||||
|
||||
1. execute quickly
|
||||
2. don't flake
|
||||
3. are straightforward to write and understand
|
||||
|
||||
For several reasons, this is a tall order when it comes to code that depends on time, and we found
|
||||
the existing libraries insufficient for our goals.
|
||||
|
||||
### Preventing test flakes
|
||||
|
||||
The following example comes from the README from benbjohnson/clock:
|
||||
|
||||
```go
|
||||
mock := clock.NewMock()
|
||||
count := 0
|
||||
|
||||
// Kick off a timer to increment every 1 mock second.
|
||||
go func() {
|
||||
ticker := mock.Ticker(1 * time.Second)
|
||||
for {
|
||||
<-ticker.C
|
||||
count++
|
||||
}
|
||||
}()
|
||||
runtime.Gosched()
|
||||
|
||||
// Move the clock forward 10 seconds.
|
||||
mock.Add(10 * time.Second)
|
||||
|
||||
// This prints 10.
|
||||
fmt.Println(count)
|
||||
```
|
||||
|
||||
The first race condition is fairly obvious: moving the clock forward 10 seconds may generate 10
|
||||
ticks on the `ticker.C` channel, but there is no guarantee that `count++` executes before
|
||||
`fmt.Println(count)`.
|
||||
|
||||
The second race condition is more subtle, but `runtime.Gosched()` is the tell. Since the ticker
|
||||
is started on a separate goroutine, there is no guarantee that `mock.Ticker()` executes before
|
||||
`mock.Add()`. `runtime.Gosched()` is an attempt to get this to happen, but it makes no hard
|
||||
promises. On a busy system, especially when running tests in parallel, this can flake, advance the
|
||||
time 10 seconds first, then start the ticker and never generate a tick.
|
||||
|
||||
Let's talk about how Quartz tackles these problems.
|
||||
|
||||
In our experience, an extremely common use case is creating a ticker then doing a 2-arm `select`
|
||||
with ticks in one and context expiring in another, i.e.
|
||||
|
||||
```go
|
||||
t := time.NewTicker(duration)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
err := do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In Quartz, we refactor this to be more compact and testing friendly:
|
||||
|
||||
```go
|
||||
t := clock.TickerFunc(ctx, duration, do)
|
||||
return t.Wait()
|
||||
```
|
||||
|
||||
This affords the mock `Clock` the ability to explicitly know when processing of a tick is finished
|
||||
because it's wrapped in the function passed to `TickerFunc` (`do()` in this example).
|
||||
|
||||
In Quartz, when you advance the clock, you are returned an object you can `Wait()` on to ensure all
|
||||
ticks and timers triggered are finished. This solves the first race condition in the example.
|
||||
|
||||
(As an aside, we still support a traditional standard library-style `Ticker`. You may find it useful
|
||||
if you want to keep your code as close as possible to the standard library, or if you need to use
|
||||
the channel in a larger `select` block. In that case, you'll have to find some other mechanism to
|
||||
sync tick processing to your test code.)
|
||||
|
||||
To prevent race conditions related to the starting of the ticker, Quartz allows you to set "traps"
|
||||
for calls that access the clock.
|
||||
|
||||
```go
|
||||
func TestTicker(t *testing.T) {
|
||||
mClock := quartz.NewMock(t)
|
||||
trap := mClock.Trap().TickerFunc()
|
||||
defer trap.Close() // stop trapping at end
|
||||
go runMyTicker(mClock) // async calls TickerFunc()
|
||||
call := trap.Wait(context.Background()) // waits for a call and blocks its return
|
||||
call.Release() // allow the TickerFunc() call to return
|
||||
// optionally check the duration using call.Duration
|
||||
// Move the clock forward 1 tick
|
||||
mClock.Advance(time.Second).MustWait(context.Background())
|
||||
// assert results of the tick
|
||||
}
|
||||
```
|
||||
|
||||
Trapping and then releasing the call to `TickerFunc()` ensures the ticker is started at a
|
||||
deterministic time, so our calls to `Advance()` will have a predictable effect.
|
||||
|
||||
Take a look at `TestExampleTickerFunc` in `example_test.go` for a complete worked example.
|
||||
|
||||
### Complex time dependence
|
||||
|
||||
Another difficult issue to handle when unit testing is when some code under test makes multiple
|
||||
calls that depend on the time, and you want to simulate some time passing between them.
|
||||
|
||||
A very basic example is measuring how long something took:
|
||||
|
||||
```go
|
||||
var measurement time.Duration
|
||||
go func(clock quartz.Clock) {
|
||||
start := clock.Now()
|
||||
doSomething()
|
||||
measurement = clock.Since(start)
|
||||
}(mClock)
|
||||
|
||||
// how to get measurement to be, say, 5 seconds?
|
||||
```
|
||||
|
||||
The two calls into the clock happen asynchronously, so we need to be able to advance the clock after
|
||||
the first call to `Now()` but before the call to `Since()`. Doing this with the libraries we
|
||||
mentioned above means that you have to be able to mock out or otherwise block the completion of
|
||||
`doSomething()`.
|
||||
|
||||
But, with the trap functionality we mentioned in the previous section, you can deterministically
|
||||
control the time each call sees.
|
||||
|
||||
```go
|
||||
trap := mClock.Trap().Since()
|
||||
var measurement time.Duration
|
||||
go func(clock quartz.Clock) {
|
||||
start := clock.Now()
|
||||
doSomething()
|
||||
measurement = clock.Since(start)
|
||||
}(mClock)
|
||||
|
||||
c := trap.Wait(ctx)
|
||||
mClock.Advance(5*time.Second)
|
||||
c.Release()
|
||||
```
|
||||
|
||||
We wait until we trap the `clock.Since()` call, which implies that `clock.Now()` has completed, then
|
||||
advance the mock clock 5 seconds. Finally, we release the `clock.Since()` call. Any changes to the
|
||||
clock that happen _before_ we release the call will be included in the time used for the
|
||||
`clock.Since()` call.
|
||||
|
||||
As a more involved example, consider an inactivity timeout: we want something to happen if there is
|
||||
no activity recorded for some period, say 10 minutes in the following example:
|
||||
|
||||
```go
|
||||
type InactivityTimer struct {
|
||||
mu sync.Mutex
|
||||
activity time.Time
|
||||
clock quartz.Clock
|
||||
}
|
||||
|
||||
func (i *InactivityTimer) Start() {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
next := i.clock.Until(i.activity.Add(10*time.Minute))
|
||||
t := i.clock.AfterFunc(next, func() {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
next := i.clock.Until(i.activity.Add(10*time.Minute))
|
||||
if next == 0 {
|
||||
i.timeoutLocked()
|
||||
return
|
||||
}
|
||||
t.Reset(next)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The actual contents of `timeoutLocked()` doesn't matter for this example, and assume there are other
|
||||
functions that record the latest `activity`.
|
||||
|
||||
We found that some time testing libraries hold a lock on the mock clock while calling the function
|
||||
passed to `AfterFunc`, resulting in a deadlock if you made clock calls from within.
|
||||
|
||||
Others allow this sort of thing, but don't have the flexibility to test edge cases. There is a
|
||||
subtle bug in our `Start()` function. The timer may pop a little late, and/or some measurable real
|
||||
time may elapse before `Until()` gets called inside the `AfterFunc`. If there hasn't been activity,
|
||||
`next` might be negative.
|
||||
|
||||
To test this in Quartz, we'll use a trap. We only want to trap the inner `Until()` call, not the
|
||||
initial one, so to make testing easier we can "tag" the call we want. Like this:
|
||||
|
||||
```go
|
||||
func (i *InactivityTimer) Start() {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
next := i.clock.Until(i.activity.Add(10*time.Minute))
|
||||
t := i.clock.AfterFunc(next, func() {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
next := i.clock.Until(i.activity.Add(10*time.Minute), "inner")
|
||||
if next == 0 {
|
||||
i.timeoutLocked()
|
||||
return
|
||||
}
|
||||
t.Reset(next)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
All Quartz `Clock` functions, and functions on returned timers and tickers support zero or more
|
||||
string tags that allow traps to match on them.
|
||||
|
||||
```go
|
||||
func TestInactivityTimer_Late(t *testing.T) {
|
||||
// set a timeout on the test itself, so that if Wait functions get blocked, we don't have to
|
||||
// wait for the default test timeout of 10 minutes.
|
||||
ctx, cancel := context.WithTimeout(10*time.Second)
|
||||
defer cancel()
|
||||
mClock := quartz.NewMock(t)
|
||||
trap := mClock.Trap.Until("inner")
|
||||
defer trap.Close()
|
||||
|
||||
it := &InactivityTimer{
|
||||
activity: mClock.Now(),
|
||||
clock: mClock,
|
||||
}
|
||||
it.Start()
|
||||
|
||||
// Trigger the AfterFunc
|
||||
w := mClock.Advance(10*time.Minute)
|
||||
c := trap.Wait(ctx)
|
||||
// Advance the clock a few ms to simulate a busy system
|
||||
mClock.Advance(3*time.Millisecond)
|
||||
c.Release() // Until() returns
|
||||
w.MustWait(ctx) // Wait for the AfterFunc to wrap up
|
||||
|
||||
// Assert that the timeoutLocked() function was called
|
||||
}
|
||||
```
|
||||
|
||||
This test case will fail with our bugged implementation, since the triggered AfterFunc won't call
|
||||
`timeoutLocked()` and instead will reset the timer with a negative number. The fix is easy, use
|
||||
`next <= 0` as the comparison.
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package clock is a library for testing time related code. It exports an interface Clock that
|
||||
// mimics the standard library time package functions. In production, an implementation that calls
|
||||
// thru to the standard library is used. In testing, a Mock clock is used to precisely control and
|
||||
// intercept time functions.
|
||||
package clock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Clock interface {
|
||||
// NewTicker returns a new Ticker containing a channel that will send the current time on the
|
||||
// channel after each tick. The period of the ticks is specified by the duration argument. The
|
||||
// ticker will adjust the time interval or drop ticks to make up for slow receivers. The
|
||||
// duration d must be greater than zero; if not, NewTicker will panic. Stop the ticker to
|
||||
// release associated resources.
|
||||
NewTicker(d time.Duration, tags ...string) *Ticker
|
||||
// TickerFunc is a convenience function that calls f on the interval d until either the given
|
||||
// context expires or f returns an error. Callers may call Wait() on the returned Waiter to
|
||||
// wait until this happens and obtain the error. The duration d must be greater than zero; if
|
||||
// not, TickerFunc will panic.
|
||||
TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter
|
||||
// NewTimer creates a new Timer that will send the current time on its channel after at least
|
||||
// duration d.
|
||||
NewTimer(d time.Duration, tags ...string) *Timer
|
||||
// AfterFunc waits for the duration to elapse and then calls f in its own goroutine. It returns
|
||||
// a Timer that can be used to cancel the call using its Stop method. The returned Timer's C
|
||||
// field is not used and will be nil.
|
||||
AfterFunc(d time.Duration, f func(), tags ...string) *Timer
|
||||
|
||||
// Now returns the current local time.
|
||||
Now(tags ...string) time.Time
|
||||
// Since returns the time elapsed since t. It is shorthand for Clock.Now().Sub(t).
|
||||
Since(t time.Time, tags ...string) time.Duration
|
||||
// Until returns the duration until t. It is shorthand for t.Sub(Clock.Now()).
|
||||
Until(t time.Time, tags ...string) time.Duration
|
||||
}
|
||||
|
||||
// Waiter can be waited on for an error.
|
||||
type Waiter interface {
|
||||
Wait(tags ...string) error
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package clock_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/v2/clock"
|
||||
)
|
||||
|
||||
type exampleTickCounter struct {
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
ticks int
|
||||
clock clock.Clock
|
||||
}
|
||||
|
||||
func (c *exampleTickCounter) Ticks() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.ticks
|
||||
}
|
||||
|
||||
func (c *exampleTickCounter) count() {
|
||||
_ = c.clock.TickerFunc(c.ctx, time.Hour, func() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.ticks++
|
||||
return nil
|
||||
}, "mytag")
|
||||
}
|
||||
|
||||
func newExampleTickCounter(ctx context.Context, clk clock.Clock) *exampleTickCounter {
|
||||
tc := &exampleTickCounter{ctx: ctx, clock: clk}
|
||||
go tc.count()
|
||||
return tc
|
||||
}
|
||||
|
||||
// TestExampleTickerFunc demonstrates how to test the use of TickerFunc.
|
||||
func TestExampleTickerFunc(t *testing.T) {
|
||||
t.Parallel()
|
||||
// nolint:gocritic // trying to avoid Coder-specific stuff with an eye toward spinning this out
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mClock := clock.NewMock(t)
|
||||
|
||||
// Because the ticker is started on a goroutine, we can't immediately start
|
||||
// advancing the clock, or we will race with the start of the ticker. If we
|
||||
// win that race, the clock gets advanced _before_ the ticker starts, and
|
||||
// our ticker will not get a tick.
|
||||
//
|
||||
// To handle this, we set a trap for the call to TickerFunc(), so that we
|
||||
// can assert it has been called before advancing the clock.
|
||||
trap := mClock.Trap().TickerFunc("mytag")
|
||||
defer trap.Close()
|
||||
|
||||
tc := newExampleTickCounter(ctx, mClock)
|
||||
|
||||
// Here, we wait for our trap to be triggered.
|
||||
call, err := trap.Wait(ctx)
|
||||
if err != nil {
|
||||
t.Fatal("ticker never started")
|
||||
}
|
||||
// it's good practice to release calls before any possible t.Fatal() calls
|
||||
// so that we don't leave dangling goroutines waiting for the call to be
|
||||
// released.
|
||||
call.Release()
|
||||
if call.Duration != time.Hour {
|
||||
t.Fatal("unexpected duration")
|
||||
}
|
||||
|
||||
if tks := tc.Ticks(); tks != 0 {
|
||||
t.Fatalf("expected 0 got %d ticks", tks)
|
||||
}
|
||||
|
||||
// Now that we know the ticker is started, we can advance the time.
|
||||
mClock.Advance(time.Hour).MustWait(ctx)
|
||||
|
||||
if tks := tc.Ticks(); tks != 1 {
|
||||
t.Fatalf("expected 1 got %d ticks", tks)
|
||||
}
|
||||
}
|
||||
|
||||
type exampleLatencyMeasurer struct {
|
||||
mu sync.Mutex
|
||||
lastLatency time.Duration
|
||||
}
|
||||
|
||||
func newExampleLatencyMeasurer(ctx context.Context, clk clock.Clock) *exampleLatencyMeasurer {
|
||||
m := &exampleLatencyMeasurer{}
|
||||
clk.TickerFunc(ctx, 10*time.Second, func() error {
|
||||
start := clk.Now()
|
||||
// m.doSomething()
|
||||
latency := clk.Since(start)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.lastLatency = latency
|
||||
return nil
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *exampleLatencyMeasurer) LastLatency() time.Duration {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.lastLatency
|
||||
}
|
||||
|
||||
func TestExampleLatencyMeasurer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// nolint:gocritic // trying to avoid Coder-specific stuff with an eye toward spinning this out
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mClock := clock.NewMock(t)
|
||||
trap := mClock.Trap().Since()
|
||||
defer trap.Close()
|
||||
|
||||
lm := newExampleLatencyMeasurer(ctx, mClock)
|
||||
|
||||
w := mClock.Advance(10 * time.Second) // triggers first tick
|
||||
c := trap.MustWait(ctx) // call to Since()
|
||||
mClock.Advance(33 * time.Millisecond)
|
||||
c.Release()
|
||||
w.MustWait(ctx)
|
||||
|
||||
if l := lm.LastLatency(); l != 33*time.Millisecond {
|
||||
t.Fatalf("expected 33ms got %s", l.String())
|
||||
}
|
||||
|
||||
// Next tick is in 10s - 33ms, but if we don't want to calculate, we can use:
|
||||
d, w2 := mClock.AdvanceNext()
|
||||
c = trap.MustWait(ctx)
|
||||
mClock.Advance(17 * time.Millisecond)
|
||||
c.Release()
|
||||
w2.MustWait(ctx)
|
||||
|
||||
expectedD := 10*time.Second - 33*time.Millisecond
|
||||
if d != expectedD {
|
||||
t.Fatalf("expected %s got %s", expectedD.String(), d.String())
|
||||
}
|
||||
|
||||
if l := lm.LastLatency(); l != 17*time.Millisecond {
|
||||
t.Fatalf("expected 17ms got %s", l.String())
|
||||
}
|
||||
}
|
||||
+647
@@ -0,0 +1,647 @@
|
||||
package clock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// Mock is the testing implementation of Clock. It tracks a time that monotonically increases
|
||||
// during a test, triggering any timers or tickers automatically.
|
||||
type Mock struct {
|
||||
tb testing.TB
|
||||
mu sync.Mutex
|
||||
|
||||
// cur is the current time
|
||||
cur time.Time
|
||||
|
||||
all []event
|
||||
nextTime time.Time
|
||||
nextEvents []event
|
||||
traps []*Trap
|
||||
}
|
||||
|
||||
type event interface {
|
||||
next() time.Time
|
||||
fire(t time.Time)
|
||||
}
|
||||
|
||||
func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter {
|
||||
if d <= 0 {
|
||||
panic("TickerFunc called with negative or zero duration")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c := newCall(clockFunctionTickerFunc, tags, withDuration(d))
|
||||
m.matchCallLocked(c)
|
||||
defer close(c.complete)
|
||||
t := &mockTickerFunc{
|
||||
ctx: ctx,
|
||||
d: d,
|
||||
f: f,
|
||||
nxt: m.cur.Add(d),
|
||||
mock: m,
|
||||
cond: sync.NewCond(&m.mu),
|
||||
}
|
||||
m.all = append(m.all, t)
|
||||
m.recomputeNextLocked()
|
||||
go t.waitForCtx()
|
||||
return t
|
||||
}
|
||||
|
||||
func (m *Mock) NewTicker(d time.Duration, tags ...string) *Ticker {
|
||||
if d <= 0 {
|
||||
panic("NewTicker called with negative or zero duration")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c := newCall(clockFunctionNewTicker, tags, withDuration(d))
|
||||
m.matchCallLocked(c)
|
||||
defer close(c.complete)
|
||||
// 1 element buffer follows standard library implementation
|
||||
ticks := make(chan time.Time, 1)
|
||||
t := &Ticker{
|
||||
C: ticks,
|
||||
c: ticks,
|
||||
d: d,
|
||||
nxt: m.cur.Add(d),
|
||||
mock: m,
|
||||
}
|
||||
m.addEventLocked(t)
|
||||
return t
|
||||
}
|
||||
|
||||
func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c := newCall(clockFunctionNewTimer, tags, withDuration(d))
|
||||
defer close(c.complete)
|
||||
m.matchCallLocked(c)
|
||||
ch := make(chan time.Time, 1)
|
||||
t := &Timer{
|
||||
C: ch,
|
||||
c: ch,
|
||||
nxt: m.cur.Add(d),
|
||||
mock: m,
|
||||
}
|
||||
if d <= 0 {
|
||||
// zero or negative duration timer means we should immediately fire
|
||||
// it, rather than add it.
|
||||
go t.fire(t.mock.cur)
|
||||
return t
|
||||
}
|
||||
m.addEventLocked(t)
|
||||
return t
|
||||
}
|
||||
|
||||
func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c := newCall(clockFunctionAfterFunc, tags, withDuration(d))
|
||||
defer close(c.complete)
|
||||
m.matchCallLocked(c)
|
||||
t := &Timer{
|
||||
nxt: m.cur.Add(d),
|
||||
fn: f,
|
||||
mock: m,
|
||||
}
|
||||
if d <= 0 {
|
||||
// zero or negative duration timer means we should immediately fire
|
||||
// it, rather than add it.
|
||||
go t.fire(t.mock.cur)
|
||||
return t
|
||||
}
|
||||
m.addEventLocked(t)
|
||||
return t
|
||||
}
|
||||
|
||||
func (m *Mock) Now(tags ...string) time.Time {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c := newCall(clockFunctionNow, tags)
|
||||
defer close(c.complete)
|
||||
m.matchCallLocked(c)
|
||||
return m.cur
|
||||
}
|
||||
|
||||
func (m *Mock) Since(t time.Time, tags ...string) time.Duration {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c := newCall(clockFunctionSince, tags, withTime(t))
|
||||
defer close(c.complete)
|
||||
m.matchCallLocked(c)
|
||||
return m.cur.Sub(t)
|
||||
}
|
||||
|
||||
func (m *Mock) Until(t time.Time, tags ...string) time.Duration {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c := newCall(clockFunctionUntil, tags, withTime(t))
|
||||
defer close(c.complete)
|
||||
m.matchCallLocked(c)
|
||||
return t.Sub(m.cur)
|
||||
}
|
||||
|
||||
func (m *Mock) addEventLocked(e event) {
|
||||
m.all = append(m.all, e)
|
||||
m.recomputeNextLocked()
|
||||
}
|
||||
|
||||
func (m *Mock) recomputeNextLocked() {
|
||||
var best time.Time
|
||||
var events []event
|
||||
for _, e := range m.all {
|
||||
if best.IsZero() || e.next().Before(best) {
|
||||
best = e.next()
|
||||
events = []event{e}
|
||||
continue
|
||||
}
|
||||
if e.next().Equal(best) {
|
||||
events = append(events, e)
|
||||
continue
|
||||
}
|
||||
}
|
||||
m.nextTime = best
|
||||
m.nextEvents = events
|
||||
}
|
||||
|
||||
func (m *Mock) removeTimer(t *Timer) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.removeTimerLocked(t)
|
||||
}
|
||||
|
||||
func (m *Mock) removeTimerLocked(t *Timer) {
|
||||
t.stopped = true
|
||||
m.removeEventLocked(t)
|
||||
}
|
||||
|
||||
func (m *Mock) removeEventLocked(e event) {
|
||||
defer m.recomputeNextLocked()
|
||||
for i := range m.all {
|
||||
if m.all[i] == e {
|
||||
m.all = append(m.all[:i], m.all[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mock) matchCallLocked(c *Call) {
|
||||
var traps []*Trap
|
||||
for _, t := range m.traps {
|
||||
if t.matches(c) {
|
||||
traps = append(traps, t)
|
||||
}
|
||||
}
|
||||
if len(traps) == 0 {
|
||||
return
|
||||
}
|
||||
c.releases.Add(len(traps))
|
||||
m.mu.Unlock()
|
||||
for _, t := range traps {
|
||||
go t.catch(c)
|
||||
}
|
||||
c.releases.Wait()
|
||||
m.mu.Lock()
|
||||
}
|
||||
|
||||
// AdvanceWaiter is returned from Advance and Set calls and allows you to wait for ticks and timers
|
||||
// to complete. In the case of functions passed to AfterFunc or TickerFunc, it waits for the
|
||||
// functions to return. For other ticks & timers, it just waits for the tick to be delivered to
|
||||
// the channel.
|
||||
//
|
||||
// If multiple timers or tickers trigger simultaneously, they are all run on separate
|
||||
// go routines.
|
||||
type AdvanceWaiter struct {
|
||||
tb testing.TB
|
||||
ch chan struct{}
|
||||
}
|
||||
|
||||
// Wait for all timers and ticks to complete, or until context expires.
|
||||
func (w AdvanceWaiter) Wait(ctx context.Context) error {
|
||||
select {
|
||||
case <-w.ch:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// MustWait waits for all timers and ticks to complete, and fails the test immediately if the
|
||||
// context completes first. MustWait must be called from the goroutine running the test or
|
||||
// benchmark, similar to `t.FailNow()`.
|
||||
func (w AdvanceWaiter) MustWait(ctx context.Context) {
|
||||
w.tb.Helper()
|
||||
select {
|
||||
case <-w.ch:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
w.tb.Fatalf("context expired while waiting for clock to advance: %s", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when all timers and ticks complete.
|
||||
func (w AdvanceWaiter) Done() <-chan struct{} {
|
||||
return w.ch
|
||||
}
|
||||
|
||||
// Advance moves the clock forward by d, triggering any timers or tickers. The returned value can
|
||||
// be used to wait for all timers and ticks to complete. Advance sets the clock forward before
|
||||
// returning, and can only advance up to the next timer or tick event. It will fail the test if you
|
||||
// attempt to advance beyond.
|
||||
//
|
||||
// If you need to advance exactly to the next event, and don't know or don't wish to calculate it,
|
||||
// consider AdvanceNext().
|
||||
func (m *Mock) Advance(d time.Duration) AdvanceWaiter {
|
||||
m.tb.Helper()
|
||||
w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})}
|
||||
m.mu.Lock()
|
||||
fin := m.cur.Add(d)
|
||||
// nextTime.IsZero implies no events scheduled.
|
||||
if m.nextTime.IsZero() || fin.Before(m.nextTime) {
|
||||
m.cur = fin
|
||||
m.mu.Unlock()
|
||||
close(w.ch)
|
||||
return w
|
||||
}
|
||||
if fin.After(m.nextTime) {
|
||||
m.tb.Errorf(fmt.Sprintf("cannot advance %s which is beyond next timer/ticker event in %s",
|
||||
d.String(), m.nextTime.Sub(m.cur)))
|
||||
m.mu.Unlock()
|
||||
close(w.ch)
|
||||
return w
|
||||
}
|
||||
|
||||
m.cur = m.nextTime
|
||||
go m.advanceLocked(w)
|
||||
return w
|
||||
}
|
||||
|
||||
func (m *Mock) advanceLocked(w AdvanceWaiter) {
|
||||
defer close(w.ch)
|
||||
wg := sync.WaitGroup{}
|
||||
for i := range m.nextEvents {
|
||||
e := m.nextEvents[i]
|
||||
t := m.cur
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
e.fire(t)
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
// release the lock and let the events resolve. This allows them to call back into the
|
||||
// Mock to query the time or set new timers. Each event should remove or reschedule
|
||||
// itself from nextEvents.
|
||||
m.mu.Unlock()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Set the time to t. If the time is after the current mocked time, then this is equivalent to
|
||||
// Advance() with the difference. You may only Set the time earlier than the current time before
|
||||
// starting tickers and timers (e.g. at the start of your test case).
|
||||
func (m *Mock) Set(t time.Time) AdvanceWaiter {
|
||||
m.tb.Helper()
|
||||
w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})}
|
||||
m.mu.Lock()
|
||||
if t.Before(m.cur) {
|
||||
defer close(w.ch)
|
||||
defer m.mu.Unlock()
|
||||
// past
|
||||
if !m.nextTime.IsZero() {
|
||||
m.tb.Error("Set mock clock to the past after timers/tickers started")
|
||||
}
|
||||
m.cur = t
|
||||
return w
|
||||
}
|
||||
// future
|
||||
// nextTime.IsZero implies no events scheduled.
|
||||
if m.nextTime.IsZero() || t.Before(m.nextTime) {
|
||||
defer close(w.ch)
|
||||
defer m.mu.Unlock()
|
||||
m.cur = t
|
||||
return w
|
||||
}
|
||||
if t.After(m.nextTime) {
|
||||
defer close(w.ch)
|
||||
defer m.mu.Unlock()
|
||||
m.tb.Errorf("cannot Set time to %s which is beyond next timer/ticker event at %s",
|
||||
t.String(), m.nextTime)
|
||||
return w
|
||||
}
|
||||
|
||||
m.cur = m.nextTime
|
||||
go m.advanceLocked(w)
|
||||
return w
|
||||
}
|
||||
|
||||
// AdvanceNext advances the clock to the next timer or tick event. It fails the test if there are
|
||||
// none scheduled. It returns the duration the clock was advanced and a waiter that can be used to
|
||||
// wait for the timer/tick event(s) to finish.
|
||||
func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) {
|
||||
m.mu.Lock()
|
||||
m.tb.Helper()
|
||||
w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})}
|
||||
if m.nextTime.IsZero() {
|
||||
defer close(w.ch)
|
||||
defer m.mu.Unlock()
|
||||
m.tb.Error("cannot AdvanceNext because there are no timers or tickers running")
|
||||
}
|
||||
d := m.nextTime.Sub(m.cur)
|
||||
m.cur = m.nextTime
|
||||
go m.advanceLocked(w)
|
||||
return d, w
|
||||
}
|
||||
|
||||
// Peek returns the duration until the next ticker or timer event and the value
|
||||
// true, or, if there are no running tickers or timers, it returns zero and
|
||||
// false.
|
||||
func (m *Mock) Peek() (d time.Duration, ok bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.nextTime.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
return m.nextTime.Sub(m.cur), true
|
||||
}
|
||||
|
||||
// Trapper allows the creation of Traps
|
||||
type Trapper struct {
|
||||
// mock is the underlying Mock. This is a thin wrapper around Mock so that
|
||||
// we can have our interface look like mClock.Trap().NewTimer("foo")
|
||||
mock *Mock
|
||||
}
|
||||
|
||||
func (t Trapper) NewTimer(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionNewTimer, tags)
|
||||
}
|
||||
|
||||
func (t Trapper) AfterFunc(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionAfterFunc, tags)
|
||||
}
|
||||
|
||||
func (t Trapper) TimerStop(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionTimerStop, tags)
|
||||
}
|
||||
|
||||
func (t Trapper) TimerReset(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionTimerReset, tags)
|
||||
}
|
||||
|
||||
func (t Trapper) TickerFunc(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionTickerFunc, tags)
|
||||
}
|
||||
|
||||
func (t Trapper) TickerFuncWait(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionTickerFuncWait, tags)
|
||||
}
|
||||
|
||||
func (t Trapper) NewTicker(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionNewTicker, tags)
|
||||
}
|
||||
|
||||
func (t Trapper) TickerStop(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionTickerStop, tags)
|
||||
}
|
||||
|
||||
func (t Trapper) TickerReset(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionTickerReset, tags)
|
||||
}
|
||||
|
||||
func (t Trapper) Now(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionNow, tags)
|
||||
}
|
||||
|
||||
func (t Trapper) Since(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionSince, tags)
|
||||
}
|
||||
|
||||
func (t Trapper) Until(tags ...string) *Trap {
|
||||
return t.mock.newTrap(clockFunctionUntil, tags)
|
||||
}
|
||||
|
||||
func (m *Mock) Trap() Trapper {
|
||||
return Trapper{m}
|
||||
}
|
||||
|
||||
func (m *Mock) newTrap(fn clockFunction, tags []string) *Trap {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
tr := &Trap{
|
||||
fn: fn,
|
||||
tags: tags,
|
||||
mock: m,
|
||||
calls: make(chan *Call),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
m.traps = append(m.traps, tr)
|
||||
return tr
|
||||
}
|
||||
|
||||
// NewMock creates a new Mock with the time set to midnight UTC on Jan 1, 2024.
|
||||
// You may re-set the time earlier than this, but only before timers or tickers
|
||||
// are created.
|
||||
func NewMock(tb testing.TB) *Mock {
|
||||
cur, err := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &Mock{
|
||||
tb: tb,
|
||||
cur: cur,
|
||||
}
|
||||
}
|
||||
|
||||
var _ Clock = &Mock{}
|
||||
|
||||
type mockTickerFunc struct {
|
||||
ctx context.Context
|
||||
d time.Duration
|
||||
f func() error
|
||||
nxt time.Time
|
||||
mock *Mock
|
||||
|
||||
// cond is a condition Locked on the main Mock.mu
|
||||
cond *sync.Cond
|
||||
// done is true when the ticker exits
|
||||
done bool
|
||||
// err holds the error when the ticker exits
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockTickerFunc) next() time.Time {
|
||||
return m.nxt
|
||||
}
|
||||
|
||||
func (m *mockTickerFunc) fire(_ time.Time) {
|
||||
m.mock.mu.Lock()
|
||||
defer m.mock.mu.Unlock()
|
||||
if m.done {
|
||||
return
|
||||
}
|
||||
m.nxt = m.nxt.Add(m.d)
|
||||
m.mock.recomputeNextLocked()
|
||||
|
||||
m.mock.mu.Unlock()
|
||||
err := m.f()
|
||||
m.mock.mu.Lock()
|
||||
if err != nil {
|
||||
m.exitLocked(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockTickerFunc) exitLocked(err error) {
|
||||
if m.done {
|
||||
return
|
||||
}
|
||||
m.done = true
|
||||
m.err = err
|
||||
m.mock.removeEventLocked(m)
|
||||
m.cond.Broadcast()
|
||||
}
|
||||
|
||||
func (m *mockTickerFunc) waitForCtx() {
|
||||
<-m.ctx.Done()
|
||||
m.mock.mu.Lock()
|
||||
defer m.mock.mu.Unlock()
|
||||
m.exitLocked(m.ctx.Err())
|
||||
}
|
||||
|
||||
func (m *mockTickerFunc) Wait(tags ...string) error {
|
||||
m.mock.mu.Lock()
|
||||
defer m.mock.mu.Unlock()
|
||||
c := newCall(clockFunctionTickerFuncWait, tags)
|
||||
m.mock.matchCallLocked(c)
|
||||
defer close(c.complete)
|
||||
for !m.done {
|
||||
m.cond.Wait()
|
||||
}
|
||||
return m.err
|
||||
}
|
||||
|
||||
var _ Waiter = &mockTickerFunc{}
|
||||
|
||||
type clockFunction int
|
||||
|
||||
const (
|
||||
clockFunctionNewTimer clockFunction = iota
|
||||
clockFunctionAfterFunc
|
||||
clockFunctionTimerStop
|
||||
clockFunctionTimerReset
|
||||
clockFunctionTickerFunc
|
||||
clockFunctionTickerFuncWait
|
||||
clockFunctionNewTicker
|
||||
clockFunctionTickerReset
|
||||
clockFunctionTickerStop
|
||||
clockFunctionNow
|
||||
clockFunctionSince
|
||||
clockFunctionUntil
|
||||
)
|
||||
|
||||
type callArg func(c *Call)
|
||||
|
||||
type Call struct {
|
||||
Time time.Time
|
||||
Duration time.Duration
|
||||
Tags []string
|
||||
|
||||
fn clockFunction
|
||||
releases sync.WaitGroup
|
||||
complete chan struct{}
|
||||
}
|
||||
|
||||
func (c *Call) Release() {
|
||||
c.releases.Done()
|
||||
<-c.complete
|
||||
}
|
||||
|
||||
func withTime(t time.Time) callArg {
|
||||
return func(c *Call) {
|
||||
c.Time = t
|
||||
}
|
||||
}
|
||||
|
||||
func withDuration(d time.Duration) callArg {
|
||||
return func(c *Call) {
|
||||
c.Duration = d
|
||||
}
|
||||
}
|
||||
|
||||
func newCall(fn clockFunction, tags []string, args ...callArg) *Call {
|
||||
c := &Call{
|
||||
fn: fn,
|
||||
Tags: tags,
|
||||
complete: make(chan struct{}),
|
||||
}
|
||||
for _, a := range args {
|
||||
a(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
type Trap struct {
|
||||
fn clockFunction
|
||||
tags []string
|
||||
mock *Mock
|
||||
calls chan *Call
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (t *Trap) catch(c *Call) {
|
||||
select {
|
||||
case t.calls <- c:
|
||||
case <-t.done:
|
||||
c.Release()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Trap) matches(c *Call) bool {
|
||||
if t.fn != c.fn {
|
||||
return false
|
||||
}
|
||||
for _, tag := range t.tags {
|
||||
if !slices.Contains(c.Tags, tag) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *Trap) Close() {
|
||||
t.mock.mu.Lock()
|
||||
defer t.mock.mu.Unlock()
|
||||
for i, tr := range t.mock.traps {
|
||||
if t == tr {
|
||||
t.mock.traps = append(t.mock.traps[:i], t.mock.traps[i+1:]...)
|
||||
}
|
||||
}
|
||||
close(t.done)
|
||||
}
|
||||
|
||||
var ErrTrapClosed = xerrors.New("trap closed")
|
||||
|
||||
func (t *Trap) Wait(ctx context.Context) (*Call, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-t.done:
|
||||
return nil, ErrTrapClosed
|
||||
case c := <-t.calls:
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
// MustWait calls Wait() and then if there is an error, immediately fails the
|
||||
// test via tb.Fatalf()
|
||||
func (t *Trap) MustWait(ctx context.Context) *Call {
|
||||
t.mock.tb.Helper()
|
||||
c, err := t.Wait(ctx)
|
||||
if err != nil {
|
||||
t.mock.tb.Fatalf("context expired while waiting for trap: %s", err.Error())
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package clock_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/v2/clock"
|
||||
)
|
||||
|
||||
func TestTimer_NegativeDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
// nolint:gocritic // trying to avoid Coder-specific stuff with an eye toward spinning this out
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mClock := clock.NewMock(t)
|
||||
start := mClock.Now()
|
||||
trap := mClock.Trap().NewTimer()
|
||||
defer trap.Close()
|
||||
|
||||
timers := make(chan *clock.Timer, 1)
|
||||
go func() {
|
||||
timers <- mClock.NewTimer(-time.Second)
|
||||
}()
|
||||
c := trap.MustWait(ctx)
|
||||
c.Release()
|
||||
// trap returns the actual passed value
|
||||
if c.Duration != -time.Second {
|
||||
t.Fatalf("expected -time.Second, got: %v", c.Duration)
|
||||
}
|
||||
|
||||
tmr := <-timers
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for timer")
|
||||
case tme := <-tmr.C:
|
||||
// the tick is the current time, not the past
|
||||
if !tme.Equal(start) {
|
||||
t.Fatalf("expected time %v, got %v", start, tme)
|
||||
}
|
||||
}
|
||||
if tmr.Stop() {
|
||||
t.Fatal("timer still running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterFunc_NegativeDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
// nolint:gocritic // trying to avoid Coder-specific stuff with an eye toward spinning this out
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mClock := clock.NewMock(t)
|
||||
trap := mClock.Trap().AfterFunc()
|
||||
defer trap.Close()
|
||||
|
||||
timers := make(chan *clock.Timer, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
timers <- mClock.AfterFunc(-time.Second, func() {
|
||||
close(done)
|
||||
})
|
||||
}()
|
||||
c := trap.MustWait(ctx)
|
||||
c.Release()
|
||||
// trap returns the actual passed value
|
||||
if c.Duration != -time.Second {
|
||||
t.Fatalf("expected -time.Second, got: %v", c.Duration)
|
||||
}
|
||||
|
||||
tmr := <-timers
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for timer")
|
||||
case <-done:
|
||||
// OK!
|
||||
}
|
||||
if tmr.Stop() {
|
||||
t.Fatal("timer still running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTicker(t *testing.T) {
|
||||
t.Parallel()
|
||||
// nolint:gocritic // trying to avoid Coder-specific stuff with an eye toward spinning this out
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mClock := clock.NewMock(t)
|
||||
start := mClock.Now()
|
||||
trapNT := mClock.Trap().NewTicker("new")
|
||||
defer trapNT.Close()
|
||||
trapStop := mClock.Trap().TickerStop("stop")
|
||||
defer trapStop.Close()
|
||||
trapReset := mClock.Trap().TickerReset("reset")
|
||||
defer trapReset.Close()
|
||||
|
||||
tickers := make(chan *clock.Ticker, 1)
|
||||
go func() {
|
||||
tickers <- mClock.NewTicker(time.Hour, "new")
|
||||
}()
|
||||
c := trapNT.MustWait(ctx)
|
||||
c.Release()
|
||||
if c.Duration != time.Hour {
|
||||
t.Fatalf("expected time.Hour, got: %v", c.Duration)
|
||||
}
|
||||
tkr := <-tickers
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
mClock.Advance(time.Hour).MustWait(ctx)
|
||||
}
|
||||
|
||||
// should get first tick, rest dropped
|
||||
tTime := start.Add(time.Hour)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for ticker")
|
||||
case tick := <-tkr.C:
|
||||
if !tick.Equal(tTime) {
|
||||
t.Fatalf("expected time %v, got %v", tTime, tick)
|
||||
}
|
||||
}
|
||||
|
||||
go tkr.Reset(time.Minute, "reset")
|
||||
c = trapReset.MustWait(ctx)
|
||||
mClock.Advance(time.Second).MustWait(ctx)
|
||||
c.Release()
|
||||
if c.Duration != time.Minute {
|
||||
t.Fatalf("expected time.Minute, got: %v", c.Duration)
|
||||
}
|
||||
mClock.Advance(time.Minute).MustWait(ctx)
|
||||
|
||||
// tick should show present time, ensuring the 2 hour ticks got dropped when
|
||||
// we didn't read from the channel.
|
||||
tTime = mClock.Now()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for ticker")
|
||||
case tick := <-tkr.C:
|
||||
if !tick.Equal(tTime) {
|
||||
t.Fatalf("expected time %v, got %v", tTime, tick)
|
||||
}
|
||||
}
|
||||
|
||||
go tkr.Stop("stop")
|
||||
trapStop.MustWait(ctx).Release()
|
||||
mClock.Advance(time.Hour).MustWait(ctx)
|
||||
select {
|
||||
case <-tkr.C:
|
||||
t.Fatal("ticker still running")
|
||||
default:
|
||||
// OK
|
||||
}
|
||||
|
||||
// Resetting after stop
|
||||
go tkr.Reset(time.Minute, "reset")
|
||||
trapReset.MustWait(ctx).Release()
|
||||
mClock.Advance(time.Minute).MustWait(ctx)
|
||||
tTime = mClock.Now()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for ticker")
|
||||
case tick := <-tkr.C:
|
||||
if !tick.Equal(tTime) {
|
||||
t.Fatalf("expected time %v, got %v", tTime, tick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeek(t *testing.T) {
|
||||
t.Parallel()
|
||||
// nolint:gocritic // trying to avoid Coder-specific stuff with an eye toward spinning this out
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mClock := clock.NewMock(t)
|
||||
d, ok := mClock.Peek()
|
||||
if d != 0 {
|
||||
t.Fatal("expected Peek() to return 0")
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected Peek() to return false")
|
||||
}
|
||||
|
||||
tmr := mClock.NewTimer(time.Second)
|
||||
d, ok = mClock.Peek()
|
||||
if d != time.Second {
|
||||
t.Fatal("expected Peek() to return 1s")
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected Peek() to return true")
|
||||
}
|
||||
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx)
|
||||
d, ok = mClock.Peek()
|
||||
if d != time.Millisecond {
|
||||
t.Fatal("expected Peek() to return 1ms")
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected Peek() to return true")
|
||||
}
|
||||
|
||||
stopped := tmr.Stop()
|
||||
if !stopped {
|
||||
t.Fatal("expected Stop() to return true")
|
||||
}
|
||||
|
||||
d, ok = mClock.Peek()
|
||||
if d != 0 {
|
||||
t.Fatal("expected Peek() to return 0")
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected Peek() to return false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package clock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type realClock struct{}
|
||||
|
||||
func NewReal() Clock {
|
||||
return realClock{}
|
||||
}
|
||||
|
||||
func (realClock) NewTicker(d time.Duration, _ ...string) *Ticker {
|
||||
tkr := time.NewTicker(d)
|
||||
return &Ticker{ticker: tkr, C: tkr.C}
|
||||
}
|
||||
|
||||
func (realClock) TickerFunc(ctx context.Context, d time.Duration, f func() error, _ ...string) Waiter {
|
||||
ct := &realContextTicker{
|
||||
ctx: ctx,
|
||||
tkr: time.NewTicker(d),
|
||||
f: f,
|
||||
err: make(chan error, 1),
|
||||
}
|
||||
go ct.run()
|
||||
return ct
|
||||
}
|
||||
|
||||
type realContextTicker struct {
|
||||
ctx context.Context
|
||||
tkr *time.Ticker
|
||||
f func() error
|
||||
err chan error
|
||||
}
|
||||
|
||||
func (t *realContextTicker) Wait(_ ...string) error {
|
||||
return <-t.err
|
||||
}
|
||||
|
||||
func (t *realContextTicker) run() {
|
||||
defer t.tkr.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
t.err <- t.ctx.Err()
|
||||
return
|
||||
case <-t.tkr.C:
|
||||
err := t.f()
|
||||
if err != nil {
|
||||
t.err <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (realClock) NewTimer(d time.Duration, _ ...string) *Timer {
|
||||
rt := time.NewTimer(d)
|
||||
return &Timer{C: rt.C, timer: rt}
|
||||
}
|
||||
|
||||
func (realClock) AfterFunc(d time.Duration, f func(), _ ...string) *Timer {
|
||||
rt := time.AfterFunc(d, f)
|
||||
return &Timer{C: rt.C, timer: rt}
|
||||
}
|
||||
|
||||
func (realClock) Now(_ ...string) time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (realClock) Since(t time.Time, _ ...string) time.Duration {
|
||||
return time.Since(t)
|
||||
}
|
||||
|
||||
func (realClock) Until(t time.Time, _ ...string) time.Duration {
|
||||
return time.Until(t)
|
||||
}
|
||||
|
||||
var _ Clock = realClock{}
|
||||
@@ -0,0 +1,75 @@
|
||||
package clock
|
||||
|
||||
import "time"
|
||||
|
||||
// A Ticker holds a channel that delivers “ticks” of a clock at intervals.
|
||||
type Ticker struct {
|
||||
C <-chan time.Time
|
||||
//nolint: revive
|
||||
c chan time.Time
|
||||
ticker *time.Ticker // realtime impl, if set
|
||||
d time.Duration // period, if set
|
||||
nxt time.Time // next tick time
|
||||
mock *Mock // mock clock, if set
|
||||
stopped bool // true if the ticker is not running
|
||||
}
|
||||
|
||||
func (t *Ticker) fire(tt time.Time) {
|
||||
t.mock.mu.Lock()
|
||||
defer t.mock.mu.Unlock()
|
||||
if t.stopped {
|
||||
return
|
||||
}
|
||||
for !t.nxt.After(t.mock.cur) {
|
||||
t.nxt = t.nxt.Add(t.d)
|
||||
}
|
||||
t.mock.recomputeNextLocked()
|
||||
select {
|
||||
case t.c <- tt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Ticker) next() time.Time {
|
||||
return t.nxt
|
||||
}
|
||||
|
||||
// Stop turns off a ticker. After Stop, no more ticks will be sent. Stop does
|
||||
// not close the channel, to prevent a concurrent goroutine reading from the
|
||||
// channel from seeing an erroneous "tick".
|
||||
func (t *Ticker) Stop(tags ...string) {
|
||||
if t.ticker != nil {
|
||||
t.ticker.Stop()
|
||||
return
|
||||
}
|
||||
t.mock.mu.Lock()
|
||||
defer t.mock.mu.Unlock()
|
||||
c := newCall(clockFunctionTickerStop, tags)
|
||||
t.mock.matchCallLocked(c)
|
||||
defer close(c.complete)
|
||||
t.mock.removeEventLocked(t)
|
||||
t.stopped = true
|
||||
}
|
||||
|
||||
// Reset stops a ticker and resets its period to the specified duration. The
|
||||
// next tick will arrive after the new period elapses. The duration d must be
|
||||
// greater than zero; if not, Reset will panic.
|
||||
func (t *Ticker) Reset(d time.Duration, tags ...string) {
|
||||
if t.ticker != nil {
|
||||
t.ticker.Reset(d)
|
||||
return
|
||||
}
|
||||
t.mock.mu.Lock()
|
||||
defer t.mock.mu.Unlock()
|
||||
c := newCall(clockFunctionTickerReset, tags, withDuration(d))
|
||||
t.mock.matchCallLocked(c)
|
||||
defer close(c.complete)
|
||||
t.nxt = t.mock.cur.Add(d)
|
||||
t.d = d
|
||||
if t.stopped {
|
||||
t.stopped = false
|
||||
t.mock.addEventLocked(t)
|
||||
} else {
|
||||
t.mock.recomputeNextLocked()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package clock
|
||||
|
||||
import "time"
|
||||
|
||||
// The Timer type represents a single event. When the Timer expires, the current time will be sent
|
||||
// on C, unless the Timer was created by AfterFunc. A Timer must be created with NewTimer or
|
||||
// AfterFunc.
|
||||
type Timer struct {
|
||||
C <-chan time.Time
|
||||
//nolint: revive
|
||||
c chan time.Time
|
||||
timer *time.Timer // realtime impl, if set
|
||||
nxt time.Time // next tick time
|
||||
mock *Mock // mock clock, if set
|
||||
fn func() // AfterFunc function, if set
|
||||
stopped bool // True if stopped, false if running
|
||||
}
|
||||
|
||||
func (t *Timer) fire(tt time.Time) {
|
||||
t.mock.removeTimer(t)
|
||||
if t.fn != nil {
|
||||
t.fn()
|
||||
} else {
|
||||
t.c <- tt
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Timer) next() time.Time {
|
||||
return t.nxt
|
||||
}
|
||||
|
||||
// Stop prevents the Timer from firing. It returns true if the call stops the timer, false if the
|
||||
// timer has already expired or been stopped. Stop does not close the channel, to prevent a read
|
||||
// from the channel succeeding incorrectly.
|
||||
//
|
||||
// See https://pkg.go.dev/time#Timer.Stop for more information.
|
||||
func (t *Timer) Stop(tags ...string) bool {
|
||||
if t.timer != nil {
|
||||
return t.timer.Stop()
|
||||
}
|
||||
t.mock.mu.Lock()
|
||||
defer t.mock.mu.Unlock()
|
||||
c := newCall(clockFunctionTimerStop, tags)
|
||||
t.mock.matchCallLocked(c)
|
||||
defer close(c.complete)
|
||||
result := !t.stopped
|
||||
t.mock.removeTimerLocked(t)
|
||||
return result
|
||||
}
|
||||
|
||||
// Reset changes the timer to expire after duration d. It returns true if the timer had been active,
|
||||
// false if the timer had expired or been stopped.
|
||||
//
|
||||
// See https://pkg.go.dev/time#Timer.Reset for more information.
|
||||
func (t *Timer) Reset(d time.Duration, tags ...string) bool {
|
||||
if t.timer != nil {
|
||||
return t.timer.Reset(d)
|
||||
}
|
||||
t.mock.mu.Lock()
|
||||
defer t.mock.mu.Unlock()
|
||||
c := newCall(clockFunctionTimerReset, tags, withDuration(d))
|
||||
t.mock.matchCallLocked(c)
|
||||
defer close(c.complete)
|
||||
result := !t.stopped
|
||||
select {
|
||||
case <-t.c:
|
||||
default:
|
||||
}
|
||||
if d <= 0 {
|
||||
// zero or negative duration timer means we should immediately re-fire
|
||||
// it, rather than remove and re-add it.
|
||||
t.stopped = false
|
||||
go t.fire(t.mock.cur)
|
||||
return result
|
||||
}
|
||||
t.mock.removeTimerLocked(t)
|
||||
t.stopped = false
|
||||
t.nxt = t.mock.cur.Add(d)
|
||||
t.mock.addEventLocked(t)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/appearance"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
type AnnouncementBannerAPI struct {
|
||||
appearanceFetcher *atomic.Pointer[appearance.Fetcher]
|
||||
}
|
||||
|
||||
// Deprecated: GetServiceBanner has been deprecated in favor of GetAnnouncementBanners.
|
||||
func (a *AnnouncementBannerAPI) GetServiceBanner(ctx context.Context, _ *proto.GetServiceBannerRequest) (*proto.ServiceBanner, error) {
|
||||
cfg, err := (*a.appearanceFetcher.Load()).Fetch(ctx)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("fetch appearance: %w", err)
|
||||
}
|
||||
return agentsdk.ProtoFromServiceBanner(cfg.ServiceBanner), nil
|
||||
}
|
||||
|
||||
func (a *AnnouncementBannerAPI) GetAnnouncementBanners(ctx context.Context, _ *proto.GetAnnouncementBannersRequest) (*proto.GetAnnouncementBannersResponse, error) {
|
||||
cfg, err := (*a.appearanceFetcher.Load()).Fetch(ctx)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("fetch appearance: %w", err)
|
||||
}
|
||||
banners := make([]*proto.BannerConfig, 0, len(cfg.AnnouncementBanners))
|
||||
for _, banner := range cfg.AnnouncementBanners {
|
||||
banners = append(banners, agentsdk.ProtoFromBannerConfig(banner))
|
||||
}
|
||||
return &proto.GetAnnouncementBannersResponse{
|
||||
AnnouncementBanners: banners,
|
||||
}, nil
|
||||
}
|
||||
+13
-22
@@ -11,36 +11,30 @@ import (
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/appearance"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
func TestGetServiceBanner(t *testing.T) {
|
||||
func TestGetAnnouncementBanners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := codersdk.ServiceBannerConfig{
|
||||
cfg := []codersdk.BannerConfig{{
|
||||
Enabled: true,
|
||||
Message: "hello world",
|
||||
BackgroundColor: "#000000",
|
||||
}
|
||||
Message: "The beep-bop will be boop-beeped on Saturday at 12AM PST.",
|
||||
BackgroundColor: "#00FF00",
|
||||
}}
|
||||
|
||||
var ff appearance.Fetcher = fakeFetcher{cfg: codersdk.AppearanceConfig{ServiceBanner: cfg}}
|
||||
var ff appearance.Fetcher = fakeFetcher{cfg: codersdk.AppearanceConfig{AnnouncementBanners: cfg}}
|
||||
ptr := atomic.Pointer[appearance.Fetcher]{}
|
||||
ptr.Store(&ff)
|
||||
|
||||
api := &ServiceBannerAPI{
|
||||
appearanceFetcher: &ptr,
|
||||
}
|
||||
|
||||
resp, err := api.GetServiceBanner(context.Background(), &agentproto.GetServiceBannerRequest{})
|
||||
api := &AnnouncementBannerAPI{appearanceFetcher: &ptr}
|
||||
resp, err := api.GetAnnouncementBanners(context.Background(), &agentproto.GetAnnouncementBannersRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, &agentproto.ServiceBanner{
|
||||
Enabled: cfg.Enabled,
|
||||
Message: cfg.Message,
|
||||
BackgroundColor: cfg.BackgroundColor,
|
||||
}, resp)
|
||||
require.Len(t, resp.AnnouncementBanners, 1)
|
||||
require.Equal(t, cfg[0], agentsdk.BannerConfigFromProto(resp.AnnouncementBanners[0]))
|
||||
})
|
||||
|
||||
t.Run("FetchError", func(t *testing.T) {
|
||||
@@ -51,11 +45,8 @@ func TestGetServiceBanner(t *testing.T) {
|
||||
ptr := atomic.Pointer[appearance.Fetcher]{}
|
||||
ptr.Store(&ff)
|
||||
|
||||
api := &ServiceBannerAPI{
|
||||
appearanceFetcher: &ptr,
|
||||
}
|
||||
|
||||
resp, err := api.GetServiceBanner(context.Background(), &agentproto.GetServiceBannerRequest{})
|
||||
api := &AnnouncementBannerAPI{appearanceFetcher: &ptr}
|
||||
resp, err := api.GetAnnouncementBanners(context.Background(), &agentproto.GetAnnouncementBannersRequest{})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, expectedErr)
|
||||
require.Nil(t, resp)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user