Compare commits
545 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e491217a12 | |||
| 9d2b805fb7 | |||
| 7fc1a65b14 | |||
| fdf035cd06 | |||
| fc1d823cae | |||
| 8fe3dcf18a | |||
| 5abfe5afd0 | |||
| 7a8da08124 | |||
| 6b7858c516 | |||
| 9d3785def8 | |||
| 2a6fd90140 | |||
| c2e3648484 | |||
| 3b50530a63 | |||
| 7fecd39e23 | |||
| 99fda4a8e2 | |||
| 51aa32cfcf | |||
| 6ae8bfed94 | |||
| 35e7d7854a | |||
| edcbd4f394 | |||
| ea578ceabb | |||
| 0ddd54d34b | |||
| fdc9097d6c | |||
| e7fd2cb1a6 | |||
| 670ee4d54f | |||
| 39fbf74c7d | |||
| eac155aec2 | |||
| 7732ac475a | |||
| 6b2aee4133 | |||
| d8592bf09a | |||
| 4af8446f48 | |||
| 1286904de8 | |||
| 09f7b8e88c | |||
| 1a2aea3a6b | |||
| 6683ad989a | |||
| 8f1b4fb061 | |||
| a7243b3f3b | |||
| 1372bf82f5 | |||
| 57c9d88703 | |||
| 5ebb702e00 | |||
| 9dbc913798 | |||
| ed5567ba28 | |||
| ac322724b0 | |||
| 3d9bfdd5dc | |||
| 1ba5169109 | |||
| d33526108f | |||
| f5f150d568 | |||
| b799014832 | |||
| 9c9319f81e | |||
| ab2904a676 | |||
| 557adab224 | |||
| 21f87313bd | |||
| 42c21d400f | |||
| f677c4470b | |||
| b8c7b56fda | |||
| c4f590581e | |||
| 997493d4ae | |||
| 1ad998ee3a | |||
| 504cedf15a | |||
| 9b73020f11 | |||
| c93fe8ddbe | |||
| fe05fd1e6e | |||
| 2b5e02f5b2 | |||
| ab456276dc | |||
| 09d995c8dc | |||
| 619df23ad1 | |||
| 492da15890 | |||
| 1656249e07 | |||
| 35f9e2ef7f | |||
| 0f2d4fdb6d | |||
| 8f39ec5cc3 | |||
| 2f4ca0f566 | |||
| a49e6b88f9 | |||
| b5e5b39de2 | |||
| 2acf195b13 | |||
| eaea918a59 | |||
| 4240200b5d | |||
| 43f26dfec5 | |||
| 9a0aac88e0 | |||
| 6ebe9b0402 | |||
| 5a90228c60 | |||
| 8ffe0e22b6 | |||
| 8efa1239e7 | |||
| 3c49290dd7 | |||
| 6875faf238 | |||
| 01792f064e | |||
| f64b9cab90 | |||
| 493e2bd2ac | |||
| dd86100f33 | |||
| 1be24dcb5c | |||
| 2029543eba | |||
| 39c0539d42 | |||
| 98b6c8bcb0 | |||
| cbc0c39792 | |||
| def980b973 | |||
| 76c65b1e1b | |||
| 4857d4bd55 | |||
| 7eeba15d16 | |||
| 1b1ab97c24 | |||
| 13036dd088 | |||
| ab7dd24d97 | |||
| d56f49f619 | |||
| 2a4ac2a53c | |||
| 570f963aea | |||
| 5fc9ff29d1 | |||
| 88605b9d01 | |||
| e5198a25a6 | |||
| 782c22a293 | |||
| 7df40b85f2 | |||
| 1e75762cb4 | |||
| 3adf86b608 | |||
| 5f0457f160 | |||
| 9bf3b35bbf | |||
| 6ef1beec13 | |||
| a9077812e2 | |||
| a67a5a8105 | |||
| 301c045aad | |||
| 5be4b12378 | |||
| 7c6687813d | |||
| 59ae69b7f2 | |||
| 04e67836a5 | |||
| 98a076fb46 | |||
| ac623b4717 | |||
| 1e950fa9a8 | |||
| edbd51955c | |||
| 43fa4349d6 | |||
| a2cd6640f3 | |||
| a1ee4d44aa | |||
| e829cbf2db | |||
| ed8092c83d | |||
| b3471bd23a | |||
| dc117051e6 | |||
| fafecbd9b3 | |||
| 3c43216e99 | |||
| 4452a1484d | |||
| 7c71053eab | |||
| fbabb43cbb | |||
| b0d2828f9e | |||
| ec9b480ac0 | |||
| 652e1a7d43 | |||
| e7d9b8d858 | |||
| f48bc33e00 | |||
| 91555c3a85 | |||
| 05a393cd06 | |||
| 7e6b549170 | |||
| 21e0d540dc | |||
| 7ea58eac18 | |||
| 00589d6422 | |||
| 69d13f1676 | |||
| c11f241622 | |||
| 2506415def | |||
| db8592fa93 | |||
| 19400d6794 | |||
| b780bff429 | |||
| 5ae6cda89f | |||
| 78b9201b31 | |||
| 8a47262faf | |||
| a0485c00ac | |||
| c83af5e627 | |||
| 017d7e9dad | |||
| 211718f95a | |||
| f36fba2486 | |||
| b039dc6989 | |||
| 9c098b218f | |||
| 3eb9a43190 | |||
| a61f8ee45c | |||
| 863c2e7b64 | |||
| 35538e1051 | |||
| 20438ae6c2 | |||
| 42fb6cab12 | |||
| cb3b617ee9 | |||
| af63909134 | |||
| 583d44e60e | |||
| 1cdc62b332 | |||
| 54648b90ca | |||
| 3bbfcc593e | |||
| 2881b8b252 | |||
| b9c7bc4d3c | |||
| 584a2e87c9 | |||
| 54fd350913 | |||
| 9e622d00a6 | |||
| 24c80bf532 | |||
| 17e889af16 | |||
| b402f2a816 | |||
| 17869ecb74 | |||
| bda68b143a | |||
| 236e84c4d6 | |||
| 791144ddfd | |||
| 5673aca408 | |||
| c6cf719f6c | |||
| 38bb854c8b | |||
| ae113179b3 | |||
| da47ac87db | |||
| 19dbf19177 | |||
| 71ad5909f2 | |||
| 36f3151b71 | |||
| 03a7d2f70b | |||
| 2d2bea79a7 | |||
| 69b65693c9 | |||
| 23425d36a1 | |||
| 983e8c3ae8 | |||
| d24d2d2c8d | |||
| 127f65c98b | |||
| 4ad080c3b9 | |||
| 14c8824c83 | |||
| fa0a597530 | |||
| f270d9d351 | |||
| 04e7748a9b | |||
| 1eb21d247b | |||
| a5f8300c76 | |||
| 2d6c4fe90a | |||
| ad47ef17e8 | |||
| eb4826a11f | |||
| 3c87c4df1b | |||
| b32d79ef0b | |||
| 91265678ad | |||
| 888b97fd86 | |||
| 246dae0e1a | |||
| f001a57614 | |||
| 48ee80a559 | |||
| 5d5a7da67f | |||
| ab9276bd08 | |||
| 30440915bc | |||
| 5021e23105 | |||
| d5040441aa | |||
| df8e10cc4c | |||
| bca7416069 | |||
| 1cd4405caf | |||
| 03c377b754 | |||
| a8ed88b22e | |||
| 1076d16456 | |||
| fd06b7f7a0 | |||
| 252ec14556 | |||
| 7f9b4ad9a8 | |||
| 516b88dc25 | |||
| 46551e619d | |||
| 64692f0b69 | |||
| defef4671c | |||
| 2c2e98cc39 | |||
| 8e44dce5b3 | |||
| 6651aff57b | |||
| b468415a81 | |||
| 5e0cb372b4 | |||
| 2405bbe1b9 | |||
| c7218b40c9 | |||
| 4ab52766d2 | |||
| 39846d69d3 | |||
| 2a19b46ab7 | |||
| 6322e13046 | |||
| efdbb6f9e6 | |||
| e6aeee2ba2 | |||
| 4df5c1ddec | |||
| bdb9954f87 | |||
| e7042e601c | |||
| c194119689 | |||
| 4b97ac269b | |||
| 5e3bf275da | |||
| 70a4e56c01 | |||
| f16eb1331f | |||
| eeab33b1c3 | |||
| 9aac15212b | |||
| 45b53c285f | |||
| f62f45a303 | |||
| cb60409a8c | |||
| cc2772c646 | |||
| e55c25e037 | |||
| 352ec7bc4f | |||
| 4966ef02cf | |||
| 465546eefd | |||
| 3980dbd029 | |||
| 9e1e365b32 | |||
| 42e25740eb | |||
| 885b2502ed | |||
| 148fa819ae | |||
| fabcc41a6b | |||
| 6452008e32 | |||
| f694204773 | |||
| 0a54506940 | |||
| f802fba89e | |||
| 89c2938b20 | |||
| 06411b8b17 | |||
| 57909e0c72 | |||
| 4b0565c895 | |||
| e6d2ddb54b | |||
| 1a07ee0b16 | |||
| 1c48610d56 | |||
| 1906cc4806 | |||
| 81a046e0a9 | |||
| 247eeab3c8 | |||
| 52b16f0622 | |||
| 8af28717a3 | |||
| d650cf9b8c | |||
| cac677b4ba | |||
| 3dc478ad6b | |||
| b035c4d88a | |||
| 3d71173e74 | |||
| 5b18007311 | |||
| 5596fb20b5 | |||
| 16a2d4d733 | |||
| c1cd93da30 | |||
| 3d21872230 | |||
| e9ccb8dc78 | |||
| fa297e1096 | |||
| e6cb2c5a8e | |||
| 710b170adf | |||
| 8abca9bea7 | |||
| 2b5428e95f | |||
| 4da1223a80 | |||
| 61154a6bb5 | |||
| 92308bec3b | |||
| 87ebe6c2c2 | |||
| c0705ec40e | |||
| 2dd49cc0a7 | |||
| beac36027f | |||
| 885041a65b | |||
| 8cff6237ba | |||
| 92c0237899 | |||
| ed8ae2f123 | |||
| 9e845213f5 | |||
| 305556f655 | |||
| 0f946669c1 | |||
| dae528f5e7 | |||
| 2d1b35390e | |||
| dcad8fdc2f | |||
| 20a681af8d | |||
| 066b25f710 | |||
| 72e8f88af3 | |||
| 0878381d0b | |||
| de6d0b9a1a | |||
| cb5f8df4c2 | |||
| 68738771b9 | |||
| fad02081fc | |||
| d8515f02af | |||
| 4e442040f7 | |||
| c67db6efb0 | |||
| 399b428149 | |||
| 726a4dadf2 | |||
| 75366ec6b5 | |||
| 6f0e2a7968 | |||
| 4c3b579f58 | |||
| 1f4335733c | |||
| b0b9d32a2a | |||
| 93ef696b57 | |||
| 96c5076c69 | |||
| c4718fd747 | |||
| 04c85c3833 | |||
| f7c89082d2 | |||
| 442fb105c9 | |||
| 20bfe6e9e5 | |||
| 89292264be | |||
| 6e6b808143 | |||
| d3220c5db9 | |||
| 1262eef2c0 | |||
| dac1375880 | |||
| ff3fc0971c | |||
| 4adbf24a08 | |||
| b6c5e94ffa | |||
| 47d3161b0b | |||
| 3757005e82 | |||
| 415818035c | |||
| 4622ea2c10 | |||
| cb545bcc30 | |||
| 63ea12e74c | |||
| 7da3180036 | |||
| 9d5af5b483 | |||
| e649b7cefe | |||
| 5c1ee6990e | |||
| 9358b3bd84 | |||
| 26e0d7580c | |||
| 382843dc5f | |||
| 85ab9c2d48 | |||
| 7bb0061804 | |||
| df0c597843 | |||
| cc009fe121 | |||
| 733171a93b | |||
| c201fc2538 | |||
| f6ee08d100 | |||
| 9216725698 | |||
| 8d8402da00 | |||
| a1f3a6b606 | |||
| 84999cb33d | |||
| e9077f3bd2 | |||
| 94a0612cd2 | |||
| c900b5f8df | |||
| fbad06f406 | |||
| 91a04c0132 | |||
| 201a6c0c79 | |||
| 801c6c994b | |||
| 866ba8ede5 | |||
| fa858531a8 | |||
| b742661abd | |||
| 92a90eb9ae | |||
| 1cd0bea86e | |||
| fae8a470df | |||
| f89b68056d | |||
| ae1896f2dc | |||
| 8e012e4e1a | |||
| a18bf73131 | |||
| 1fd1c654a9 | |||
| eda32659a8 | |||
| 70e481e7a5 | |||
| 269b1c59f1 | |||
| 530dd9d247 | |||
| 87d50f17a2 | |||
| 161a3cfa26 | |||
| ceb52ac24a | |||
| d6089ae0ad | |||
| 7bc98c296b | |||
| e26bb2d91b | |||
| ffa77ba6ff | |||
| 94cccd0a01 | |||
| e9c183d0dc | |||
| ceeb9987a5 | |||
| b0e3daa120 | |||
| b358e3d558 | |||
| 375c70d141 | |||
| 6cf531bfef | |||
| 8fe4401e23 | |||
| aa8652c928 | |||
| ed25f1449d | |||
| f85aa443dd | |||
| 622442203d | |||
| 45eadfc136 | |||
| 17f9991118 | |||
| 2caf7a7ceb | |||
| a2aff1f527 | |||
| e74d8a7b21 | |||
| e1bd6ddc25 | |||
| a989e8363b | |||
| 24bff1098d | |||
| 1d8a4ed201 | |||
| 2319486806 | |||
| 5a22f08f3f | |||
| d326f1b10c | |||
| 972425e3d4 | |||
| b76a430d22 | |||
| ea93b4bbe4 | |||
| bd3f2f8c10 | |||
| 010a13c654 | |||
| 9c8140270a | |||
| c58e5bf09a | |||
| 3ed65de82e | |||
| eb72866a29 | |||
| 1df7589105 | |||
| e6865e0df5 | |||
| 9bcff30dee | |||
| 1f5eb088b5 | |||
| 653488e8ee | |||
| 5de5d20808 | |||
| b2f84668c8 | |||
| ab08b2c3e4 | |||
| fcc8b9ec92 | |||
| b104e0ec0c | |||
| 9e053ce220 | |||
| efe804498b | |||
| 72dff7f188 | |||
| bc97eaa41b | |||
| d0d64bbdca | |||
| 65db7a71b7 | |||
| 281faf9ccd | |||
| b63dfe7b75 | |||
| 7311ffbd9d | |||
| 79d4179123 | |||
| 3b088a5cb8 | |||
| 225cf8acec | |||
| dcad0a437c | |||
| 8b6e2862fd | |||
| 38560dd922 | |||
| e7b0181519 | |||
| e0e6d7c9a6 | |||
| 0e4d6896e3 | |||
| 53a985ff11 | |||
| 6c409b8872 | |||
| 3dc1e22d56 | |||
| d171b3611b | |||
| 3be783b319 | |||
| 254f459d69 | |||
| a229855e71 | |||
| 4ebf490d97 | |||
| 2ac532982d | |||
| a6f7f71808 | |||
| e2579e9440 | |||
| 18c34ee456 | |||
| 641bf272ed | |||
| 0726eb56bb | |||
| b3c98395ab | |||
| b33cb0ef97 | |||
| 64bc317cd4 | |||
| 5ca8c4287f | |||
| 6db89b0372 | |||
| b7550bfda5 | |||
| 0e28397c82 | |||
| 898971b329 | |||
| 228d1cf361 | |||
| 531e1334af | |||
| 7f126758a5 | |||
| d49bc2003b | |||
| 8b08a78168 | |||
| f1f522a9a7 | |||
| 2c19995712 | |||
| e85981713d | |||
| 140a7d2de2 | |||
| 3e0969004d | |||
| abbd780373 | |||
| d4bdb96883 | |||
| 13acf5976c | |||
| 1a1c230534 | |||
| 67fe3ae8d6 | |||
| d055f93706 | |||
| 84ede326e8 | |||
| f703a5b34e | |||
| e361f1107b | |||
| 11404af9ca | |||
| 554ddb11cd | |||
| 9e5a59e222 | |||
| ad23075e1b | |||
| 392b11272b | |||
| a3a16a1586 | |||
| 8d7eb1728c | |||
| fb3616c37e | |||
| 11b6068112 | |||
| 8b51a2f3c5 | |||
| dd97fe2bce | |||
| 8421f56137 | |||
| ccda1c5c7d | |||
| 0306631518 | |||
| 869d040cc6 | |||
| 4f142fa959 | |||
| 40fcabfa0e | |||
| 7d7c84bb4d | |||
| ed7f682fd1 | |||
| 5a6f6e5679 | |||
| fd565e0e0b | |||
| bfff88d2d3 | |||
| 2dae60038a | |||
| fd8a86808f | |||
| 988c9af015 | |||
| bef38b8413 | |||
| 4ed8dd0d6c | |||
| b15bfa41c2 | |||
| 75139d1d06 | |||
| 1bcc4152af | |||
| 4ec2fea66b | |||
| 7918e65510 | |||
| 3bd0fd396c | |||
| 2849895832 |
@@ -0,0 +1,5 @@
|
||||
# If you would like `git blame` to ignore commits from this file, run...
|
||||
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
|
||||
# chore: format code with semicolons when using prettier (#9555)
|
||||
988c9af0153561397686c119da9d1336d2433fdd
|
||||
@@ -4,7 +4,7 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.20.7"
|
||||
default: "1.20.10"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
|
||||
@@ -5,6 +5,6 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup sqlc
|
||||
uses: sqlc-dev/setup-sqlc@v3
|
||||
uses: sqlc-dev/setup-sqlc@v4
|
||||
with:
|
||||
sqlc-version: "1.20.0"
|
||||
|
||||
@@ -20,7 +20,7 @@ runs:
|
||||
echo "No API key provided, skipping..."
|
||||
exit 0
|
||||
fi
|
||||
npm install -g @datadog/datadog-ci@2.10.0
|
||||
npm install -g @datadog/datadog-ci@2.21.0
|
||||
datadog-ci junit upload --service coder ./gotests.xml \
|
||||
--tags os:${{runner.os}} --tags runner_name:${{runner.name}}
|
||||
env:
|
||||
|
||||
@@ -92,6 +92,7 @@ updates:
|
||||
- dependency-name: "@types/node"
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
open-pull-requests-limit: 15
|
||||
groups:
|
||||
react:
|
||||
patterns:
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = "~> 0.11.0"
|
||||
source = "coder/coder"
|
||||
}
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
version = "~> 2.22"
|
||||
source = "hashicorp/kubernetes"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +235,9 @@ resource "kubernetes_deployment" "main" {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
}
|
||||
}
|
||||
strategy {
|
||||
type = "Recreate"
|
||||
}
|
||||
|
||||
template {
|
||||
metadata {
|
||||
|
||||
+39
-36
@@ -39,7 +39,7 @@ jobs:
|
||||
offlinedocs: ${{ steps.filter.outputs.offlinedocs }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# For pull requests it's not necessary to checkout the code
|
||||
@@ -53,7 +53,6 @@ jobs:
|
||||
docs:
|
||||
- "docs/**"
|
||||
- "README.md"
|
||||
- "examples/templates/**"
|
||||
- "examples/web-server/**"
|
||||
- "examples/monitoring/**"
|
||||
- "examples/lima/**"
|
||||
@@ -110,7 +109,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -137,7 +136,7 @@ jobs:
|
||||
|
||||
# Check for any typos
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.16.10
|
||||
uses: crate-ci/typos@v1.16.21
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
|
||||
@@ -165,7 +164,7 @@ jobs:
|
||||
if: needs.changes.outputs.docs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -209,7 +208,7 @@ jobs:
|
||||
timeout-minutes: 7
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -221,10 +220,10 @@ jobs:
|
||||
with:
|
||||
# This doesn't need caching. It's super fast anyways!
|
||||
cache: false
|
||||
go-version: 1.20.7
|
||||
go-version: 1.20.10
|
||||
|
||||
- name: Install shfmt
|
||||
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.5.0
|
||||
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0
|
||||
|
||||
- name: make fmt
|
||||
run: |
|
||||
@@ -235,7 +234,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-xl' || matrix.os == 'windows-2019' && github.repository_owner == 'coder' && 'windows-latest-8-cores' || matrix.os }}
|
||||
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 }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 20
|
||||
@@ -245,10 +244,10 @@ jobs:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-2019
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -272,22 +271,29 @@ jobs:
|
||||
echo "cover=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# if macOS, install google-chrome for scaletests. As another concern,
|
||||
# should we really have this kind of external dependency requirement
|
||||
# on standard CI?
|
||||
if [ "${{ matrix.os }}" == "macos-latest" ]; then
|
||||
brew install google-chrome
|
||||
fi
|
||||
|
||||
# By default Go will use the number of logical CPUs, which
|
||||
# is a fine default.
|
||||
PARALLEL_FLAG=""
|
||||
|
||||
# macOS will output "The default interactive shell is now zsh"
|
||||
# intermittently in CI...
|
||||
if [ "${{ matrix.os }}" == "macos-latest" ]; then
|
||||
touch ~/.bash_profile && echo "export BASH_SILENCE_DEPRECATION_WARNING=1" >> ~/.bash_profile
|
||||
fi
|
||||
export TS_DEBUG_DISCO=true
|
||||
gotestsum --junitfile="gotests.xml" --jsonfile="gotests.json" \
|
||||
--packages="./..." -- $PARALLEL_FLAG -short -failfast $COVERAGE_FLAGS
|
||||
|
||||
- name: Print test stats
|
||||
if: success() || failure()
|
||||
run: |
|
||||
# Artifacts are not available after rerunning a job,
|
||||
# so we need to print the test stats to the log.
|
||||
go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
timeout-minutes: 1
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/upload-datadog
|
||||
if: success() || failure()
|
||||
with:
|
||||
@@ -317,7 +323,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -332,14 +338,9 @@ jobs:
|
||||
export TS_DEBUG_DISCO=true
|
||||
make test-postgres
|
||||
|
||||
- name: Print test stats
|
||||
if: success() || failure()
|
||||
run: |
|
||||
# Artifacts are not available after rerunning a job,
|
||||
# so we need to print the test stats to the log.
|
||||
go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
timeout-minutes: 1
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/upload-datadog
|
||||
if: success() || failure()
|
||||
with:
|
||||
@@ -365,7 +366,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -380,6 +381,8 @@ jobs:
|
||||
gotestsum --junitfile="gotests.xml" -- -race ./...
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
timeout-minutes: 1
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/upload-datadog
|
||||
if: always()
|
||||
with:
|
||||
@@ -387,7 +390,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
name: "deploy"
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-16vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 30
|
||||
needs: changes
|
||||
if: |
|
||||
@@ -398,7 +401,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -486,7 +489,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -510,13 +513,13 @@ jobs:
|
||||
flags: unittest-js
|
||||
|
||||
test-e2e:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-16vcpu-ubuntu-2204' || '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
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -582,7 +585,7 @@ jobs:
|
||||
if: needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Required by Chromatic for build-over-build history, otherwise we
|
||||
# only get 1 commit on shallow checkout.
|
||||
@@ -647,7 +650,7 @@ jobs:
|
||||
if: needs.changes.outputs.offlinedocs == 'true' || needs.changes.outputs.ci == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# 0 is required here for version.sh to work.
|
||||
fetch-depth: 0
|
||||
@@ -727,7 +730,7 @@ jobs:
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -741,7 +744,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -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.0
|
||||
uses: contributor-assistant/github-action@v2.3.1
|
||||
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
|
||||
|
||||
@@ -32,10 +32,10 @@ jobs:
|
||||
if: github.repository_owner == 'coder'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get branch name
|
||||
id: branch-name
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
echo "tag=${tag}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v4
|
||||
uses: DeterminateSystems/nix-installer-action@v6
|
||||
|
||||
- name: Run the Magic Nix Cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@v2
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
- run: nix build .#devEnvImage && ./result | docker load
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get short commit SHA
|
||||
id: vars
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
@@ -14,4 +14,4 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Assign author
|
||||
uses: toshimaru/auto-author-assign@v1.6.2
|
||||
uses: toshimaru/auto-author-assign@v2.0.1
|
||||
|
||||
@@ -37,10 +37,6 @@ permissions:
|
||||
packages: write
|
||||
pull-requests: write # needed for commenting on PRs
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check_pr:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -48,7 +44,7 @@ jobs:
|
||||
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check if PR is open
|
||||
id: check_pr
|
||||
@@ -73,12 +69,12 @@ jobs:
|
||||
CODER_BASE_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_BASE_IMAGE_TAG }}
|
||||
CODER_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_IMAGE_TAG }}
|
||||
NEW: ${{ steps.check_deployment.outputs.NEW }}
|
||||
BUILD: ${{ steps.build_conditionals.outputs.first_or_force_build || steps.build_conditionals.outputs.automatic_rebuild }}
|
||||
BUILD: ${{ steps.build_conditionals.outputs.first_or_force_build == 'true' || steps.build_conditionals.outputs.automatic_rebuild == 'true' }}
|
||||
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -110,6 +106,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config
|
||||
chmod 644 ~/.kube/config
|
||||
export KUBECONFIG=~/.kube/config
|
||||
|
||||
- name: Check if the helm deployment already exists
|
||||
@@ -165,7 +162,7 @@ jobs:
|
||||
echo "automatic_rebuild=${{ steps.check_deployment.outputs.NEW == 'false' && steps.filter.outputs.all_count > steps.filter.outputs.ignored_count }}" >> $GITHUB_OUTPUT
|
||||
|
||||
comment-pr:
|
||||
needs: [check_pr, get_info]
|
||||
needs: get_info
|
||||
if: needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true'
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
@@ -197,12 +194,16 @@ jobs:
|
||||
# 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' }}
|
||||
# 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 }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -216,7 +217,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -257,6 +258,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config
|
||||
chmod 644 ~/.kube/config
|
||||
export KUBECONFIG=~/.kube/config
|
||||
|
||||
- name: Check if image exists
|
||||
@@ -296,7 +298,7 @@ jobs:
|
||||
kubectl create namespace "pr${{ env.PR_NUMBER }}"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check and Create Certificate
|
||||
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
|
||||
@@ -417,7 +419,6 @@ jobs:
|
||||
|
||||
# Create template
|
||||
cd ./.github/pr-deployments/template
|
||||
terraform init
|
||||
coder templates create -y --variable namespace=pr${{ env.PR_NUMBER }} kubernetes
|
||||
|
||||
# Create workspace
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
cat "$CODER_RELEASE_NOTES_FILE"
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -357,7 +357,7 @@ jobs:
|
||||
cd "$temp_dir"
|
||||
|
||||
# Download checksums
|
||||
checksums_url="$(gh release view --repo coder/coder v2.1.4 --json assets \
|
||||
checksums_url="$(gh release view --repo coder/coder "v$coder_version" --json assets \
|
||||
| jq -r ".assets | map(.url) | .[]" \
|
||||
| grep -e ".checksums.txt\$")"
|
||||
wget "$checksums_url" -O checksums.txt
|
||||
@@ -409,7 +409,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -489,7 +489,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
image_name: ${{ steps.build.outputs.image }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@fbd16365eb88e12433951383f5e99bd901fc618f
|
||||
uses: aquasecurity/trivy-action@b77b85c0254bba6789e787844f0585cde1e56320
|
||||
with:
|
||||
image-ref: ${{ steps.build.outputs.image }}
|
||||
format: sarif
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Run delete-old-branches-action
|
||||
uses: beatlabs/delete-old-branches-action@v0.0.10
|
||||
with:
|
||||
@@ -52,8 +52,8 @@ jobs:
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
retain_days: 1
|
||||
keep_minimum_runs: 1
|
||||
retain_days: 30
|
||||
keep_minimum_runs: 30
|
||||
delete_workflow_pattern: pr-cleanup.yaml
|
||||
|
||||
- name: Delete PR Deploy workflow skipped runs
|
||||
@@ -61,7 +61,6 @@ jobs:
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
retain_days: 0
|
||||
keep_minimum_runs: 0
|
||||
delete_run_by_conclusion_pattern: skipped
|
||||
retain_days: 30
|
||||
keep_minimum_runs: 30
|
||||
delete_workflow_pattern: pr-deploy.yaml
|
||||
|
||||
+9
-4
@@ -30,15 +30,14 @@ site/e2e/states/*.json
|
||||
site/e2e/.auth.json
|
||||
site/playwright-report/*
|
||||
site/.swc
|
||||
site/dist/
|
||||
|
||||
# Make target for updating golden files (any dir).
|
||||
.gen-golden
|
||||
|
||||
# Build
|
||||
/build/
|
||||
/dist/
|
||||
site/out/
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
|
||||
# Bundle analysis
|
||||
site/stats/
|
||||
@@ -64,3 +63,9 @@ scaletest/terraform/secrets.tfvars
|
||||
|
||||
# Nix
|
||||
result
|
||||
|
||||
# Data dumps from unit tests
|
||||
**/*.test.sql
|
||||
|
||||
# Filebrowser.db
|
||||
**/filebrowser.db
|
||||
|
||||
@@ -10,6 +10,9 @@ linters-settings:
|
||||
include:
|
||||
# Gradually extend to cover more of the codebase.
|
||||
- 'httpmw\.\w+'
|
||||
# We want to enforce all values are specified when inserting or updating
|
||||
# a database row. Ref: #9936
|
||||
- 'github.com/coder/coder/v2/coderd/database\.[^G][^e][^t]\w+Params'
|
||||
gocognit:
|
||||
min-complexity: 300
|
||||
|
||||
|
||||
+9
-4
@@ -33,15 +33,14 @@ site/e2e/states/*.json
|
||||
site/e2e/.auth.json
|
||||
site/playwright-report/*
|
||||
site/.swc
|
||||
site/dist/
|
||||
|
||||
# Make target for updating golden files (any dir).
|
||||
.gen-golden
|
||||
|
||||
# Build
|
||||
/build/
|
||||
/dist/
|
||||
site/out/
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
|
||||
# Bundle analysis
|
||||
site/stats/
|
||||
@@ -67,6 +66,12 @@ scaletest/terraform/secrets.tfvars
|
||||
|
||||
# Nix
|
||||
result
|
||||
|
||||
# Data dumps from unit tests
|
||||
**/*.test.sql
|
||||
|
||||
# Filebrowser.db
|
||||
**/filebrowser.db
|
||||
# .prettierignore.include:
|
||||
# Helm templates contain variables that are invalid YAML and can't be formatted
|
||||
# by Prettier.
|
||||
|
||||
+2
-2
@@ -1,9 +1,8 @@
|
||||
# This config file is used in conjunction with `.editorconfig` to specify
|
||||
# formatting for prettier-supported files. See `.editorconfig` and
|
||||
# `site/.editorconfig`for whitespace formatting options.
|
||||
# `site/.editorconfig` for whitespace formatting options.
|
||||
printWidth: 80
|
||||
proseWrap: always
|
||||
semi: false
|
||||
trailingComma: all
|
||||
useTabs: false
|
||||
tabWidth: 2
|
||||
@@ -12,6 +11,7 @@ overrides:
|
||||
- README.md
|
||||
- docs/api/**/*.md
|
||||
- docs/cli/**/*.md
|
||||
- docs/changelogs/*.md
|
||||
- .github/**/*.{yaml,yml,toml}
|
||||
- scripts/**/*.{yaml,yml,toml}
|
||||
options:
|
||||
|
||||
Vendored
+7
-5
@@ -20,7 +20,7 @@
|
||||
"codersdk",
|
||||
"cronstrue",
|
||||
"databasefake",
|
||||
"dbfake",
|
||||
"dbmem",
|
||||
"dbgen",
|
||||
"dbtype",
|
||||
"DERP",
|
||||
@@ -39,6 +39,7 @@
|
||||
"enterprisemeta",
|
||||
"errgroup",
|
||||
"eventsourcemock",
|
||||
"externalauth",
|
||||
"Failf",
|
||||
"fatih",
|
||||
"Formik",
|
||||
@@ -186,9 +187,6 @@
|
||||
]
|
||||
},
|
||||
"eslint.workingDirectories": ["./site"],
|
||||
"files.exclude": {
|
||||
"**/node_modules": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**.pb.go": true,
|
||||
"**/*.gen.json": true,
|
||||
@@ -198,7 +196,11 @@
|
||||
"docs/api/*.md": true,
|
||||
"docs/templates/*.md": true,
|
||||
"LICENSE": true,
|
||||
"scripts/metricsdocgen/metrics": true
|
||||
"scripts/metricsdocgen/metrics": true,
|
||||
"site/out/**": true,
|
||||
"site/storybook-static/**": true,
|
||||
"**.map": true,
|
||||
"pnpm-lock.yaml": true
|
||||
},
|
||||
// Ensure files always have a newline.
|
||||
"files.insertFinalNewline": true,
|
||||
|
||||
@@ -107,9 +107,9 @@ endif
|
||||
|
||||
|
||||
clean:
|
||||
rm -rf build site/out
|
||||
mkdir -p build site/out/bin
|
||||
git restore site/out
|
||||
rm -rf build/ site/build/ site/out/
|
||||
mkdir -p build/ site/out/bin/
|
||||
git restore site/out/
|
||||
.PHONY: clean
|
||||
|
||||
build-slim: $(CODER_SLIM_BINARIES)
|
||||
@@ -419,7 +419,6 @@ lint: lint/shellcheck lint/go lint/ts lint/helm lint/site-icons
|
||||
|
||||
lint/site-icons:
|
||||
./scripts/check_site_icons.sh
|
||||
|
||||
.PHONY: lint/site-icons
|
||||
|
||||
lint/ts:
|
||||
@@ -449,13 +448,15 @@ lint/helm:
|
||||
DB_GEN_FILES := \
|
||||
coderd/database/querier.go \
|
||||
coderd/database/unique_constraint.go \
|
||||
coderd/database/dbfake/dbfake.go \
|
||||
coderd/database/dbmem/dbmem.go \
|
||||
coderd/database/dbmetrics/dbmetrics.go \
|
||||
coderd/database/dbauthz/dbauthz.go \
|
||||
coderd/database/dbmock/dbmock.go
|
||||
|
||||
# all gen targets should be added here and to gen/mark-fresh
|
||||
gen: \
|
||||
tailnet/proto/tailnet.pb.go \
|
||||
agent/proto/agent.pb.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
coderd/database/dump.sql \
|
||||
@@ -472,6 +473,7 @@ gen: \
|
||||
site/.prettierignore \
|
||||
site/.eslintignore \
|
||||
site/e2e/provisionerGenerated.ts \
|
||||
site/src/theme/icons.json \
|
||||
examples/examples.gen.json
|
||||
.PHONY: gen
|
||||
|
||||
@@ -479,6 +481,8 @@ gen: \
|
||||
# used during releases so we don't run generation scripts.
|
||||
gen/mark-fresh:
|
||||
files="\
|
||||
tailnet/proto/tailnet.pb.go \
|
||||
agent/proto/agent.pb.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
coderd/database/dump.sql \
|
||||
@@ -495,6 +499,7 @@ gen/mark-fresh:
|
||||
site/.prettierignore \
|
||||
site/.eslintignore \
|
||||
site/e2e/provisionerGenerated.ts \
|
||||
site/src/theme/icons.json \
|
||||
examples/examples.gen.json \
|
||||
"
|
||||
for file in $$files; do
|
||||
@@ -515,12 +520,30 @@ coderd/database/dump.sql: coderd/database/gen/dump/main.go $(wildcard coderd/dat
|
||||
go run ./coderd/database/gen/dump/main.go
|
||||
|
||||
# Generates Go code for querying the database.
|
||||
# coderd/database/queries.sql.go
|
||||
# coderd/database/models.go
|
||||
coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql)
|
||||
./coderd/database/generate.sh
|
||||
|
||||
coderd/database/dbmock/dbmock.go: coderd/database/db.go coderd/database/querier.go
|
||||
go generate ./coderd/database/dbmock/
|
||||
|
||||
tailnet/proto/tailnet.pb.go: tailnet/proto/tailnet.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./tailnet/proto/tailnet.proto
|
||||
|
||||
agent/proto/agent.pb.go: agent/proto/agent.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./agent/proto/agent.proto
|
||||
|
||||
provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
@@ -537,16 +560,18 @@ provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./provisionerd/proto/provisionerd.proto
|
||||
|
||||
site/src/api/typesGenerated.ts: scripts/apitypings/main.go $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
go run scripts/apitypings/main.go > site/src/api/typesGenerated.ts
|
||||
cd site
|
||||
pnpm run format:types ./src/api/typesGenerated.ts
|
||||
site/src/api/typesGenerated.ts: $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
go run ./scripts/apitypings/ > $@
|
||||
pnpm run format:write:only "$@"
|
||||
|
||||
site/e2e/provisionerGenerated.ts:
|
||||
site/e2e/provisionerGenerated.ts: provisionerd/proto/provisionerd.pb.go provisionersdk/proto/provisioner.pb.go
|
||||
cd site
|
||||
../scripts/pnpm_install.sh
|
||||
pnpm run gen:provisioner
|
||||
|
||||
site/src/theme/icons.json: $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*)
|
||||
go run ./scripts/gensite/ -icons "$@"
|
||||
pnpm run format:write:only "$@"
|
||||
|
||||
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
|
||||
@@ -559,7 +584,7 @@ docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/me
|
||||
pnpm run format:write:only ./docs/admin/prometheus.md
|
||||
|
||||
docs/cli.md: scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
|
||||
BASE_PATH="." go run ./scripts/clidocgen
|
||||
CI=true BASE_PATH="." go run ./scripts/clidocgen
|
||||
pnpm run format:write:only ./docs/cli.md ./docs/cli/*.md ./docs/manifest.json
|
||||
|
||||
docs/admin/audit-logs.md: scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go
|
||||
@@ -570,7 +595,7 @@ coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS)
|
||||
./scripts/apidocgen/generate.sh
|
||||
pnpm run format:write:only ./docs/api ./docs/manifest.json ./coderd/apidoc/swagger.json
|
||||
|
||||
update-golden-files: cli/testdata/.gen-golden helm/coder/tests/testdata/.gen-golden helm/provisioner/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden enterprise/cli/testdata/.gen-golden coderd/.gen-golden
|
||||
update-golden-files: cli/testdata/.gen-golden helm/coder/tests/testdata/.gen-golden helm/provisioner/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden enterprise/cli/testdata/.gen-golden coderd/.gen-golden provisioner/terraform/testdata/.gen-golden
|
||||
.PHONY: update-golden-files
|
||||
|
||||
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go)
|
||||
@@ -593,6 +618,10 @@ coderd/.gen-golden: $(wildcard coderd/testdata/*/*.golden) $(GO_SRC_FILES) $(wil
|
||||
go test ./coderd -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
|
||||
provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard provisioner/terraform/*_test.go)
|
||||
go test ./provisioner/terraform -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
|
||||
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 "$@"
|
||||
|
||||
@@ -70,7 +70,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. You can modify the installation process by including flags. Run the install script with `--help` for reference.
|
||||
|
||||
> See [install](docs/install) for additional methods.
|
||||
> See [install](https://coder.com/docs/v2/latest/install) for additional methods.
|
||||
|
||||
Once installed, you can start a production deployment<sup>1</sup> with a single command:
|
||||
|
||||
|
||||
+384
-274
@@ -12,9 +12,10 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -34,6 +35,8 @@ import (
|
||||
"tailscale.com/types/netlogtype"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentproc"
|
||||
"github.com/coder/coder/v2/agent/agentscripts"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/reconnectingpty"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
@@ -51,6 +54,10 @@ const (
|
||||
ProtocolDial = "dial"
|
||||
)
|
||||
|
||||
// EnvProcPrioMgmt determines whether we attempt to manage
|
||||
// process CPU and OOM Killer priority.
|
||||
const EnvProcPrioMgmt = "CODER_PROC_PRIO_MGMT"
|
||||
|
||||
type Options struct {
|
||||
Filesystem afero.Fs
|
||||
LogDir string
|
||||
@@ -68,6 +75,11 @@ type Options struct {
|
||||
PrometheusRegistry *prometheus.Registry
|
||||
ReportMetadataInterval time.Duration
|
||||
ServiceBannerRefreshInterval time.Duration
|
||||
Syscaller agentproc.Syscaller
|
||||
// ModifiedProcesses is used for testing process priority management.
|
||||
ModifiedProcesses chan []*agentproc.Process
|
||||
// ProcessManagementTick is used for testing process priority management.
|
||||
ProcessManagementTick <-chan time.Time
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
@@ -78,7 +90,7 @@ type Client interface {
|
||||
PostLifecycle(ctx context.Context, state agentsdk.PostLifecycleRequest) error
|
||||
PostAppHealth(ctx context.Context, req agentsdk.PostAppHealthsRequest) error
|
||||
PostStartup(ctx context.Context, req agentsdk.PostStartupRequest) error
|
||||
PostMetadata(ctx context.Context, key string, req agentsdk.PostMetadataRequest) error
|
||||
PostMetadata(ctx context.Context, req agentsdk.PostMetadataRequest) error
|
||||
PatchLogs(ctx context.Context, req agentsdk.PatchLogs) error
|
||||
GetServiceBanner(ctx context.Context) (codersdk.ServiceBannerConfig, error)
|
||||
}
|
||||
@@ -120,6 +132,10 @@ func New(options Options) Agent {
|
||||
prometheusRegistry = prometheus.NewRegistry()
|
||||
}
|
||||
|
||||
if options.Syscaller == nil {
|
||||
options.Syscaller = agentproc.NewSyscaller()
|
||||
}
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
a := &agent{
|
||||
tailnetListenPort: options.TailnetListenPort,
|
||||
@@ -143,6 +159,9 @@ func New(options Options) Agent {
|
||||
sshMaxTimeout: options.SSHMaxTimeout,
|
||||
subsystems: options.Subsystems,
|
||||
addresses: options.Addresses,
|
||||
syscaller: options.Syscaller,
|
||||
modifiedProcs: options.ModifiedProcesses,
|
||||
processManagementTick: options.ProcessManagementTick,
|
||||
|
||||
prometheusRegistry: prometheusRegistry,
|
||||
metrics: newAgentMetrics(prometheusRegistry),
|
||||
@@ -177,6 +196,7 @@ type agent struct {
|
||||
|
||||
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]
|
||||
@@ -197,6 +217,12 @@ type agent struct {
|
||||
|
||||
prometheusRegistry *prometheus.Registry
|
||||
metrics *agentMetrics
|
||||
syscaller agentproc.Syscaller
|
||||
|
||||
// modifiedProcs is used for testing process priority management.
|
||||
modifiedProcs chan []*agentproc.Process
|
||||
// processManagementTick is used for testing process priority management.
|
||||
processManagementTick <-chan time.Time
|
||||
}
|
||||
|
||||
func (a *agent) TailnetConn() *tailnet.Conn {
|
||||
@@ -213,7 +239,13 @@ func (a *agent) init(ctx context.Context) {
|
||||
sshSrv.Manifest = &a.manifest
|
||||
sshSrv.ServiceBanner = &a.serviceBanner
|
||||
a.sshServer = sshSrv
|
||||
|
||||
a.scriptRunner = agentscripts.New(agentscripts.Options{
|
||||
LogDir: a.logDir,
|
||||
Logger: a.logger,
|
||||
SSHServer: sshSrv,
|
||||
Filesystem: a.filesystem,
|
||||
PatchLogs: a.client.PatchLogs,
|
||||
})
|
||||
go a.runLoop(ctx)
|
||||
}
|
||||
|
||||
@@ -225,6 +257,7 @@ func (a *agent) runLoop(ctx context.Context) {
|
||||
go a.reportLifecycleLoop(ctx)
|
||||
go a.reportMetadataLoop(ctx)
|
||||
go a.fetchServiceBannerLoop(ctx)
|
||||
go a.manageProcessPriorityLoop(ctx)
|
||||
|
||||
for retrier := retry.New(100*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
|
||||
a.logger.Info(ctx, "connecting to coderd")
|
||||
@@ -329,140 +362,210 @@ func (t *trySingleflight) Do(key string, fn func()) {
|
||||
}
|
||||
|
||||
func (a *agent) reportMetadataLoop(ctx context.Context) {
|
||||
const metadataLimit = 128
|
||||
tickerDone := make(chan struct{})
|
||||
collectDone := make(chan struct{})
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
cancel()
|
||||
<-collectDone
|
||||
<-tickerDone
|
||||
}()
|
||||
|
||||
var (
|
||||
baseTicker = time.NewTicker(a.reportMetadataInterval)
|
||||
lastCollectedAtMu sync.RWMutex
|
||||
lastCollectedAts = make(map[string]time.Time)
|
||||
metadataResults = make(chan metadataResultAndKey, metadataLimit)
|
||||
logger = a.logger.Named("metadata")
|
||||
logger = a.logger.Named("metadata")
|
||||
report = make(chan struct{}, 1)
|
||||
collect = make(chan struct{}, 1)
|
||||
metadataResults = make(chan metadataResultAndKey, 1)
|
||||
)
|
||||
defer baseTicker.Stop()
|
||||
|
||||
// We use a custom singleflight that immediately returns if there is already
|
||||
// a goroutine running for a given key. This is to prevent a build-up of
|
||||
// goroutines waiting on Do when the script takes many multiples of
|
||||
// baseInterval to run.
|
||||
flight := trySingleflight{m: map[string]struct{}{}}
|
||||
|
||||
postMetadata := func(mr metadataResultAndKey) {
|
||||
err := a.client.PostMetadata(ctx, mr.key, *mr.result)
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "agent failed to report metadata", slog.Error(err))
|
||||
// Set up collect and report as a single ticker with two channels,
|
||||
// this is to allow collection and reporting to be triggered
|
||||
// independently of each other.
|
||||
go func() {
|
||||
t := time.NewTicker(a.reportMetadataInterval)
|
||||
defer func() {
|
||||
t.Stop()
|
||||
close(report)
|
||||
close(collect)
|
||||
close(tickerDone)
|
||||
}()
|
||||
wake := func(c chan<- struct{}) {
|
||||
select {
|
||||
case c <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
wake(collect) // Start immediately.
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
wake(report)
|
||||
wake(collect)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer close(collectDone)
|
||||
|
||||
var (
|
||||
// We use a custom singleflight that immediately returns if there is already
|
||||
// a goroutine running for a given key. This is to prevent a build-up of
|
||||
// goroutines waiting on Do when the script takes many multiples of
|
||||
// baseInterval to run.
|
||||
flight = trySingleflight{m: map[string]struct{}{}}
|
||||
lastCollectedAtMu sync.RWMutex
|
||||
lastCollectedAts = make(map[string]time.Time)
|
||||
)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-collect:
|
||||
}
|
||||
|
||||
manifest := a.manifest.Load()
|
||||
if manifest == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the manifest changes (e.g. on agent reconnect) we need to
|
||||
// purge old cache values to prevent lastCollectedAt from growing
|
||||
// boundlessly.
|
||||
lastCollectedAtMu.Lock()
|
||||
for key := range lastCollectedAts {
|
||||
if slices.IndexFunc(manifest.Metadata, func(md codersdk.WorkspaceAgentMetadataDescription) bool {
|
||||
return md.Key == key
|
||||
}) < 0 {
|
||||
logger.Debug(ctx, "deleting lastCollected key, missing from manifest",
|
||||
slog.F("key", key),
|
||||
)
|
||||
delete(lastCollectedAts, key)
|
||||
}
|
||||
}
|
||||
lastCollectedAtMu.Unlock()
|
||||
|
||||
// Spawn a goroutine for each metadata collection, and use a
|
||||
// channel to synchronize the results and avoid both messy
|
||||
// mutex logic and overloading the API.
|
||||
for _, md := range manifest.Metadata {
|
||||
md := md
|
||||
// We send the result to the channel in the goroutine to avoid
|
||||
// sending the same result multiple times. So, we don't care about
|
||||
// the return values.
|
||||
go flight.Do(md.Key, func() {
|
||||
ctx := slog.With(ctx, slog.F("key", md.Key))
|
||||
lastCollectedAtMu.RLock()
|
||||
collectedAt, ok := lastCollectedAts[md.Key]
|
||||
lastCollectedAtMu.RUnlock()
|
||||
if ok {
|
||||
// If the interval is zero, we assume the user just wants
|
||||
// a single collection at startup, not a spinning loop.
|
||||
if md.Interval == 0 {
|
||||
return
|
||||
}
|
||||
intervalUnit := time.Second
|
||||
// reportMetadataInterval is only less than a second in tests,
|
||||
// so adjust the interval unit for them.
|
||||
if a.reportMetadataInterval < time.Second {
|
||||
intervalUnit = 100 * time.Millisecond
|
||||
}
|
||||
// The last collected value isn't quite stale yet, so we skip it.
|
||||
if collectedAt.Add(time.Duration(md.Interval) * intervalUnit).After(time.Now()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
timeout := md.Timeout
|
||||
if timeout == 0 {
|
||||
if md.Interval != 0 {
|
||||
timeout = md.Interval
|
||||
} else if interval := int64(a.reportMetadataInterval.Seconds()); interval != 0 {
|
||||
// Fallback to the report interval
|
||||
timeout = interval * 3
|
||||
} else {
|
||||
// If the interval is still 0 (possible if the interval
|
||||
// is less than a second), default to 5. This was
|
||||
// randomly picked.
|
||||
timeout = 5
|
||||
}
|
||||
}
|
||||
ctxTimeout := time.Duration(timeout) * time.Second
|
||||
ctx, cancel := context.WithTimeout(ctx, ctxTimeout)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Warn(ctx, "metadata collection timed out", slog.F("timeout", ctxTimeout))
|
||||
case metadataResults <- metadataResultAndKey{
|
||||
key: md.Key,
|
||||
result: a.collectMetadata(ctx, md, now),
|
||||
}:
|
||||
lastCollectedAtMu.Lock()
|
||||
lastCollectedAts[md.Key] = now
|
||||
lastCollectedAtMu.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Gather metadata updates and report them once every interval. If a
|
||||
// previous report is in flight, wait for it to complete before
|
||||
// sending a new one. If the network conditions are bad, we won't
|
||||
// benefit from canceling the previous send and starting a new one.
|
||||
var (
|
||||
updatedMetadata = make(map[string]*codersdk.WorkspaceAgentMetadataResult)
|
||||
reportTimeout = 30 * time.Second
|
||||
reportSemaphore = make(chan struct{}, 1)
|
||||
)
|
||||
reportSemaphore <- struct{}{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case mr := <-metadataResults:
|
||||
postMetadata(mr)
|
||||
// This can overwrite unsent values, but that's fine because
|
||||
// we're only interested about up-to-date values.
|
||||
updatedMetadata[mr.key] = mr.result
|
||||
continue
|
||||
case <-baseTicker.C:
|
||||
}
|
||||
|
||||
if len(metadataResults) > 0 {
|
||||
// The inner collection loop expects the channel is empty before spinning up
|
||||
// all the collection goroutines.
|
||||
logger.Debug(ctx, "metadata collection backpressured",
|
||||
slog.F("queue_len", len(metadataResults)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
manifest := a.manifest.Load()
|
||||
if manifest == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(manifest.Metadata) > metadataLimit {
|
||||
logger.Error(
|
||||
ctx, "metadata limit exceeded",
|
||||
slog.F("limit", metadataLimit), slog.F("got", len(manifest.Metadata)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// If the manifest changes (e.g. on agent reconnect) we need to
|
||||
// purge old cache values to prevent lastCollectedAt from growing
|
||||
// boundlessly.
|
||||
lastCollectedAtMu.Lock()
|
||||
for key := range lastCollectedAts {
|
||||
if slices.IndexFunc(manifest.Metadata, func(md codersdk.WorkspaceAgentMetadataDescription) bool {
|
||||
return md.Key == key
|
||||
}) < 0 {
|
||||
logger.Debug(ctx, "deleting lastCollected key, missing from manifest",
|
||||
slog.F("key", key),
|
||||
)
|
||||
delete(lastCollectedAts, key)
|
||||
}
|
||||
}
|
||||
lastCollectedAtMu.Unlock()
|
||||
|
||||
// Spawn a goroutine for each metadata collection, and use a
|
||||
// channel to synchronize the results and avoid both messy
|
||||
// mutex logic and overloading the API.
|
||||
for _, md := range manifest.Metadata {
|
||||
md := md
|
||||
// We send the result to the channel in the goroutine to avoid
|
||||
// sending the same result multiple times. So, we don't care about
|
||||
// the return values.
|
||||
go flight.Do(md.Key, func() {
|
||||
ctx := slog.With(ctx, slog.F("key", md.Key))
|
||||
lastCollectedAtMu.RLock()
|
||||
collectedAt, ok := lastCollectedAts[md.Key]
|
||||
lastCollectedAtMu.RUnlock()
|
||||
if ok {
|
||||
// If the interval is zero, we assume the user just wants
|
||||
// a single collection at startup, not a spinning loop.
|
||||
if md.Interval == 0 {
|
||||
return
|
||||
}
|
||||
intervalUnit := time.Second
|
||||
// reportMetadataInterval is only less than a second in tests,
|
||||
// so adjust the interval unit for them.
|
||||
if a.reportMetadataInterval < time.Second {
|
||||
intervalUnit = 100 * time.Millisecond
|
||||
}
|
||||
// The last collected value isn't quite stale yet, so we skip it.
|
||||
if collectedAt.Add(time.Duration(md.Interval) * intervalUnit).After(time.Now()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
timeout := md.Timeout
|
||||
if timeout == 0 {
|
||||
if md.Interval != 0 {
|
||||
timeout = md.Interval
|
||||
} else if interval := int64(a.reportMetadataInterval.Seconds()); interval != 0 {
|
||||
// Fallback to the report interval
|
||||
timeout = interval * 3
|
||||
} else {
|
||||
// If the interval is still 0 (possible if the interval
|
||||
// is less than a second), default to 5. This was
|
||||
// randomly picked.
|
||||
timeout = 5
|
||||
}
|
||||
}
|
||||
ctxTimeout := time.Duration(timeout) * time.Second
|
||||
ctx, cancel := context.WithTimeout(ctx, ctxTimeout)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
case <-report:
|
||||
if len(updatedMetadata) > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Warn(ctx, "metadata collection timed out", slog.F("timeout", ctxTimeout))
|
||||
case metadataResults <- metadataResultAndKey{
|
||||
key: md.Key,
|
||||
result: a.collectMetadata(ctx, md, now),
|
||||
}:
|
||||
lastCollectedAtMu.Lock()
|
||||
lastCollectedAts[md.Key] = now
|
||||
lastCollectedAtMu.Unlock()
|
||||
case <-reportSemaphore:
|
||||
default:
|
||||
// If there's already a report in flight, don't send
|
||||
// another one, wait for next tick instead.
|
||||
continue
|
||||
}
|
||||
})
|
||||
|
||||
metadata := make([]agentsdk.Metadata, 0, len(updatedMetadata))
|
||||
for key, result := range updatedMetadata {
|
||||
metadata = append(metadata, agentsdk.Metadata{
|
||||
Key: key,
|
||||
WorkspaceAgentMetadataResult: *result,
|
||||
})
|
||||
delete(updatedMetadata, key)
|
||||
}
|
||||
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(ctx, reportTimeout)
|
||||
defer func() {
|
||||
cancel()
|
||||
reportSemaphore <- struct{}{}
|
||||
}()
|
||||
|
||||
err := a.client.PostMetadata(ctx, agentsdk.PostMetadataRequest{Metadata: metadata})
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "agent failed to report metadata", slog.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -631,41 +734,29 @@ func (a *agent) run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleState := codersdk.WorkspaceAgentLifecycleReady
|
||||
scriptDone := make(chan error, 1)
|
||||
err = a.scriptRunner.Init(manifest.Scripts)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("init script runner: %w", err)
|
||||
}
|
||||
err = a.trackConnGoroutine(func() {
|
||||
defer close(scriptDone)
|
||||
scriptDone <- a.runStartupScript(ctx, manifest.StartupScript)
|
||||
err := a.scriptRunner.Execute(ctx, func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return script.RunOnStart
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "startup script(s) failed", slog.Error(err))
|
||||
if errors.Is(err, agentscripts.ErrTimeout) {
|
||||
a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleStartTimeout)
|
||||
} else {
|
||||
a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleStartError)
|
||||
}
|
||||
} else {
|
||||
a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleReady)
|
||||
}
|
||||
a.scriptRunner.StartCron()
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("track startup script: %w", err)
|
||||
return xerrors.Errorf("track conn goroutine: %w", err)
|
||||
}
|
||||
go func() {
|
||||
var timeout <-chan time.Time
|
||||
// If timeout is zero, an older version of the coder
|
||||
// provider was used. Otherwise a timeout is always > 0.
|
||||
if manifest.StartupScriptTimeout > 0 {
|
||||
t := time.NewTimer(manifest.StartupScriptTimeout)
|
||||
defer t.Stop()
|
||||
timeout = t.C
|
||||
}
|
||||
|
||||
var err error
|
||||
select {
|
||||
case err = <-scriptDone:
|
||||
case <-timeout:
|
||||
a.logger.Warn(ctx, "script timed out", slog.F("lifecycle", "startup"), slog.F("timeout", manifest.StartupScriptTimeout))
|
||||
a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleStartTimeout)
|
||||
err = <-scriptDone // The script can still complete after a timeout.
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
lifecycleState = codersdk.WorkspaceAgentLifecycleStartError
|
||||
}
|
||||
a.setLifecycle(ctx, lifecycleState)
|
||||
}()
|
||||
}
|
||||
|
||||
// This automatically closes when the context ends!
|
||||
@@ -812,7 +903,10 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t
|
||||
}
|
||||
break
|
||||
}
|
||||
logger.Debug(ctx, "accepted conn", slog.F("remote", conn.RemoteAddr().String()))
|
||||
clog := logger.With(
|
||||
slog.F("remote", conn.RemoteAddr().String()),
|
||||
slog.F("local", conn.LocalAddr().String()))
|
||||
clog.Info(ctx, "accepted conn")
|
||||
wg.Add(1)
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
@@ -844,7 +938,7 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t
|
||||
logger.Warn(ctx, "failed to unmarshal init", slog.F("raw", data))
|
||||
return
|
||||
}
|
||||
_ = a.handleReconnectingPTY(ctx, logger, msg, conn)
|
||||
_ = a.handleReconnectingPTY(ctx, clog, msg, conn)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -871,6 +965,10 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t
|
||||
}
|
||||
break
|
||||
}
|
||||
clog := a.logger.Named("speedtest").With(
|
||||
slog.F("remote", conn.RemoteAddr().String()),
|
||||
slog.F("local", conn.LocalAddr().String()))
|
||||
clog.Info(ctx, "accepted conn")
|
||||
wg.Add(1)
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
@@ -883,7 +981,12 @@ func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *t
|
||||
}()
|
||||
go func() {
|
||||
defer close(closed)
|
||||
_ = speedtest.ServeConn(conn)
|
||||
sErr := speedtest.ServeConn(conn)
|
||||
if sErr != nil {
|
||||
clog.Error(ctx, "test ended with error", slog.Error(sErr))
|
||||
return
|
||||
}
|
||||
clog.Info(ctx, "test ended")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -980,93 +1083,6 @@ func (a *agent) runDERPMapSubscriber(ctx context.Context, network *tailnet.Conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) runStartupScript(ctx context.Context, script string) error {
|
||||
return a.runScript(ctx, "startup", script)
|
||||
}
|
||||
|
||||
func (a *agent) runShutdownScript(ctx context.Context, script string) error {
|
||||
return a.runScript(ctx, "shutdown", script)
|
||||
}
|
||||
|
||||
func (a *agent) runScript(ctx context.Context, lifecycle, script string) (err error) {
|
||||
if script == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := a.logger.With(slog.F("lifecycle", lifecycle))
|
||||
|
||||
logger.Info(ctx, fmt.Sprintf("running %s script", lifecycle), slog.F("script", script))
|
||||
fileWriter, err := a.filesystem.OpenFile(filepath.Join(a.logDir, fmt.Sprintf("coder-%s-script.log", lifecycle)), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("open %s script log file: %w", lifecycle, err)
|
||||
}
|
||||
defer func() {
|
||||
err := fileWriter.Close()
|
||||
if err != nil {
|
||||
logger.Warn(ctx, fmt.Sprintf("close %s script log file", lifecycle), slog.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
cmdPty, err := a.sshServer.CreateCommand(ctx, script, nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("%s script: create command: %w", lifecycle, err)
|
||||
}
|
||||
cmd := cmdPty.AsExec()
|
||||
|
||||
var stdout, stderr io.Writer = fileWriter, fileWriter
|
||||
if lifecycle == "startup" {
|
||||
send, flushAndClose := agentsdk.LogsSender(a.client.PatchLogs, logger)
|
||||
// If ctx is canceled here (or in a writer below), we may be
|
||||
// discarding logs, but that's okay because we're shutting down
|
||||
// anyway. We could consider creating a new context here if we
|
||||
// want better control over flush during shutdown.
|
||||
defer func() {
|
||||
if err := flushAndClose(ctx); err != nil {
|
||||
logger.Warn(ctx, "flush startup logs failed", slog.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
infoW := agentsdk.StartupLogsWriter(ctx, send, codersdk.WorkspaceAgentLogSourceStartupScript, codersdk.LogLevelInfo)
|
||||
defer infoW.Close()
|
||||
errW := agentsdk.StartupLogsWriter(ctx, send, codersdk.WorkspaceAgentLogSourceStartupScript, codersdk.LogLevelError)
|
||||
defer errW.Close()
|
||||
|
||||
stdout = io.MultiWriter(fileWriter, infoW)
|
||||
stderr = io.MultiWriter(fileWriter, errW)
|
||||
}
|
||||
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
end := time.Now()
|
||||
execTime := end.Sub(start)
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
exitCode = 255 // Unknown status.
|
||||
var exitError *exec.ExitError
|
||||
if xerrors.As(err, &exitError) {
|
||||
exitCode = exitError.ExitCode()
|
||||
}
|
||||
logger.Warn(ctx, fmt.Sprintf("%s script failed", lifecycle), slog.F("execution_time", execTime), slog.F("exit_code", exitCode), slog.Error(err))
|
||||
} else {
|
||||
logger.Info(ctx, fmt.Sprintf("%s script completed", lifecycle), slog.F("execution_time", execTime), slog.F("exit_code", exitCode))
|
||||
}
|
||||
}()
|
||||
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
// cmd.Run does not return a context canceled error, it returns "signal: killed".
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
return xerrors.Errorf("%s script: run: %w", lifecycle, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, msg codersdk.WorkspaceAgentReconnectingPTYInit, conn net.Conn) (retErr error) {
|
||||
defer conn.Close()
|
||||
a.metrics.connectionsTotal.Add(1)
|
||||
@@ -1087,12 +1103,12 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, m
|
||||
// If the agent is closed, we don't want to
|
||||
// log this as an error since it's expected.
|
||||
if closed {
|
||||
connLogger.Debug(ctx, "reconnecting pty failed with attach error (agent closed)", slog.Error(err))
|
||||
connLogger.Info(ctx, "reconnecting pty failed with attach error (agent closed)", slog.Error(err))
|
||||
} else {
|
||||
connLogger.Error(ctx, "reconnecting pty failed with attach error", slog.Error(err))
|
||||
}
|
||||
}
|
||||
connLogger.Debug(ctx, "reconnecting pty connection closed")
|
||||
connLogger.Info(ctx, "reconnecting pty connection closed")
|
||||
}()
|
||||
|
||||
var rpty reconnectingpty.ReconnectingPTY
|
||||
@@ -1253,6 +1269,115 @@ func (a *agent) startReportingConnectionStats(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
var prioritizedProcs = []string{"coder agent"}
|
||||
|
||||
func (a *agent) manageProcessPriorityLoop(ctx context.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
a.logger.Critical(ctx, "recovered from panic",
|
||||
slog.F("panic", r),
|
||||
slog.F("stack", string(debug.Stack())),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
if val := a.envVars[EnvProcPrioMgmt]; val == "" || runtime.GOOS != "linux" {
|
||||
a.logger.Debug(ctx, "process priority not enabled, agent will not manage process niceness/oom_score_adj ",
|
||||
slog.F("env_var", EnvProcPrioMgmt),
|
||||
slog.F("value", val),
|
||||
slog.F("goos", runtime.GOOS),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if a.processManagementTick == nil {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
a.processManagementTick = ticker.C
|
||||
}
|
||||
|
||||
for {
|
||||
procs, err := a.manageProcessPriority(ctx)
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "manage process priority",
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
if a.modifiedProcs != nil {
|
||||
a.modifiedProcs <- procs
|
||||
}
|
||||
|
||||
select {
|
||||
case <-a.processManagementTick:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) manageProcessPriority(ctx context.Context) ([]*agentproc.Process, error) {
|
||||
const (
|
||||
niceness = 10
|
||||
)
|
||||
|
||||
procs, err := agentproc.List(a.filesystem, a.syscaller)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("list: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
modProcs = []*agentproc.Process{}
|
||||
logger slog.Logger
|
||||
)
|
||||
|
||||
for _, proc := range procs {
|
||||
logger = a.logger.With(
|
||||
slog.F("cmd", proc.Cmd()),
|
||||
slog.F("pid", proc.PID),
|
||||
)
|
||||
|
||||
containsFn := func(e string) bool {
|
||||
contains := strings.Contains(proc.Cmd(), e)
|
||||
return contains
|
||||
}
|
||||
|
||||
// If the process is prioritized we should adjust
|
||||
// it's oom_score_adj and avoid lowering its niceness.
|
||||
if slices.ContainsFunc[[]string, string](prioritizedProcs, containsFn) {
|
||||
continue
|
||||
}
|
||||
|
||||
score, err := proc.Niceness(a.syscaller)
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "unable to get proc niceness",
|
||||
slog.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// We only want processes that don't have a nice value set
|
||||
// so we don't override user nice values.
|
||||
// Getpriority actually returns priority for the nice value
|
||||
// which is niceness + 20, so here 20 = a niceness of 0 (aka unset).
|
||||
if score != 20 {
|
||||
// We don't log here since it can get spammy
|
||||
continue
|
||||
}
|
||||
|
||||
err = proc.SetNiceness(a.syscaller, niceness)
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "unable to set proc niceness",
|
||||
slog.F("niceness", niceness),
|
||||
slog.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
modProcs = append(modProcs, proc)
|
||||
}
|
||||
return modProcs, nil
|
||||
}
|
||||
|
||||
// isClosed returns whether the API is closed or not.
|
||||
func (a *agent) isClosed() bool {
|
||||
select {
|
||||
@@ -1336,39 +1461,24 @@ func (a *agent) Close() error {
|
||||
}
|
||||
|
||||
lifecycleState := codersdk.WorkspaceAgentLifecycleOff
|
||||
if manifest := a.manifest.Load(); manifest != nil && manifest.ShutdownScript != "" {
|
||||
scriptDone := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(scriptDone)
|
||||
scriptDone <- a.runShutdownScript(ctx, manifest.ShutdownScript)
|
||||
}()
|
||||
|
||||
var timeout <-chan time.Time
|
||||
// If timeout is zero, an older version of the coder
|
||||
// provider was used. Otherwise a timeout is always > 0.
|
||||
if manifest.ShutdownScriptTimeout > 0 {
|
||||
t := time.NewTimer(manifest.ShutdownScriptTimeout)
|
||||
defer t.Stop()
|
||||
timeout = t.C
|
||||
}
|
||||
|
||||
var err error
|
||||
select {
|
||||
case err = <-scriptDone:
|
||||
case <-timeout:
|
||||
a.logger.Warn(ctx, "script timed out", slog.F("lifecycle", "shutdown"), slog.F("timeout", manifest.ShutdownScriptTimeout))
|
||||
a.setLifecycle(ctx, codersdk.WorkspaceAgentLifecycleShutdownTimeout)
|
||||
err = <-scriptDone // The script can still complete after a timeout.
|
||||
}
|
||||
if err != nil {
|
||||
err = a.scriptRunner.Execute(ctx, func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return script.RunOnStop
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "shutdown script(s) failed", slog.Error(err))
|
||||
if errors.Is(err, agentscripts.ErrTimeout) {
|
||||
lifecycleState = codersdk.WorkspaceAgentLifecycleShutdownTimeout
|
||||
} else {
|
||||
lifecycleState = codersdk.WorkspaceAgentLifecycleShutdownError
|
||||
}
|
||||
}
|
||||
|
||||
// Set final state and wait for it to be reported because context
|
||||
// cancellation will stop the report loop.
|
||||
a.setLifecycle(ctx, lifecycleState)
|
||||
|
||||
err = a.scriptRunner.Close()
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "script runner close", slog.Error(err))
|
||||
}
|
||||
|
||||
// Wait for the lifecycle to be reported, but don't wait forever so
|
||||
// that we don't break user expectations.
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
|
||||
+338
-174
@@ -21,10 +21,12 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
scp "github.com/bramvdbogaerde/go-scp"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pion/udp"
|
||||
"github.com/pkg/sftp"
|
||||
@@ -41,11 +43,13 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agentproc"
|
||||
"github.com/coder/coder/v2/agent/agentproc/agentproctest"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
@@ -346,13 +350,18 @@ func TestAgent_Session_TTY_MOTD(t *testing.T) {
|
||||
unexpected: []string{},
|
||||
},
|
||||
{
|
||||
name: "Trim",
|
||||
manifest: agentsdk.Manifest{},
|
||||
name: "Trim",
|
||||
// Enable motd since it will be printed after the banner,
|
||||
// this ensures that we can test for an exact mount of
|
||||
// newlines.
|
||||
manifest: agentsdk.Manifest{
|
||||
MOTDFile: name,
|
||||
},
|
||||
banner: codersdk.ServiceBannerConfig{
|
||||
Enabled: true,
|
||||
Message: "\n\n\n\n\n\nbanner\n\n\n\n\n\n",
|
||||
},
|
||||
expectedRe: regexp.MustCompile("([^\n\r]|^)banner\r\n\r\n[^\r\n]"),
|
||||
expectedRe: regexp.MustCompile(`([^\n\r]|^)banner\r\n\r\n[^\r\n]`),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -371,6 +380,7 @@ func TestAgent_Session_TTY_MOTD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:tparallel // Sub tests need to run sequentially.
|
||||
func TestAgent_Session_TTY_MOTD_Update(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
@@ -430,33 +440,38 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) {
|
||||
}
|
||||
//nolint:dogsled // Allow the blank identifiers.
|
||||
conn, client, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, setSBInterval)
|
||||
for _, test := range tests {
|
||||
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = sshClient.Close()
|
||||
})
|
||||
|
||||
//nolint:paralleltest // These tests need to swap the banner func.
|
||||
for i, test := range tests {
|
||||
test := test
|
||||
// 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) {
|
||||
select {
|
||||
case ready <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return test.banner, nil
|
||||
})
|
||||
<-ready
|
||||
<-ready // Wait for two updates to ensure the value has propagated.
|
||||
t.Run(fmt.Sprintf("%d", i), func(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) {
|
||||
select {
|
||||
case ready <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return test.banner, nil
|
||||
})
|
||||
<-ready
|
||||
<-ready // Wait for two updates to ensure the value has propagated.
|
||||
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = sshClient.Close()
|
||||
})
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = session.Close()
|
||||
})
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = session.Close()
|
||||
})
|
||||
|
||||
testSessionOutput(t, session, test.expected, test.unexpected, nil)
|
||||
testSessionOutput(t, session, test.expected, test.unexpected, nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,84 +1070,6 @@ func TestAgent_SSHConnectionEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_StartupScript(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := "something"
|
||||
command := "sh -c 'echo " + output + "'"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo " + output
|
||||
}
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
client := agenttest.NewClient(t,
|
||||
logger,
|
||||
uuid.New(),
|
||||
agentsdk.Manifest{
|
||||
StartupScript: command,
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
make(chan *agentsdk.Stats),
|
||||
tailnet.NewCoordinator(logger),
|
||||
)
|
||||
closer := agent.New(agent.Options{
|
||||
Client: client,
|
||||
Filesystem: afero.NewMemMapFs(),
|
||||
Logger: logger.Named("agent"),
|
||||
ReconnectingPTYTimeout: 0,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = closer.Close()
|
||||
})
|
||||
assert.Eventually(t, func() bool {
|
||||
got := client.GetLifecycleStates()
|
||||
return len(got) > 0 && got[len(got)-1] == codersdk.WorkspaceAgentLifecycleReady
|
||||
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||
|
||||
require.Len(t, client.GetStartupLogs(), 1)
|
||||
require.Equal(t, output, client.GetStartupLogs()[0].Output)
|
||||
})
|
||||
// This ensures that even when coderd sends back that the startup
|
||||
// script has written too many lines it will still succeed!
|
||||
t.Run("OverflowsAndSkips", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
client := agenttest.NewClient(t,
|
||||
logger,
|
||||
uuid.New(),
|
||||
agentsdk.Manifest{
|
||||
StartupScript: command,
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
make(chan *agentsdk.Stats, 50),
|
||||
tailnet.NewCoordinator(logger),
|
||||
)
|
||||
client.PatchWorkspaceLogs = func() error {
|
||||
resp := httptest.NewRecorder()
|
||||
httpapi.Write(context.Background(), resp, http.StatusRequestEntityTooLarge, codersdk.Response{
|
||||
Message: "Too many lines!",
|
||||
})
|
||||
res := resp.Result()
|
||||
defer res.Body.Close()
|
||||
return codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
closer := agent.New(agent.Options{
|
||||
Client: client,
|
||||
Filesystem: afero.NewMemMapFs(),
|
||||
Logger: logger.Named("agent"),
|
||||
ReconnectingPTYTimeout: 0,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = closer.Close()
|
||||
})
|
||||
assert.Eventually(t, func() bool {
|
||||
got := client.GetLifecycleStates()
|
||||
return len(got) > 0 && got[len(got)-1] == codersdk.WorkspaceAgentLifecycleReady
|
||||
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||
require.Len(t, client.GetStartupLogs(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgent_Metadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1140,34 +1077,43 @@ func TestAgent_Metadata(t *testing.T) {
|
||||
|
||||
t.Run("Once", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
//nolint:dogsled
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
Metadata: []codersdk.WorkspaceAgentMetadataDescription{
|
||||
{
|
||||
Key: "greeting",
|
||||
Key: "greeting1",
|
||||
Interval: 0,
|
||||
Script: echoHello,
|
||||
},
|
||||
{
|
||||
Key: "greeting2",
|
||||
Interval: 1,
|
||||
Script: echoHello,
|
||||
},
|
||||
},
|
||||
}, 0, func(_ *agenttest.Client, opts *agent.Options) {
|
||||
opts.ReportMetadataInterval = 100 * time.Millisecond
|
||||
opts.ReportMetadataInterval = testutil.IntervalFast
|
||||
})
|
||||
|
||||
var gotMd map[string]agentsdk.PostMetadataRequest
|
||||
var gotMd map[string]agentsdk.Metadata
|
||||
require.Eventually(t, func() bool {
|
||||
gotMd = client.GetMetadata()
|
||||
return len(gotMd) == 1
|
||||
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||
return len(gotMd) == 2
|
||||
}, testutil.WaitShort, testutil.IntervalFast/2)
|
||||
|
||||
collectedAt := gotMd["greeting"].CollectedAt
|
||||
collectedAt1 := gotMd["greeting1"].CollectedAt
|
||||
collectedAt2 := gotMd["greeting2"].CollectedAt
|
||||
|
||||
require.Never(t, func() bool {
|
||||
require.Eventually(t, func() bool {
|
||||
gotMd = client.GetMetadata()
|
||||
if len(gotMd) != 1 {
|
||||
if len(gotMd) != 2 {
|
||||
panic("unexpected number of metadata")
|
||||
}
|
||||
return !gotMd["greeting"].CollectedAt.Equal(collectedAt)
|
||||
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||
return !gotMd["greeting2"].CollectedAt.Equal(collectedAt2)
|
||||
}, testutil.WaitShort, testutil.IntervalFast/2)
|
||||
|
||||
require.Equal(t, gotMd["greeting1"].CollectedAt, collectedAt1, "metadata should not be collected again")
|
||||
})
|
||||
|
||||
t.Run("Many", func(t *testing.T) {
|
||||
@@ -1186,7 +1132,7 @@ func TestAgent_Metadata(t *testing.T) {
|
||||
opts.ReportMetadataInterval = testutil.IntervalFast
|
||||
})
|
||||
|
||||
var gotMd map[string]agentsdk.PostMetadataRequest
|
||||
var gotMd map[string]agentsdk.Metadata
|
||||
require.Eventually(t, func() bool {
|
||||
gotMd = client.GetMetadata()
|
||||
return len(gotMd) == 1
|
||||
@@ -1287,8 +1233,11 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
StartupScript: "sleep 3",
|
||||
StartupScriptTimeout: time.Nanosecond,
|
||||
Scripts: []codersdk.WorkspaceAgentScript{{
|
||||
Script: "sleep 3",
|
||||
Timeout: time.Millisecond,
|
||||
RunOnStart: true,
|
||||
}},
|
||||
}, 0)
|
||||
|
||||
want := []codersdk.WorkspaceAgentLifecycle{
|
||||
@@ -1309,8 +1258,11 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
StartupScript: "false",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
Scripts: []codersdk.WorkspaceAgentScript{{
|
||||
Script: "false",
|
||||
Timeout: 30 * time.Second,
|
||||
RunOnStart: true,
|
||||
}},
|
||||
}, 0)
|
||||
|
||||
want := []codersdk.WorkspaceAgentLifecycle{
|
||||
@@ -1331,8 +1283,11 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
StartupScript: "true",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
Scripts: []codersdk.WorkspaceAgentScript{{
|
||||
Script: "true",
|
||||
Timeout: 30 * time.Second,
|
||||
RunOnStart: true,
|
||||
}},
|
||||
}, 0)
|
||||
|
||||
want := []codersdk.WorkspaceAgentLifecycle{
|
||||
@@ -1353,8 +1308,11 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _, closer := setupAgent(t, agentsdk.Manifest{
|
||||
ShutdownScript: "sleep 3",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
Scripts: []codersdk.WorkspaceAgentScript{{
|
||||
Script: "sleep 3",
|
||||
Timeout: 30 * time.Second,
|
||||
RunOnStop: true,
|
||||
}},
|
||||
}, 0)
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
@@ -1391,8 +1349,11 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _, closer := setupAgent(t, agentsdk.Manifest{
|
||||
ShutdownScript: "sleep 3",
|
||||
ShutdownScriptTimeout: time.Nanosecond,
|
||||
Scripts: []codersdk.WorkspaceAgentScript{{
|
||||
Script: "sleep 3",
|
||||
Timeout: time.Millisecond,
|
||||
RunOnStop: true,
|
||||
}},
|
||||
}, 0)
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
@@ -1430,8 +1391,11 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _, closer := setupAgent(t, agentsdk.Manifest{
|
||||
ShutdownScript: "false",
|
||||
ShutdownScriptTimeout: 30 * time.Second,
|
||||
Scripts: []codersdk.WorkspaceAgentScript{{
|
||||
Script: "false",
|
||||
Timeout: 30 * time.Second,
|
||||
RunOnStop: true,
|
||||
}},
|
||||
}, 0)
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
@@ -1475,9 +1439,16 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
logger,
|
||||
uuid.New(),
|
||||
agentsdk.Manifest{
|
||||
DERPMap: derpMap,
|
||||
StartupScript: "echo 1",
|
||||
ShutdownScript: "echo " + expected,
|
||||
DERPMap: derpMap,
|
||||
Scripts: []codersdk.WorkspaceAgentScript{{
|
||||
LogPath: "coder-startup-script.log",
|
||||
Script: "echo 1",
|
||||
RunOnStart: true,
|
||||
}, {
|
||||
LogPath: "coder-shutdown-script.log",
|
||||
Script: "echo " + expected,
|
||||
RunOnStop: true,
|
||||
}},
|
||||
},
|
||||
make(chan *agentsdk.Stats, 50),
|
||||
tailnet.NewCoordinator(logger),
|
||||
@@ -1528,9 +1499,7 @@ func TestAgent_Startup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
StartupScript: "true",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
Directory: "",
|
||||
Directory: "",
|
||||
}, 0)
|
||||
assert.Eventually(t, func() bool {
|
||||
return client.GetStartup().Version != ""
|
||||
@@ -1542,9 +1511,7 @@ func TestAgent_Startup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
StartupScript: "true",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
Directory: "~",
|
||||
Directory: "~",
|
||||
}, 0)
|
||||
assert.Eventually(t, func() bool {
|
||||
return client.GetStartup().Version != ""
|
||||
@@ -1558,9 +1525,7 @@ func TestAgent_Startup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
StartupScript: "true",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
Directory: "coder/coder",
|
||||
Directory: "coder/coder",
|
||||
}, 0)
|
||||
assert.Eventually(t, func() bool {
|
||||
return client.GetStartup().Version != ""
|
||||
@@ -1574,9 +1539,7 @@ func TestAgent_Startup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
StartupScript: "true",
|
||||
StartupScriptTimeout: 30 * time.Second,
|
||||
Directory: "$HOME",
|
||||
Directory: "$HOME",
|
||||
}, 0)
|
||||
assert.Eventually(t, func() bool {
|
||||
return client.GetStartup().Version != ""
|
||||
@@ -1601,11 +1564,13 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
|
||||
_, err := exec.LookPath("screen")
|
||||
hasScreen := err == nil
|
||||
|
||||
// Make sure UTF-8 works even with LANG set to something like C.
|
||||
t.Setenv("LANG", "C")
|
||||
|
||||
for _, backendType := range backends {
|
||||
backendType := backendType
|
||||
t.Run(backendType, func(t *testing.T) {
|
||||
if backendType == "Screen" {
|
||||
t.Parallel()
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skipf("`screen` is not supported on %s", runtime.GOOS)
|
||||
} else if !hasScreen {
|
||||
@@ -1620,8 +1585,6 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
|
||||
err = os.Symlink(bashPath, filepath.Join(dir, "bash"))
|
||||
require.NoError(t, err, "symlink bash into reconnecting pty PATH")
|
||||
t.Setenv("PATH", dir)
|
||||
} else {
|
||||
t.Parallel()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
@@ -1630,26 +1593,21 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
|
||||
//nolint:dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
id := uuid.New()
|
||||
netConn1, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash")
|
||||
// --norc disables executing .bashrc, which is often used to customize the bash prompt
|
||||
netConn1, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash --norc")
|
||||
require.NoError(t, err)
|
||||
defer netConn1.Close()
|
||||
tr1 := testutil.NewTerminalReader(t, netConn1)
|
||||
|
||||
// A second simultaneous connection.
|
||||
netConn2, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash")
|
||||
netConn2, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash --norc")
|
||||
require.NoError(t, err)
|
||||
defer netConn2.Close()
|
||||
tr2 := testutil.NewTerminalReader(t, netConn2)
|
||||
|
||||
// Brief pause to reduce the likelihood that we send keystrokes while
|
||||
// the shell is simultaneously sending a prompt.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
|
||||
Data: "echo test\r\n",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = netConn1.Write(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
matchPrompt := func(line string) bool {
|
||||
return strings.Contains(line, "$ ") || strings.Contains(line, "# ")
|
||||
}
|
||||
matchEchoCommand := func(line string) bool {
|
||||
return strings.Contains(line, "echo test")
|
||||
}
|
||||
@@ -1663,47 +1621,72 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
|
||||
return strings.Contains(line, "exit") || strings.Contains(line, "logout")
|
||||
}
|
||||
|
||||
// Wait for the prompt before writing commands. If the command arrives before the prompt is written, screen
|
||||
// will sometimes put the command output on the same line as the command and the test will flake
|
||||
require.NoError(t, tr1.ReadUntil(ctx, matchPrompt), "find prompt")
|
||||
require.NoError(t, tr2.ReadUntil(ctx, matchPrompt), "find prompt")
|
||||
|
||||
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
|
||||
Data: "echo test\r",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = netConn1.Write(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Once for typing the command...
|
||||
require.NoError(t, testutil.ReadUntil(ctx, t, netConn1, matchEchoCommand), "find echo command")
|
||||
require.NoError(t, tr1.ReadUntil(ctx, matchEchoCommand), "find echo command")
|
||||
// And another time for the actual output.
|
||||
require.NoError(t, testutil.ReadUntil(ctx, t, netConn1, matchEchoOutput), "find echo output")
|
||||
require.NoError(t, tr1.ReadUntil(ctx, matchEchoOutput), "find echo output")
|
||||
|
||||
// Same for the other connection.
|
||||
require.NoError(t, testutil.ReadUntil(ctx, t, netConn2, matchEchoCommand), "find echo command")
|
||||
require.NoError(t, testutil.ReadUntil(ctx, t, netConn2, matchEchoOutput), "find echo output")
|
||||
require.NoError(t, tr2.ReadUntil(ctx, matchEchoCommand), "find echo command")
|
||||
require.NoError(t, tr2.ReadUntil(ctx, matchEchoOutput), "find echo output")
|
||||
|
||||
_ = netConn1.Close()
|
||||
_ = netConn2.Close()
|
||||
netConn3, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash")
|
||||
netConn3, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash --norc")
|
||||
require.NoError(t, err)
|
||||
defer netConn3.Close()
|
||||
tr3 := testutil.NewTerminalReader(t, netConn3)
|
||||
|
||||
// Same output again!
|
||||
require.NoError(t, testutil.ReadUntil(ctx, t, netConn3, matchEchoCommand), "find echo command")
|
||||
require.NoError(t, testutil.ReadUntil(ctx, t, netConn3, matchEchoOutput), "find echo output")
|
||||
require.NoError(t, tr3.ReadUntil(ctx, matchEchoCommand), "find echo command")
|
||||
require.NoError(t, tr3.ReadUntil(ctx, matchEchoOutput), "find echo output")
|
||||
|
||||
// Exit should cause the connection to close.
|
||||
data, err = json.Marshal(codersdk.ReconnectingPTYRequest{
|
||||
Data: "exit\r\n",
|
||||
Data: "exit\r",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = netConn3.Write(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Once for the input and again for the output.
|
||||
require.NoError(t, testutil.ReadUntil(ctx, t, netConn3, matchExitCommand), "find exit command")
|
||||
require.NoError(t, testutil.ReadUntil(ctx, t, netConn3, matchExitOutput), "find exit output")
|
||||
require.NoError(t, tr3.ReadUntil(ctx, matchExitCommand), "find exit command")
|
||||
require.NoError(t, tr3.ReadUntil(ctx, matchExitOutput), "find exit output")
|
||||
|
||||
// Wait for the connection to close.
|
||||
require.ErrorIs(t, testutil.ReadUntil(ctx, t, netConn3, nil), io.EOF)
|
||||
require.ErrorIs(t, tr3.ReadUntil(ctx, nil), io.EOF)
|
||||
|
||||
// Try a non-shell command. It should output then immediately exit.
|
||||
netConn4, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "echo test")
|
||||
require.NoError(t, err)
|
||||
defer netConn4.Close()
|
||||
|
||||
require.NoError(t, testutil.ReadUntil(ctx, t, netConn4, matchEchoOutput), "find echo output")
|
||||
require.ErrorIs(t, testutil.ReadUntil(ctx, t, netConn3, nil), io.EOF)
|
||||
tr4 := testutil.NewTerminalReader(t, netConn4)
|
||||
require.NoError(t, tr4.ReadUntil(ctx, matchEchoOutput), "find echo output")
|
||||
require.ErrorIs(t, tr4.ReadUntil(ctx, nil), io.EOF)
|
||||
|
||||
// Ensure that UTF-8 is supported. Avoid the terminal emulator because it
|
||||
// does not appear to support UTF-8, just make sure the bytes that come
|
||||
// back have the character in it.
|
||||
netConn5, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "echo ❯")
|
||||
require.NoError(t, err)
|
||||
defer netConn5.Close()
|
||||
|
||||
bytes, err := io.ReadAll(netConn5)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(bytes), "❯")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1898,13 +1881,16 @@ func TestAgent_UpdatedDERP(t *testing.T) {
|
||||
func TestAgent_Speedtest(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Skip("This test is relatively flakey because of Tailscale's speedtest code...")
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
|
||||
//nolint:dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
DERPMap: derpMap,
|
||||
}, 0)
|
||||
}, 0, func(client *agenttest.Client, options *agent.Options) {
|
||||
options.Logger = logger.Named("agent")
|
||||
})
|
||||
defer conn.Close()
|
||||
res, err := conn.Speedtest(ctx, speedtest.Upload, 250*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
@@ -2395,6 +2381,173 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAgent_ManageProcessPriority(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("Skipping non-linux environment")
|
||||
}
|
||||
|
||||
var (
|
||||
expectedProcs = map[int32]agentproc.Process{}
|
||||
fs = afero.NewMemMapFs()
|
||||
syscaller = agentproctest.NewMockSyscaller(gomock.NewController(t))
|
||||
ticker = make(chan time.Time)
|
||||
modProcs = make(chan []*agentproc.Process)
|
||||
logger = slog.Make(sloghuman.Sink(io.Discard))
|
||||
)
|
||||
|
||||
// Create some processes.
|
||||
for i := 0; i < 4; i++ {
|
||||
// Create a prioritized process. This process should
|
||||
// have it's oom_score_adj set to -500 and its nice
|
||||
// score should be untouched.
|
||||
var proc agentproc.Process
|
||||
if i == 0 {
|
||||
proc = agentproctest.GenerateProcess(t, fs,
|
||||
func(p *agentproc.Process) {
|
||||
p.CmdLine = "./coder\x00agent\x00--no-reap"
|
||||
p.PID = int32(i)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
proc = agentproctest.GenerateProcess(t, fs,
|
||||
func(p *agentproc.Process) {
|
||||
// Make the cmd something similar to a prioritized
|
||||
// process but differentiate the arguments.
|
||||
p.CmdLine = "./coder\x00stat"
|
||||
},
|
||||
)
|
||||
|
||||
syscaller.EXPECT().SetPriority(proc.PID, 10).Return(nil)
|
||||
syscaller.EXPECT().GetPriority(proc.PID).Return(20, nil)
|
||||
}
|
||||
syscaller.EXPECT().
|
||||
Kill(proc.PID, syscall.Signal(0)).
|
||||
Return(nil)
|
||||
|
||||
expectedProcs[proc.PID] = proc
|
||||
}
|
||||
|
||||
_, _, _, _, _ = setupAgent(t, agentsdk.Manifest{}, 0, func(c *agenttest.Client, o *agent.Options) {
|
||||
o.Syscaller = syscaller
|
||||
o.ModifiedProcesses = modProcs
|
||||
o.EnvironmentVariables = map[string]string{agent.EnvProcPrioMgmt: "1"}
|
||||
o.Filesystem = fs
|
||||
o.Logger = logger
|
||||
o.ProcessManagementTick = ticker
|
||||
})
|
||||
actualProcs := <-modProcs
|
||||
require.Len(t, actualProcs, len(expectedProcs)-1)
|
||||
})
|
||||
|
||||
t.Run("IgnoreCustomNice", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("Skipping non-linux environment")
|
||||
}
|
||||
|
||||
var (
|
||||
expectedProcs = map[int32]agentproc.Process{}
|
||||
fs = afero.NewMemMapFs()
|
||||
ticker = make(chan time.Time)
|
||||
syscaller = agentproctest.NewMockSyscaller(gomock.NewController(t))
|
||||
modProcs = make(chan []*agentproc.Process)
|
||||
logger = slog.Make(sloghuman.Sink(io.Discard))
|
||||
)
|
||||
|
||||
// Create some processes.
|
||||
for i := 0; i < 2; i++ {
|
||||
proc := agentproctest.GenerateProcess(t, fs)
|
||||
syscaller.EXPECT().
|
||||
Kill(proc.PID, syscall.Signal(0)).
|
||||
Return(nil)
|
||||
|
||||
if i == 0 {
|
||||
// Set a random nice score. This one should not be adjusted by
|
||||
// our management loop.
|
||||
syscaller.EXPECT().GetPriority(proc.PID).Return(25, nil)
|
||||
} else {
|
||||
syscaller.EXPECT().GetPriority(proc.PID).Return(20, nil)
|
||||
syscaller.EXPECT().SetPriority(proc.PID, 10).Return(nil)
|
||||
}
|
||||
|
||||
expectedProcs[proc.PID] = proc
|
||||
}
|
||||
|
||||
_, _, _, _, _ = setupAgent(t, agentsdk.Manifest{}, 0, func(c *agenttest.Client, o *agent.Options) {
|
||||
o.Syscaller = syscaller
|
||||
o.ModifiedProcesses = modProcs
|
||||
o.EnvironmentVariables = map[string]string{agent.EnvProcPrioMgmt: "1"}
|
||||
o.Filesystem = fs
|
||||
o.Logger = logger
|
||||
o.ProcessManagementTick = ticker
|
||||
})
|
||||
actualProcs := <-modProcs
|
||||
// We should ignore the process with a custom nice score.
|
||||
require.Len(t, actualProcs, 1)
|
||||
})
|
||||
|
||||
t.Run("DisabledByDefault", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("Skipping non-linux environment")
|
||||
}
|
||||
|
||||
var (
|
||||
buf bytes.Buffer
|
||||
wr = &syncWriter{
|
||||
w: &buf,
|
||||
}
|
||||
)
|
||||
log := slog.Make(sloghuman.Sink(wr)).Leveled(slog.LevelDebug)
|
||||
|
||||
_, _, _, _, _ = setupAgent(t, agentsdk.Manifest{}, 0, func(c *agenttest.Client, o *agent.Options) {
|
||||
o.Logger = log
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
wr.mu.Lock()
|
||||
defer wr.mu.Unlock()
|
||||
return strings.Contains(buf.String(), "process priority not enabled")
|
||||
}, testutil.WaitLong, testutil.IntervalFast)
|
||||
})
|
||||
|
||||
t.Run("DisabledForNonLinux", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
t.Skip("Skipping linux environment")
|
||||
}
|
||||
|
||||
var (
|
||||
buf bytes.Buffer
|
||||
wr = &syncWriter{
|
||||
w: &buf,
|
||||
}
|
||||
)
|
||||
log := slog.Make(sloghuman.Sink(wr)).Leveled(slog.LevelDebug)
|
||||
|
||||
_, _, _, _, _ = setupAgent(t, agentsdk.Manifest{}, 0, func(c *agenttest.Client, o *agent.Options) {
|
||||
o.Logger = log
|
||||
// Try to enable it so that we can assert that non-linux
|
||||
// environments are truly disabled.
|
||||
o.EnvironmentVariables = map[string]string{agent.EnvProcPrioMgmt: "1"}
|
||||
})
|
||||
require.Eventually(t, func() bool {
|
||||
wr.mu.Lock()
|
||||
defer wr.mu.Unlock()
|
||||
|
||||
return strings.Contains(buf.String(), "process priority not enabled")
|
||||
}, testutil.WaitLong, testutil.IntervalFast)
|
||||
})
|
||||
}
|
||||
|
||||
func verifyCollectedMetrics(t *testing.T, expected []agentsdk.AgentMetric, actual []*promgo.MetricFamily) bool {
|
||||
t.Helper()
|
||||
|
||||
@@ -2416,3 +2569,14 @@ func verifyCollectedMetrics(t *testing.T, expected []agentsdk.AgentMetric, actua
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type syncWriter struct {
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func (s *syncWriter) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.w.Write(p)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package agentproctest contains utility functions
|
||||
// for testing process management in the agent.
|
||||
package agentproctest
|
||||
|
||||
//go:generate mockgen -destination ./syscallermock.go -package agentproctest github.com/coder/coder/v2/agent/agentproc Syscaller
|
||||
@@ -0,0 +1,49 @@
|
||||
package agentproctest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentproc"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
func GenerateProcess(t *testing.T, fs afero.Fs, muts ...func(*agentproc.Process)) agentproc.Process {
|
||||
t.Helper()
|
||||
|
||||
pid, err := cryptorand.Intn(1<<31 - 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
arg1, err := cryptorand.String(5)
|
||||
require.NoError(t, err)
|
||||
|
||||
arg2, err := cryptorand.String(5)
|
||||
require.NoError(t, err)
|
||||
|
||||
arg3, err := cryptorand.String(5)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmdline := fmt.Sprintf("%s\x00%s\x00%s", arg1, arg2, arg3)
|
||||
|
||||
process := agentproc.Process{
|
||||
CmdLine: cmdline,
|
||||
PID: int32(pid),
|
||||
}
|
||||
|
||||
for _, mut := range muts {
|
||||
mut(&process)
|
||||
}
|
||||
|
||||
process.Dir = fmt.Sprintf("%s/%d", "/proc", process.PID)
|
||||
|
||||
err = fs.MkdirAll(process.Dir, 0o555)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = afero.WriteFile(fs, fmt.Sprintf("%s/cmdline", process.Dir), []byte(process.CmdLine), 0o444)
|
||||
require.NoError(t, err)
|
||||
|
||||
return process
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/coder/coder/v2/agent/agentproc (interfaces: Syscaller)
|
||||
|
||||
// Package agentproctest is a generated GoMock package.
|
||||
package agentproctest
|
||||
|
||||
import (
|
||||
reflect "reflect"
|
||||
syscall "syscall"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
)
|
||||
|
||||
// MockSyscaller is a mock of Syscaller interface.
|
||||
type MockSyscaller struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockSyscallerMockRecorder
|
||||
}
|
||||
|
||||
// MockSyscallerMockRecorder is the mock recorder for MockSyscaller.
|
||||
type MockSyscallerMockRecorder struct {
|
||||
mock *MockSyscaller
|
||||
}
|
||||
|
||||
// NewMockSyscaller creates a new mock instance.
|
||||
func NewMockSyscaller(ctrl *gomock.Controller) *MockSyscaller {
|
||||
mock := &MockSyscaller{ctrl: ctrl}
|
||||
mock.recorder = &MockSyscallerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockSyscaller) EXPECT() *MockSyscallerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetPriority mocks base method.
|
||||
func (m *MockSyscaller) GetPriority(arg0 int32) (int, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPriority", arg0)
|
||||
ret0, _ := ret[0].(int)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetPriority indicates an expected call of GetPriority.
|
||||
func (mr *MockSyscallerMockRecorder) GetPriority(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriority", reflect.TypeOf((*MockSyscaller)(nil).GetPriority), arg0)
|
||||
}
|
||||
|
||||
// Kill mocks base method.
|
||||
func (m *MockSyscaller) Kill(arg0 int32, arg1 syscall.Signal) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Kill", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Kill indicates an expected call of Kill.
|
||||
func (mr *MockSyscallerMockRecorder) Kill(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kill", reflect.TypeOf((*MockSyscaller)(nil).Kill), arg0, arg1)
|
||||
}
|
||||
|
||||
// SetPriority mocks base method.
|
||||
func (m *MockSyscaller) SetPriority(arg0 int32, arg1 int) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SetPriority", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SetPriority indicates an expected call of SetPriority.
|
||||
func (mr *MockSyscallerMockRecorder) SetPriority(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriority", reflect.TypeOf((*MockSyscaller)(nil).SetPriority), arg0, arg1)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package agentproc contains logic for interfacing with local
|
||||
// processes running in the same context as the agent.
|
||||
package agentproc
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !linux
|
||||
// +build !linux
|
||||
|
||||
package agentproc
|
||||
|
||||
import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func (p *Process) Niceness(sc Syscaller) (int, error) {
|
||||
return 0, errUnimplemented
|
||||
}
|
||||
|
||||
func (p *Process) SetNiceness(sc Syscaller, score int) error {
|
||||
return errUnimplemented
|
||||
}
|
||||
|
||||
func (p *Process) Cmd() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) {
|
||||
return nil, errUnimplemented
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package agentproc_test
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentproc"
|
||||
"github.com/coder/coder/v2/agent/agentproc/agentproctest"
|
||||
)
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skipf("skipping non-linux environment")
|
||||
}
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
fs = afero.NewMemMapFs()
|
||||
sc = agentproctest.NewMockSyscaller(gomock.NewController(t))
|
||||
expectedProcs = make(map[int32]agentproc.Process)
|
||||
)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
proc := agentproctest.GenerateProcess(t, fs)
|
||||
expectedProcs[proc.PID] = proc
|
||||
|
||||
sc.EXPECT().
|
||||
Kill(proc.PID, syscall.Signal(0)).
|
||||
Return(nil)
|
||||
}
|
||||
|
||||
actualProcs, err := agentproc.List(fs, sc)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, actualProcs, len(expectedProcs))
|
||||
for _, proc := range actualProcs {
|
||||
expected, ok := expectedProcs[proc.PID]
|
||||
require.True(t, ok)
|
||||
require.Equal(t, expected.PID, proc.PID)
|
||||
require.Equal(t, expected.CmdLine, proc.CmdLine)
|
||||
require.Equal(t, expected.Dir, proc.Dir)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FinishedProcess", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
fs = afero.NewMemMapFs()
|
||||
sc = agentproctest.NewMockSyscaller(gomock.NewController(t))
|
||||
expectedProcs = make(map[int32]agentproc.Process)
|
||||
)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
proc := agentproctest.GenerateProcess(t, fs)
|
||||
expectedProcs[proc.PID] = proc
|
||||
|
||||
sc.EXPECT().
|
||||
Kill(proc.PID, syscall.Signal(0)).
|
||||
Return(nil)
|
||||
}
|
||||
|
||||
// Create a process that's already finished. We're not adding
|
||||
// it to the map because it should be skipped over.
|
||||
proc := agentproctest.GenerateProcess(t, fs)
|
||||
sc.EXPECT().
|
||||
Kill(proc.PID, syscall.Signal(0)).
|
||||
Return(xerrors.New("os: process already finished"))
|
||||
|
||||
actualProcs, err := agentproc.List(fs, sc)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, actualProcs, len(expectedProcs))
|
||||
for _, proc := range actualProcs {
|
||||
expected, ok := expectedProcs[proc.PID]
|
||||
require.True(t, ok)
|
||||
require.Equal(t, expected.PID, proc.PID)
|
||||
require.Equal(t, expected.CmdLine, proc.CmdLine)
|
||||
require.Equal(t, expected.Dir, proc.Dir)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoSuchProcess", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
fs = afero.NewMemMapFs()
|
||||
sc = agentproctest.NewMockSyscaller(gomock.NewController(t))
|
||||
expectedProcs = make(map[int32]agentproc.Process)
|
||||
)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
proc := agentproctest.GenerateProcess(t, fs)
|
||||
expectedProcs[proc.PID] = proc
|
||||
|
||||
sc.EXPECT().
|
||||
Kill(proc.PID, syscall.Signal(0)).
|
||||
Return(nil)
|
||||
}
|
||||
|
||||
// Create a process that doesn't exist. We're not adding
|
||||
// it to the map because it should be skipped over.
|
||||
proc := agentproctest.GenerateProcess(t, fs)
|
||||
sc.EXPECT().
|
||||
Kill(proc.PID, syscall.Signal(0)).
|
||||
Return(syscall.ESRCH)
|
||||
|
||||
actualProcs, err := agentproc.List(fs, sc)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, actualProcs, len(expectedProcs))
|
||||
for _, proc := range actualProcs {
|
||||
expected, ok := expectedProcs[proc.PID]
|
||||
require.True(t, ok)
|
||||
require.Equal(t, expected.PID, proc.PID)
|
||||
require.Equal(t, expected.CmdLine, proc.CmdLine)
|
||||
require.Equal(t, expected.Dir, proc.Dir)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// These tests are not very interesting but they provide some modicum of
|
||||
// confidence.
|
||||
func TestProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skipf("skipping non-linux environment")
|
||||
}
|
||||
|
||||
t.Run("SetNiceness", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
sc = agentproctest.NewMockSyscaller(gomock.NewController(t))
|
||||
proc = &agentproc.Process{
|
||||
PID: 32,
|
||||
}
|
||||
score = 20
|
||||
)
|
||||
|
||||
sc.EXPECT().SetPriority(proc.PID, score).Return(nil)
|
||||
err := proc.SetNiceness(sc, score)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Cmd", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
proc = &agentproc.Process{
|
||||
CmdLine: "helloworld\x00--arg1\x00--arg2",
|
||||
}
|
||||
expectedName = "helloworld --arg1 --arg2"
|
||||
)
|
||||
|
||||
require.Equal(t, expectedName, proc.Cmd())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package agentproc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) {
|
||||
d, err := fs.Open(defaultProcDir)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("open dir %q: %w", defaultProcDir, err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
entries, err := d.Readdirnames(0)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("readdirnames: %w", err)
|
||||
}
|
||||
|
||||
processes := make([]*Process, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
pid, err := strconv.ParseInt(entry, 10, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check that the process still exists.
|
||||
exists, err := isProcessExist(syscaller, int32(pid))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("check process exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
cmdline, err := afero.ReadFile(fs, filepath.Join(defaultProcDir, entry, "cmdline"))
|
||||
if err != nil {
|
||||
var errNo syscall.Errno
|
||||
if xerrors.As(err, &errNo) && errNo == syscall.EPERM {
|
||||
continue
|
||||
}
|
||||
return nil, xerrors.Errorf("read cmdline: %w", err)
|
||||
}
|
||||
processes = append(processes, &Process{
|
||||
PID: int32(pid),
|
||||
CmdLine: string(cmdline),
|
||||
Dir: filepath.Join(defaultProcDir, entry),
|
||||
})
|
||||
}
|
||||
|
||||
return processes, nil
|
||||
}
|
||||
|
||||
func isProcessExist(syscaller Syscaller, pid int32) (bool, error) {
|
||||
err := syscaller.Kill(pid, syscall.Signal(0))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if err.Error() == "os: process already finished" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var errno syscall.Errno
|
||||
if !errors.As(err, &errno) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
switch errno {
|
||||
case syscall.ESRCH:
|
||||
return false, nil
|
||||
case syscall.EPERM:
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, xerrors.Errorf("kill: %w", err)
|
||||
}
|
||||
|
||||
func (p *Process) Niceness(sc Syscaller) (int, error) {
|
||||
nice, err := sc.GetPriority(p.PID)
|
||||
if err != nil {
|
||||
return 0, xerrors.Errorf("get priority for %q: %w", p.CmdLine, err)
|
||||
}
|
||||
return nice, nil
|
||||
}
|
||||
|
||||
func (p *Process) SetNiceness(sc Syscaller, score int) error {
|
||||
err := sc.SetPriority(p.PID, score)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("set priority for %q: %w", p.CmdLine, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Process) Cmd() string {
|
||||
return strings.Join(p.cmdLine(), " ")
|
||||
}
|
||||
|
||||
func (p *Process) cmdLine() []string {
|
||||
return strings.Split(p.CmdLine, "\x00")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package agentproc
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type Syscaller interface {
|
||||
SetPriority(pid int32, priority int) error
|
||||
GetPriority(pid int32) (int, error)
|
||||
Kill(pid int32, sig syscall.Signal) error
|
||||
}
|
||||
|
||||
const defaultProcDir = "/proc"
|
||||
|
||||
type Process struct {
|
||||
Dir string
|
||||
CmdLine string
|
||||
PID int32
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build !linux
|
||||
// +build !linux
|
||||
|
||||
package agentproc
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func NewSyscaller() Syscaller {
|
||||
return nopSyscaller{}
|
||||
}
|
||||
|
||||
var errUnimplemented = xerrors.New("unimplemented")
|
||||
|
||||
type nopSyscaller struct{}
|
||||
|
||||
func (nopSyscaller) SetPriority(pid int32, priority int) error {
|
||||
return errUnimplemented
|
||||
}
|
||||
|
||||
func (nopSyscaller) GetPriority(pid int32) (int, error) {
|
||||
return 0, errUnimplemented
|
||||
}
|
||||
|
||||
func (nopSyscaller) Kill(pid int32, sig syscall.Signal) error {
|
||||
return errUnimplemented
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package agentproc
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func NewSyscaller() Syscaller {
|
||||
return UnixSyscaller{}
|
||||
}
|
||||
|
||||
type UnixSyscaller struct{}
|
||||
|
||||
func (UnixSyscaller) SetPriority(pid int32, nice int) error {
|
||||
err := unix.Setpriority(unix.PRIO_PROCESS, int(pid), nice)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("set priority: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (UnixSyscaller) GetPriority(pid int32) (int, error) {
|
||||
nice, err := unix.Getpriority(0, int(pid))
|
||||
if err != nil {
|
||||
return 0, xerrors.Errorf("get priority: %w", err)
|
||||
}
|
||||
return nice, nil
|
||||
}
|
||||
|
||||
func (UnixSyscaller) Kill(pid int32, sig syscall.Signal) error {
|
||||
err := syscall.Kill(int(pid), sig)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("kill: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package agentscripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrTimeout is returned when a script times out.
|
||||
ErrTimeout = xerrors.New("script timed out")
|
||||
// ErrOutputPipesOpen is returned when a script exits leaving the output
|
||||
// pipe(s) (stdout, stderr) open. This happens because we set WaitDelay on
|
||||
// the command, which gives us two things:
|
||||
//
|
||||
// 1. The ability to ensure that a script exits (this is important for e.g.
|
||||
// blocking login, and avoiding doing so indefinitely)
|
||||
// 2. Improved command cancellation on timeout
|
||||
ErrOutputPipesOpen = xerrors.New("script exited without closing output pipes")
|
||||
|
||||
parser = cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.DowOptional)
|
||||
)
|
||||
|
||||
// Options are a set of options for the runner.
|
||||
type Options struct {
|
||||
LogDir string
|
||||
Logger slog.Logger
|
||||
SSHServer *agentssh.Server
|
||||
Filesystem afero.Fs
|
||||
PatchLogs func(ctx context.Context, req agentsdk.PatchLogs) error
|
||||
}
|
||||
|
||||
// New creates a runner for the provided scripts.
|
||||
func New(opts Options) *Runner {
|
||||
cronCtx, cronCtxCancel := context.WithCancel(context.Background())
|
||||
return &Runner{
|
||||
Options: opts,
|
||||
cronCtx: cronCtx,
|
||||
cronCtxCancel: cronCtxCancel,
|
||||
cron: cron.New(cron.WithParser(parser)),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
Options
|
||||
|
||||
cronCtx context.Context
|
||||
cronCtxCancel context.CancelFunc
|
||||
cmdCloseWait sync.WaitGroup
|
||||
closed chan struct{}
|
||||
closeMutex sync.Mutex
|
||||
cron *cron.Cron
|
||||
initialized atomic.Bool
|
||||
scripts []codersdk.WorkspaceAgentScript
|
||||
}
|
||||
|
||||
// Init initializes the runner with the provided scripts.
|
||||
// It also schedules any scripts that have a schedule.
|
||||
// This function must be called before Execute.
|
||||
func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript) error {
|
||||
if r.initialized.Load() {
|
||||
return xerrors.New("init: already initialized")
|
||||
}
|
||||
r.initialized.Store(true)
|
||||
r.scripts = scripts
|
||||
r.Logger.Info(r.cronCtx, "initializing agent scripts", slog.F("script_count", len(scripts)), slog.F("log_dir", r.LogDir))
|
||||
|
||||
for _, script := range scripts {
|
||||
if script.Cron == "" {
|
||||
continue
|
||||
}
|
||||
script := script
|
||||
_, err := r.cron.AddFunc(script.Cron, func() {
|
||||
err := r.run(r.cronCtx, script)
|
||||
if err != nil {
|
||||
r.Logger.Warn(context.Background(), "run agent script on schedule", slog.Error(err))
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("add schedule: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartCron starts the cron scheduler.
|
||||
// This is done async to allow for the caller to execute scripts prior.
|
||||
func (r *Runner) StartCron() {
|
||||
// cron.Start() and cron.Stop() does not guarantee that the cron goroutine
|
||||
// has exited by the time the `cron.Stop()` context returns, so we need to
|
||||
// track it manually.
|
||||
err := r.trackCommandGoroutine(func() {
|
||||
r.cron.Run()
|
||||
})
|
||||
if err != nil {
|
||||
r.Logger.Warn(context.Background(), "start cron failed", slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Execute runs a set of scripts according to a filter.
|
||||
func (r *Runner) Execute(ctx context.Context, filter func(script codersdk.WorkspaceAgentScript) bool) error {
|
||||
if filter == nil {
|
||||
// Execute em' all!
|
||||
filter = func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
var eg errgroup.Group
|
||||
for _, script := range r.scripts {
|
||||
if !filter(script) {
|
||||
continue
|
||||
}
|
||||
script := script
|
||||
eg.Go(func() error {
|
||||
err := r.run(ctx, script)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run agent script %q: %w", script.LogSourceID, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return eg.Wait()
|
||||
}
|
||||
|
||||
// run executes the provided script with the timeout.
|
||||
// If the timeout is exceeded, the process is sent an interrupt signal.
|
||||
// If the process does not exit after a few seconds, it is forcefully killed.
|
||||
// This function immediately returns after a timeout, and does not wait for the process to exit.
|
||||
func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript) error {
|
||||
logPath := script.LogPath
|
||||
if logPath == "" {
|
||||
logPath = fmt.Sprintf("coder-script-%s.log", script.LogSourceID)
|
||||
}
|
||||
if logPath[0] == '~' {
|
||||
// First we check the environment.
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("current user: %w", err)
|
||||
}
|
||||
homeDir = u.HomeDir
|
||||
}
|
||||
logPath = filepath.Join(homeDir, logPath[1:])
|
||||
}
|
||||
logPath = os.ExpandEnv(logPath)
|
||||
if !filepath.IsAbs(logPath) {
|
||||
logPath = filepath.Join(r.LogDir, logPath)
|
||||
}
|
||||
logger := r.Logger.With(slog.F("log_path", logPath))
|
||||
logger.Info(ctx, "running agent script", slog.F("script", script.Script))
|
||||
|
||||
fileWriter, err := r.Filesystem.OpenFile(logPath, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("open %s script log file: %w", logPath, err)
|
||||
}
|
||||
defer func() {
|
||||
err := fileWriter.Close()
|
||||
if err != nil {
|
||||
logger.Warn(ctx, fmt.Sprintf("close %s script log file", logPath), slog.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
cmdCtx := ctx
|
||||
if script.Timeout > 0 {
|
||||
var ctxCancel context.CancelFunc
|
||||
cmdCtx, ctxCancel = context.WithTimeout(ctx, script.Timeout)
|
||||
defer ctxCancel()
|
||||
}
|
||||
cmdPty, err := r.SSHServer.CreateCommand(cmdCtx, script.Script, nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("%s script: create command: %w", logPath, err)
|
||||
}
|
||||
cmd = cmdPty.AsExec()
|
||||
cmd.SysProcAttr = cmdSysProcAttr()
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
cmd.Cancel = cmdCancel(cmd)
|
||||
|
||||
send, flushAndClose := agentsdk.LogsSender(script.LogSourceID, r.PatchLogs, logger)
|
||||
// If ctx is canceled here (or in a writer below), we may be
|
||||
// discarding logs, but that's okay because we're shutting down
|
||||
// anyway. We could consider creating a new context here if we
|
||||
// want better control over flush during shutdown.
|
||||
defer func() {
|
||||
if err := flushAndClose(ctx); err != nil {
|
||||
logger.Warn(ctx, "flush startup logs failed", slog.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
infoW := agentsdk.LogsWriter(ctx, send, script.LogSourceID, codersdk.LogLevelInfo)
|
||||
defer infoW.Close()
|
||||
errW := agentsdk.LogsWriter(ctx, send, script.LogSourceID, codersdk.LogLevelError)
|
||||
defer errW.Close()
|
||||
cmd.Stdout = io.MultiWriter(fileWriter, infoW)
|
||||
cmd.Stderr = io.MultiWriter(fileWriter, errW)
|
||||
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
end := time.Now()
|
||||
execTime := end.Sub(start)
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
exitCode = 255 // Unknown status.
|
||||
var exitError *exec.ExitError
|
||||
if xerrors.As(err, &exitError) {
|
||||
exitCode = exitError.ExitCode()
|
||||
}
|
||||
logger.Warn(ctx, fmt.Sprintf("%s script failed", logPath), slog.F("execution_time", execTime), slog.F("exit_code", exitCode), slog.Error(err))
|
||||
} else {
|
||||
logger.Info(ctx, fmt.Sprintf("%s script completed", logPath), slog.F("execution_time", execTime), slog.F("exit_code", exitCode))
|
||||
}
|
||||
}()
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return ErrTimeout
|
||||
}
|
||||
return xerrors.Errorf("%s script: start command: %w", logPath, err)
|
||||
}
|
||||
|
||||
cmdDone := make(chan error, 1)
|
||||
err = r.trackCommandGoroutine(func() {
|
||||
cmdDone <- cmd.Wait()
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("%s script: track command goroutine: %w", logPath, err)
|
||||
}
|
||||
select {
|
||||
case <-cmdCtx.Done():
|
||||
// Wait for the command to drain!
|
||||
select {
|
||||
case <-cmdDone:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
err = cmdCtx.Err()
|
||||
case err = <-cmdDone:
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, exec.ErrWaitDelay):
|
||||
err = ErrOutputPipesOpen
|
||||
message := fmt.Sprintf("script exited successfully, but output pipes were not closed after %s", cmd.WaitDelay)
|
||||
details := fmt.Sprint(
|
||||
"This usually means a child process was started with references to stdout or stderr. As a result, this " +
|
||||
"process may now have been terminated. Consider redirecting the output or using a separate " +
|
||||
"\"coder_script\" for the process, see " +
|
||||
"https://coder.com/docs/v2/latest/templates/troubleshooting#startup-script-issues for more information.",
|
||||
)
|
||||
// Inform the user by propagating the message via log writers.
|
||||
_, _ = fmt.Fprintf(cmd.Stderr, "WARNING: %s. %s\n", message, details)
|
||||
// Also log to agent logs for ease of debugging.
|
||||
r.Logger.Warn(ctx, message, slog.F("details", details), slog.Error(err))
|
||||
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
err = ErrTimeout
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Runner) Close() error {
|
||||
r.closeMutex.Lock()
|
||||
defer r.closeMutex.Unlock()
|
||||
if r.isClosed() {
|
||||
return nil
|
||||
}
|
||||
close(r.closed)
|
||||
r.cronCtxCancel()
|
||||
<-r.cron.Stop().Done()
|
||||
r.cmdCloseWait.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) trackCommandGoroutine(fn func()) error {
|
||||
r.closeMutex.Lock()
|
||||
defer r.closeMutex.Unlock()
|
||||
if r.isClosed() {
|
||||
return xerrors.New("track command goroutine: closed")
|
||||
}
|
||||
r.cmdCloseWait.Add(1)
|
||||
go func() {
|
||||
defer r.cmdCloseWait.Done()
|
||||
fn()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) isClosed() bool {
|
||||
select {
|
||||
case <-r.closed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package agentscripts
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func cmdSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func cmdCancel(cmd *exec.Cmd) func() error {
|
||||
return func() error {
|
||||
return syscall.Kill(-cmd.Process.Pid, syscall.SIGHUP)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package agentscripts_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/atomic"
|
||||
"go.uber.org/goleak"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentscripts"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
func TestExecuteBasic(t *testing.T) {
|
||||
t.Parallel()
|
||||
logs := make(chan agentsdk.PatchLogs, 1)
|
||||
runner := setup(t, func(ctx context.Context, req agentsdk.PatchLogs) error {
|
||||
logs <- req
|
||||
return nil
|
||||
})
|
||||
defer runner.Close()
|
||||
err := runner.Init([]codersdk.WorkspaceAgentScript{{
|
||||
Script: "echo hello",
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, runner.Execute(context.Background(), func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return true
|
||||
}))
|
||||
log := <-logs
|
||||
require.Equal(t, "hello", log.Logs[0].Output)
|
||||
}
|
||||
|
||||
func TestTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := setup(t, nil)
|
||||
defer runner.Close()
|
||||
err := runner.Init([]codersdk.WorkspaceAgentScript{{
|
||||
Script: "sleep infinity",
|
||||
Timeout: time.Millisecond,
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.ErrorIs(t, runner.Execute(context.Background(), nil), agentscripts.ErrTimeout)
|
||||
}
|
||||
|
||||
func setup(t *testing.T, patchLogs func(ctx context.Context, req agentsdk.PatchLogs) error) *agentscripts.Runner {
|
||||
t.Helper()
|
||||
if patchLogs == nil {
|
||||
// noop
|
||||
patchLogs = func(ctx context.Context, req agentsdk.PatchLogs) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
fs := afero.NewMemMapFs()
|
||||
logger := slogtest.Make(t, nil)
|
||||
s, err := agentssh.NewServer(context.Background(), logger, prometheus.NewRegistry(), fs, 0, "")
|
||||
require.NoError(t, err)
|
||||
s.AgentToken = func() string { return "" }
|
||||
s.Manifest = atomic.NewPointer(&agentsdk.Manifest{})
|
||||
t.Cleanup(func() {
|
||||
_ = s.Close()
|
||||
})
|
||||
return agentscripts.New(agentscripts.Options{
|
||||
LogDir: t.TempDir(),
|
||||
Logger: logger,
|
||||
SSHServer: s,
|
||||
Filesystem: fs,
|
||||
PatchLogs: patchLogs,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package agentscripts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func cmdSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{}
|
||||
}
|
||||
|
||||
func cmdCancel(cmd *exec.Cmd) func() error {
|
||||
return func() error {
|
||||
return cmd.Process.Signal(os.Interrupt)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/spf13/afero"
|
||||
@@ -254,11 +255,13 @@ func (s *Server) sessionStart(session ssh.Session, extraEnv []string) (retErr er
|
||||
magicType = strings.TrimPrefix(kv, MagicSessionTypeEnvironmentVariable+"=")
|
||||
env = append(env[:index], env[index+1:]...)
|
||||
}
|
||||
switch magicType {
|
||||
case MagicSessionTypeVSCode:
|
||||
|
||||
// Always force lowercase checking to be case-insensitive.
|
||||
switch strings.ToLower(magicType) {
|
||||
case strings.ToLower(MagicSessionTypeVSCode):
|
||||
s.connCountVSCode.Add(1)
|
||||
defer s.connCountVSCode.Add(-1)
|
||||
case MagicSessionTypeJetBrains:
|
||||
case strings.ToLower(MagicSessionTypeJetBrains):
|
||||
s.connCountJetBrains.Add(1)
|
||||
defer s.connCountJetBrains.Add(-1)
|
||||
case "":
|
||||
@@ -513,8 +516,32 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string)
|
||||
if runtime.GOOS == "windows" {
|
||||
caller = "/c"
|
||||
}
|
||||
name := shell
|
||||
args := []string{caller, script}
|
||||
|
||||
// A preceding space is generally not idiomatic for a shebang,
|
||||
// but in Terraform it's quite standard to use <<EOF for a multi-line
|
||||
// string which would indent with spaces, so we accept it for user-ease.
|
||||
if strings.HasPrefix(strings.TrimSpace(script), "#!") {
|
||||
// If the script starts with a shebang, we should
|
||||
// execute it directly. This is useful for running
|
||||
// scripts that aren't executable.
|
||||
shebang := strings.SplitN(strings.TrimSpace(script), "\n", 2)[0]
|
||||
shebang = strings.TrimSpace(shebang)
|
||||
shebang = strings.TrimPrefix(shebang, "#!")
|
||||
words, err := shellquote.Split(shebang)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("split shebang: %w", err)
|
||||
}
|
||||
name = words[0]
|
||||
if len(words) > 1 {
|
||||
args = words[1:]
|
||||
} else {
|
||||
args = []string{}
|
||||
}
|
||||
args = append(args, caller, script)
|
||||
}
|
||||
|
||||
// gliderlabs/ssh returns a command slice of zero
|
||||
// when a shell is requested.
|
||||
if len(script) == 0 {
|
||||
@@ -526,7 +553,7 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := pty.CommandContext(ctx, shell, args...)
|
||||
cmd := pty.CommandContext(ctx, name, args...)
|
||||
cmd.Dir = manifest.Directory
|
||||
|
||||
// If the metadata directory doesn't exist, we run the command
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -71,6 +72,42 @@ func TestNewServer_ServeClient(t *testing.T) {
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestNewServer_ExecuteShebang(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("bash doesn't exist on Windows")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
logger := slogtest.Make(t, nil)
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), 0, "")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = s.Close()
|
||||
})
|
||||
s.AgentToken = func() string { return "" }
|
||||
s.Manifest = atomic.NewPointer(&agentsdk.Manifest{})
|
||||
|
||||
t.Run("Basic", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd, err := s.CreateCommand(ctx, `#!/bin/bash
|
||||
echo test`, nil)
|
||||
require.NoError(t, err)
|
||||
output, err := cmd.AsExec().CombinedOutput()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test\n", string(output))
|
||||
})
|
||||
t.Run("Args", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd, err := s.CreateCommand(ctx, `#!/usr/bin/env bash
|
||||
echo test`, nil)
|
||||
require.NoError(t, err)
|
||||
output, err := cmd.AsExec().CombinedOutput()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test\n", string(output))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewServer_CloseActiveConnections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package agentssh
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
@@ -78,5 +80,6 @@ func magicTypeMetricLabel(magicType string) string {
|
||||
default:
|
||||
magicType = "unknown"
|
||||
}
|
||||
return magicType
|
||||
// Always be case insensitive
|
||||
return strings.ToLower(magicType)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package agenttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
// New starts a new agent for use in tests.
|
||||
// The agent will use the provided coder URL and session token.
|
||||
// The options passed to agent.New() can be modified by passing an optional
|
||||
// variadic func(*agent.Options).
|
||||
// Returns the agent. Closing the agent is handled by the test cleanup.
|
||||
// It is the responsibility of the caller to call coderdtest.AwaitWorkspaceAgents
|
||||
// to ensure agent is connected.
|
||||
func New(t testing.TB, coderURL *url.URL, agentToken string, opts ...func(*agent.Options)) agent.Agent {
|
||||
t.Helper()
|
||||
|
||||
var o agent.Options
|
||||
log := slogtest.Make(t, nil).Leveled(slog.LevelDebug).Named("agent")
|
||||
o.Logger = log
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
|
||||
if o.Client == nil {
|
||||
agentClient := agentsdk.New(coderURL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentClient.SDK.SetLogger(log)
|
||||
o.Client = agentClient
|
||||
}
|
||||
|
||||
if o.ExchangeToken == nil {
|
||||
o.ExchangeToken = func(_ context.Context) (string, error) {
|
||||
return agentToken, nil
|
||||
}
|
||||
}
|
||||
|
||||
if o.LogDir == "" {
|
||||
o.LogDir = t.TempDir()
|
||||
}
|
||||
|
||||
agt := agent.New(o)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, agt.Close(), "failed to close agent during cleanup")
|
||||
})
|
||||
|
||||
return agt
|
||||
}
|
||||
@@ -45,7 +45,7 @@ type Client struct {
|
||||
logger slog.Logger
|
||||
agentID uuid.UUID
|
||||
manifest agentsdk.Manifest
|
||||
metadata map[string]agentsdk.PostMetadataRequest
|
||||
metadata map[string]agentsdk.Metadata
|
||||
statsChan chan *agentsdk.Stats
|
||||
coordinator tailnet.Coordinator
|
||||
LastWorkspaceAgent func()
|
||||
@@ -136,20 +136,22 @@ func (c *Client) GetStartup() agentsdk.PostStartupRequest {
|
||||
return c.startup
|
||||
}
|
||||
|
||||
func (c *Client) GetMetadata() map[string]agentsdk.PostMetadataRequest {
|
||||
func (c *Client) GetMetadata() map[string]agentsdk.Metadata {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return maps.Clone(c.metadata)
|
||||
}
|
||||
|
||||
func (c *Client) PostMetadata(ctx context.Context, key string, req agentsdk.PostMetadataRequest) error {
|
||||
func (c *Client) PostMetadata(ctx context.Context, req agentsdk.PostMetadataRequest) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.metadata == nil {
|
||||
c.metadata = make(map[string]agentsdk.PostMetadataRequest)
|
||||
c.metadata = make(map[string]agentsdk.Metadata)
|
||||
}
|
||||
for _, md := range req.Metadata {
|
||||
c.metadata[md.Key] = md
|
||||
c.logger.Debug(ctx, "post metadata", slog.F("key", md.Key), slog.F("md", md))
|
||||
}
|
||||
c.metadata[key] = req
|
||||
c.logger.Debug(ctx, "post metadata", slog.F("key", key), slog.F("req", req))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
syntax = "proto3";
|
||||
option go_package = "github.com/coder/coder/v2/agent/proto";
|
||||
|
||||
package coder.agent.v2;
|
||||
|
||||
import "tailnet/proto/tailnet.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/duration.proto";
|
||||
|
||||
message WorkspaceApp {
|
||||
bytes uuid = 1;
|
||||
string url = 2;
|
||||
bool external = 3;
|
||||
string slug = 4;
|
||||
string display_name = 5;
|
||||
string command = 6;
|
||||
string icon = 7;
|
||||
bool subdomain = 8;
|
||||
string subdomain_name = 9;
|
||||
|
||||
enum SharingLevel {
|
||||
SHARING_LEVEL_UNSPECIFIED = 0;
|
||||
OWNER = 1;
|
||||
AUTHENTICATED = 2;
|
||||
PUBLIC = 3;
|
||||
}
|
||||
SharingLevel sharing_level = 10;
|
||||
|
||||
message HealthCheck {
|
||||
string url = 1;
|
||||
int32 interval = 2;
|
||||
int32 threshold = 3;
|
||||
}
|
||||
HealthCheck healthcheck = 11;
|
||||
|
||||
enum Health {
|
||||
HEALTH_UNSPECIFIED = 0;
|
||||
DISABLED = 1;
|
||||
INITIALIZING = 2;
|
||||
HEALTHY = 3;
|
||||
UNHEALTHY = 4;
|
||||
}
|
||||
Health health = 12;
|
||||
}
|
||||
|
||||
message Manifest {
|
||||
uint32 git_auth_configs = 1;
|
||||
string vs_code_port_proxy_uri = 2;
|
||||
repeated WorkspaceApp apps = 3;
|
||||
coder.tailnet.v2.DERPMap derp_map = 4;
|
||||
}
|
||||
|
||||
message GetManifestRequest {}
|
||||
|
||||
message ServiceBanner {
|
||||
bool enabled = 1;
|
||||
string message = 2;
|
||||
string background_color = 3;
|
||||
}
|
||||
|
||||
message GetServiceBannerRequest {}
|
||||
|
||||
message Stats {
|
||||
// ConnectionsByProto is a count of connections by protocol.
|
||||
map<string, int64> connections_by_proto = 1;
|
||||
// ConnectionCount is the number of connections received by an agent.
|
||||
int64 connection_count = 2;
|
||||
// ConnectionMedianLatencyMS is the median latency of all connections in milliseconds.
|
||||
double connection_median_latency_ms = 3;
|
||||
// RxPackets is the number of received packets.
|
||||
int64 rx_packets = 4;
|
||||
// RxBytes is the number of received bytes.
|
||||
int64 rx_bytes = 5;
|
||||
// TxPackets is the number of transmitted bytes.
|
||||
int64 tx_packets = 6;
|
||||
// TxBytes is the number of transmitted bytes.
|
||||
int64 tx_bytes = 7;
|
||||
|
||||
// SessionCountVSCode is the number of connections received by an agent
|
||||
// that are from our VS Code extension.
|
||||
int64 session_count_vscode = 8;
|
||||
// SessionCountJetBrains is the number of connections received by an agent
|
||||
// that are from our JetBrains extension.
|
||||
int64 session_count_jetbrains = 9;
|
||||
// SessionCountReconnectingPTY is the number of connections received by an agent
|
||||
// that are from the reconnecting web terminal.
|
||||
int64 session_count_reconnecting_pty = 10;
|
||||
// SessionCountSSH is the number of connections received by an agent
|
||||
// that are normal, non-tagged SSH sessions.
|
||||
int64 session_count_ssh = 11;
|
||||
|
||||
message Metric {
|
||||
string name = 1;
|
||||
|
||||
enum Type {
|
||||
TYPE_UNSPECIFIED = 0;
|
||||
COUNTER = 1;
|
||||
GAUGE = 2;
|
||||
}
|
||||
Type type = 2;
|
||||
|
||||
double value = 3;
|
||||
map<string, string> labels = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message UpdateStatsRequest{
|
||||
Stats stats = 1;
|
||||
}
|
||||
|
||||
message UpdateStatsResponse {
|
||||
google.protobuf.Duration report_interval_nanoseconds = 1;
|
||||
}
|
||||
|
||||
message Lifecycle {
|
||||
enum State {
|
||||
STATE_UNSPECIFIED = 0;
|
||||
CREATED = 1;
|
||||
STARTED = 2;
|
||||
START_TIMEOUT = 3;
|
||||
START_ERROR = 4;
|
||||
READY = 5;
|
||||
SHUTTING_DOWN = 6;
|
||||
SHUTDOWN_TIMEOUT = 7;
|
||||
SHUTDOWN_ERROR = 8;
|
||||
OFF = 9;
|
||||
}
|
||||
State state = 1;
|
||||
}
|
||||
|
||||
message UpdateLifecycleRequest {
|
||||
Lifecycle lifecycle = 1;
|
||||
}
|
||||
|
||||
enum AppHealth {
|
||||
APP_HEALTH_UNSPECIFIED = 0;
|
||||
DISABLED = 1;
|
||||
INITIALIZING = 2;
|
||||
HEALTHY = 3;
|
||||
UNHEALTHY = 4;
|
||||
}
|
||||
|
||||
message BatchUpdateAppHealthRequest {
|
||||
message HealthUpdate {
|
||||
bytes uuid = 1;
|
||||
AppHealth health = 2;
|
||||
}
|
||||
repeated HealthUpdate updates = 1;
|
||||
}
|
||||
|
||||
message BatchUpdateAppHealthResponse {}
|
||||
|
||||
message Startup {
|
||||
string version = 1;
|
||||
string expanded_directory = 2;
|
||||
repeated string subsystems = 3;
|
||||
}
|
||||
|
||||
message UpdateStartupRequest{
|
||||
Startup startup = 1;
|
||||
}
|
||||
|
||||
message Metadata {
|
||||
string key = 1;
|
||||
google.protobuf.Timestamp collected_at = 2;
|
||||
int64 age = 3;
|
||||
string value = 4;
|
||||
string error = 5;
|
||||
}
|
||||
|
||||
message BatchUpdateMetadataRequest {
|
||||
repeated Metadata metadata = 2;
|
||||
}
|
||||
|
||||
message BatchUpdateMetadataResponse {}
|
||||
|
||||
message Log {
|
||||
google.protobuf.Timestamp created_at = 1;
|
||||
string output = 2;
|
||||
|
||||
enum Level {
|
||||
LEVEL_UNSPECIFIED = 0;
|
||||
TRACE = 1;
|
||||
DEBUG = 2;
|
||||
INFO = 3;
|
||||
WARN = 4;
|
||||
ERROR = 5;
|
||||
}
|
||||
Level level = 3;
|
||||
}
|
||||
|
||||
message BatchCreateLogsRequest {
|
||||
bytes source_id = 1;
|
||||
repeated Log logs = 2;
|
||||
}
|
||||
|
||||
message BatchCreateLogsResponse {}
|
||||
|
||||
service Agent {
|
||||
rpc GetManifest(GetManifestRequest) returns (Manifest);
|
||||
rpc GetServiceBanner(GetServiceBannerRequest) returns (ServiceBanner);
|
||||
rpc UpdateStats(UpdateStatsRequest) returns (UpdateStatsResponse);
|
||||
rpc UpdateLifecycle(UpdateLifecycleRequest) returns (Lifecycle);
|
||||
rpc BatchUpdateAppHealths(BatchUpdateAppHealthRequest) returns (BatchUpdateAppHealthResponse);
|
||||
rpc UpdateStartup(UpdateStartupRequest) returns (Startup);
|
||||
rpc BatchUpdateMetadata(BatchUpdateMetadataRequest) returns (BatchUpdateMetadataResponse);
|
||||
rpc BatchCreateLogs(BatchCreateLogsRequest) returns (BatchCreateLogsResponse);
|
||||
|
||||
rpc StreamDERPMaps(tailnet.v2.StreamDERPMapsRequest) returns (stream tailnet.v2.DERPMap);
|
||||
rpc CoordinateTailnet(stream tailnet.v2.CoordinateRequest) returns (stream tailnet.v2.CoordinateResponse);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
// Code generated by protoc-gen-go-drpc. DO NOT EDIT.
|
||||
// protoc-gen-go-drpc version: v0.0.33
|
||||
// source: agent/proto/agent.proto
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
context "context"
|
||||
errors "errors"
|
||||
proto1 "github.com/coder/coder/v2/tailnet/proto"
|
||||
protojson "google.golang.org/protobuf/encoding/protojson"
|
||||
proto "google.golang.org/protobuf/proto"
|
||||
drpc "storj.io/drpc"
|
||||
drpcerr "storj.io/drpc/drpcerr"
|
||||
)
|
||||
|
||||
type drpcEncoding_File_agent_proto_agent_proto struct{}
|
||||
|
||||
func (drpcEncoding_File_agent_proto_agent_proto) Marshal(msg drpc.Message) ([]byte, error) {
|
||||
return proto.Marshal(msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_proto_agent_proto) MarshalAppend(buf []byte, msg drpc.Message) ([]byte, error) {
|
||||
return proto.MarshalOptions{}.MarshalAppend(buf, msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_proto_agent_proto) Unmarshal(buf []byte, msg drpc.Message) error {
|
||||
return proto.Unmarshal(buf, msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_proto_agent_proto) JSONMarshal(msg drpc.Message) ([]byte, error) {
|
||||
return protojson.Marshal(msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_proto_agent_proto) JSONUnmarshal(buf []byte, msg drpc.Message) error {
|
||||
return protojson.Unmarshal(buf, msg.(proto.Message))
|
||||
}
|
||||
|
||||
type DRPCAgentClient interface {
|
||||
DRPCConn() drpc.Conn
|
||||
|
||||
GetManifest(ctx context.Context, in *GetManifestRequest) (*Manifest, error)
|
||||
GetServiceBanner(ctx context.Context, in *GetServiceBannerRequest) (*ServiceBanner, error)
|
||||
UpdateStats(ctx context.Context, in *UpdateStatsRequest) (*UpdateStatsResponse, error)
|
||||
UpdateLifecycle(ctx context.Context, in *UpdateLifecycleRequest) (*Lifecycle, error)
|
||||
BatchUpdateAppHealths(ctx context.Context, in *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error)
|
||||
UpdateStartup(ctx context.Context, in *UpdateStartupRequest) (*Startup, error)
|
||||
BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
|
||||
BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
|
||||
StreamDERPMaps(ctx context.Context, in *proto1.StreamDERPMapsRequest) (DRPCAgent_StreamDERPMapsClient, error)
|
||||
CoordinateTailnet(ctx context.Context) (DRPCAgent_CoordinateTailnetClient, error)
|
||||
}
|
||||
|
||||
type drpcAgentClient struct {
|
||||
cc drpc.Conn
|
||||
}
|
||||
|
||||
func NewDRPCAgentClient(cc drpc.Conn) DRPCAgentClient {
|
||||
return &drpcAgentClient{cc}
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) DRPCConn() drpc.Conn { return c.cc }
|
||||
|
||||
func (c *drpcAgentClient) GetManifest(ctx context.Context, in *GetManifestRequest) (*Manifest, error) {
|
||||
out := new(Manifest)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/GetManifest", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) GetServiceBanner(ctx context.Context, in *GetServiceBannerRequest) (*ServiceBanner, error) {
|
||||
out := new(ServiceBanner)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/GetServiceBanner", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) UpdateStats(ctx context.Context, in *UpdateStatsRequest) (*UpdateStatsResponse, error) {
|
||||
out := new(UpdateStatsResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/UpdateStats", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) UpdateLifecycle(ctx context.Context, in *UpdateLifecycleRequest) (*Lifecycle, error) {
|
||||
out := new(Lifecycle)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/UpdateLifecycle", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) BatchUpdateAppHealths(ctx context.Context, in *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error) {
|
||||
out := new(BatchUpdateAppHealthResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/BatchUpdateAppHealths", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) UpdateStartup(ctx context.Context, in *UpdateStartupRequest) (*Startup, error) {
|
||||
out := new(Startup)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/UpdateStartup", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error) {
|
||||
out := new(BatchUpdateMetadataResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/BatchUpdateMetadata", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error) {
|
||||
out := new(BatchCreateLogsResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/BatchCreateLogs", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) StreamDERPMaps(ctx context.Context, in *proto1.StreamDERPMapsRequest) (DRPCAgent_StreamDERPMapsClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, "/coder.agent.v2.Agent/StreamDERPMaps", drpcEncoding_File_agent_proto_agent_proto{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &drpcAgent_StreamDERPMapsClient{stream}
|
||||
if err := x.MsgSend(in, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := x.CloseSend(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type DRPCAgent_StreamDERPMapsClient interface {
|
||||
drpc.Stream
|
||||
Recv() (*proto1.DERPMap, error)
|
||||
}
|
||||
|
||||
type drpcAgent_StreamDERPMapsClient struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_StreamDERPMapsClient) GetStream() drpc.Stream {
|
||||
return x.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_StreamDERPMapsClient) Recv() (*proto1.DERPMap, error) {
|
||||
m := new(proto1.DERPMap)
|
||||
if err := x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (x *drpcAgent_StreamDERPMapsClient) RecvMsg(m *proto1.DERPMap) error {
|
||||
return x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{})
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) CoordinateTailnet(ctx context.Context) (DRPCAgent_CoordinateTailnetClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, "/coder.agent.v2.Agent/CoordinateTailnet", drpcEncoding_File_agent_proto_agent_proto{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &drpcAgent_CoordinateTailnetClient{stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type DRPCAgent_CoordinateTailnetClient interface {
|
||||
drpc.Stream
|
||||
Send(*proto1.CoordinateRequest) error
|
||||
Recv() (*proto1.CoordinateResponse, error)
|
||||
}
|
||||
|
||||
type drpcAgent_CoordinateTailnetClient struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_CoordinateTailnetClient) GetStream() drpc.Stream {
|
||||
return x.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_CoordinateTailnetClient) Send(m *proto1.CoordinateRequest) error {
|
||||
return x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{})
|
||||
}
|
||||
|
||||
func (x *drpcAgent_CoordinateTailnetClient) Recv() (*proto1.CoordinateResponse, error) {
|
||||
m := new(proto1.CoordinateResponse)
|
||||
if err := x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (x *drpcAgent_CoordinateTailnetClient) RecvMsg(m *proto1.CoordinateResponse) error {
|
||||
return x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{})
|
||||
}
|
||||
|
||||
type DRPCAgentServer interface {
|
||||
GetManifest(context.Context, *GetManifestRequest) (*Manifest, error)
|
||||
GetServiceBanner(context.Context, *GetServiceBannerRequest) (*ServiceBanner, error)
|
||||
UpdateStats(context.Context, *UpdateStatsRequest) (*UpdateStatsResponse, error)
|
||||
UpdateLifecycle(context.Context, *UpdateLifecycleRequest) (*Lifecycle, error)
|
||||
BatchUpdateAppHealths(context.Context, *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error)
|
||||
UpdateStartup(context.Context, *UpdateStartupRequest) (*Startup, error)
|
||||
BatchUpdateMetadata(context.Context, *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
|
||||
BatchCreateLogs(context.Context, *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
|
||||
StreamDERPMaps(*proto1.StreamDERPMapsRequest, DRPCAgent_StreamDERPMapsStream) error
|
||||
CoordinateTailnet(DRPCAgent_CoordinateTailnetStream) error
|
||||
}
|
||||
|
||||
type DRPCAgentUnimplementedServer struct{}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) GetManifest(context.Context, *GetManifestRequest) (*Manifest, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) GetServiceBanner(context.Context, *GetServiceBannerRequest) (*ServiceBanner, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) UpdateStats(context.Context, *UpdateStatsRequest) (*UpdateStatsResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) UpdateLifecycle(context.Context, *UpdateLifecycleRequest) (*Lifecycle, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) BatchUpdateAppHealths(context.Context, *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) UpdateStartup(context.Context, *UpdateStartupRequest) (*Startup, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) BatchUpdateMetadata(context.Context, *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) BatchCreateLogs(context.Context, *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) StreamDERPMaps(*proto1.StreamDERPMapsRequest, DRPCAgent_StreamDERPMapsStream) error {
|
||||
return drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) CoordinateTailnet(DRPCAgent_CoordinateTailnetStream) error {
|
||||
return drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
type DRPCAgentDescription struct{}
|
||||
|
||||
func (DRPCAgentDescription) NumMethods() int { return 10 }
|
||||
|
||||
func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
|
||||
switch n {
|
||||
case 0:
|
||||
return "/coder.agent.v2.Agent/GetManifest", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
GetManifest(
|
||||
ctx,
|
||||
in1.(*GetManifestRequest),
|
||||
)
|
||||
}, DRPCAgentServer.GetManifest, true
|
||||
case 1:
|
||||
return "/coder.agent.v2.Agent/GetServiceBanner", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
GetServiceBanner(
|
||||
ctx,
|
||||
in1.(*GetServiceBannerRequest),
|
||||
)
|
||||
}, DRPCAgentServer.GetServiceBanner, true
|
||||
case 2:
|
||||
return "/coder.agent.v2.Agent/UpdateStats", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
UpdateStats(
|
||||
ctx,
|
||||
in1.(*UpdateStatsRequest),
|
||||
)
|
||||
}, DRPCAgentServer.UpdateStats, true
|
||||
case 3:
|
||||
return "/coder.agent.v2.Agent/UpdateLifecycle", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
UpdateLifecycle(
|
||||
ctx,
|
||||
in1.(*UpdateLifecycleRequest),
|
||||
)
|
||||
}, DRPCAgentServer.UpdateLifecycle, true
|
||||
case 4:
|
||||
return "/coder.agent.v2.Agent/BatchUpdateAppHealths", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
BatchUpdateAppHealths(
|
||||
ctx,
|
||||
in1.(*BatchUpdateAppHealthRequest),
|
||||
)
|
||||
}, DRPCAgentServer.BatchUpdateAppHealths, true
|
||||
case 5:
|
||||
return "/coder.agent.v2.Agent/UpdateStartup", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
UpdateStartup(
|
||||
ctx,
|
||||
in1.(*UpdateStartupRequest),
|
||||
)
|
||||
}, DRPCAgentServer.UpdateStartup, true
|
||||
case 6:
|
||||
return "/coder.agent.v2.Agent/BatchUpdateMetadata", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
BatchUpdateMetadata(
|
||||
ctx,
|
||||
in1.(*BatchUpdateMetadataRequest),
|
||||
)
|
||||
}, DRPCAgentServer.BatchUpdateMetadata, true
|
||||
case 7:
|
||||
return "/coder.agent.v2.Agent/BatchCreateLogs", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
BatchCreateLogs(
|
||||
ctx,
|
||||
in1.(*BatchCreateLogsRequest),
|
||||
)
|
||||
}, DRPCAgentServer.BatchCreateLogs, true
|
||||
case 8:
|
||||
return "/coder.agent.v2.Agent/StreamDERPMaps", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return nil, srv.(DRPCAgentServer).
|
||||
StreamDERPMaps(
|
||||
in1.(*proto1.StreamDERPMapsRequest),
|
||||
&drpcAgent_StreamDERPMapsStream{in2.(drpc.Stream)},
|
||||
)
|
||||
}, DRPCAgentServer.StreamDERPMaps, true
|
||||
case 9:
|
||||
return "/coder.agent.v2.Agent/CoordinateTailnet", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return nil, srv.(DRPCAgentServer).
|
||||
CoordinateTailnet(
|
||||
&drpcAgent_CoordinateTailnetStream{in1.(drpc.Stream)},
|
||||
)
|
||||
}, DRPCAgentServer.CoordinateTailnet, true
|
||||
default:
|
||||
return "", nil, nil, nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func DRPCRegisterAgent(mux drpc.Mux, impl DRPCAgentServer) error {
|
||||
return mux.Register(impl, DRPCAgentDescription{})
|
||||
}
|
||||
|
||||
type DRPCAgent_GetManifestStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*Manifest) error
|
||||
}
|
||||
|
||||
type drpcAgent_GetManifestStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_GetManifestStream) SendAndClose(m *Manifest) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_GetServiceBannerStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*ServiceBanner) error
|
||||
}
|
||||
|
||||
type drpcAgent_GetServiceBannerStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_GetServiceBannerStream) SendAndClose(m *ServiceBanner) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_UpdateStatsStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*UpdateStatsResponse) error
|
||||
}
|
||||
|
||||
type drpcAgent_UpdateStatsStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_UpdateStatsStream) SendAndClose(m *UpdateStatsResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_UpdateLifecycleStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*Lifecycle) error
|
||||
}
|
||||
|
||||
type drpcAgent_UpdateLifecycleStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_UpdateLifecycleStream) SendAndClose(m *Lifecycle) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_BatchUpdateAppHealthsStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*BatchUpdateAppHealthResponse) error
|
||||
}
|
||||
|
||||
type drpcAgent_BatchUpdateAppHealthsStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_BatchUpdateAppHealthsStream) SendAndClose(m *BatchUpdateAppHealthResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_UpdateStartupStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*Startup) error
|
||||
}
|
||||
|
||||
type drpcAgent_UpdateStartupStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_UpdateStartupStream) SendAndClose(m *Startup) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_BatchUpdateMetadataStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*BatchUpdateMetadataResponse) error
|
||||
}
|
||||
|
||||
type drpcAgent_BatchUpdateMetadataStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_BatchUpdateMetadataStream) SendAndClose(m *BatchUpdateMetadataResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_BatchCreateLogsStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*BatchCreateLogsResponse) error
|
||||
}
|
||||
|
||||
type drpcAgent_BatchCreateLogsStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_BatchCreateLogsStream) SendAndClose(m *BatchCreateLogsResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_StreamDERPMapsStream interface {
|
||||
drpc.Stream
|
||||
Send(*proto1.DERPMap) error
|
||||
}
|
||||
|
||||
type drpcAgent_StreamDERPMapsStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_StreamDERPMapsStream) Send(m *proto1.DERPMap) error {
|
||||
return x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{})
|
||||
}
|
||||
|
||||
type DRPCAgent_CoordinateTailnetStream interface {
|
||||
drpc.Stream
|
||||
Send(*proto1.CoordinateResponse) error
|
||||
Recv() (*proto1.CoordinateRequest, error)
|
||||
}
|
||||
|
||||
type drpcAgent_CoordinateTailnetStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_CoordinateTailnetStream) Send(m *proto1.CoordinateResponse) error {
|
||||
return x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{})
|
||||
}
|
||||
|
||||
func (x *drpcAgent_CoordinateTailnetStream) Recv() (*proto1.CoordinateRequest, error) {
|
||||
m := new(proto1.CoordinateRequest)
|
||||
if err := x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (x *drpcAgent_CoordinateTailnetStream) RecvMsg(m *proto1.CoordinateRequest) error {
|
||||
return x.MsgRecv(m, drpcEncoding_File_agent_proto_agent_proto{})
|
||||
}
|
||||
@@ -196,8 +196,8 @@ func (s *ptyState) waitForStateOrContext(ctx context.Context, state State) (Stat
|
||||
// until EOF or an error writing to ptty or reading from conn.
|
||||
func readConnLoop(ctx context.Context, conn net.Conn, ptty pty.PTYCmd, metrics *prometheus.CounterVec, logger slog.Logger) {
|
||||
decoder := json.NewDecoder(conn)
|
||||
var req codersdk.ReconnectingPTYRequest
|
||||
for {
|
||||
var req codersdk.ReconnectingPTYRequest
|
||||
err := decoder.Decode(&req)
|
||||
if xerrors.Is(err, io.EOF) {
|
||||
return
|
||||
|
||||
@@ -206,12 +206,13 @@ func (rpty *screenReconnectingPTY) doAttach(ctx context.Context, conn net.Conn,
|
||||
cmd := pty.CommandContext(ctx, "screen", append([]string{
|
||||
// -S is for setting the session's name.
|
||||
"-S", rpty.id,
|
||||
// -U tells screen to use UTF-8 encoding.
|
||||
// -x allows attaching to an already attached session.
|
||||
// -RR reattaches to the daemon or creates the session daemon if missing.
|
||||
// -q disables the "New screen..." message that appears for five seconds
|
||||
// when creating a new session with -RR.
|
||||
// -c is the flag for the config file.
|
||||
"-xRRqc", rpty.configFile,
|
||||
"-UxRRqc", rpty.configFile,
|
||||
rpty.command.Path,
|
||||
// pty.Cmd duplicates Path as the first argument so remove it.
|
||||
}, rpty.command.Args[1:]...)...)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build boringcrypto
|
||||
|
||||
package buildinfo
|
||||
|
||||
import "crypto/boring"
|
||||
|
||||
var boringcrypto = boring.Enabled()
|
||||
+24
-7
@@ -30,8 +30,15 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
// develPrefix is prefixed to developer versions of the application.
|
||||
develPrefix = "v0.0.0-devel"
|
||||
// noVersion is the reported version when the version cannot be determined.
|
||||
// Usually because `go build` is run instead of `make build`.
|
||||
noVersion = "v0.0.0"
|
||||
|
||||
// develPreRelease is the pre-release tag for developer versions of the
|
||||
// application. This includes CI builds. The pre-release tag should be appended
|
||||
// to the version with a "-".
|
||||
// Example: v0.0.0-devel
|
||||
develPreRelease = "devel"
|
||||
)
|
||||
|
||||
// Version returns the semantic version of the build.
|
||||
@@ -45,7 +52,8 @@ func Version() string {
|
||||
if tag == "" {
|
||||
// This occurs when the tag hasn't been injected,
|
||||
// like when using "go run".
|
||||
version = develPrefix + revision
|
||||
// <version>-<pre-release>+<revision>
|
||||
version = fmt.Sprintf("%s-%s%s", noVersion, develPreRelease, revision)
|
||||
return
|
||||
}
|
||||
version = "v" + tag
|
||||
@@ -63,18 +71,23 @@ func Version() string {
|
||||
// disregarded. If it detects that either version is a developer build it
|
||||
// returns true.
|
||||
func VersionsMatch(v1, v2 string) bool {
|
||||
// Developer versions are disregarded...hopefully they know what they are
|
||||
// doing.
|
||||
if strings.HasPrefix(v1, develPrefix) || strings.HasPrefix(v2, develPrefix) {
|
||||
// If no version is attached, then it is a dev build outside of CI. The version
|
||||
// will be disregarded... hopefully they know what they are doing.
|
||||
if strings.Contains(v1, noVersion) || strings.Contains(v2, noVersion) {
|
||||
return true
|
||||
}
|
||||
|
||||
return semver.MajorMinor(v1) == semver.MajorMinor(v2)
|
||||
}
|
||||
|
||||
func IsDevVersion(v string) bool {
|
||||
return strings.Contains(v, "-"+develPreRelease)
|
||||
}
|
||||
|
||||
// IsDev returns true if this is a development build.
|
||||
// CI builds are also considered development builds.
|
||||
func IsDev() bool {
|
||||
return strings.HasPrefix(Version(), develPrefix)
|
||||
return IsDevVersion(Version())
|
||||
}
|
||||
|
||||
// IsSlim returns true if this is a slim build.
|
||||
@@ -87,6 +100,10 @@ func IsAGPL() bool {
|
||||
return strings.Contains(agpl, "t")
|
||||
}
|
||||
|
||||
func IsBoringCrypto() bool {
|
||||
return boringcrypto
|
||||
}
|
||||
|
||||
// ExternalURL returns a URL referencing the current Coder version.
|
||||
// For production builds, this will link directly to a release.
|
||||
// For development builds, this will link to a commit.
|
||||
|
||||
@@ -57,13 +57,19 @@ func TestBuildInfo(t *testing.T) {
|
||||
expectMatch: true,
|
||||
},
|
||||
// Our CI instance uses a "-devel" prerelease
|
||||
// flag. This is not the same as a developer WIP build.
|
||||
// flag.
|
||||
{
|
||||
name: "DevelPreleaseNotIgnored",
|
||||
name: "DevelPreleaseMajor",
|
||||
v1: "v1.1.1-devel+123abac",
|
||||
v2: "v1.2.3",
|
||||
expectMatch: false,
|
||||
},
|
||||
{
|
||||
name: "DevelPreleaseSame",
|
||||
v1: "v1.1.1-devel+123abac",
|
||||
v2: "v1.1.9",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "MajorMismatch",
|
||||
v1: "v1.2.3",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !boringcrypto
|
||||
|
||||
package buildinfo
|
||||
|
||||
var boringcrypto = false
|
||||
+22
-4
@@ -29,6 +29,7 @@ import (
|
||||
"cdr.dev/slog/sloggers/slogjson"
|
||||
"cdr.dev/slog/sloggers/slogstackdriver"
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agentproc"
|
||||
"github.com/coder/coder/v2/agent/reaper"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
@@ -198,9 +199,19 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
|
||||
var exchangeToken func(context.Context) (agentsdk.AuthenticateResponse, error)
|
||||
switch auth {
|
||||
case "token":
|
||||
token, err := inv.ParsedFlags().GetString(varAgentToken)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("CODER_AGENT_TOKEN must be set for token auth: %w", err)
|
||||
token, _ := inv.ParsedFlags().GetString(varAgentToken)
|
||||
if token == "" {
|
||||
tokenFile, _ := inv.ParsedFlags().GetString(varAgentTokenFile)
|
||||
if tokenFile != "" {
|
||||
tokenBytes, err := os.ReadFile(tokenFile)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("read token file %q: %w", tokenFile, err)
|
||||
}
|
||||
token = strings.TrimSpace(string(tokenBytes))
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return xerrors.Errorf("CODER_AGENT_TOKEN or CODER_AGENT_TOKEN_FILE must be set for token auth")
|
||||
}
|
||||
client.SetSessionToken(token)
|
||||
case "google-instance-identity":
|
||||
@@ -267,6 +278,8 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
|
||||
subsystems = append(subsystems, subsystem)
|
||||
}
|
||||
|
||||
procTicker := time.NewTicker(time.Second)
|
||||
defer procTicker.Stop()
|
||||
agnt := agent.New(agent.Options{
|
||||
Client: client,
|
||||
Logger: logger,
|
||||
@@ -284,13 +297,18 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd {
|
||||
return resp.SessionToken, nil
|
||||
},
|
||||
EnvironmentVariables: map[string]string{
|
||||
"GIT_ASKPASS": executablePath,
|
||||
"GIT_ASKPASS": executablePath,
|
||||
agent.EnvProcPrioMgmt: os.Getenv(agent.EnvProcPrioMgmt),
|
||||
},
|
||||
IgnorePorts: ignorePorts,
|
||||
SSHMaxTimeout: sshMaxTimeout,
|
||||
Subsystems: subsystems,
|
||||
|
||||
PrometheusRegistry: prometheusRegistry,
|
||||
Syscaller: agentproc.NewSyscaller(),
|
||||
// Intentionally set this to nil. It's mainly used
|
||||
// for testing.
|
||||
ModifiedProcesses: nil,
|
||||
})
|
||||
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, prometheusMetricsHandler(prometheusRegistry, logger), prometheusAddress, "prometheus")
|
||||
|
||||
+16
-15
@@ -38,9 +38,9 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
logDir := t.TempDir()
|
||||
inv, _ := clitest.New(t,
|
||||
@@ -92,9 +92,9 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
inv, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--agent-url", client.URL.String())
|
||||
inv = inv.WithContext(
|
||||
@@ -144,9 +144,9 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
inv, _ := clitest.New(t, "agent", "--auth", "aws-instance-identity", "--agent-url", client.URL.String())
|
||||
inv = inv.WithContext(
|
||||
@@ -176,8 +176,9 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
GoogleTokenValidator: validator,
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: []*proto.Response{{
|
||||
Type: &proto.Response_Apply{
|
||||
@@ -195,14 +196,14 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
inv, cfg := clitest.New(t, "agent", "--auth", "google-instance-identity", "--agent-url", client.URL.String())
|
||||
ptytest.New(t).Attach(inv)
|
||||
clitest.SetupConfig(t, client, cfg)
|
||||
clitest.SetupConfig(t, member, cfg)
|
||||
clitest.Start(t,
|
||||
inv.WithContext(
|
||||
//nolint:revive,staticcheck
|
||||
@@ -253,9 +254,9 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
logDir := t.TempDir()
|
||||
inv, _ := clitest.New(t,
|
||||
|
||||
+129
-18
@@ -1,6 +1,8 @@
|
||||
package clibase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -65,6 +67,20 @@ type Option struct {
|
||||
ValueSource ValueSource `json:"value_source,omitempty"`
|
||||
}
|
||||
|
||||
// optionNoMethods is just a wrapper around Option so we can defer to the
|
||||
// default json.Unmarshaler behavior.
|
||||
type optionNoMethods Option
|
||||
|
||||
func (o *Option) UnmarshalJSON(data []byte) error {
|
||||
// If an option has no values, we have no idea how to unmarshal it.
|
||||
// So just discard the json data.
|
||||
if o.Value == nil {
|
||||
o.Value = &DiscardValue
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, (*optionNoMethods)(o))
|
||||
}
|
||||
|
||||
func (o Option) YAMLPath() string {
|
||||
if o.YAML == "" {
|
||||
return ""
|
||||
@@ -79,15 +95,101 @@ func (o Option) YAMLPath() string {
|
||||
// OptionSet is a group of options that can be applied to a command.
|
||||
type OptionSet []Option
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for OptionSets. Options have an
|
||||
// interface Value type that cannot handle unmarshalling because the types cannot
|
||||
// be inferred. Since it is a slice, instantiating the Options first does not
|
||||
// help.
|
||||
//
|
||||
// However, we typically do instantiate the slice to have the correct types.
|
||||
// So this unmarshaller will attempt to find the named option in the existing
|
||||
// set, if it cannot, the value is discarded. If the option exists, the value
|
||||
// is unmarshalled into the existing option, and replaces the existing option.
|
||||
//
|
||||
// The value is discarded if it's type cannot be inferred. This behavior just
|
||||
// feels "safer", although it should never happen if the correct option set
|
||||
// is passed in. The situation where this could occur is if a client and server
|
||||
// are on different versions with different options.
|
||||
func (optSet *OptionSet) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewBuffer(data))
|
||||
// Should be a json array, so consume the starting open bracket.
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("read array open bracket: %w", err)
|
||||
}
|
||||
if t != json.Delim('[') {
|
||||
return xerrors.Errorf("expected array open bracket, got %q", t)
|
||||
}
|
||||
|
||||
// As long as json elements exist, consume them. The counter is used for
|
||||
// better errors.
|
||||
var i int
|
||||
OptionSetDecodeLoop:
|
||||
for dec.More() {
|
||||
var opt Option
|
||||
// jValue is a placeholder value that allows us to capture the
|
||||
// raw json for the value to attempt to unmarshal later.
|
||||
var jValue jsonValue
|
||||
opt.Value = &jValue
|
||||
err := dec.Decode(&opt)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decode %d option: %w", i, err)
|
||||
}
|
||||
// This counter is used to contextualize errors to show which element of
|
||||
// the array we failed to decode. It is only used in the error above, as
|
||||
// if the above works, we can instead use the Option.Name which is more
|
||||
// descriptive and useful. So increment here for the next decode.
|
||||
i++
|
||||
|
||||
// Try to see if the option already exists in the option set.
|
||||
// If it does, just update the existing option.
|
||||
for optIndex, have := range *optSet {
|
||||
if have.Name == opt.Name {
|
||||
if jValue != nil {
|
||||
err := json.Unmarshal(jValue, &(*optSet)[optIndex].Value)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decode option %q value: %w", have.Name, err)
|
||||
}
|
||||
// Set the opt's value
|
||||
opt.Value = (*optSet)[optIndex].Value
|
||||
} else {
|
||||
// Hopefully the user passed empty values in the option set. There is no easy way
|
||||
// to tell, and if we do not do this, it breaks json.Marshal if we do it again on
|
||||
// this new option set.
|
||||
opt.Value = (*optSet)[optIndex].Value
|
||||
}
|
||||
// Override the existing.
|
||||
(*optSet)[optIndex] = opt
|
||||
// Go to the next option to decode.
|
||||
continue OptionSetDecodeLoop
|
||||
}
|
||||
}
|
||||
|
||||
// If the option doesn't exist, the value will be discarded.
|
||||
// We do this because we cannot infer the type of the value.
|
||||
opt.Value = DiscardValue
|
||||
*optSet = append(*optSet, opt)
|
||||
}
|
||||
|
||||
t, err = dec.Token()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("read array close bracket: %w", err)
|
||||
}
|
||||
if t != json.Delim(']') {
|
||||
return xerrors.Errorf("expected array close bracket, got %q", t)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add adds the given Options to the OptionSet.
|
||||
func (s *OptionSet) Add(opts ...Option) {
|
||||
*s = append(*s, opts...)
|
||||
func (optSet *OptionSet) Add(opts ...Option) {
|
||||
*optSet = append(*optSet, opts...)
|
||||
}
|
||||
|
||||
// Filter will only return options that match the given filter. (return true)
|
||||
func (s OptionSet) Filter(filter func(opt Option) bool) OptionSet {
|
||||
func (optSet OptionSet) Filter(filter func(opt Option) bool) OptionSet {
|
||||
cpy := make(OptionSet, 0)
|
||||
for _, opt := range s {
|
||||
for _, opt := range optSet {
|
||||
if filter(opt) {
|
||||
cpy = append(cpy, opt)
|
||||
}
|
||||
@@ -96,13 +198,13 @@ func (s OptionSet) Filter(filter func(opt Option) bool) OptionSet {
|
||||
}
|
||||
|
||||
// FlagSet returns a pflag.FlagSet for the OptionSet.
|
||||
func (s *OptionSet) FlagSet() *pflag.FlagSet {
|
||||
if s == nil {
|
||||
func (optSet *OptionSet) FlagSet() *pflag.FlagSet {
|
||||
if optSet == nil {
|
||||
return &pflag.FlagSet{}
|
||||
}
|
||||
|
||||
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
|
||||
for _, opt := range *s {
|
||||
for _, opt := range *optSet {
|
||||
if opt.Flag == "" {
|
||||
continue
|
||||
}
|
||||
@@ -139,8 +241,8 @@ func (s *OptionSet) FlagSet() *pflag.FlagSet {
|
||||
|
||||
// ParseEnv parses the given environment variables into the OptionSet.
|
||||
// Use EnvsWithPrefix to filter out prefixes.
|
||||
func (s *OptionSet) ParseEnv(vs []EnvVar) error {
|
||||
if s == nil {
|
||||
func (optSet *OptionSet) ParseEnv(vs []EnvVar) error {
|
||||
if optSet == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -154,12 +256,21 @@ func (s *OptionSet) ParseEnv(vs []EnvVar) error {
|
||||
envs[v.Name] = v.Value
|
||||
}
|
||||
|
||||
for i, opt := range *s {
|
||||
for i, opt := range *optSet {
|
||||
if opt.Env == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
envVal, ok := envs[opt.Env]
|
||||
if !ok {
|
||||
// Homebrew strips all environment variables that do not start with `HOMEBREW_`.
|
||||
// This prevented using brew to invoke the Coder agent, because the environment
|
||||
// variables to not get passed down.
|
||||
//
|
||||
// A customer wanted to use their custom tap inside a workspace, which was failing
|
||||
// because the agent lacked the environment variables to authenticate with Git.
|
||||
envVal, ok = envs[`HOMEBREW_`+opt.Env]
|
||||
}
|
||||
// Currently, empty values are treated as if the environment variable is
|
||||
// unset. This behavior is technically not correct as there is now no
|
||||
// way for a user to change a Default value to an empty string from
|
||||
@@ -172,7 +283,7 @@ func (s *OptionSet) ParseEnv(vs []EnvVar) error {
|
||||
continue
|
||||
}
|
||||
|
||||
(*s)[i].ValueSource = ValueSourceEnv
|
||||
(*optSet)[i].ValueSource = ValueSourceEnv
|
||||
if err := opt.Value.Set(envVal); err != nil {
|
||||
merr = multierror.Append(
|
||||
merr, xerrors.Errorf("parse %q: %w", opt.Name, err),
|
||||
@@ -185,14 +296,14 @@ func (s *OptionSet) ParseEnv(vs []EnvVar) error {
|
||||
|
||||
// SetDefaults sets the default values for each Option, skipping values
|
||||
// that already have a value source.
|
||||
func (s *OptionSet) SetDefaults() error {
|
||||
if s == nil {
|
||||
func (optSet *OptionSet) SetDefaults() error {
|
||||
if optSet == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
|
||||
for i, opt := range *s {
|
||||
for i, opt := range *optSet {
|
||||
// Skip values that may have already been set by the user.
|
||||
if opt.ValueSource != ValueSourceNone {
|
||||
continue
|
||||
@@ -212,7 +323,7 @@ func (s *OptionSet) SetDefaults() error {
|
||||
)
|
||||
continue
|
||||
}
|
||||
(*s)[i].ValueSource = ValueSourceDefault
|
||||
(*optSet)[i].ValueSource = ValueSourceDefault
|
||||
if err := opt.Value.Set(opt.Default); err != nil {
|
||||
merr = multierror.Append(
|
||||
merr, xerrors.Errorf("parse %q: %w", opt.Name, err),
|
||||
@@ -224,9 +335,9 @@ func (s *OptionSet) SetDefaults() error {
|
||||
|
||||
// ByName returns the Option with the given name, or nil if no such option
|
||||
// exists.
|
||||
func (s *OptionSet) ByName(name string) *Option {
|
||||
for i := range *s {
|
||||
opt := &(*s)[i]
|
||||
func (optSet *OptionSet) ByName(name string) *Option {
|
||||
for i := range *optSet {
|
||||
opt := &(*optSet)[i]
|
||||
if opt.Name == name {
|
||||
return opt
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package clibase_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestOptionSet_ParseFlags(t *testing.T) {
|
||||
@@ -200,4 +206,186 @@ func TestOptionSet_ParseEnv(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, expected, actual.Value)
|
||||
})
|
||||
|
||||
t.Run("Homebrew", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var agentToken clibase.String
|
||||
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "Agent Token",
|
||||
Value: &agentToken,
|
||||
Env: "AGENT_TOKEN",
|
||||
},
|
||||
}
|
||||
|
||||
err := os.ParseEnv([]clibase.EnvVar{
|
||||
{Name: "HOMEBREW_AGENT_TOKEN", Value: "foo"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, "foo", agentToken)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOptionSet_JsonMarshal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This unit test ensures if the source optionset is missing the option
|
||||
// and cannot determine the type, it will not panic. The unmarshal will
|
||||
// succeed with a best effort.
|
||||
t.Run("MissingSrcOption", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var str clibase.String = "something"
|
||||
var arr clibase.StringArray = []string{"foo", "bar"}
|
||||
opts := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "StringOpt",
|
||||
Value: &str,
|
||||
},
|
||||
clibase.Option{
|
||||
Name: "ArrayOpt",
|
||||
Value: &arr,
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(opts)
|
||||
require.NoError(t, err, "marshal option set")
|
||||
|
||||
tgt := clibase.OptionSet{}
|
||||
err = json.Unmarshal(data, &tgt)
|
||||
require.NoError(t, err, "unmarshal option set")
|
||||
for i := range opts {
|
||||
compareOptionsExceptValues(t, opts[i], tgt[i])
|
||||
require.Empty(t, tgt[i].Value.String(), "unknown value types are empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RegexCase", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
val := clibase.Regexp(*regexp.MustCompile(".*"))
|
||||
opts := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "Regex",
|
||||
Value: &val,
|
||||
Default: ".*",
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(opts)
|
||||
require.NoError(t, err, "marshal option set")
|
||||
|
||||
var foundVal clibase.Regexp
|
||||
newOpts := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "Regex",
|
||||
Value: &foundVal,
|
||||
},
|
||||
}
|
||||
err = json.Unmarshal(data, &newOpts)
|
||||
require.NoError(t, err, "unmarshal option set")
|
||||
|
||||
require.EqualValues(t, opts[0].Value.String(), newOpts[0].Value.String())
|
||||
})
|
||||
|
||||
t.Run("AllValues", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
vals := coderdtest.DeploymentValues(t)
|
||||
opts := vals.Options()
|
||||
sources := []clibase.ValueSource{
|
||||
clibase.ValueSourceNone,
|
||||
clibase.ValueSourceFlag,
|
||||
clibase.ValueSourceEnv,
|
||||
clibase.ValueSourceYAML,
|
||||
clibase.ValueSourceDefault,
|
||||
}
|
||||
for i := range opts {
|
||||
opts[i].ValueSource = sources[i%len(sources)]
|
||||
}
|
||||
|
||||
data, err := json.Marshal(opts)
|
||||
require.NoError(t, err, "marshal option set")
|
||||
|
||||
newOpts := (&codersdk.DeploymentValues{}).Options()
|
||||
err = json.Unmarshal(data, &newOpts)
|
||||
require.NoError(t, err, "unmarshal option set")
|
||||
|
||||
for i := range opts {
|
||||
exp := opts[i]
|
||||
found := newOpts[i]
|
||||
|
||||
compareOptionsExceptValues(t, exp, found)
|
||||
compareValues(t, exp, found)
|
||||
}
|
||||
|
||||
thirdOpts := (&codersdk.DeploymentValues{}).Options()
|
||||
data, err = json.Marshal(newOpts)
|
||||
require.NoError(t, err, "marshal option set")
|
||||
|
||||
err = json.Unmarshal(data, &thirdOpts)
|
||||
require.NoError(t, err, "unmarshal option set")
|
||||
// Compare to the original opts again
|
||||
for i := range opts {
|
||||
exp := opts[i]
|
||||
found := thirdOpts[i]
|
||||
|
||||
compareOptionsExceptValues(t, exp, found)
|
||||
compareValues(t, exp, found)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func compareOptionsExceptValues(t *testing.T, exp, found clibase.Option) {
|
||||
t.Helper()
|
||||
|
||||
require.Equalf(t, exp.Name, found.Name, "option name %q", exp.Name)
|
||||
require.Equalf(t, exp.Description, found.Description, "option description %q", exp.Name)
|
||||
require.Equalf(t, exp.Required, found.Required, "option required %q", exp.Name)
|
||||
require.Equalf(t, exp.Flag, found.Flag, "option flag %q", exp.Name)
|
||||
require.Equalf(t, exp.FlagShorthand, found.FlagShorthand, "option flag shorthand %q", exp.Name)
|
||||
require.Equalf(t, exp.Env, found.Env, "option env %q", exp.Name)
|
||||
require.Equalf(t, exp.YAML, found.YAML, "option yaml %q", exp.Name)
|
||||
require.Equalf(t, exp.Default, found.Default, "option default %q", exp.Name)
|
||||
require.Equalf(t, exp.ValueSource, found.ValueSource, "option value source %q", exp.Name)
|
||||
require.Equalf(t, exp.Hidden, found.Hidden, "option hidden %q", exp.Name)
|
||||
require.Equalf(t, exp.Annotations, found.Annotations, "option annotations %q", exp.Name)
|
||||
require.Equalf(t, exp.Group, found.Group, "option group %q", exp.Name)
|
||||
// UseInstead is the same comparison problem, just check the length
|
||||
require.Equalf(t, len(exp.UseInstead), len(found.UseInstead), "option use instead %q", exp.Name)
|
||||
}
|
||||
|
||||
func compareValues(t *testing.T, exp, found clibase.Option) {
|
||||
t.Helper()
|
||||
|
||||
if (exp.Value == nil || found.Value == nil) || (exp.Value.String() != found.Value.String() && found.Value.String() == "") {
|
||||
// If the string values are different, this can be a "nil" issue.
|
||||
// So only run this case if the found string is the empty string.
|
||||
// We use MarshalYAML for struct strings, and it will return an
|
||||
// empty string '""' for nil slices/maps/etc.
|
||||
// So use json to compare.
|
||||
|
||||
expJSON, err := json.Marshal(exp.Value)
|
||||
require.NoError(t, err, "marshal")
|
||||
foundJSON, err := json.Marshal(found.Value)
|
||||
require.NoError(t, err, "marshal")
|
||||
|
||||
expJSON = normalizeJSON(expJSON)
|
||||
foundJSON = normalizeJSON(foundJSON)
|
||||
assert.Equalf(t, string(expJSON), string(foundJSON), "option value %q", exp.Name)
|
||||
} else {
|
||||
assert.Equal(t,
|
||||
exp.Value.String(),
|
||||
found.Value.String(),
|
||||
"option value %q", exp.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeJSON handles the fact that an empty map/slice is not the same
|
||||
// as a nil empty/slice. For our purposes, they are the same.
|
||||
func normalizeJSON(data []byte) []byte {
|
||||
if bytes.Equal(data, []byte("[]")) || bytes.Equal(data, []byte("{}")) {
|
||||
return []byte("null")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -430,6 +430,35 @@ func (discardValue) Type() string {
|
||||
return "discard"
|
||||
}
|
||||
|
||||
func (discardValue) UnmarshalJSON([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonValue is intentionally not exported. It is just used to store the raw JSON
|
||||
// data for a value to defer it's unmarshal. It implements the pflag.Value to be
|
||||
// usable in an Option.
|
||||
type jsonValue json.RawMessage
|
||||
|
||||
func (jsonValue) Set(string) error {
|
||||
return xerrors.Errorf("json value is read-only")
|
||||
}
|
||||
|
||||
func (jsonValue) String() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (jsonValue) Type() string {
|
||||
return "json"
|
||||
}
|
||||
|
||||
func (j *jsonValue) UnmarshalJSON(data []byte) error {
|
||||
if j == nil {
|
||||
return xerrors.New("json.RawMessage: UnmarshalJSON on nil pointer")
|
||||
}
|
||||
*j = append((*j)[0:0], data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ pflag.Value = (*Enum)(nil)
|
||||
|
||||
type Enum struct {
|
||||
@@ -464,6 +493,25 @@ func (e *Enum) String() string {
|
||||
|
||||
type Regexp regexp.Regexp
|
||||
|
||||
func (r *Regexp) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(r.String())
|
||||
}
|
||||
|
||||
func (r *Regexp) UnmarshalJSON(data []byte) error {
|
||||
var source string
|
||||
err := json.Unmarshal(data, &source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
exp, err := regexp.Compile(source)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("invalid regex expression: %w", err)
|
||||
}
|
||||
*r = Regexp(*exp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Regexp) MarshalYAML() (interface{}, error) {
|
||||
return yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
|
||||
+5
-5
@@ -51,12 +51,12 @@ func deepMapNode(n *yaml.Node, path []string, headComment string) *yaml.Node {
|
||||
// the stack.
|
||||
//
|
||||
// It is isomorphic with FromYAML.
|
||||
func (s *OptionSet) MarshalYAML() (any, error) {
|
||||
func (optSet *OptionSet) MarshalYAML() (any, error) {
|
||||
root := yaml.Node{
|
||||
Kind: yaml.MappingNode,
|
||||
}
|
||||
|
||||
for _, opt := range *s {
|
||||
for _, opt := range *optSet {
|
||||
if opt.YAML == "" {
|
||||
continue
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func (o *Option) setFromYAMLNode(n *yaml.Node) error {
|
||||
|
||||
// UnmarshalYAML converts the given YAML node into the option set.
|
||||
// It is isomorphic with ToYAML.
|
||||
func (s *OptionSet) UnmarshalYAML(rootNode *yaml.Node) error {
|
||||
func (optSet *OptionSet) UnmarshalYAML(rootNode *yaml.Node) error {
|
||||
// The rootNode will be a DocumentNode if it's read from a file. We do
|
||||
// not support multiple documents in a single file.
|
||||
if rootNode.Kind == yaml.DocumentNode {
|
||||
@@ -240,8 +240,8 @@ func (s *OptionSet) UnmarshalYAML(rootNode *yaml.Node) error {
|
||||
matchedNodes := make(map[string]*yaml.Node, len(yamlNodes))
|
||||
|
||||
var merr error
|
||||
for i := range *s {
|
||||
opt := &(*s)[i]
|
||||
for i := range *optSet {
|
||||
opt := &(*optSet)[i]
|
||||
if opt.YAML == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
|
||||
// New creates a CLI instance with a configuration pointed to a
|
||||
// temporary testing directory.
|
||||
func New(t *testing.T, args ...string) (*clibase.Invocation, config.Root) {
|
||||
func New(t testing.TB, args ...string) (*clibase.Invocation, config.Root) {
|
||||
var root cli.RootCmd
|
||||
|
||||
cmd, err := root.Command(root.AGPL())
|
||||
@@ -56,7 +56,7 @@ func (l *logWriter) Write(p []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
func NewWithCommand(
|
||||
t *testing.T, cmd *clibase.Cmd, args ...string,
|
||||
t testing.TB, cmd *clibase.Cmd, args ...string,
|
||||
) (*clibase.Invocation, config.Root) {
|
||||
configDir := config.Root(t.TempDir())
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
+4
-14
@@ -11,8 +11,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
@@ -28,7 +26,7 @@ import (
|
||||
// make update-golden-files
|
||||
var UpdateGoldenFiles = flag.Bool("update", false, "update .golden files")
|
||||
|
||||
var timestampRegex = regexp.MustCompile(`(?i)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?Z`)
|
||||
var timestampRegex = regexp.MustCompile(`(?i)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?(Z|[+-]\d+:\d+)`)
|
||||
|
||||
type CommandHelpCase struct {
|
||||
Name string
|
||||
@@ -50,16 +48,8 @@ func DefaultCases() []CommandHelpCase {
|
||||
|
||||
// TestCommandHelp will test the help output of the given commands
|
||||
// using golden files.
|
||||
//
|
||||
//nolint:tparallel,paralleltest
|
||||
func TestCommandHelp(t *testing.T, getRoot func(t *testing.T) *clibase.Cmd, cases []CommandHelpCase) {
|
||||
ogColorProfile := lipgloss.ColorProfile()
|
||||
// ANSI256 escape codes are far easier for humans to parse in a diff,
|
||||
// but TrueColor is probably more popular with modern terminals.
|
||||
lipgloss.SetColorProfile(termenv.ANSI)
|
||||
t.Cleanup(func() {
|
||||
lipgloss.SetColorProfile(ogColorProfile)
|
||||
})
|
||||
t.Parallel()
|
||||
rootClient, replacements := prepareTestData(t)
|
||||
|
||||
root := getRoot(t)
|
||||
@@ -192,14 +182,14 @@ func prepareTestData(t *testing.T) (*codersdk.Client, map[string]string) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
version := coderdtest.CreateTemplateVersion(t, rootClient, firstUser.OrganizationID, nil)
|
||||
version = coderdtest.AwaitTemplateVersionJob(t, rootClient, version.ID)
|
||||
version = coderdtest.AwaitTemplateVersionJobCompleted(t, rootClient, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, rootClient, firstUser.OrganizationID, version.ID, func(req *codersdk.CreateTemplateRequest) {
|
||||
req.Name = "test-template"
|
||||
})
|
||||
workspace := coderdtest.CreateWorkspace(t, rootClient, firstUser.OrganizationID, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
|
||||
req.Name = "test-workspace"
|
||||
})
|
||||
workspaceBuild := coderdtest.AwaitWorkspaceBuildJob(t, rootClient, workspace.LatestBuild.ID)
|
||||
workspaceBuild := coderdtest.AwaitWorkspaceBuildJobCompleted(t, rootClient, workspace.LatestBuild.ID)
|
||||
|
||||
replacements := map[string]string{
|
||||
firstUser.UserID.String(): "[first user ID]",
|
||||
|
||||
+19
-5
@@ -80,6 +80,10 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch: %w", err)
|
||||
}
|
||||
logSources := map[uuid.UUID]codersdk.WorkspaceAgentLogSource{}
|
||||
for _, source := range agent.LogSources {
|
||||
logSources[source.ID] = source
|
||||
}
|
||||
|
||||
sw := &stageWriter{w: writer}
|
||||
|
||||
@@ -123,7 +127,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
return nil
|
||||
}
|
||||
|
||||
stage := "Running workspace agent startup script"
|
||||
stage := "Running workspace agent startup scripts"
|
||||
follow := opts.Wait
|
||||
if !follow {
|
||||
stage += " (non-blocking)"
|
||||
@@ -173,7 +177,12 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
return nil
|
||||
}
|
||||
for _, log := range logs {
|
||||
sw.Log(log.CreatedAt, log.Level, log.Output)
|
||||
source, hasSource := logSources[log.SourceID]
|
||||
output := log.Output
|
||||
if hasSource && source.DisplayName != "" {
|
||||
output = source.DisplayName + ": " + output
|
||||
}
|
||||
sw.Log(log.CreatedAt, log.Level, output)
|
||||
lastLog = log
|
||||
}
|
||||
}
|
||||
@@ -192,16 +201,19 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
switch agent.LifecycleState {
|
||||
case codersdk.WorkspaceAgentLifecycleReady:
|
||||
sw.Complete(stage, agent.ReadyAt.Sub(*agent.StartedAt))
|
||||
case codersdk.WorkspaceAgentLifecycleStartTimeout:
|
||||
sw.Fail(stage, 0)
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: A startup script timed out and your workspace may be incomplete.")
|
||||
case codersdk.WorkspaceAgentLifecycleStartError:
|
||||
sw.Fail(stage, agent.ReadyAt.Sub(*agent.StartedAt))
|
||||
// Use zero time (omitted) to separate these from the startup logs.
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: The startup script exited with an error and your workspace may be incomplete.")
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: A startup script exited with an error and your workspace may be incomplete.")
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates#startup-script-exited-with-an-error"))
|
||||
default:
|
||||
switch {
|
||||
case agent.LifecycleState.Starting():
|
||||
// Use zero time (omitted) to separate these from the startup logs.
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Notice: The startup script is still running and your workspace may be incomplete.")
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Notice: The startup scripts are still running and your workspace may be incomplete.")
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates#your-workspace-may-be-incomplete"))
|
||||
// Note: We don't complete or fail the stage here, it's
|
||||
// intentionally left open to indicate this stage didn't
|
||||
@@ -225,12 +237,14 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
sw.Start(stage)
|
||||
sw.Log(time.Now(), codersdk.LogLevelWarn, "Wait for it to reconnect or restart your workspace.")
|
||||
sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates#agent-connection-issues"))
|
||||
|
||||
disconnectedAt := *agent.DisconnectedAt
|
||||
for agent.Status == codersdk.WorkspaceAgentDisconnected {
|
||||
if agent, err = fetch(); err != nil {
|
||||
return xerrors.Errorf("fetch: %w", err)
|
||||
}
|
||||
}
|
||||
sw.Complete(stage, agent.LastConnectedAt.Sub(*agent.DisconnectedAt))
|
||||
sw.Complete(stage, agent.LastConnectedAt.Sub(disconnectedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+147
-56
@@ -5,12 +5,14 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
@@ -25,9 +27,31 @@ import (
|
||||
func TestAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
waitLines := func(t *testing.T, output <-chan string, lines ...string) error {
|
||||
t.Helper()
|
||||
|
||||
var got []string
|
||||
outerLoop:
|
||||
for _, want := range lines {
|
||||
for {
|
||||
select {
|
||||
case line := <-output:
|
||||
got = append(got, line)
|
||||
if strings.Contains(line, want) {
|
||||
continue outerLoop
|
||||
}
|
||||
case <-time.After(testutil.WaitShort):
|
||||
assert.Failf(t, "timed out waiting for line", "want: %q; got: %q", want, got)
|
||||
return xerrors.Errorf("timed out waiting for line: %q; got: %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
iter []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error
|
||||
iter []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error
|
||||
logs chan []codersdk.WorkspaceAgentLog
|
||||
opts cliui.AgentOptions
|
||||
want []string
|
||||
@@ -38,12 +62,15 @@ func TestAgent(t *testing.T) {
|
||||
opts: cliui.AgentOptions{
|
||||
FetchInterval: time.Millisecond,
|
||||
},
|
||||
iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnecting
|
||||
return nil
|
||||
},
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
return waitLines(t, output, "⧗ Waiting for the workspace agent to connect")
|
||||
},
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
agent.FirstConnectedAt = ptr.Ref(time.Now())
|
||||
return nil
|
||||
@@ -52,28 +79,62 @@ func TestAgent(t *testing.T) {
|
||||
want: []string{
|
||||
"⧗ Waiting for the workspace agent to connect",
|
||||
"✔ Waiting for the workspace agent to connect",
|
||||
"⧗ Running workspace agent startup script (non-blocking)",
|
||||
"Notice: The startup script is still running and your workspace may be incomplete.",
|
||||
"⧗ Running workspace agent startup scripts (non-blocking)",
|
||||
"Notice: The startup scripts are still running and your workspace may be incomplete.",
|
||||
"For more information and troubleshooting, see",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Start timeout",
|
||||
opts: cliui.AgentOptions{
|
||||
FetchInterval: time.Millisecond,
|
||||
},
|
||||
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnecting
|
||||
return nil
|
||||
},
|
||||
func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
return waitLines(t, output, "⧗ Waiting for the workspace agent to connect")
|
||||
},
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStartTimeout
|
||||
agent.FirstConnectedAt = ptr.Ref(time.Now())
|
||||
return nil
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
"⧗ Waiting for the workspace agent to connect",
|
||||
"✔ Waiting for the workspace agent to connect",
|
||||
"⧗ Running workspace agent startup scripts (non-blocking)",
|
||||
"✘ Running workspace agent startup scripts (non-blocking)",
|
||||
"Warning: A startup script timed out and your workspace may be incomplete.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Initial connection timeout",
|
||||
opts: cliui.AgentOptions{
|
||||
FetchInterval: 1 * time.Millisecond,
|
||||
},
|
||||
iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnecting
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStarting
|
||||
agent.StartedAt = ptr.Ref(time.Now())
|
||||
return nil
|
||||
},
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
return waitLines(t, output, "⧗ Waiting for the workspace agent to connect")
|
||||
},
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentTimeout
|
||||
return nil
|
||||
},
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
return waitLines(t, output, "The workspace agent is having trouble connecting, wait for it to connect or restart your workspace.")
|
||||
},
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
agent.FirstConnectedAt = ptr.Ref(time.Now())
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleReady
|
||||
@@ -86,8 +147,8 @@ func TestAgent(t *testing.T) {
|
||||
"The workspace agent is having trouble connecting, wait for it to connect or restart your workspace.",
|
||||
"For more information and troubleshooting, see",
|
||||
"✔ Waiting for the workspace agent to connect",
|
||||
"⧗ Running workspace agent startup script (non-blocking)",
|
||||
"✔ Running workspace agent startup script (non-blocking)",
|
||||
"⧗ Running workspace agent startup scripts (non-blocking)",
|
||||
"✔ Running workspace agent startup scripts (non-blocking)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -95,8 +156,8 @@ func TestAgent(t *testing.T) {
|
||||
opts: cliui.AgentOptions{
|
||||
FetchInterval: 1 * time.Millisecond,
|
||||
},
|
||||
iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentDisconnected
|
||||
agent.FirstConnectedAt = ptr.Ref(time.Now().Add(-1 * time.Minute))
|
||||
agent.LastConnectedAt = ptr.Ref(time.Now().Add(-1 * time.Minute))
|
||||
@@ -106,8 +167,12 @@ func TestAgent(t *testing.T) {
|
||||
agent.ReadyAt = ptr.Ref(time.Now())
|
||||
return nil
|
||||
},
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
return waitLines(t, output, "⧗ The workspace agent lost connection")
|
||||
},
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
agent.DisconnectedAt = nil
|
||||
agent.LastConnectedAt = ptr.Ref(time.Now())
|
||||
return nil
|
||||
},
|
||||
@@ -120,26 +185,31 @@ func TestAgent(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Startup script logs",
|
||||
name: "Startup Logs",
|
||||
opts: cliui.AgentOptions{
|
||||
FetchInterval: time.Millisecond,
|
||||
Wait: true,
|
||||
},
|
||||
iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
agent.FirstConnectedAt = ptr.Ref(time.Now())
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStarting
|
||||
agent.StartedAt = ptr.Ref(time.Now())
|
||||
agent.LogSources = []codersdk.WorkspaceAgentLogSource{{
|
||||
ID: uuid.Nil,
|
||||
DisplayName: "testing",
|
||||
}}
|
||||
logs <- []codersdk.WorkspaceAgentLog{
|
||||
{
|
||||
CreatedAt: time.Now(),
|
||||
Output: "Hello world",
|
||||
SourceID: uuid.Nil,
|
||||
},
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleReady
|
||||
agent.ReadyAt = ptr.Ref(time.Now())
|
||||
logs <- []codersdk.WorkspaceAgentLog{
|
||||
@@ -152,10 +222,10 @@ func TestAgent(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
"⧗ Running workspace agent startup script",
|
||||
"Hello world",
|
||||
"⧗ Running workspace agent startup scripts",
|
||||
"testing: Hello world",
|
||||
"Bye now",
|
||||
"✔ Running workspace agent startup script",
|
||||
"✔ Running workspace agent startup scripts",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -164,8 +234,8 @@ func TestAgent(t *testing.T) {
|
||||
FetchInterval: time.Millisecond,
|
||||
Wait: true,
|
||||
},
|
||||
iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
agent.FirstConnectedAt = ptr.Ref(time.Now())
|
||||
agent.StartedAt = ptr.Ref(time.Now())
|
||||
@@ -181,10 +251,10 @@ func TestAgent(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
"⧗ Running workspace agent startup script",
|
||||
"⧗ Running workspace agent startup scripts",
|
||||
"Hello world",
|
||||
"✘ Running workspace agent startup script",
|
||||
"Warning: The startup script exited with an error and your workspace may be incomplete.",
|
||||
"✘ Running workspace agent startup scripts",
|
||||
"Warning: A startup script exited with an error and your workspace may be incomplete.",
|
||||
"For more information and troubleshooting, see",
|
||||
},
|
||||
},
|
||||
@@ -193,8 +263,8 @@ func TestAgent(t *testing.T) {
|
||||
opts: cliui.AgentOptions{
|
||||
FetchInterval: time.Millisecond,
|
||||
},
|
||||
iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentDisconnected
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleOff
|
||||
return nil
|
||||
@@ -208,8 +278,8 @@ func TestAgent(t *testing.T) {
|
||||
FetchInterval: time.Millisecond,
|
||||
Wait: true,
|
||||
},
|
||||
iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
agent.FirstConnectedAt = ptr.Ref(time.Now())
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStarting
|
||||
@@ -222,16 +292,19 @@ func TestAgent(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, logs chan []codersdk.WorkspaceAgentLog) error {
|
||||
func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
return waitLines(t, output, "Hello world")
|
||||
},
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.ReadyAt = ptr.Ref(time.Now())
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleShuttingDown
|
||||
return nil
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
"⧗ Running workspace agent startup script",
|
||||
"⧗ Running workspace agent startup scripts",
|
||||
"Hello world",
|
||||
"✔ Running workspace agent startup script",
|
||||
"✔ Running workspace agent startup scripts",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
@@ -241,12 +314,15 @@ func TestAgent(t *testing.T) {
|
||||
FetchInterval: time.Millisecond,
|
||||
Wait: true,
|
||||
},
|
||||
iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnecting
|
||||
return nil
|
||||
},
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
return waitLines(t, output, "⧗ Waiting for the workspace agent to connect")
|
||||
},
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
return xerrors.New("bad")
|
||||
},
|
||||
},
|
||||
@@ -261,13 +337,16 @@ func TestAgent(t *testing.T) {
|
||||
FetchInterval: time.Millisecond,
|
||||
Wait: true,
|
||||
},
|
||||
iter: []func(context.Context, *codersdk.WorkspaceAgent, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentTimeout
|
||||
agent.TroubleshootingURL = "https://troubleshoot"
|
||||
return nil
|
||||
},
|
||||
func(_ context.Context, agent *codersdk.WorkspaceAgent, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
return waitLines(t, output, "The workspace agent is having trouble connecting, wait for it to connect or restart your workspace.")
|
||||
},
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
return xerrors.New("bad")
|
||||
},
|
||||
},
|
||||
@@ -286,22 +365,27 @@ func TestAgent(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err, "create pipe failed")
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
|
||||
agent := codersdk.WorkspaceAgent{
|
||||
ID: uuid.New(),
|
||||
Status: codersdk.WorkspaceAgentConnecting,
|
||||
StartupScriptBehavior: codersdk.WorkspaceAgentStartupScriptBehaviorNonBlocking,
|
||||
CreatedAt: time.Now(),
|
||||
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||
ID: uuid.New(),
|
||||
Status: codersdk.WorkspaceAgentConnecting,
|
||||
CreatedAt: time.Now(),
|
||||
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||
}
|
||||
output := make(chan string, 100) // Buffered to avoid blocking, overflow is discarded.
|
||||
logs := make(chan []codersdk.WorkspaceAgentLog, 1)
|
||||
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
tc.opts.Fetch = func(_ context.Context, _ uuid.UUID) (codersdk.WorkspaceAgent, error) {
|
||||
t.Log("iter", len(tc.iter))
|
||||
var err error
|
||||
if len(tc.iter) > 0 {
|
||||
err = tc.iter[0](ctx, &agent, logs)
|
||||
err = tc.iter[0](ctx, t, &agent, output, logs)
|
||||
tc.iter = tc.iter[1:]
|
||||
}
|
||||
return agent, err
|
||||
@@ -322,25 +406,26 @@ func TestAgent(t *testing.T) {
|
||||
close(fetchLogs)
|
||||
return fetchLogs, closeFunc(func() error { return nil }), nil
|
||||
}
|
||||
err := cliui.Agent(inv.Context(), &buf, uuid.Nil, tc.opts)
|
||||
err := cliui.Agent(inv.Context(), w, uuid.Nil, tc.opts)
|
||||
_ = w.Close()
|
||||
return err
|
||||
},
|
||||
}
|
||||
inv := cmd.Invoke()
|
||||
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
if tc.wantErr {
|
||||
w.RequireError()
|
||||
} else {
|
||||
w.RequireSuccess()
|
||||
}
|
||||
waiter := clitest.StartWithWaiter(t, inv)
|
||||
|
||||
s := bufio.NewScanner(&buf)
|
||||
s := bufio.NewScanner(r)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
t.Log(line)
|
||||
select {
|
||||
case output <- line:
|
||||
default:
|
||||
t.Logf("output overflow: %s", line)
|
||||
}
|
||||
if len(tc.want) == 0 {
|
||||
require.Fail(t, "unexpected line: "+line)
|
||||
require.Fail(t, "unexpected line", line)
|
||||
}
|
||||
require.Contains(t, line, tc.want[0])
|
||||
tc.want = tc.want[1:]
|
||||
@@ -349,6 +434,12 @@ func TestAgent(t *testing.T) {
|
||||
if len(tc.want) > 0 {
|
||||
require.Fail(t, "missing lines: "+strings.Join(tc.want, ", "))
|
||||
}
|
||||
|
||||
if tc.wantErr {
|
||||
waiter.RequireError()
|
||||
} else {
|
||||
waiter.RequireSuccess()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+131
-36
@@ -1,12 +1,15 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/charm/ui/common"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
)
|
||||
|
||||
var Canceled = xerrors.New("canceled")
|
||||
@@ -15,55 +18,147 @@ var Canceled = xerrors.New("canceled")
|
||||
var DefaultStyles Styles
|
||||
|
||||
type Styles struct {
|
||||
Bold,
|
||||
Checkmark,
|
||||
Code,
|
||||
Crossmark,
|
||||
DateTimeStamp,
|
||||
Error,
|
||||
Field,
|
||||
Keyword,
|
||||
Paragraph,
|
||||
Placeholder,
|
||||
Prompt,
|
||||
FocusedPrompt,
|
||||
Fuchsia,
|
||||
Logo,
|
||||
Warn,
|
||||
Wrap lipgloss.Style
|
||||
Wrap pretty.Style
|
||||
}
|
||||
|
||||
var (
|
||||
color termenv.Profile
|
||||
colorOnce sync.Once
|
||||
)
|
||||
|
||||
var (
|
||||
Green = Color("#04B575")
|
||||
Red = Color("#ED567A")
|
||||
Fuchsia = Color("#EE6FF8")
|
||||
Yellow = Color("#ECFD65")
|
||||
Blue = Color("#5000ff")
|
||||
)
|
||||
|
||||
// Color returns a color for the given string.
|
||||
func Color(s string) termenv.Color {
|
||||
colorOnce.Do(func() {
|
||||
color = termenv.NewOutput(os.Stdout).ColorProfile()
|
||||
if flag.Lookup("test.v") != nil {
|
||||
// Use a consistent colorless profile in tests so that results
|
||||
// are deterministic.
|
||||
color = termenv.Ascii
|
||||
}
|
||||
})
|
||||
return color.Color(s)
|
||||
}
|
||||
|
||||
func isTerm() bool {
|
||||
return color != termenv.Ascii
|
||||
}
|
||||
|
||||
// Bold returns a formatter that renders text in bold
|
||||
// if the terminal supports it.
|
||||
func Bold(s string) string {
|
||||
if !isTerm() {
|
||||
return s
|
||||
}
|
||||
return pretty.Sprint(pretty.Bold(), s)
|
||||
}
|
||||
|
||||
// BoldFmt returns a formatter that renders text in bold
|
||||
// if the terminal supports it.
|
||||
func BoldFmt() pretty.Formatter {
|
||||
if !isTerm() {
|
||||
return pretty.Style{}
|
||||
}
|
||||
return pretty.Bold()
|
||||
}
|
||||
|
||||
// Timestamp formats a timestamp for display.
|
||||
func Timestamp(t time.Time) string {
|
||||
return pretty.Sprint(DefaultStyles.DateTimeStamp, t.Format(time.Stamp))
|
||||
}
|
||||
|
||||
// Keyword formats a keyword for display.
|
||||
func Keyword(s string) string {
|
||||
return pretty.Sprint(DefaultStyles.Keyword, s)
|
||||
}
|
||||
|
||||
// Placeholder formats a placeholder for display.
|
||||
func Placeholder(s string) string {
|
||||
return pretty.Sprint(DefaultStyles.Placeholder, s)
|
||||
}
|
||||
|
||||
// Wrap prevents the text from overflowing the terminal.
|
||||
func Wrap(s string) string {
|
||||
return pretty.Sprint(DefaultStyles.Wrap, s)
|
||||
}
|
||||
|
||||
// Code formats code for display.
|
||||
func Code(s string) string {
|
||||
return pretty.Sprint(DefaultStyles.Code, s)
|
||||
}
|
||||
|
||||
// Field formats a field for display.
|
||||
func Field(s string) string {
|
||||
return pretty.Sprint(DefaultStyles.Field, s)
|
||||
}
|
||||
|
||||
func ifTerm(fmt pretty.Formatter) pretty.Formatter {
|
||||
if !isTerm() {
|
||||
return pretty.Nop
|
||||
}
|
||||
return fmt
|
||||
}
|
||||
|
||||
func init() {
|
||||
lipgloss.SetDefaultRenderer(
|
||||
lipgloss.NewRenderer(os.Stdout, termenv.WithColorCache(true)),
|
||||
)
|
||||
|
||||
// All Styles are set after we change the DefaultRenderer so that the ColorCache
|
||||
// is in effect, mitigating the severe performance issue seen here:
|
||||
// https://github.com/coder/coder/issues/7884.
|
||||
|
||||
charmStyles := common.DefaultStyles()
|
||||
|
||||
// We do not adapt the color based on whether the terminal is light or dark.
|
||||
// Doing so would require a round-trip between the program and the terminal
|
||||
// due to the OSC query and response.
|
||||
DefaultStyles = Styles{
|
||||
Bold: lipgloss.NewStyle().Bold(true),
|
||||
Checkmark: charmStyles.Checkmark,
|
||||
Code: charmStyles.Code,
|
||||
Crossmark: charmStyles.Error.Copy().SetString("✘"),
|
||||
DateTimeStamp: charmStyles.LabelDim,
|
||||
Error: charmStyles.Error,
|
||||
Field: charmStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}),
|
||||
Keyword: charmStyles.Keyword,
|
||||
Paragraph: charmStyles.Paragraph,
|
||||
Placeholder: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#585858", Dark: "#4d46b3"}),
|
||||
Prompt: charmStyles.Prompt.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}),
|
||||
FocusedPrompt: charmStyles.FocusedPrompt.Copy().Foreground(lipgloss.Color("#651fff")),
|
||||
Fuchsia: charmStyles.SelectedMenuItem.Copy(),
|
||||
Logo: charmStyles.Logo.Copy().SetString("Coder"),
|
||||
Warn: lipgloss.NewStyle().Foreground(
|
||||
lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"},
|
||||
),
|
||||
Wrap: lipgloss.NewStyle().Width(80),
|
||||
Code: pretty.Style{
|
||||
ifTerm(pretty.XPad(1, 1)),
|
||||
pretty.FgColor(Red),
|
||||
pretty.BgColor(color.Color("#2c2c2c")),
|
||||
},
|
||||
DateTimeStamp: pretty.Style{
|
||||
pretty.FgColor(color.Color("#7571F9")),
|
||||
},
|
||||
Error: pretty.Style{
|
||||
pretty.FgColor(Red),
|
||||
},
|
||||
Field: pretty.Style{
|
||||
pretty.XPad(1, 1),
|
||||
pretty.FgColor(color.Color("#FFFFFF")),
|
||||
pretty.BgColor(color.Color("#2b2a2a")),
|
||||
},
|
||||
Keyword: pretty.Style{
|
||||
pretty.FgColor(Green),
|
||||
},
|
||||
Placeholder: pretty.Style{
|
||||
pretty.FgColor(color.Color("#4d46b3")),
|
||||
},
|
||||
Prompt: pretty.Style{
|
||||
pretty.FgColor(color.Color("#5C5C5C")),
|
||||
pretty.Wrap("> ", ""),
|
||||
},
|
||||
Warn: pretty.Style{
|
||||
pretty.FgColor(Yellow),
|
||||
},
|
||||
Wrap: pretty.Style{
|
||||
pretty.LineWrap(80),
|
||||
},
|
||||
}
|
||||
|
||||
DefaultStyles.FocusedPrompt = append(
|
||||
DefaultStyles.Prompt,
|
||||
pretty.FgColor(Blue),
|
||||
)
|
||||
}
|
||||
|
||||
// ValidateNotEmpty is a helper function to disallow empty inputs!
|
||||
|
||||
@@ -11,12 +11,12 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
type GitAuthOptions struct {
|
||||
Fetch func(context.Context) ([]codersdk.TemplateVersionGitAuth, error)
|
||||
type ExternalAuthOptions struct {
|
||||
Fetch func(context.Context) ([]codersdk.TemplateVersionExternalAuth, error)
|
||||
FetchInterval time.Duration
|
||||
}
|
||||
|
||||
func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error {
|
||||
func ExternalAuth(ctx context.Context, writer io.Writer, opts ExternalAuthOptions) error {
|
||||
if opts.FetchInterval == 0 {
|
||||
opts.FetchInterval = 500 * time.Millisecond
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(writer, "You must authenticate with %s to create a workspace with this template. Visit:\n\n\t%s\n\n", auth.Type.Pretty(), auth.AuthenticateURL)
|
||||
_, _ = 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)
|
||||
|
||||
ticker.Reset(opts.FetchInterval)
|
||||
spin.Start()
|
||||
@@ -66,7 +66,7 @@ func GitAuth(ctx context.Context, writer io.Writer, opts GitAuthOptions) error {
|
||||
}
|
||||
}
|
||||
spin.Stop()
|
||||
_, _ = fmt.Fprintf(writer, "Successfully authenticated with %s!\n\n", auth.Type.Pretty())
|
||||
_, _ = fmt.Fprintf(writer, "Successfully authenticated with %s!\n\n", auth.DisplayName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestGitAuth(t *testing.T) {
|
||||
func TestExternalAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
@@ -25,12 +25,13 @@ func TestGitAuth(t *testing.T) {
|
||||
cmd := &clibase.Cmd{
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
var fetched atomic.Bool
|
||||
return cliui.GitAuth(inv.Context(), inv.Stdout, cliui.GitAuthOptions{
|
||||
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionGitAuth, error) {
|
||||
return cliui.ExternalAuth(inv.Context(), inv.Stdout, cliui.ExternalAuthOptions{
|
||||
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) {
|
||||
defer fetched.Store(true)
|
||||
return []codersdk.TemplateVersionGitAuth{{
|
||||
return []codersdk.TemplateVersionExternalAuth{{
|
||||
ID: "github",
|
||||
Type: codersdk.GitProviderGitHub,
|
||||
DisplayName: "GitHub",
|
||||
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
||||
Authenticated: fetched.Load(),
|
||||
AuthenticateURL: "https://example.com/gitauth/github",
|
||||
}}, nil
|
||||
+7
-7
@@ -5,12 +5,12 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/coder/pretty"
|
||||
)
|
||||
|
||||
// cliMessage provides a human-readable message for CLI errors and messages.
|
||||
type cliMessage struct {
|
||||
Style lipgloss.Style
|
||||
Style pretty.Style
|
||||
Header string
|
||||
Prefix string
|
||||
Lines []string
|
||||
@@ -21,13 +21,13 @@ func (m cliMessage) String() string {
|
||||
var str strings.Builder
|
||||
|
||||
if m.Prefix != "" {
|
||||
_, _ = str.WriteString(m.Style.Bold(true).Render(m.Prefix))
|
||||
_, _ = str.WriteString(Bold(m.Prefix))
|
||||
}
|
||||
|
||||
_, _ = str.WriteString(m.Style.Bold(false).Render(m.Header))
|
||||
pretty.Fprint(&str, m.Style, m.Header)
|
||||
_, _ = str.WriteString("\r\n")
|
||||
for _, line := range m.Lines {
|
||||
_, _ = fmt.Fprintf(&str, " %s %s\r\n", m.Style.Render("|"), line)
|
||||
_, _ = fmt.Fprintf(&str, " %s %s\r\n", pretty.Sprint(m.Style, "|"), line)
|
||||
}
|
||||
return str.String()
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func (m cliMessage) String() string {
|
||||
// Warn writes a log to the writer provided.
|
||||
func Warn(wtr io.Writer, header string, lines ...string) {
|
||||
_, _ = fmt.Fprint(wtr, cliMessage{
|
||||
Style: DefaultStyles.Warn.Copy(),
|
||||
Style: DefaultStyles.Warn,
|
||||
Prefix: "WARN: ",
|
||||
Header: header,
|
||||
Lines: lines,
|
||||
@@ -63,7 +63,7 @@ func Infof(wtr io.Writer, fmtStr string, args ...interface{}) {
|
||||
// Error writes a log to the writer provided.
|
||||
func Error(wtr io.Writer, header string, lines ...string) {
|
||||
_, _ = fmt.Fprint(wtr, cliMessage{
|
||||
Style: DefaultStyles.Error.Copy(),
|
||||
Style: DefaultStyles.Error,
|
||||
Prefix: "ERROR: ",
|
||||
Header: header,
|
||||
Lines: lines,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/pretty"
|
||||
)
|
||||
|
||||
func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.TemplateVersionParameter) (string, error) {
|
||||
@@ -16,10 +17,10 @@ func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.Te
|
||||
}
|
||||
|
||||
if templateVersionParameter.Ephemeral {
|
||||
label += DefaultStyles.Warn.Render(" (build option)")
|
||||
label += pretty.Sprint(DefaultStyles.Warn, " (build option)")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stdout, DefaultStyles.Bold.Render(label))
|
||||
_, _ = fmt.Fprintln(inv.Stdout, Bold(label))
|
||||
|
||||
if templateVersionParameter.DescriptionPlaintext != "" {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.DescriptionPlaintext, "\n"), "\n "))+"\n")
|
||||
@@ -45,7 +46,10 @@ func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.Te
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stdout)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+DefaultStyles.Prompt.String()+DefaultStyles.Field.Render(strings.Join(values, ", ")))
|
||||
pretty.Fprintf(
|
||||
inv.Stdout,
|
||||
DefaultStyles.Prompt, "%s\n", strings.Join(values, ", "),
|
||||
)
|
||||
value = string(v)
|
||||
}
|
||||
} else if len(templateVersionParameter.Options) > 0 {
|
||||
@@ -59,7 +63,7 @@ func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.Te
|
||||
})
|
||||
if err == nil {
|
||||
_, _ = fmt.Fprintln(inv.Stdout)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+DefaultStyles.Prompt.String()+DefaultStyles.Field.Render(richParameterOption.Name))
|
||||
pretty.Fprintf(inv.Stdout, DefaultStyles.Prompt, "%s\n", richParameterOption.Name)
|
||||
value = richParameterOption.Value
|
||||
}
|
||||
} else {
|
||||
@@ -70,7 +74,7 @@ func RichParameter(inv *clibase.Invocation, templateVersionParameter codersdk.Te
|
||||
text += ":"
|
||||
|
||||
value, err = Prompt(inv, PromptOptions{
|
||||
Text: DefaultStyles.Bold.Render(text),
|
||||
Text: Bold(text),
|
||||
Validate: func(value string) error {
|
||||
return validateRichPrompt(value, templateVersionParameter)
|
||||
},
|
||||
|
||||
+12
-8
@@ -14,6 +14,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/pretty"
|
||||
)
|
||||
|
||||
// PromptOptions supply a set of options to the prompt.
|
||||
@@ -55,21 +56,24 @@ func Prompt(inv *clibase.Invocation, opts PromptOptions) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(inv.Stdout, DefaultStyles.FocusedPrompt.String()+opts.Text+" ")
|
||||
pretty.Fprintf(inv.Stdout, DefaultStyles.FocusedPrompt, "")
|
||||
pretty.Fprintf(inv.Stdout, pretty.Nop, "%s ", opts.Text)
|
||||
if opts.IsConfirm {
|
||||
if len(opts.Default) == 0 {
|
||||
opts.Default = ConfirmYes
|
||||
}
|
||||
renderedYes := DefaultStyles.Placeholder.Render(ConfirmYes)
|
||||
renderedNo := DefaultStyles.Placeholder.Render(ConfirmNo)
|
||||
var (
|
||||
renderedYes = pretty.Sprint(DefaultStyles.Placeholder, ConfirmYes)
|
||||
renderedNo = pretty.Sprint(DefaultStyles.Placeholder, ConfirmNo)
|
||||
)
|
||||
if opts.Default == ConfirmYes {
|
||||
renderedYes = DefaultStyles.Bold.Render(ConfirmYes)
|
||||
renderedYes = Bold(ConfirmYes)
|
||||
} else {
|
||||
renderedNo = DefaultStyles.Bold.Render(ConfirmNo)
|
||||
renderedNo = Bold(ConfirmNo)
|
||||
}
|
||||
_, _ = fmt.Fprint(inv.Stdout, DefaultStyles.Placeholder.Render("("+renderedYes+DefaultStyles.Placeholder.Render("/"+renderedNo+DefaultStyles.Placeholder.Render(") "))))
|
||||
pretty.Fprintf(inv.Stdout, DefaultStyles.Placeholder, "(%s/%s)", renderedYes, renderedNo)
|
||||
} else if opts.Default != "" {
|
||||
_, _ = fmt.Fprint(inv.Stdout, DefaultStyles.Placeholder.Render("("+opts.Default+") "))
|
||||
_, _ = fmt.Fprint(inv.Stdout, pretty.Sprint(DefaultStyles.Placeholder, "("+opts.Default+") "))
|
||||
}
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
|
||||
@@ -126,7 +130,7 @@ func Prompt(inv *clibase.Invocation, opts PromptOptions) (string, error) {
|
||||
if opts.Validate != nil {
|
||||
err := opts.Validate(line)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, DefaultStyles.Error.Render(err.Error()))
|
||||
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(DefaultStyles.Error, err.Error()))
|
||||
return Prompt(inv, opts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/pretty"
|
||||
)
|
||||
|
||||
func WorkspaceBuild(ctx context.Context, writer io.Writer, client *codersdk.Client, build uuid.UUID) error {
|
||||
@@ -54,7 +55,7 @@ func (err *ProvisionerJobError) Error() string {
|
||||
}
|
||||
|
||||
// ProvisionerJob renders a provisioner job with interactive cancellation.
|
||||
func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOptions) error {
|
||||
func ProvisionerJob(ctx context.Context, wr io.Writer, opts ProvisionerJobOptions) error {
|
||||
if opts.FetchInterval == 0 {
|
||||
opts.FetchInterval = time.Second
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
|
||||
jobMutex sync.Mutex
|
||||
)
|
||||
|
||||
sw := &stageWriter{w: writer, verbose: opts.Verbose, silentLogs: opts.Silent}
|
||||
sw := &stageWriter{w: wr, verbose: opts.Verbose, silentLogs: opts.Silent}
|
||||
|
||||
printStage := func() {
|
||||
sw.Start(currentStage)
|
||||
@@ -127,7 +128,11 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
|
||||
return
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprintf(writer, DefaultStyles.FocusedPrompt.String()+DefaultStyles.Bold.Render("Gracefully canceling...")+"\n\n")
|
||||
pretty.Fprintf(
|
||||
wr,
|
||||
DefaultStyles.FocusedPrompt.With(BoldFmt()),
|
||||
"Gracefully canceling...\n\n",
|
||||
)
|
||||
err := opts.Cancel()
|
||||
if err != nil {
|
||||
errChan <- xerrors.Errorf("cancel: %w", err)
|
||||
@@ -236,7 +241,7 @@ func (s *stageWriter) Log(createdAt time.Time, level codersdk.LogLevel, line str
|
||||
w = &s.logBuf
|
||||
}
|
||||
|
||||
render := func(s ...string) string { return strings.Join(s, " ") }
|
||||
var style pretty.Style
|
||||
|
||||
var lines []string
|
||||
if !createdAt.IsZero() {
|
||||
@@ -249,14 +254,14 @@ func (s *stageWriter) Log(createdAt time.Time, level codersdk.LogLevel, line str
|
||||
if !s.verbose {
|
||||
return
|
||||
}
|
||||
render = DefaultStyles.Placeholder.Render
|
||||
style = DefaultStyles.Placeholder
|
||||
case codersdk.LogLevelError:
|
||||
render = DefaultStyles.Error.Render
|
||||
style = DefaultStyles.Error
|
||||
case codersdk.LogLevelWarn:
|
||||
render = DefaultStyles.Warn.Render
|
||||
style = DefaultStyles.Warn
|
||||
case codersdk.LogLevelInfo:
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\n", render(lines...))
|
||||
pretty.Fprintf(w, style, "%s\n", strings.Join(lines, " "))
|
||||
}
|
||||
|
||||
func (s *stageWriter) flushLogs() {
|
||||
|
||||
+16
-15
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/pretty"
|
||||
)
|
||||
|
||||
type WorkspaceResourcesOptions struct {
|
||||
@@ -78,7 +79,7 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
|
||||
|
||||
// Display a line for the resource.
|
||||
tableWriter.AppendRow(table.Row{
|
||||
DefaultStyles.Bold.Render(resourceAddress),
|
||||
Bold(resourceAddress),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
@@ -107,7 +108,7 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
|
||||
if totalAgents > 1 {
|
||||
sshCommand += "." + agent.Name
|
||||
}
|
||||
sshCommand = DefaultStyles.Code.Render(sshCommand)
|
||||
sshCommand = pretty.Sprint(DefaultStyles.Code, sshCommand)
|
||||
row = append(row, sshCommand)
|
||||
}
|
||||
tableWriter.AppendRow(row)
|
||||
@@ -122,31 +123,31 @@ func renderAgentStatus(agent codersdk.WorkspaceAgent) string {
|
||||
switch agent.Status {
|
||||
case codersdk.WorkspaceAgentConnecting:
|
||||
since := dbtime.Now().Sub(agent.CreatedAt)
|
||||
return DefaultStyles.Warn.Render("⦾ connecting") + " " +
|
||||
DefaultStyles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
return pretty.Sprint(DefaultStyles.Warn, "⦾ connecting") + " " +
|
||||
pretty.Sprint(DefaultStyles.Placeholder, "["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentDisconnected:
|
||||
since := dbtime.Now().Sub(*agent.DisconnectedAt)
|
||||
return DefaultStyles.Error.Render("⦾ disconnected") + " " +
|
||||
DefaultStyles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
return pretty.Sprint(DefaultStyles.Error, "⦾ disconnected") + " " +
|
||||
pretty.Sprint(DefaultStyles.Placeholder, "["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentTimeout:
|
||||
since := dbtime.Now().Sub(agent.CreatedAt)
|
||||
return fmt.Sprintf(
|
||||
"%s %s",
|
||||
DefaultStyles.Warn.Render("⦾ timeout"),
|
||||
DefaultStyles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]"),
|
||||
pretty.Sprint(DefaultStyles.Warn, "⦾ timeout"),
|
||||
pretty.Sprint(DefaultStyles.Placeholder, "["+strconv.Itoa(int(since.Seconds()))+"s]"),
|
||||
)
|
||||
case codersdk.WorkspaceAgentConnected:
|
||||
return DefaultStyles.Keyword.Render("⦿ connected")
|
||||
return pretty.Sprint(DefaultStyles.Keyword, "⦿ connected")
|
||||
default:
|
||||
return DefaultStyles.Warn.Render("○ unknown")
|
||||
return pretty.Sprint(DefaultStyles.Warn, "○ unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func renderAgentHealth(agent codersdk.WorkspaceAgent) string {
|
||||
if agent.Health.Healthy {
|
||||
return DefaultStyles.Keyword.Render("✔ healthy")
|
||||
return pretty.Sprint(DefaultStyles.Keyword, "✔ healthy")
|
||||
}
|
||||
return DefaultStyles.Error.Render("✘ " + agent.Health.Reason)
|
||||
return pretty.Sprint(DefaultStyles.Error, "✘ "+agent.Health.Reason)
|
||||
}
|
||||
|
||||
func renderAgentVersion(agentVersion, serverVersion string) string {
|
||||
@@ -154,11 +155,11 @@ func renderAgentVersion(agentVersion, serverVersion string) string {
|
||||
agentVersion = "(unknown)"
|
||||
}
|
||||
if !semver.IsValid(serverVersion) || !semver.IsValid(agentVersion) {
|
||||
return DefaultStyles.Placeholder.Render(agentVersion)
|
||||
return pretty.Sprint(DefaultStyles.Placeholder, agentVersion)
|
||||
}
|
||||
outdated := semver.Compare(agentVersion, serverVersion) < 0
|
||||
if outdated {
|
||||
return DefaultStyles.Warn.Render(agentVersion + " (outdated)")
|
||||
return pretty.Sprint(DefaultStyles.Warn, agentVersion+" (outdated)")
|
||||
}
|
||||
return DefaultStyles.Keyword.Render(agentVersion)
|
||||
return pretty.Sprint(DefaultStyles.Keyword, agentVersion)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestRenderAgentVersion(t *testing.T) {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
actual := renderAgentVersion(testCase.agentVersion, testCase.serverVersion)
|
||||
assert.Equal(t, testCase.expected, actual)
|
||||
assert.Equal(t, testCase.expected, (actual))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+20
-29
@@ -19,13 +19,10 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
@@ -78,9 +75,10 @@ func TestConfigSSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: []*proto.Response{{
|
||||
Type: &proto.Response_Plan{
|
||||
@@ -98,19 +96,11 @@ func TestConfigSSH(t *testing.T) {
|
||||
}},
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
}()
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
_ = agenttest.New(t, client.URL, authToken)
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
agentConn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
@@ -156,7 +146,7 @@ func TestConfigSSH(t *testing.T) {
|
||||
"--ssh-option", "Port "+strconv.Itoa(tcpAddr.Port),
|
||||
"--ssh-config-file", sshConfigFile,
|
||||
"--skip-proxy-command")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
@@ -605,10 +595,10 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, tt.echoResponse)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
)
|
||||
|
||||
// Prepare ssh config files.
|
||||
@@ -721,19 +711,20 @@ func TestConfigSSH_Hostnames(t *testing.T) {
|
||||
}
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
// authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID,
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID,
|
||||
echo.WithResources(resources))
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
sshConfigFile := sshConfigFileName(t)
|
||||
|
||||
inv, root := clitest.New(t, "config-ssh", "--ssh-config-file", sshConfigFile)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
|
||||
+22
-6
@@ -10,6 +10,8 @@ import (
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
@@ -25,6 +27,7 @@ func (r *RootCmd) create() *clibase.Cmd {
|
||||
workspaceName string
|
||||
|
||||
parameterFlags workspaceParameterFlags
|
||||
autoUpdates string
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
cmd := &clibase.Cmd{
|
||||
@@ -75,7 +78,7 @@ func (r *RootCmd) create() *clibase.Cmd {
|
||||
|
||||
var template codersdk.Template
|
||||
if templateName == "" {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Wrap.Render("Select a template below to preview the provisioned infrastructure:"))
|
||||
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))
|
||||
|
||||
templates, err := client.TemplatesByOrganization(inv.Context(), organization.ID)
|
||||
if err != nil {
|
||||
@@ -93,7 +96,7 @@ func (r *RootCmd) create() *clibase.Cmd {
|
||||
templateName := template.Name
|
||||
|
||||
if template.ActiveUserCount > 0 {
|
||||
templateName += cliui.DefaultStyles.Placeholder.Render(
|
||||
templateName += cliui.Placeholder(
|
||||
fmt.Sprintf(
|
||||
" (used by %s)",
|
||||
formatActiveDevelopers(template.ActiveUserCount),
|
||||
@@ -167,6 +170,7 @@ func (r *RootCmd) create() *clibase.Cmd {
|
||||
AutostartSchedule: schedSpec,
|
||||
TTLMillis: ttlMillis,
|
||||
RichParameterValues: richParameters,
|
||||
AutomaticUpdates: codersdk.AutomaticUpdates(autoUpdates),
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create workspace: %w", err)
|
||||
@@ -177,7 +181,12 @@ func (r *RootCmd) create() *clibase.Cmd {
|
||||
return xerrors.Errorf("watch build: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "\nThe %s workspace has been created at %s!\n", cliui.DefaultStyles.Keyword.Render(workspace.Name), cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
_, _ = fmt.Fprintf(
|
||||
inv.Stdout,
|
||||
"\nThe %s workspace has been created at %s!\n",
|
||||
cliui.Keyword(workspace.Name),
|
||||
cliui.Timestamp(time.Now()),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -201,6 +210,13 @@ func (r *RootCmd) create() *clibase.Cmd {
|
||||
Description: "Specify a duration after which the workspace should shut down (e.g. 8h).",
|
||||
Value: clibase.DurationOf(&stopAfter),
|
||||
},
|
||||
clibase.Option{
|
||||
Flag: "automatic-updates",
|
||||
Env: "CODER_WORKSPACE_AUTOMATIC_UPDATES",
|
||||
Description: "Specify automatic updates setting for the workspace (accepts 'always' or 'never').",
|
||||
Default: string(codersdk.AutomaticUpdatesNever),
|
||||
Value: clibase.StringOf(&autoUpdates),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
)
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
|
||||
@@ -258,9 +274,9 @@ func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args p
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = cliui.GitAuth(ctx, inv.Stdout, cliui.GitAuthOptions{
|
||||
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionGitAuth, error) {
|
||||
return client.TemplateVersionGitAuth(ctx, templateVersion.ID)
|
||||
err = cliui.ExternalAuth(ctx, inv.Stdout, cliui.ExternalAuthOptions{
|
||||
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) {
|
||||
return client.TemplateVersionExternalAuth(ctx, templateVersion.ID)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
+92
-73
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/gitauth"
|
||||
"github.com/coder/coder/v2/coderd/externalauth"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
@@ -28,19 +28,21 @@ func TestCreate(t *testing.T) {
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, completeWithAgent())
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"--template", template.Name,
|
||||
"--start-at", "9:30AM Mon-Fri US/Central",
|
||||
"--stop-after", "8h",
|
||||
"--automatic-updates", "always",
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
@@ -64,7 +66,7 @@ func TestCreate(t *testing.T) {
|
||||
}
|
||||
<-doneChan
|
||||
|
||||
ws, err := client.WorkspaceByOwnerAndName(context.Background(), "testuser", "my-workspace", codersdk.WorkspaceOptions{})
|
||||
ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
|
||||
if assert.NoError(t, err, "expected workspace to be created") {
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
if assert.NotNil(t, ws.AutostartSchedule) {
|
||||
@@ -73,6 +75,7 @@ func TestCreate(t *testing.T) {
|
||||
if assert.NotNil(t, ws.TTLMillis) {
|
||||
assert.Equal(t, *ws.TTLMillis, 8*time.Hour.Milliseconds())
|
||||
}
|
||||
assert.Equal(t, codersdk.AutomaticUpdatesAlways, ws.AutomaticUpdates)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -81,7 +84,7 @@ func TestCreate(t *testing.T) {
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent())
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
_, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
args := []string{
|
||||
@@ -93,6 +96,7 @@ func TestCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
inv, root := clitest.New(t, args...)
|
||||
//nolint:gocritic // Creating a workspace for another user requires owner permissions.
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
@@ -132,10 +136,11 @@ func TestCreate(t *testing.T) {
|
||||
t.Run("InheritStopAfterFromTemplate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, completeWithAgent())
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent())
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
var defaultTTLMillis int64 = 2 * 60 * 60 * 1000 // 2 hours
|
||||
ctr.DefaultTTLMillis = &defaultTTLMillis
|
||||
})
|
||||
@@ -145,7 +150,7 @@ func TestCreate(t *testing.T) {
|
||||
"--template", template.Name,
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
waiter := clitest.StartWithWaiter(t, inv)
|
||||
matches := []struct {
|
||||
@@ -164,7 +169,7 @@ func TestCreate(t *testing.T) {
|
||||
}
|
||||
waiter.RequireSuccess()
|
||||
|
||||
ws, err := client.WorkspaceByOwnerAndName(context.Background(), "testuser", "my-workspace", codersdk.WorkspaceOptions{})
|
||||
ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err, "expected workspace to be created")
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
assert.Equal(t, *ws.TTLMillis, template.DefaultTTLMillis)
|
||||
@@ -175,7 +180,7 @@ func TestCreate(t *testing.T) {
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "-y")
|
||||
|
||||
@@ -195,12 +200,13 @@ func TestCreate(t *testing.T) {
|
||||
t.Run("FromNothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
inv, root := clitest.New(t, "create", "")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
@@ -220,7 +226,7 @@ func TestCreate(t *testing.T) {
|
||||
}
|
||||
<-doneChan
|
||||
|
||||
ws, err := client.WorkspaceByOwnerAndName(inv.Context(), "testuser", "my-workspace", codersdk.WorkspaceOptions{})
|
||||
ws, err := member.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
|
||||
if assert.NoError(t, err, "expected workspace to be created") {
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
assert.Nil(t, ws.AutostartSchedule, "expected workspace autostart schedule to be nil")
|
||||
@@ -273,14 +279,15 @@ func TestCreateWithRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
@@ -312,11 +319,12 @@ func TestCreateWithRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
@@ -326,7 +334,7 @@ func TestCreateWithRichParameters(t *testing.T) {
|
||||
secondParameterName + ": " + secondParameterValue + "\n" +
|
||||
immutableParameterName + ": " + immutableParameterValue)
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
@@ -352,17 +360,18 @@ func TestCreateWithRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name,
|
||||
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
|
||||
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
@@ -420,14 +429,15 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(stringRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(stringRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
@@ -446,7 +456,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
if value != "" {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
@@ -455,14 +467,15 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(numberRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(numberRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
@@ -481,7 +494,6 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
|
||||
if value != "" {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
@@ -493,14 +505,15 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(boolRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(boolRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
@@ -519,7 +532,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
if value != "" {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
@@ -528,13 +543,14 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(listOfStringsRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(listOfStringsRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
clitest.Start(t, inv)
|
||||
|
||||
@@ -557,10 +573,11 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, prepareEchoResponses(listOfStringsRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(listOfStringsRichParameters))
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
@@ -570,7 +587,7 @@ func TestCreateValidateRichParameters(t *testing.T) {
|
||||
- eee
|
||||
- fff`)
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
clitest.Start(t, inv)
|
||||
@@ -597,7 +614,7 @@ func TestCreateWithGitAuth(t *testing.T) {
|
||||
{
|
||||
Type: &proto.Response_Plan{
|
||||
Plan: &proto.PlanComplete{
|
||||
GitAuthProviders: []string{"github"},
|
||||
ExternalAuthProviders: []string{"github"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -606,26 +623,28 @@ func TestCreateWithGitAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
GitAuthConfigs: []*gitauth.Config{{
|
||||
ExternalAuthConfigs: []*externalauth.Config{{
|
||||
OAuth2Config: &testutil.OAuth2Config{},
|
||||
ID: "github",
|
||||
Regex: regexp.MustCompile(`github\.com`),
|
||||
Type: codersdk.GitProviderGitHub,
|
||||
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
|
||||
DisplayName: "GitHub",
|
||||
}},
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
clitest.Start(t, inv)
|
||||
|
||||
pty.ExpectMatch("You must authenticate with GitHub to create a workspace")
|
||||
resp := coderdtest.RequestGitAuthCallback(t, "github", client)
|
||||
resp := coderdtest.RequestExternalAuthCallback(t, "github", member)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
pty.ExpectMatch("Confirm create?")
|
||||
|
||||
+5
-1
@@ -54,7 +54,11 @@ func (r *RootCmd) deleteWorkspace() *clibase.Cmd {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "\n%s has been deleted at %s!\n", cliui.DefaultStyles.Keyword.Render(workspace.FullName()), cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
_, _ = fmt.Fprintf(
|
||||
inv.Stdout,
|
||||
"\n%s has been deleted at %s!\n", cliui.Keyword(workspace.FullName()),
|
||||
cliui.Timestamp(time.Now()),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
+26
-22
@@ -23,14 +23,15 @@ func TestDelete(t *testing.T) {
|
||||
t.Run("WithParameter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
inv, root := clitest.New(t, "delete", workspace.Name, "-y")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
@@ -48,14 +49,15 @@ func TestDelete(t *testing.T) {
|
||||
t.Run("Orphan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
inv, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan")
|
||||
|
||||
//nolint:gocritic // Deleting orphaned workspaces requires an admin.
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
@@ -80,14 +82,14 @@ func TestDelete(t *testing.T) {
|
||||
t.Run("OrphanDeletedUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
deleteMeClient, deleteMeUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
deleteMeClient, deleteMeUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
workspace := coderdtest.CreateWorkspace(t, deleteMeClient, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, deleteMeClient, workspace.LatestBuild.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, deleteMeClient, owner.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, deleteMeClient, workspace.LatestBuild.ID)
|
||||
|
||||
// The API checks if the user has any workspaces, so we cannot delete a user
|
||||
// this way.
|
||||
@@ -101,6 +103,7 @@ func TestDelete(t *testing.T) {
|
||||
|
||||
inv, root := clitest.New(t, "delete", fmt.Sprintf("%s/%s", deleteMeUser.ID, workspace.Name), "-y", "--orphan")
|
||||
|
||||
//nolint:gocritic // Deleting orphaned workspaces requires an admin.
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
@@ -127,12 +130,13 @@ func TestDelete(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, adminClient, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, adminClient, orgID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
inv, root := clitest.New(t, "delete", user.Username+"/"+workspace.Name, "-y")
|
||||
//nolint:gocritic // This requires an admin.
|
||||
clitest.SetupConfig(t, adminClient, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
+16
-7
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
)
|
||||
@@ -20,6 +22,7 @@ import (
|
||||
func (r *RootCmd) dotfiles() *clibase.Cmd {
|
||||
var symlinkDir string
|
||||
var gitbranch string
|
||||
var dotfilesRepoDir string
|
||||
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "dotfiles <git_repo_url>",
|
||||
@@ -33,11 +36,10 @@ func (r *RootCmd) dotfiles() *clibase.Cmd {
|
||||
),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
var (
|
||||
dotfilesRepoDir = "dotfiles"
|
||||
gitRepo = inv.Args[0]
|
||||
cfg = r.createConfig()
|
||||
cfgDir = string(cfg)
|
||||
dotfilesDir = filepath.Join(cfgDir, dotfilesRepoDir)
|
||||
gitRepo = inv.Args[0]
|
||||
cfg = r.createConfig()
|
||||
cfgDir = string(cfg)
|
||||
dotfilesDir = filepath.Join(cfgDir, dotfilesRepoDir)
|
||||
// This follows the same pattern outlined by others in the market:
|
||||
// https://github.com/coder/coder/pull/1696#issue-1245742312
|
||||
installScriptSet = []string{
|
||||
@@ -143,7 +145,7 @@ func (r *RootCmd) dotfiles() *clibase.Cmd {
|
||||
return err
|
||||
}
|
||||
// if the repo exists we soft fail the update operation and try to continue
|
||||
_, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Error.Render("Failed to update repo, continuing..."))
|
||||
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Error, "Failed to update repo, continuing..."))
|
||||
}
|
||||
|
||||
if dotfilesExists && gitbranch != "" {
|
||||
@@ -159,7 +161,7 @@ func (r *RootCmd) dotfiles() *clibase.Cmd {
|
||||
if err != nil {
|
||||
// Do not block on this error, just log it and continue
|
||||
_, _ = fmt.Fprintln(inv.Stdout,
|
||||
cliui.DefaultStyles.Error.Render(fmt.Sprintf("Failed to use branch %q (%s), continuing...", err.Error(), gitbranch)))
|
||||
pretty.Sprint(cliui.DefaultStyles.Error, fmt.Sprintf("Failed to use branch %q (%s), continuing...", err.Error(), gitbranch)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +290,13 @@ func (r *RootCmd) dotfiles() *clibase.Cmd {
|
||||
"If empty, will default to cloning the default branch or using the existing branch in the cloned repo on disk.",
|
||||
Value: clibase.StringOf(&gitbranch),
|
||||
},
|
||||
{
|
||||
Flag: "repo-dir",
|
||||
Default: "dotfiles",
|
||||
Env: "CODER_DOTFILES_REPO_DIR",
|
||||
Description: "Specifies the directory for the dotfiles repository, relative to global config directory.",
|
||||
Value: clibase.StringOf(&dotfilesRepoDir),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
}
|
||||
return cmd
|
||||
|
||||
@@ -50,6 +50,68 @@ func TestDotfiles(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(b), "wow")
|
||||
})
|
||||
t.Run("SwitchRepoDir", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, root := clitest.New(t)
|
||||
testRepo := testGitRepo(t, root)
|
||||
|
||||
// nolint:gosec
|
||||
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0o750)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := exec.Command("git", "add", ".bashrc")
|
||||
c.Dir = testRepo
|
||||
err = c.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
c = exec.Command("git", "commit", "-m", `"add .bashrc"`)
|
||||
c.Dir = testRepo
|
||||
out, err := c.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
inv, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "--repo-dir", "testrepo", "-y", testRepo)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(b), "wow")
|
||||
|
||||
stat, staterr := os.Stat(filepath.Join(string(root), "testrepo"))
|
||||
require.NoError(t, staterr)
|
||||
require.True(t, stat.IsDir())
|
||||
})
|
||||
t.Run("SwitchRepoDirRelative", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, root := clitest.New(t)
|
||||
testRepo := testGitRepo(t, root)
|
||||
|
||||
// nolint:gosec
|
||||
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0o750)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := exec.Command("git", "add", ".bashrc")
|
||||
c.Dir = testRepo
|
||||
err = c.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
c = exec.Command("git", "commit", "-m", `"add .bashrc"`)
|
||||
c.Dir = testRepo
|
||||
out, err := c.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
inv, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "--repo-dir", "./relrepo", "-y", testRepo)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(b), "wow")
|
||||
|
||||
stat, staterr := os.Stat(filepath.Join(string(root), "relrepo"))
|
||||
require.NoError(t, staterr)
|
||||
require.True(t, stat.IsDir())
|
||||
})
|
||||
t.Run("InstallScript", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func (RootCmd) errorExample() *clibase.Cmd {
|
||||
errorCmd := func(use string, err error) *clibase.Cmd {
|
||||
return &clibase.Cmd{
|
||||
Use: use,
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Make an api error
|
||||
recorder := httptest.NewRecorder()
|
||||
recorder.WriteHeader(http.StatusBadRequest)
|
||||
resp := recorder.Result()
|
||||
_ = resp.Body.Close()
|
||||
resp.Request, _ = http.NewRequest(http.MethodPost, "http://example.com", nil)
|
||||
apiError := codersdk.ReadBodyAsError(resp)
|
||||
//nolint:errorlint,forcetypeassert
|
||||
apiError.(*codersdk.Error).Response = codersdk.Response{
|
||||
Message: "Top level sdk error message.",
|
||||
Detail: "magic dust unavailable, please try again later",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{
|
||||
Field: "region",
|
||||
Detail: "magic dust is not available in your region",
|
||||
},
|
||||
},
|
||||
}
|
||||
//nolint:errorlint,forcetypeassert
|
||||
apiError.(*codersdk.Error).Helper = "Have you tried turning it off and on again?"
|
||||
|
||||
// Some flags
|
||||
var magicWord clibase.String
|
||||
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "example-error",
|
||||
Short: "Shows what different error messages look like",
|
||||
Long: "This command is pretty pointless, but without it testing errors is" +
|
||||
"difficult to visually inspect. Error message formatting is inherently" +
|
||||
"visual, so we need a way to quickly see what they look like.",
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Children: []*clibase.Cmd{
|
||||
// Typical codersdk api error
|
||||
errorCmd("api", apiError),
|
||||
|
||||
// Typical cli error
|
||||
errorCmd("cmd", xerrors.Errorf("some error: %w", errorWithStackTrace())),
|
||||
|
||||
// A multi-error
|
||||
{
|
||||
Use: "multi-error",
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
// Closing the stdin file descriptor will cause the next close
|
||||
// to fail. This is joined to the returned Command error.
|
||||
if f, ok := inv.Stdin.(*os.File); ok {
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
return xerrors.Errorf("some error: %w", errorWithStackTrace())
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
Use: "validation",
|
||||
Options: clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "magic-word",
|
||||
Description: "Take a good guess.",
|
||||
Required: true,
|
||||
Flag: "magic-word",
|
||||
Default: "",
|
||||
Value: clibase.Validate(&magicWord, func(value *clibase.String) error {
|
||||
return xerrors.Errorf("magic word is incorrect")
|
||||
}),
|
||||
},
|
||||
},
|
||||
Handler: func(i *clibase.Invocation) error {
|
||||
_, _ = fmt.Fprint(i.Stdout, "Try setting the --magic-word flag\n")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func errorWithStackTrace() error {
|
||||
return xerrors.Errorf("function decided not to work, and it never will")
|
||||
}
|
||||
@@ -12,6 +12,7 @@ func (r *RootCmd) expCmd() *clibase.Cmd {
|
||||
Hidden: true,
|
||||
Children: []*clibase.Cmd{
|
||||
r.scaletestCmd(),
|
||||
r.errorExample(),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
|
||||
+96
-67
@@ -1,3 +1,5 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
@@ -5,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -105,7 +108,6 @@ func (s *scaletestTracingFlags) provider(ctx context.Context) (trace.TracerProvi
|
||||
|
||||
tracerProvider, closeTracing, err := tracing.TracerProvider(ctx, scaletestTracerName, tracing.TracerOpts{
|
||||
Default: s.traceEnable,
|
||||
Coder: s.traceCoder,
|
||||
Honeycomb: s.traceHoneycombAPIKey,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -501,7 +503,6 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
|
||||
count int64
|
||||
template string
|
||||
|
||||
noPlan bool
|
||||
noCleanup bool
|
||||
// TODO: implement this flag
|
||||
// noCleanupFailures bool
|
||||
@@ -523,6 +524,8 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
|
||||
|
||||
useHostUser bool
|
||||
|
||||
parameterFlags workspaceParameterFlags
|
||||
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
strategy = &scaletestStrategyFlags{}
|
||||
cleanupStrategy = &scaletestStrategyFlags{cleanup: true}
|
||||
@@ -590,37 +593,22 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
|
||||
if tpl.ID == uuid.Nil {
|
||||
return xerrors.Errorf("could not find template %q in any organization", template)
|
||||
}
|
||||
templateVersion, err := client.TemplateVersion(ctx, tpl.ActiveVersionID)
|
||||
|
||||
cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template version %q: %w", tpl.ActiveVersionID, err)
|
||||
return xerrors.Errorf("can't parse given parameter values: %w", err)
|
||||
}
|
||||
|
||||
// Do a dry-run to ensure the template and parameters are valid
|
||||
// before we start creating users and workspaces.
|
||||
if !noPlan {
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(ctx, templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: "scaletest",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("start dry run workspace creation: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
return client.TemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelTemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return client.TemplateVersionDryRunLogsAfter(inv.Context(), templateVersion.ID, dryRun.ID, 0)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
|
||||
Action: WorkspaceCreate,
|
||||
Template: tpl,
|
||||
NewWorkspaceName: "scaletest-N", // TODO: the scaletest runner will pass in a different name here. Does this matter?
|
||||
|
||||
RichParameterFile: parameterFlags.richParameterFile,
|
||||
RichParameters: cliRichParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("prepare build: %w", err)
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
@@ -651,7 +639,8 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
// UserID is set by the test automatically.
|
||||
Request: codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: tpl.ID,
|
||||
TemplateID: tpl.ID,
|
||||
RichParameterValues: richParameters,
|
||||
},
|
||||
NoWaitForAgents: noWaitForAgents,
|
||||
},
|
||||
@@ -770,12 +759,6 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
|
||||
Description: "Required: Name or ID of the template to use for workspaces.",
|
||||
Value: clibase.StringOf(&template),
|
||||
},
|
||||
{
|
||||
Flag: "no-plan",
|
||||
Env: "CODER_SCALETEST_NO_PLAN",
|
||||
Description: `Skip the dry-run step to plan the workspace creation. This step ensures that the given parameters are valid for the given template.`,
|
||||
Value: clibase.BoolOf(&noPlan),
|
||||
},
|
||||
{
|
||||
Flag: "no-cleanup",
|
||||
Env: "CODER_SCALETEST_NO_CLEANUP",
|
||||
@@ -858,11 +841,12 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
|
||||
Flag: "use-host-login",
|
||||
Env: "CODER_SCALETEST_USE_HOST_LOGIN",
|
||||
Default: "false",
|
||||
Description: "Use the use logged in on the host machine, instead of creating users.",
|
||||
Description: "Use the user logged in on the host machine, instead of creating users.",
|
||||
Value: clibase.BoolOf(&useHostUser),
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
strategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
@@ -1047,9 +1031,10 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *clibase.Cmd {
|
||||
|
||||
func (r *RootCmd) scaletestDashboard() *clibase.Cmd {
|
||||
var (
|
||||
count int64
|
||||
minWait time.Duration
|
||||
maxWait time.Duration
|
||||
interval time.Duration
|
||||
jitter time.Duration
|
||||
headless bool
|
||||
randSeed int64
|
||||
|
||||
client = &codersdk.Client{}
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
@@ -1066,8 +1051,17 @@ func (r *RootCmd) scaletestDashboard() *clibase.Cmd {
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
if !(interval > 0) {
|
||||
return xerrors.Errorf("--interval must be greater than zero")
|
||||
}
|
||||
if !(jitter < interval) {
|
||||
return xerrors.Errorf("--jitter must be less than --interval")
|
||||
}
|
||||
ctx := inv.Context()
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelInfo)
|
||||
if r.verbose {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
@@ -1095,19 +1089,47 @@ func (r *RootCmd) scaletestDashboard() *clibase.Cmd {
|
||||
|
||||
th := harness.NewTestHarness(strategy.toStrategy(), cleanupStrategy.toStrategy())
|
||||
|
||||
for i := int64(0); i < count; i++ {
|
||||
name := fmt.Sprintf("dashboard-%d", i)
|
||||
config := dashboard.Config{
|
||||
MinWait: minWait,
|
||||
MaxWait: maxWait,
|
||||
Trace: tracingEnabled,
|
||||
Logger: logger.Named(name),
|
||||
RollTable: dashboard.DefaultActions,
|
||||
users, err := getScaletestUsers(ctx, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get scaletest users")
|
||||
}
|
||||
|
||||
for _, usr := range users {
|
||||
//nolint:gosec // not used for cryptographic purposes
|
||||
rndGen := rand.New(rand.NewSource(randSeed))
|
||||
name := fmt.Sprintf("dashboard-%s", usr.Username)
|
||||
userTokResp, err := client.CreateToken(ctx, usr.ID.String(), codersdk.CreateTokenRequest{
|
||||
Lifetime: 30 * 24 * time.Hour,
|
||||
Scope: "",
|
||||
TokenName: fmt.Sprintf("scaletest-%d", time.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create token for user: %w", err)
|
||||
}
|
||||
|
||||
userClient := codersdk.New(client.URL)
|
||||
userClient.SetSessionToken(userTokResp.Key)
|
||||
|
||||
config := dashboard.Config{
|
||||
Interval: interval,
|
||||
Jitter: jitter,
|
||||
Trace: tracingEnabled,
|
||||
Logger: logger.Named(name),
|
||||
Headless: headless,
|
||||
RandIntn: rndGen.Intn,
|
||||
}
|
||||
// Only take a screenshot if we're in verbose mode.
|
||||
// This could be useful for debugging, but it will blow up the disk.
|
||||
if r.verbose {
|
||||
config.Screenshot = dashboard.Screenshot
|
||||
}
|
||||
//nolint:gocritic
|
||||
logger.Info(ctx, "runner config", slog.F("interval", interval), slog.F("jitter", jitter), slog.F("headless", headless), slog.F("trace", tracingEnabled))
|
||||
if err := config.Validate(); err != nil {
|
||||
logger.Fatal(ctx, "validate config", slog.Error(err))
|
||||
return err
|
||||
}
|
||||
var runner harness.Runnable = dashboard.NewRunner(client, metrics, config)
|
||||
var runner harness.Runnable = dashboard.NewRunner(userClient, metrics, config)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
@@ -1144,25 +1166,32 @@ func (r *RootCmd) scaletestDashboard() *clibase.Cmd {
|
||||
|
||||
cmd.Options = []clibase.Option{
|
||||
{
|
||||
Flag: "count",
|
||||
Env: "CODER_SCALETEST_DASHBOARD_COUNT",
|
||||
Default: "1",
|
||||
Description: "Number of concurrent workers.",
|
||||
Value: clibase.Int64Of(&count),
|
||||
Flag: "interval",
|
||||
Env: "CODER_SCALETEST_DASHBOARD_INTERVAL",
|
||||
Default: "10s",
|
||||
Description: "Interval between actions.",
|
||||
Value: clibase.DurationOf(&interval),
|
||||
},
|
||||
{
|
||||
Flag: "min-wait",
|
||||
Env: "CODER_SCALETEST_DASHBOARD_MIN_WAIT",
|
||||
Default: "100ms",
|
||||
Description: "Minimum wait between fetches.",
|
||||
Value: clibase.DurationOf(&minWait),
|
||||
Flag: "jitter",
|
||||
Env: "CODER_SCALETEST_DASHBOARD_JITTER",
|
||||
Default: "5s",
|
||||
Description: "Jitter between actions.",
|
||||
Value: clibase.DurationOf(&jitter),
|
||||
},
|
||||
{
|
||||
Flag: "max-wait",
|
||||
Env: "CODER_SCALETEST_DASHBOARD_MAX_WAIT",
|
||||
Default: "1s",
|
||||
Description: "Maximum wait between fetches.",
|
||||
Value: clibase.DurationOf(&maxWait),
|
||||
Flag: "headless",
|
||||
Env: "CODER_SCALETEST_DASHBOARD_HEADLESS",
|
||||
Default: "true",
|
||||
Description: "Controls headless mode. Setting to false is useful for debugging.",
|
||||
Value: clibase.BoolOf(&headless),
|
||||
},
|
||||
{
|
||||
Flag: "rand-seed",
|
||||
Env: "CODER_SCALETEST_DASHBOARD_RAND_SEED",
|
||||
Default: "0",
|
||||
Description: "Seed for the random number generator.",
|
||||
Value: clibase.Int64Of(&randSeed),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1212,7 +1241,7 @@ func (r *runnableTraceWrapper) Run(ctx context.Context, id string, logs io.Write
|
||||
return r.runner.Run(ctx2, id, logs)
|
||||
}
|
||||
|
||||
func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string) error {
|
||||
func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string, logs io.Writer) error {
|
||||
c, ok := r.runner.(harness.Cleanable)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -1224,7 +1253,7 @@ func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string) error {
|
||||
ctx, span := r.tracer.Start(ctx, r.spanName+" cleanup")
|
||||
defer span.End()
|
||||
|
||||
return c.Cleanup(ctx, id)
|
||||
return c.Cleanup(ctx, id, logs)
|
||||
}
|
||||
|
||||
// newScaleTestUser returns a random username and email address that can be used
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build slim
|
||||
|
||||
package cli
|
||||
|
||||
import "github.com/coder/coder/v2/cli/clibase"
|
||||
|
||||
func (r *RootCmd) scaletestCmd() *clibase.Cmd {
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "scaletest",
|
||||
Short: "Run a scale test against the Coder API",
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
SlimUnsupported(inv.Stderr, "exp scaletest")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
+84
-23
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
@@ -21,7 +23,12 @@ func TestScaleTestCreateWorkspaces(t *testing.T) {
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
// We are not including any provisioner daemons because we do not actually
|
||||
// build any workspaces here.
|
||||
Logger: &log,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Write a parameters file.
|
||||
@@ -41,6 +48,8 @@ func TestScaleTestCreateWorkspaces(t *testing.T) {
|
||||
"--cleanup-job-timeout", "15s",
|
||||
"--output", "text",
|
||||
"--output", "json:"+outputFile,
|
||||
"--parameter", "foo=baz",
|
||||
"--rich-parameter-file", "/path/to/some/parameter/file.ext",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
@@ -59,7 +68,10 @@ func TestScaleTestWorkspaceTraffic(t *testing.T) {
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancelFunc()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Logger: &log,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
inv, root := clitest.New(t, "exp", "scaletest", "workspace-traffic",
|
||||
@@ -82,29 +94,78 @@ func TestScaleTestWorkspaceTraffic(t *testing.T) {
|
||||
// This test just validates that the CLI command accepts its known arguments.
|
||||
func TestScaleTestDashboard(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testutil.RaceEnabled() {
|
||||
t.Skip("Flakes under race detector, see https://github.com/coder/coder/issues/9168")
|
||||
}
|
||||
t.Run("MinWait", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancelFunc()
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancelFunc()
|
||||
log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Logger: &log,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
inv, root := clitest.New(t, "exp", "scaletest", "dashboard",
|
||||
"--interval", "0s",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
|
||||
inv, root := clitest.New(t, "exp", "scaletest", "dashboard",
|
||||
"--count", "1",
|
||||
"--min-wait", "100ms",
|
||||
"--max-wait", "1s",
|
||||
"--timeout", "1s",
|
||||
"--scaletest-prometheus-address", "127.0.0.1:0",
|
||||
"--scaletest-prometheus-wait", "0s",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, "--interval must be greater than zero")
|
||||
})
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err, "")
|
||||
t.Run("MaxWait", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancelFunc()
|
||||
|
||||
log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Logger: &log,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
inv, root := clitest.New(t, "exp", "scaletest", "dashboard",
|
||||
"--interval", "1s",
|
||||
"--jitter", "1s",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, "--jitter must be less than --interval")
|
||||
})
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancelFunc()
|
||||
|
||||
log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Logger: &log,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
inv, root := clitest.New(t, "exp", "scaletest", "dashboard",
|
||||
"--interval", "1s",
|
||||
"--jitter", "500ms",
|
||||
"--timeout", "5s",
|
||||
"--scaletest-prometheus-address", "127.0.0.1:0",
|
||||
"--scaletest-prometheus-wait", "0s",
|
||||
"--rand-seed", "1234567890",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err, "")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os/signal"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
func (r *RootCmd) externalAuth() *clibase.Cmd {
|
||||
return &clibase.Cmd{
|
||||
Use: "external-auth",
|
||||
Short: "Manage external authentication",
|
||||
Long: "Authenticate with external services inside of a workspace.",
|
||||
Handler: func(i *clibase.Invocation) error {
|
||||
return i.Command.HelpHandler(i)
|
||||
},
|
||||
Children: []*clibase.Cmd{
|
||||
r.externalAuthAccessToken(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RootCmd) externalAuthAccessToken() *clibase.Cmd {
|
||||
var extra string
|
||||
return &clibase.Cmd{
|
||||
Use: "access-token <provider>",
|
||||
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{
|
||||
Description: "Ensure that the user is authenticated with GitHub before cloning.",
|
||||
Command: `#!/usr/bin/env sh
|
||||
|
||||
OUTPUT=$(coder external-auth access-token github)
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Authenticated with GitHub"
|
||||
else
|
||||
echo "Please authenticate with GitHub:"
|
||||
echo $OUTPUT
|
||||
fi
|
||||
`,
|
||||
},
|
||||
example{
|
||||
Description: "Obtain an extra property of an access token for additional metadata.",
|
||||
Command: "coder external-auth access-token slack --extra \"authed_user.id\"",
|
||||
},
|
||||
),
|
||||
Options: clibase.OptionSet{{
|
||||
Name: "Extra",
|
||||
Flag: "extra",
|
||||
Description: "Extract a field from the \"extra\" properties of the OAuth token.",
|
||||
Value: clibase.StringOf(&extra),
|
||||
}},
|
||||
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
|
||||
ctx, stop := signal.NotifyContext(ctx, InterruptSignals...)
|
||||
defer stop()
|
||||
|
||||
client, err := r.createAgentClient()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create agent client: %w", err)
|
||||
}
|
||||
|
||||
extAuth, err := client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
|
||||
ID: inv.Args[0],
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get external auth token: %w", err)
|
||||
}
|
||||
if extAuth.URL != "" {
|
||||
_, err = inv.Stdout.Write([]byte(extAuth.URL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cliui.Canceled
|
||||
}
|
||||
if extra != "" {
|
||||
if extAuth.TokenExtra == nil {
|
||||
return xerrors.Errorf("no extra properties found for token")
|
||||
}
|
||||
data, err := json.Marshal(extAuth.TokenExtra)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("marshal extra properties: %w", err)
|
||||
}
|
||||
result := gjson.GetBytes(data, extra)
|
||||
_, err = inv.Stdout.Write([]byte(result.String()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err = inv.Stdout.Write([]byte(extAuth.AccessToken))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestExternalAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("CanceledWithURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
|
||||
URL: "https://github.com",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
url := srv.URL
|
||||
inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github")
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
waiter := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatch("https://github.com")
|
||||
waiter.RequireIs(cliui.Canceled)
|
||||
})
|
||||
t.Run("SuccessWithToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
|
||||
AccessToken: "bananas",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
url := srv.URL
|
||||
inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github")
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
clitest.Start(t, inv)
|
||||
pty.ExpectMatch("bananas")
|
||||
})
|
||||
t.Run("SuccessWithExtra", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
|
||||
AccessToken: "bananas",
|
||||
TokenExtra: map[string]interface{}{
|
||||
"hey": "there",
|
||||
},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
url := srv.URL
|
||||
inv, _ := clitest.New(t, "--agent-url", url, "external-auth", "access-token", "github", "--extra", "hey")
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
clitest.Start(t, inv)
|
||||
pty.ExpectMatch("there")
|
||||
})
|
||||
}
|
||||
+8
-2
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/gitauth"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
@@ -38,7 +39,9 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd {
|
||||
return xerrors.Errorf("create agent client: %w", err)
|
||||
}
|
||||
|
||||
token, err := client.GitAuth(ctx, host, false)
|
||||
token, err := client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
|
||||
Match: host,
|
||||
})
|
||||
if err != nil {
|
||||
var apiError *codersdk.Error
|
||||
if errors.As(err, &apiError) && apiError.StatusCode() == http.StatusNotFound {
|
||||
@@ -63,7 +66,10 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd {
|
||||
}
|
||||
|
||||
for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); {
|
||||
token, err = client.GitAuth(ctx, host, true)
|
||||
token, err = client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
|
||||
Match: host,
|
||||
Listen: true,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestGitAskpass(t *testing.T) {
|
||||
t.Run("UsernameAndPassword", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.GitAuthResponse{
|
||||
httpapi.Write(context.Background(), w, http.StatusOK, agentsdk.ExternalAuthResponse{
|
||||
Username: "something",
|
||||
Password: "bananas",
|
||||
})
|
||||
@@ -65,8 +65,8 @@ func TestGitAskpass(t *testing.T) {
|
||||
|
||||
t.Run("Poll", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
resp := atomic.Pointer[agentsdk.GitAuthResponse]{}
|
||||
resp.Store(&agentsdk.GitAuthResponse{
|
||||
resp := atomic.Pointer[agentsdk.ExternalAuthResponse]{}
|
||||
resp.Store(&agentsdk.ExternalAuthResponse{
|
||||
URL: "https://something.org",
|
||||
})
|
||||
poll := make(chan struct{}, 10)
|
||||
@@ -96,7 +96,7 @@ func TestGitAskpass(t *testing.T) {
|
||||
}()
|
||||
<-poll
|
||||
stderr.ExpectMatch("Open the following URL to authenticate")
|
||||
resp.Store(&agentsdk.GitAuthResponse{
|
||||
resp.Store(&agentsdk.ExternalAuthResponse{
|
||||
Username: "username",
|
||||
Password: "password",
|
||||
})
|
||||
|
||||
+9
-5
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/pretty"
|
||||
)
|
||||
|
||||
func (r *RootCmd) gitssh() *clibase.Cmd {
|
||||
@@ -90,12 +91,15 @@ func (r *RootCmd) gitssh() *clibase.Cmd {
|
||||
exitErr := &exec.ExitError{}
|
||||
if xerrors.As(err, &exitErr) && exitErr.ExitCode() == 255 {
|
||||
_, _ = fmt.Fprintln(inv.Stderr,
|
||||
"\n"+cliui.DefaultStyles.Wrap.Render("Coder authenticates with "+cliui.DefaultStyles.Field.Render("git")+
|
||||
" using the public key below. All clones with SSH are authenticated automatically 🪄.")+"\n")
|
||||
_, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n")
|
||||
"\n"+pretty.Sprintf(
|
||||
cliui.DefaultStyles.Wrap,
|
||||
"Coder authenticates with "+pretty.Sprint(cliui.DefaultStyles.Field, "git")+
|
||||
" using the public key below. All clones with SSH are authenticated automatically 🪄.")+"\n",
|
||||
)
|
||||
_, _ = fmt.Fprintln(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Code, strings.TrimSpace(key.PublicKey))+"\n")
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Add to GitHub and GitLab:")
|
||||
_, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Prompt.String()+"https://github.com/settings/ssh/new")
|
||||
_, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Prompt.String()+"https://gitlab.com/-/profile/keys")
|
||||
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "%s", "https://github.com/settings/ssh/new\n\n")
|
||||
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "%s", "https://gitlab.com/-/profile/keys\n\n")
|
||||
_, _ = fmt.Fprintln(inv.Stderr)
|
||||
return err
|
||||
}
|
||||
|
||||
+13
-11
@@ -20,15 +20,18 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, string, gossh.PublicKey) {
|
||||
func prepareTestGitSSH(ctx context.Context, t *testing.T) (*agentsdk.Client, string, gossh.PublicKey) {
|
||||
t.Helper()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
@@ -52,18 +55,17 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, str
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(agentToken),
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// start workspace agent
|
||||
inv, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
clitest.SetupConfig(t, agentClient, root)
|
||||
clitest.Start(t, inv)
|
||||
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
|
||||
o.Client = agentClient
|
||||
})
|
||||
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
return agentClient, agentToken, pubkey
|
||||
}
|
||||
|
||||
@@ -140,7 +142,7 @@ func TestGitSSH(t *testing.T) {
|
||||
// set to agent config dir
|
||||
inv, _ := clitest.New(t,
|
||||
"gitssh",
|
||||
"--agent-url", client.URL.String(),
|
||||
"--agent-url", client.SDK.URL.String(),
|
||||
"--agent-token", token,
|
||||
"--",
|
||||
fmt.Sprintf("-p%d", addr.Port),
|
||||
@@ -203,7 +205,7 @@ func TestGitSSH(t *testing.T) {
|
||||
pty := ptytest.New(t)
|
||||
cmdArgs := []string{
|
||||
"gitssh",
|
||||
"--agent-url", client.URL.String(),
|
||||
"--agent-url", client.SDK.URL.String(),
|
||||
"--agent-token", token,
|
||||
"--",
|
||||
"-F", config,
|
||||
|
||||
+211
-183
@@ -2,23 +2,22 @@ package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"text/template"
|
||||
"unicode"
|
||||
|
||||
"github.com/mitchellh/go-wordwrap"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/pretty"
|
||||
)
|
||||
|
||||
//go:embed help.tpl
|
||||
@@ -44,203 +43,222 @@ func wrapTTY(s string) string {
|
||||
return wordwrap.WrapString(s, uint(ttyWidth()))
|
||||
}
|
||||
|
||||
var usageTemplate = template.Must(
|
||||
template.New("usage").Funcs(
|
||||
template.FuncMap{
|
||||
"wrapTTY": func(s string) string {
|
||||
return wrapTTY(s)
|
||||
},
|
||||
"trimNewline": func(s string) string {
|
||||
return strings.TrimSuffix(s, "\n")
|
||||
},
|
||||
"typeHelper": func(opt *clibase.Option) string {
|
||||
switch v := opt.Value.(type) {
|
||||
case *clibase.Enum:
|
||||
return strings.Join(v.Choices, "|")
|
||||
default:
|
||||
return v.Type()
|
||||
}
|
||||
},
|
||||
"joinStrings": func(s []string) string {
|
||||
return strings.Join(s, ", ")
|
||||
},
|
||||
"indent": func(body string, spaces int) string {
|
||||
twidth := ttyWidth()
|
||||
var usageTemplate = func() *template.Template {
|
||||
var (
|
||||
optionFg = pretty.FgColor(
|
||||
cliui.Color("#04A777"),
|
||||
)
|
||||
headerFg = pretty.FgColor(
|
||||
cliui.Color("#337CA0"),
|
||||
)
|
||||
)
|
||||
return template.Must(
|
||||
template.New("usage").Funcs(
|
||||
template.FuncMap{
|
||||
"version": func() string {
|
||||
return buildinfo.Version()
|
||||
},
|
||||
"wrapTTY": func(s string) string {
|
||||
return wrapTTY(s)
|
||||
},
|
||||
"trimNewline": func(s string) string {
|
||||
return strings.TrimSuffix(s, "\n")
|
||||
},
|
||||
"keyword": func(s string) string {
|
||||
txt := pretty.String(s)
|
||||
optionFg.Format(txt)
|
||||
return txt.String()
|
||||
},
|
||||
"prettyHeader": func(s string) string {
|
||||
s = strings.ToUpper(s)
|
||||
txt := pretty.String(s, ":")
|
||||
headerFg.Format(txt)
|
||||
return txt.String()
|
||||
},
|
||||
"typeHelper": func(opt *clibase.Option) string {
|
||||
switch v := opt.Value.(type) {
|
||||
case *clibase.Enum:
|
||||
return strings.Join(v.Choices, "|")
|
||||
default:
|
||||
return v.Type()
|
||||
}
|
||||
},
|
||||
"joinStrings": func(s []string) string {
|
||||
return strings.Join(s, ", ")
|
||||
},
|
||||
"indent": func(body string, spaces int) string {
|
||||
twidth := ttyWidth()
|
||||
|
||||
spacing := strings.Repeat(" ", spaces)
|
||||
spacing := strings.Repeat(" ", spaces)
|
||||
|
||||
body = wordwrap.WrapString(body, uint(twidth-len(spacing)))
|
||||
wrapLim := twidth - len(spacing)
|
||||
body = wordwrap.WrapString(body, uint(wrapLim))
|
||||
|
||||
var sb strings.Builder
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
// Remove existing indent, if any.
|
||||
line = strings.TrimSpace(line)
|
||||
// Use spaces so we can easily calculate wrapping.
|
||||
_, _ = sb.WriteString(spacing)
|
||||
_, _ = sb.WriteString(line)
|
||||
_, _ = sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
},
|
||||
"formatSubcommand": func(cmd *clibase.Cmd) string {
|
||||
// Minimize padding by finding the longest neighboring name.
|
||||
maxNameLength := len(cmd.Name())
|
||||
if parent := cmd.Parent; parent != nil {
|
||||
for _, c := range parent.Children {
|
||||
if len(c.Name()) > maxNameLength {
|
||||
maxNameLength = len(c.Name())
|
||||
sc := bufio.NewScanner(strings.NewReader(body))
|
||||
|
||||
var sb strings.Builder
|
||||
for sc.Scan() {
|
||||
// Remove existing indent, if any.
|
||||
// line = strings.TrimSpace(line)
|
||||
// Use spaces so we can easily calculate wrapping.
|
||||
_, _ = sb.WriteString(spacing)
|
||||
_, _ = sb.Write(sc.Bytes())
|
||||
_, _ = sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
},
|
||||
"formatSubcommand": func(cmd *clibase.Cmd) string {
|
||||
// Minimize padding by finding the longest neighboring name.
|
||||
maxNameLength := len(cmd.Name())
|
||||
if parent := cmd.Parent; parent != nil {
|
||||
for _, c := range parent.Children {
|
||||
if len(c.Name()) > maxNameLength {
|
||||
maxNameLength = len(c.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
_, _ = fmt.Fprintf(
|
||||
&sb, "%s%s%s",
|
||||
strings.Repeat(" ", 4), cmd.Name(), strings.Repeat(" ", maxNameLength-len(cmd.Name())+4),
|
||||
)
|
||||
var sb strings.Builder
|
||||
_, _ = fmt.Fprintf(
|
||||
&sb, "%s%s%s",
|
||||
strings.Repeat(" ", 4), cmd.Name(), strings.Repeat(" ", maxNameLength-len(cmd.Name())+4),
|
||||
)
|
||||
|
||||
// This is the point at which indentation begins if there's a
|
||||
// next line.
|
||||
descStart := sb.Len()
|
||||
// This is the point at which indentation begins if there's a
|
||||
// next line.
|
||||
descStart := sb.Len()
|
||||
|
||||
twidth := ttyWidth()
|
||||
twidth := ttyWidth()
|
||||
|
||||
for i, line := range strings.Split(
|
||||
wordwrap.WrapString(cmd.Short, uint(twidth-descStart)), "\n",
|
||||
) {
|
||||
if i > 0 {
|
||||
_, _ = sb.WriteString(strings.Repeat(" ", descStart))
|
||||
for i, line := range strings.Split(
|
||||
wordwrap.WrapString(cmd.Short, uint(twidth-descStart)), "\n",
|
||||
) {
|
||||
if i > 0 {
|
||||
_, _ = sb.WriteString(strings.Repeat(" ", descStart))
|
||||
}
|
||||
_, _ = sb.WriteString(line)
|
||||
_, _ = sb.WriteString("\n")
|
||||
}
|
||||
_, _ = sb.WriteString(line)
|
||||
_, _ = sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
},
|
||||
"envName": func(opt clibase.Option) string {
|
||||
if opt.Env == "" {
|
||||
return ""
|
||||
}
|
||||
return opt.Env
|
||||
},
|
||||
"flagName": func(opt clibase.Option) string {
|
||||
return opt.Flag
|
||||
},
|
||||
"prettyHeader": func(s string) string {
|
||||
return cliui.DefaultStyles.Bold.Render(s)
|
||||
},
|
||||
"isEnterprise": func(opt clibase.Option) bool {
|
||||
return opt.Annotations.IsSet("enterprise")
|
||||
},
|
||||
"isDeprecated": func(opt clibase.Option) bool {
|
||||
return len(opt.UseInstead) > 0
|
||||
},
|
||||
"useInstead": func(opt clibase.Option) string {
|
||||
var sb strings.Builder
|
||||
for i, s := range opt.UseInstead {
|
||||
if i > 0 {
|
||||
if i == len(opt.UseInstead)-1 {
|
||||
_, _ = sb.WriteString(" and ")
|
||||
return sb.String()
|
||||
},
|
||||
"envName": func(opt clibase.Option) string {
|
||||
if opt.Env == "" {
|
||||
return ""
|
||||
}
|
||||
return opt.Env
|
||||
},
|
||||
"flagName": func(opt clibase.Option) string {
|
||||
return opt.Flag
|
||||
},
|
||||
|
||||
"isEnterprise": func(opt clibase.Option) bool {
|
||||
return opt.Annotations.IsSet("enterprise")
|
||||
},
|
||||
"isDeprecated": func(opt clibase.Option) bool {
|
||||
return len(opt.UseInstead) > 0
|
||||
},
|
||||
"useInstead": func(opt clibase.Option) string {
|
||||
var sb strings.Builder
|
||||
for i, s := range opt.UseInstead {
|
||||
if i > 0 {
|
||||
if i == len(opt.UseInstead)-1 {
|
||||
_, _ = sb.WriteString(" and ")
|
||||
} else {
|
||||
_, _ = sb.WriteString(", ")
|
||||
}
|
||||
}
|
||||
if s.Flag != "" {
|
||||
_, _ = sb.WriteString("--")
|
||||
_, _ = sb.WriteString(s.Flag)
|
||||
} else if s.FlagShorthand != "" {
|
||||
_, _ = sb.WriteString("-")
|
||||
_, _ = sb.WriteString(s.FlagShorthand)
|
||||
} else if s.Env != "" {
|
||||
_, _ = sb.WriteString("$")
|
||||
_, _ = sb.WriteString(s.Env)
|
||||
} else {
|
||||
_, _ = sb.WriteString(", ")
|
||||
_, _ = sb.WriteString(s.Name)
|
||||
}
|
||||
}
|
||||
if s.Flag != "" {
|
||||
_, _ = sb.WriteString("--")
|
||||
_, _ = sb.WriteString(s.Flag)
|
||||
} else if s.FlagShorthand != "" {
|
||||
_, _ = sb.WriteString("-")
|
||||
_, _ = sb.WriteString(s.FlagShorthand)
|
||||
} else if s.Env != "" {
|
||||
_, _ = sb.WriteString("$")
|
||||
_, _ = sb.WriteString(s.Env)
|
||||
} else {
|
||||
_, _ = sb.WriteString(s.Name)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
},
|
||||
"formatLong": func(long string) string {
|
||||
// We intentionally don't wrap here because it would misformat
|
||||
// examples, where the new line would start without the prior
|
||||
// line's indentation.
|
||||
return strings.TrimSpace(long)
|
||||
},
|
||||
"formatGroupDescription": func(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", "")
|
||||
s = s + "\n"
|
||||
s = wrapTTY(s)
|
||||
return s
|
||||
},
|
||||
"visibleChildren": func(cmd *clibase.Cmd) []*clibase.Cmd {
|
||||
return filterSlice(cmd.Children, func(c *clibase.Cmd) bool {
|
||||
return !c.Hidden
|
||||
})
|
||||
},
|
||||
"optionGroups": func(cmd *clibase.Cmd) []optionGroup {
|
||||
groups := []optionGroup{{
|
||||
// Default group.
|
||||
Name: "",
|
||||
Description: "",
|
||||
}}
|
||||
return sb.String()
|
||||
},
|
||||
"formatGroupDescription": func(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", "")
|
||||
s = s + "\n"
|
||||
s = wrapTTY(s)
|
||||
return s
|
||||
},
|
||||
"visibleChildren": func(cmd *clibase.Cmd) []*clibase.Cmd {
|
||||
return filterSlice(cmd.Children, func(c *clibase.Cmd) bool {
|
||||
return !c.Hidden
|
||||
})
|
||||
},
|
||||
"optionGroups": func(cmd *clibase.Cmd) []optionGroup {
|
||||
groups := []optionGroup{{
|
||||
// Default group.
|
||||
Name: "",
|
||||
Description: "",
|
||||
}}
|
||||
|
||||
enterpriseGroup := optionGroup{
|
||||
Name: "Enterprise",
|
||||
Description: `These options are only available in the Enterprise Edition.`,
|
||||
}
|
||||
|
||||
// Sort options lexicographically.
|
||||
sort.Slice(cmd.Options, func(i, j int) bool {
|
||||
return cmd.Options[i].Name < cmd.Options[j].Name
|
||||
})
|
||||
|
||||
optionLoop:
|
||||
for _, opt := range cmd.Options {
|
||||
if opt.Hidden {
|
||||
continue
|
||||
}
|
||||
// Enterprise options are always grouped separately.
|
||||
if opt.Annotations.IsSet("enterprise") {
|
||||
enterpriseGroup.Options = append(enterpriseGroup.Options, opt)
|
||||
continue
|
||||
}
|
||||
if len(opt.Group.Ancestry()) == 0 {
|
||||
// Just add option to default group.
|
||||
groups[0].Options = append(groups[0].Options, opt)
|
||||
continue
|
||||
enterpriseGroup := optionGroup{
|
||||
Name: "Enterprise",
|
||||
Description: `These options are only available in the Enterprise Edition.`,
|
||||
}
|
||||
|
||||
groupName := opt.Group.FullName()
|
||||
// Sort options lexicographically.
|
||||
sort.Slice(cmd.Options, func(i, j int) bool {
|
||||
return cmd.Options[i].Name < cmd.Options[j].Name
|
||||
})
|
||||
|
||||
for i, foundGroup := range groups {
|
||||
if foundGroup.Name != groupName {
|
||||
optionLoop:
|
||||
for _, opt := range cmd.Options {
|
||||
if opt.Hidden {
|
||||
continue
|
||||
}
|
||||
groups[i].Options = append(groups[i].Options, opt)
|
||||
continue optionLoop
|
||||
// Enterprise options are always grouped separately.
|
||||
if opt.Annotations.IsSet("enterprise") {
|
||||
enterpriseGroup.Options = append(enterpriseGroup.Options, opt)
|
||||
continue
|
||||
}
|
||||
if len(opt.Group.Ancestry()) == 0 {
|
||||
// Just add option to default group.
|
||||
groups[0].Options = append(groups[0].Options, opt)
|
||||
continue
|
||||
}
|
||||
|
||||
groupName := opt.Group.FullName()
|
||||
|
||||
for i, foundGroup := range groups {
|
||||
if foundGroup.Name != groupName {
|
||||
continue
|
||||
}
|
||||
groups[i].Options = append(groups[i].Options, opt)
|
||||
continue optionLoop
|
||||
}
|
||||
|
||||
groups = append(groups, optionGroup{
|
||||
Name: groupName,
|
||||
Description: opt.Group.Description,
|
||||
Options: clibase.OptionSet{opt},
|
||||
})
|
||||
}
|
||||
|
||||
groups = append(groups, optionGroup{
|
||||
Name: groupName,
|
||||
Description: opt.Group.Description,
|
||||
Options: clibase.OptionSet{opt},
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
// Sort groups lexicographically.
|
||||
return groups[i].Name < groups[j].Name
|
||||
})
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
// Sort groups lexicographically.
|
||||
return groups[i].Name < groups[j].Name
|
||||
})
|
||||
|
||||
// Always show enterprise group last.
|
||||
groups = append(groups, enterpriseGroup)
|
||||
// Always show enterprise group last.
|
||||
groups = append(groups, enterpriseGroup)
|
||||
|
||||
return filterSlice(groups, func(g optionGroup) bool {
|
||||
return len(g.Options) > 0
|
||||
})
|
||||
return filterSlice(groups, func(g optionGroup) bool {
|
||||
return len(g.Options) > 0
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
).Parse(helpTemplateRaw),
|
||||
)
|
||||
).Parse(helpTemplateRaw),
|
||||
)
|
||||
}()
|
||||
|
||||
func filterSlice[T any](s []T, f func(T) bool) []T {
|
||||
var r []T
|
||||
@@ -254,31 +272,41 @@ func filterSlice[T any](s []T, f func(T) bool) []T {
|
||||
|
||||
// newLineLimiter makes working with Go templates more bearable. Without this,
|
||||
// modifying the template is a slow toil of counting newlines and constantly
|
||||
// checking that a change to one command's help doesn't clobber break another.
|
||||
// checking that a change to one command's help doesn't break another.
|
||||
type newlineLimiter struct {
|
||||
w io.Writer
|
||||
// w is not an interface since we call WriteRune byte-wise,
|
||||
// and the devirtualization overhead is significant.
|
||||
w *bufio.Writer
|
||||
limit int
|
||||
|
||||
newLineCounter int
|
||||
}
|
||||
|
||||
// isSpace is a based on unicode.IsSpace, but only checks ASCII characters.
|
||||
func isSpace(b byte) bool {
|
||||
switch b {
|
||||
case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (lm *newlineLimiter) Write(p []byte) (int, error) {
|
||||
rd := bytes.NewReader(p)
|
||||
for r, n, _ := rd.ReadRune(); n > 0; r, n, _ = rd.ReadRune() {
|
||||
for _, b := range p {
|
||||
switch {
|
||||
case r == '\r':
|
||||
case b == '\r':
|
||||
// Carriage returns can sneak into `help.tpl` when `git clone`
|
||||
// is configured to automatically convert line endings.
|
||||
continue
|
||||
case r == '\n':
|
||||
case b == '\n':
|
||||
lm.newLineCounter++
|
||||
if lm.newLineCounter > lm.limit {
|
||||
continue
|
||||
}
|
||||
case !unicode.IsSpace(r):
|
||||
case !isSpace(b):
|
||||
lm.newLineCounter = 0
|
||||
}
|
||||
_, err := lm.w.Write([]byte(string(r)))
|
||||
err := lm.w.WriteByte(b)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
+13
-11
@@ -1,20 +1,22 @@
|
||||
{{- /* Heavily inspired by the Go toolchain formatting. */ -}}
|
||||
Usage: {{.FullUsage}}
|
||||
{{- /* Heavily inspired by the Go toolchain and fd */ -}}
|
||||
coder {{version}}
|
||||
|
||||
{{prettyHeader "Usage"}}
|
||||
{{indent .FullUsage 2}}
|
||||
|
||||
|
||||
{{ with .Short }}
|
||||
{{- wrapTTY . }}
|
||||
{{- indent . 2 | wrapTTY }}
|
||||
{{"\n"}}
|
||||
{{- end}}
|
||||
|
||||
{{ with .Aliases }}
|
||||
{{ "\n" }}
|
||||
{{ "Aliases:"}} {{ joinStrings .}}
|
||||
{{ "\n" }}
|
||||
{{" Aliases: "}} {{- joinStrings .}}
|
||||
{{- end }}
|
||||
|
||||
{{- with .Long}}
|
||||
{{- formatLong . }}
|
||||
{{"\n"}}
|
||||
{{- indent . 2}}
|
||||
{{ "\n" }}
|
||||
{{- end }}
|
||||
{{ with visibleChildren . }}
|
||||
@@ -34,11 +36,11 @@ Usage: {{.FullUsage}}
|
||||
{{- else }}
|
||||
{{- end }}
|
||||
{{- range $index, $option := $group.Options }}
|
||||
{{- if not (eq $option.FlagShorthand "") }}{{- print "\n -" $option.FlagShorthand ", " -}}
|
||||
{{- if not (eq $option.FlagShorthand "") }}{{- print "\n "}} {{ keyword "-"}}{{keyword $option.FlagShorthand }}{{", "}}
|
||||
{{- else }}{{- print "\n " -}}
|
||||
{{- end }}
|
||||
{{- with flagName $option }}--{{ . }}{{ end }} {{- with typeHelper $option }} {{ . }}{{ end }}
|
||||
{{- with envName $option }}, ${{ . }}{{ end }}
|
||||
{{- with flagName $option }}{{keyword "--"}}{{ keyword . }}{{ end }} {{- with typeHelper $option }} {{ . }}{{ end }}
|
||||
{{- with envName $option }}, {{ print "$" . | keyword }}{{ end }}
|
||||
{{- with $option.Default }} (default: {{ . }}){{ end }}
|
||||
{{- with $option.Description }}
|
||||
{{- $desc := $option.Description }}
|
||||
@@ -47,7 +49,7 @@ Usage: {{.FullUsage}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
---
|
||||
———
|
||||
{{- if .Parent }}
|
||||
Run `coder --help` for a list of global options.
|
||||
{{- else }}
|
||||
|
||||
+4
-2
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/schedule/cron"
|
||||
@@ -119,9 +121,9 @@ func (r *RootCmd) list() *clibase.Cmd {
|
||||
return err
|
||||
}
|
||||
if len(res.Workspaces) == 0 {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Prompt.String()+"No workspaces found! Create one:")
|
||||
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n")
|
||||
_, _ = fmt.Fprintln(inv.Stderr)
|
||||
_, _ = fmt.Fprintln(inv.Stderr, " "+cliui.DefaultStyles.Code.Render("coder create <name>"))
|
||||
_, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder create <name>"))
|
||||
_, _ = fmt.Fprintln(inv.Stderr)
|
||||
return nil
|
||||
}
|
||||
|
||||
+16
-14
@@ -21,14 +21,15 @@ func TestList(t *testing.T) {
|
||||
t.Run("Single", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
inv, root := clitest.New(t, "ls")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
@@ -48,15 +49,16 @@ func TestList(t *testing.T) {
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
inv, root := clitest.New(t, "list", "--output=json")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
+15
-16
@@ -16,6 +16,8 @@ import (
|
||||
"github.com/pkg/browser"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/userpassword"
|
||||
@@ -43,7 +45,7 @@ func promptFirstUsername(inv *clibase.Invocation) (string, error) {
|
||||
return "", xerrors.Errorf("get current user: %w", err)
|
||||
}
|
||||
username, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "What " + cliui.DefaultStyles.Field.Render("username") + " would you like?",
|
||||
Text: "What " + pretty.Sprint(cliui.DefaultStyles.Field, "username") + " would you like?",
|
||||
Default: currentUser.Username,
|
||||
})
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
@@ -59,7 +61,7 @@ func promptFirstUsername(inv *clibase.Invocation) (string, error) {
|
||||
func promptFirstPassword(inv *clibase.Invocation) (string, error) {
|
||||
retry:
|
||||
password, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Enter a " + cliui.DefaultStyles.Field.Render("password") + ":",
|
||||
Text: "Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password") + ":",
|
||||
Secret: true,
|
||||
Validate: func(s string) error {
|
||||
return userpassword.Validate(s)
|
||||
@@ -69,7 +71,7 @@ retry:
|
||||
return "", xerrors.Errorf("specify password prompt: %w", err)
|
||||
}
|
||||
confirm, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Confirm " + cliui.DefaultStyles.Field.Render("password") + ":",
|
||||
Text: "Confirm " + pretty.Sprint(cliui.DefaultStyles.Field, "password") + ":",
|
||||
Secret: true,
|
||||
Validate: cliui.ValidateNotEmpty,
|
||||
})
|
||||
@@ -78,7 +80,7 @@ retry:
|
||||
}
|
||||
|
||||
if confirm != password {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Error.Render("Passwords do not match"))
|
||||
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Error, "Passwords do not match"))
|
||||
goto retry
|
||||
}
|
||||
|
||||
@@ -115,12 +117,8 @@ func (r *RootCmd) loginWithPassword(
|
||||
|
||||
_, _ = fmt.Fprintf(
|
||||
inv.Stdout,
|
||||
cliui.DefaultStyles.Paragraph.Render(
|
||||
fmt.Sprintf(
|
||||
"Welcome to Coder, %s! You're authenticated.",
|
||||
cliui.DefaultStyles.Keyword.Render(u.Username),
|
||||
),
|
||||
)+"\n",
|
||||
"Welcome to Coder, %s! You're authenticated.",
|
||||
pretty.Sprint(cliui.DefaultStyles.Keyword, u.Username),
|
||||
)
|
||||
|
||||
return nil
|
||||
@@ -177,7 +175,7 @@ func (r *RootCmd) login() *clibase.Cmd {
|
||||
if err != nil {
|
||||
// Checking versions isn't a fatal error so we print a warning
|
||||
// and proceed.
|
||||
_, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Warn.Render(err.Error()))
|
||||
_, _ = fmt.Fprintln(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Warn, err.Error()))
|
||||
}
|
||||
|
||||
hasFirstUser, err := client.HasFirstUser(ctx)
|
||||
@@ -209,7 +207,7 @@ func (r *RootCmd) login() *clibase.Cmd {
|
||||
|
||||
if email == "" {
|
||||
email, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "What's your " + cliui.DefaultStyles.Field.Render("email") + "?",
|
||||
Text: "What's your " + pretty.Sprint(cliui.DefaultStyles.Field, "email") + "?",
|
||||
Validate: func(s string) error {
|
||||
err := validator.New().Var(s, "email")
|
||||
if err != nil {
|
||||
@@ -261,7 +259,9 @@ func (r *RootCmd) login() *clibase.Cmd {
|
||||
|
||||
_, _ = fmt.Fprintf(
|
||||
inv.Stdout,
|
||||
cliui.DefaultStyles.Paragraph.Render("Get started by creating a template: "+cliui.DefaultStyles.Code.Render("coder templates init"))+"\n")
|
||||
"Get started by creating a template: %s\n",
|
||||
pretty.Sprint(cliui.DefaultStyles.Code, "coder templates init"),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -278,8 +278,7 @@ func (r *RootCmd) login() *clibase.Cmd {
|
||||
}
|
||||
|
||||
sessionToken, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Paste your token here:",
|
||||
Secret: true,
|
||||
Text: "Paste your token here:",
|
||||
Validate: func(token string) error {
|
||||
client.SetSessionToken(token)
|
||||
_, err := client.User(ctx, codersdk.Me)
|
||||
@@ -327,7 +326,7 @@ func (r *RootCmd) login() *clibase.Cmd {
|
||||
return xerrors.Errorf("write server url: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.DefaultStyles.Keyword.Render(resp.Username))
|
||||
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Welcome to Coder, %s! You're authenticated.\n", pretty.Sprint(cliui.DefaultStyles.Keyword, resp.Username))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
+12
-1
@@ -3,11 +3,14 @@ package cli_test
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
@@ -141,7 +144,7 @@ func TestLogin(t *testing.T) {
|
||||
|
||||
// Validate that we reprompt for matching passwords.
|
||||
pty.ExpectMatch("Passwords do not match")
|
||||
pty.ExpectMatch("Enter a " + cliui.DefaultStyles.Field.Render("password"))
|
||||
pty.ExpectMatch("Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password"))
|
||||
|
||||
pty.WriteLine("SomeSecurePassword!")
|
||||
pty.ExpectMatch("Confirm")
|
||||
@@ -168,6 +171,10 @@ func TestLogin(t *testing.T) {
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken())
|
||||
if runtime.GOOS != "windows" {
|
||||
// For some reason, the match does not show up on Windows.
|
||||
pty.ExpectMatch(client.SessionToken())
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
})
|
||||
@@ -191,6 +198,10 @@ func TestLogin(t *testing.T) {
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine("an-invalid-token")
|
||||
if runtime.GOOS != "windows" {
|
||||
// For some reason, the match does not show up on Windows.
|
||||
pty.ExpectMatch("an-invalid-token")
|
||||
}
|
||||
pty.ExpectMatch("That's not a valid token!")
|
||||
cancelFunc()
|
||||
<-doneChan
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -203,7 +205,7 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild
|
||||
Value: parameterValue,
|
||||
})
|
||||
} else if action == WorkspaceUpdate && !tvp.Mutable && !firstTimeUse {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Warn.Render(fmt.Sprintf("Parameter %q is not mutable, and cannot be customized after workspace creation.", tvp.Name)))
|
||||
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Warn, fmt.Sprintf("Parameter %q is not mutable, and cannot be customized after workspace creation.", tvp.Name)))
|
||||
}
|
||||
}
|
||||
return resolved, nil
|
||||
|
||||
+9
-7
@@ -10,6 +10,8 @@ import (
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -104,14 +106,14 @@ func (r *RootCmd) ping() *clibase.Cmd {
|
||||
if p2p {
|
||||
if !didP2p {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "p2p connection established in",
|
||||
cliui.DefaultStyles.DateTimeStamp.Render(time.Since(start).Round(time.Millisecond).String()),
|
||||
pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Since(start).Round(time.Millisecond).String()),
|
||||
)
|
||||
}
|
||||
didP2p = true
|
||||
|
||||
via = fmt.Sprintf("%s via %s",
|
||||
cliui.DefaultStyles.Fuchsia.Render("p2p"),
|
||||
cliui.DefaultStyles.Code.Render(pong.Endpoint),
|
||||
pretty.Sprint(cliui.DefaultStyles.Fuchsia, "p2p"),
|
||||
pretty.Sprint(cliui.DefaultStyles.Code, pong.Endpoint),
|
||||
)
|
||||
} else {
|
||||
derpName := "unknown"
|
||||
@@ -120,15 +122,15 @@ func (r *RootCmd) ping() *clibase.Cmd {
|
||||
derpName = derpRegion.RegionName
|
||||
}
|
||||
via = fmt.Sprintf("%s via %s",
|
||||
cliui.DefaultStyles.Fuchsia.Render("proxied"),
|
||||
cliui.DefaultStyles.Code.Render(fmt.Sprintf("DERP(%s)", derpName)),
|
||||
pretty.Sprint(cliui.DefaultStyles.Fuchsia, "proxied"),
|
||||
pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("DERP(%s)", derpName)),
|
||||
)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "pong from %s %s in %s\n",
|
||||
cliui.DefaultStyles.Keyword.Render(workspaceName),
|
||||
pretty.Sprint(cliui.DefaultStyles.Keyword, workspaceName),
|
||||
via,
|
||||
cliui.DefaultStyles.DateTimeStamp.Render(dur.String()),
|
||||
pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, dur.String()),
|
||||
)
|
||||
|
||||
if n == int(pingNum) {
|
||||
|
||||
+4
-13
@@ -6,11 +6,9 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -29,15 +27,8 @@ func TestPing(t *testing.T) {
|
||||
inv.Stderr = pty.Output()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
}()
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
+25
-33
@@ -7,12 +7,15 @@ import (
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pion/udp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -25,10 +28,11 @@ func TestPortForward_None(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
inv, root := clitest.New(t, "port-forward", "blah")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stderr = pty.Output()
|
||||
|
||||
@@ -129,8 +133,9 @@ func TestPortForward(t *testing.T) {
|
||||
// non-parallel setup).
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
workspace = runAgent(t, client, user.UserID)
|
||||
admin = coderdtest.CreateFirstUser(t, client)
|
||||
member, _ = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
|
||||
workspace = runAgent(t, client, member)
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -148,7 +153,7 @@ func TestPortForward(t *testing.T) {
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listener.
|
||||
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
@@ -195,7 +200,7 @@ func TestPortForward(t *testing.T) {
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listeners.
|
||||
inv, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag1, flag2)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
@@ -250,7 +255,7 @@ func TestPortForward(t *testing.T) {
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listeners.
|
||||
inv, root := clitest.New(t, append([]string{"-v", "port-forward", workspace.Name}, flags...)...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
inv.Stderr = pty.Output()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
@@ -291,46 +296,33 @@ func TestPortForward(t *testing.T) {
|
||||
// runAgent creates a fake workspace and starts an agent locally for that
|
||||
// workspace. The agent will be cleaned up on test completion.
|
||||
// nolint:unused
|
||||
func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk.Workspace {
|
||||
func runAgent(t *testing.T, adminClient, userClient *codersdk.Client) codersdk.Workspace {
|
||||
ctx := context.Background()
|
||||
user, err := client.User(ctx, userID.String())
|
||||
user, err := userClient.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err, "specified user does not exist")
|
||||
require.Greater(t, len(user.OrganizationIDs), 0, "user has no organizations")
|
||||
orgID := user.OrganizationIDs[0]
|
||||
|
||||
// Setup template
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{
|
||||
version := coderdtest.CreateTemplateVersion(t, adminClient, orgID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.PlanComplete,
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(agentToken),
|
||||
})
|
||||
|
||||
// Create template and workspace
|
||||
template := coderdtest.CreateTemplate(t, client, orgID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
template := coderdtest.CreateTemplate(t, adminClient, orgID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, userClient, orgID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, adminClient, workspace.LatestBuild.ID)
|
||||
|
||||
// Start workspace agent in a goroutine
|
||||
inv, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
inv.Stderr = pty.Output()
|
||||
errC := make(chan error)
|
||||
agentCtx, agentCancel := context.WithCancel(ctx)
|
||||
t.Cleanup(func() {
|
||||
agentCancel()
|
||||
err := <-errC
|
||||
require.NoError(t, err)
|
||||
})
|
||||
go func() {
|
||||
errC <- inv.WithContext(agentCtx).Run()
|
||||
}()
|
||||
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
_ = agenttest.New(t, adminClient.URL, agentToken,
|
||||
func(o *agent.Options) {
|
||||
o.SSHMaxTimeout = 60 * time.Second
|
||||
},
|
||||
)
|
||||
coderdtest.AwaitWorkspaceAgents(t, adminClient, workspace.ID)
|
||||
|
||||
return workspace
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user