Compare commits
631 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43f622a52d | |||
| d8c440188e | |||
| 582d636e54 | |||
| fc38b61819 | |||
| 60102cb22f | |||
| 8b03e2b0e1 | |||
| ac6cb269db | |||
| 2b12beef98 | |||
| 37aff0c8a9 | |||
| 7e89d91ce3 | |||
| c2720577cb | |||
| 88e8c96ddd | |||
| d6e9eab258 | |||
| 6bb76782a6 | |||
| b4f5920df5 | |||
| 61aacff444 | |||
| 89dde21837 | |||
| 0e1f868f5f | |||
| 597994548d | |||
| 0b59ed30d0 | |||
| 1a07d021fe | |||
| e09cd3e9cf | |||
| 47c7eda670 | |||
| e6ee7dd652 | |||
| c463e7801c | |||
| ab69c22ddc | |||
| b9983e417f | |||
| 419dc6b036 | |||
| 3fd4dcd9d5 | |||
| dcf03d8ba3 | |||
| 51c420c90a | |||
| 9e3a625898 | |||
| b203d40123 | |||
| 913c0f5e7f | |||
| bb400a4e82 | |||
| 46ffb67d60 | |||
| f5a8d17aa8 | |||
| b85de3ee79 | |||
| 6be8a373e0 | |||
| cc87a0cf6b | |||
| 2878346f19 | |||
| 1fa50a9da1 | |||
| 1c5d94ed5b | |||
| 7b40c692eb | |||
| 7acb742218 | |||
| 4b0ed06a26 | |||
| 56ec53d04b | |||
| c6167a94ef | |||
| 65c17a04df | |||
| 75bcb739f9 | |||
| 555bf2461a | |||
| bdacbd4989 | |||
| 6f7b7f0248 | |||
| 9b19dc9154 | |||
| 83edbee2e1 | |||
| dd55d4577d | |||
| 26a2a169df | |||
| e02ef6f228 | |||
| ae4b2d88cd | |||
| a8ae9b39b3 | |||
| 17a57a44eb | |||
| 02692402d8 | |||
| 6850db2a47 | |||
| 80ec67f3fd | |||
| 7ad68ca36b | |||
| da7ed8b292 | |||
| 5598ac05dc | |||
| cfa316be89 | |||
| dd1484e24f | |||
| 8222bdc3bc | |||
| 8cd7d4fa9c | |||
| d623eeb8d1 | |||
| d0ed107b08 | |||
| 6052607936 | |||
| 8d7499feb7 | |||
| ff542afe87 | |||
| bde3779fec | |||
| 5000edbfe0 | |||
| 984dc2bffd | |||
| 24d1a6744a | |||
| 608eb322a8 | |||
| 1a70298b5c | |||
| 14cdd85b66 | |||
| 8a5277e291 | |||
| 7eacab82a2 | |||
| e2030bba38 | |||
| ec1fe46138 | |||
| d73a0f4f23 | |||
| 655f348812 | |||
| 2b2d0291c2 | |||
| 4125863226 | |||
| a409a34819 | |||
| 7a5c8734ee | |||
| 9929189c45 | |||
| c5f06acb01 | |||
| ebaae75993 | |||
| 12227874a8 | |||
| 1361c1357a | |||
| 951dc2d8b0 | |||
| d01a687caa | |||
| 4d79b806c0 | |||
| b6d6276149 | |||
| 5833e37354 | |||
| d135f85f69 | |||
| 7467bfe4ed | |||
| d4c26d534c | |||
| 07ebd59e94 | |||
| 4d6e8526a8 | |||
| b4c41d3904 | |||
| 781f3d0641 | |||
| 7b393526c5 | |||
| d2ff5904c0 | |||
| e1b0cb0bca | |||
| 3052a6d88e | |||
| fc67c6efb1 | |||
| 8f0a5a81f1 | |||
| c04d045279 | |||
| 104d07f659 | |||
| 7c59ec4a2b | |||
| 9a70c345c7 | |||
| 31b819e83f | |||
| c78f947e09 | |||
| 841d9f277c | |||
| 35ccb88f60 | |||
| 47ef03fea4 | |||
| b5d615367e | |||
| 527f1f3bc3 | |||
| 22ef456164 | |||
| 088f842e17 | |||
| 29175d3158 | |||
| cd6fdc7832 | |||
| 3c21b070d7 | |||
| eea8dc6c16 | |||
| f8410dee3a | |||
| 5492ab75c2 | |||
| c5f4d80eda | |||
| 74329f479f | |||
| 95d7e39c80 | |||
| 4d9168c076 | |||
| 4543a3b277 | |||
| 99c79c79db | |||
| 104c76b8bc | |||
| 0ade49b758 | |||
| 7ba6449054 | |||
| 33e2e40942 | |||
| d3a0578fe1 | |||
| 363b16af38 | |||
| 61ffd03aaf | |||
| b0d52039f9 | |||
| c7ca86d374 | |||
| fcd610ee7b | |||
| 5f8d0e5dad | |||
| f763472609 | |||
| 34b1e19338 | |||
| c2f74f3cc2 | |||
| 9b70a9b2eb | |||
| 4ba3eedb70 | |||
| 62acfc9a07 | |||
| 98345e3d24 | |||
| e9818d79da | |||
| 2de47ef9f0 | |||
| b2020761d9 | |||
| c465f8a8a3 | |||
| dd4bb07193 | |||
| 80f8f605fd | |||
| 57c6d887a1 | |||
| 98c89f80b0 | |||
| ba66052181 | |||
| fc46818e31 | |||
| 7de4cd6231 | |||
| 4a78bade6d | |||
| c543fca92f | |||
| b0298a3157 | |||
| 7ac3cbe772 | |||
| 873ae90f39 | |||
| c8ed213347 | |||
| fa957d6d65 | |||
| 9f3a6d631c | |||
| 1f03277f1c | |||
| a8a8f9dbf3 | |||
| 4f75291446 | |||
| b29a2dfdde | |||
| 3653fcf256 | |||
| e40c68399d | |||
| c189fc52c1 | |||
| ce7bf0b847 | |||
| 0622603220 | |||
| ad946c3902 | |||
| 4f70f84635 | |||
| 0effb71f43 | |||
| 7c3e1a5d97 | |||
| d0fd0d7040 | |||
| 52230fab56 | |||
| 992b58389b | |||
| adb7d20c16 | |||
| a03615a01f | |||
| d1817310a1 | |||
| 1871b09697 | |||
| 376c6819e0 | |||
| 2a85d3d083 | |||
| 077f16ce2c | |||
| 0c4a65b113 | |||
| 6dae48a1a8 | |||
| a64ab6538e | |||
| 0ffcc47f32 | |||
| 3be356095f | |||
| 4afc66faf5 | |||
| 0b1a35f7b8 | |||
| d72c45e483 | |||
| 6c1117094d | |||
| a0834404f7 | |||
| c47b6f0381 | |||
| 67333b6186 | |||
| 0438430c7c | |||
| e0165c5d89 | |||
| 3f770e1111 | |||
| 4eb0bb6afd | |||
| eb8f371f34 | |||
| 38ee519f42 | |||
| ad9bdb7bd1 | |||
| 6f969214d3 | |||
| cabc164f74 | |||
| 8814cb0722 | |||
| c034e8389e | |||
| f3fe2a08ce | |||
| 0706c60445 | |||
| b8ee939e52 | |||
| 37cf3bb491 | |||
| 97699e9704 | |||
| 2638c274cb | |||
| 8bd1abee33 | |||
| e2ed581708 | |||
| a50a6e8638 | |||
| 9f402fa27f | |||
| 13571b0393 | |||
| 89fb59aa9a | |||
| e4e7e10690 | |||
| 5f21a145d1 | |||
| a3556b12da | |||
| 894646cb7c | |||
| 85a932bfaf | |||
| 9141be3656 | |||
| 76fc59aa79 | |||
| b7481489b1 | |||
| 6bed620d6c | |||
| 4e28b2d9c5 | |||
| ba818b3a10 | |||
| 72c2bf80aa | |||
| 33701862de | |||
| 98ccd0eb89 | |||
| 0f9559a784 | |||
| 65acfc9bef | |||
| 4ad5ac2d4a | |||
| 495c87b6c3 | |||
| 841b792e8e | |||
| c0b80ef899 | |||
| 5227a74ae3 | |||
| 3eaca0d436 | |||
| 190210b18d | |||
| bb2740e7c3 | |||
| 8dd32e2a0a | |||
| d5a500a73f | |||
| d177937e1c | |||
| 75dc8f59f6 | |||
| fc9efc2b79 | |||
| 668a6712e6 | |||
| 55bd7aa747 | |||
| f75d29e38e | |||
| a2ba69dd28 | |||
| 9b1ef29694 | |||
| 1ed69b95fc | |||
| 22ec366535 | |||
| e925818526 | |||
| b55d83ca82 | |||
| a77da8445e | |||
| 680de709a5 | |||
| 9d4182b189 | |||
| 4103ba0b71 | |||
| eeaa5c3b7b | |||
| abc2257624 | |||
| f007aeee1f | |||
| b73be75aeb | |||
| 0655742147 | |||
| abbe548d5c | |||
| 5447c4a3cf | |||
| 1e25bf2455 | |||
| 8ba18dd222 | |||
| 6c1ef851a2 | |||
| b06ef0ae6e | |||
| e990a9ac28 | |||
| b7049032a0 | |||
| 2a278b8698 | |||
| f3b922bbd5 | |||
| 8857c0d076 | |||
| 02087db65a | |||
| 6ca7f0b89c | |||
| 19a18164ec | |||
| d7163b2f9f | |||
| 889ec88de2 | |||
| 7bb7c6c295 | |||
| dbd5b4a47b | |||
| 4cfc9af442 | |||
| e061715315 | |||
| fe7645b8a9 | |||
| 19335df0eb | |||
| 695b709173 | |||
| 50ad2f8e31 | |||
| f970829b9e | |||
| 9410237ed5 | |||
| b2760b1faf | |||
| 4ab7a41f08 | |||
| 89e44da899 | |||
| e6168ba238 | |||
| 64a8b4ac47 | |||
| 86cba4d3f8 | |||
| 333d6a4374 | |||
| 64e408c954 | |||
| 75a5877c1d | |||
| 9ef64fd192 | |||
| 6f7d9bb1e4 | |||
| bbb8f836bf | |||
| 7b5300d0cc | |||
| 20916281d8 | |||
| a629a705d0 | |||
| 26b04cc96f | |||
| 56076a0aa2 | |||
| 2569787324 | |||
| ce660f8bbc | |||
| f4da5d4f3a | |||
| e8e6d3c2f1 | |||
| f93804a2a0 | |||
| be3bc5cc55 | |||
| 537897c0bb | |||
| 982769f0dc | |||
| 3024e25c09 | |||
| f5817248de | |||
| a169542bda | |||
| 9d94f4f714 | |||
| f816bbe801 | |||
| 97a95f1377 | |||
| e0a7aec228 | |||
| 2df92e6fd3 | |||
| c96d439f3d | |||
| e8e4cf9a37 | |||
| 47f1fd57e4 | |||
| 2d3dc436a8 | |||
| dc115b8ca0 | |||
| b675aec4dd | |||
| e6f1ce1fb2 | |||
| 3660483b97 | |||
| 48f004bb3d | |||
| 5653c4455a | |||
| 9b30ff8e59 | |||
| ddb9631d7a | |||
| 20caee1502 | |||
| ac27f645eb | |||
| d847d2b1c5 | |||
| f5693dff3d | |||
| e54324d880 | |||
| ad8d9dd71a | |||
| 00806580f5 | |||
| 97ee5600c7 | |||
| cf5aca799d | |||
| 57bb108465 | |||
| a2be7c0294 | |||
| 3dbcddc310 | |||
| 914a2f477c | |||
| f965066517 | |||
| 568574c118 | |||
| 0ccf0102d7 | |||
| d7f63217f1 | |||
| f911c8a781 | |||
| 34b91fd577 | |||
| 4c35b8174a | |||
| e860cc4814 | |||
| 9d2e788fea | |||
| d0293e4d33 | |||
| 0f9e30e54f | |||
| e530ab2838 | |||
| ba80c799d7 | |||
| 60aa40a56f | |||
| eda85a0141 | |||
| 9319c39257 | |||
| 55ad97bbd7 | |||
| 5dcaf940b6 | |||
| fd49a18b47 | |||
| 43c6bff5ae | |||
| fc642edf51 | |||
| 81bef1c83e | |||
| e4e60256ac | |||
| dacc025cf3 | |||
| 2293d7efd1 | |||
| 9032b7e33f | |||
| 6dd378c194 | |||
| 3d96785bf5 | |||
| 7fb3c5728b | |||
| 3176e10562 | |||
| e531c0930c | |||
| c2b5009208 | |||
| 252d868298 | |||
| d139a16446 | |||
| aaf6aee979 | |||
| 8701e0084c | |||
| a79aa6418a | |||
| 75343288ff | |||
| a71f3934c5 | |||
| 2043d1a4cc | |||
| 4ff5734720 | |||
| 34dbca7166 | |||
| 9b37a0de31 | |||
| b948f2dab5 | |||
| eb606924ab | |||
| 2acdd3b44f | |||
| e15566c7fa | |||
| 81577f120a | |||
| 23e5636dd0 | |||
| 021e4cd957 | |||
| 69e26c4036 | |||
| 365c96ccaa | |||
| a3decc4fba | |||
| 27811976ad | |||
| 3ebe1d27b1 | |||
| 35211e2190 | |||
| ba4c3ce3b9 | |||
| 82364d174f | |||
| 00cac37a07 | |||
| c16f105727 | |||
| 4efde58726 | |||
| 1661588bd1 | |||
| e330dc1321 | |||
| afc43fe95f | |||
| eea9729704 | |||
| 816441eff7 | |||
| a7fb018414 | |||
| 8661f92a10 | |||
| 0b1ee3303d | |||
| a769e8623d | |||
| 26f3ceda93 | |||
| d85e36ce9e | |||
| 7f423951bf | |||
| 65d5975592 | |||
| 23a1a4dc40 | |||
| 454ccf7547 | |||
| 22668c388c | |||
| 603b7da413 | |||
| 441ffd6a0b | |||
| f9ce54a51e | |||
| 8d85d80a55 | |||
| 744a00a55d | |||
| 877854a2f3 | |||
| 29d55887f9 | |||
| 947e8f9d2e | |||
| 159024a196 | |||
| 4f7ceebe65 | |||
| 88669fd578 | |||
| 759fa5f626 | |||
| a201610761 | |||
| 587cbac498 | |||
| 0c9f27c63b | |||
| 8f464ce8c7 | |||
| 9fb660a6a3 | |||
| bb3420d006 | |||
| bdc17f49e4 | |||
| 33b58a0363 | |||
| fccd4fab96 | |||
| 82552a9315 | |||
| 64348882a0 | |||
| 9056c53054 | |||
| 2a57ea757a | |||
| a2dd618849 | |||
| 09405ddc40 | |||
| 1e6f2cf750 | |||
| 5e6e626ace | |||
| fdb27eaaf8 | |||
| 185d97a65b | |||
| 66d45f391e | |||
| 8c27b4e23d | |||
| 1acc454035 | |||
| 8b54ea8562 | |||
| c08fdc0c8c | |||
| 2959137f0d | |||
| dbb4a979cc | |||
| 68d79e0f5f | |||
| 5575e3c485 | |||
| c32a006e8f | |||
| 7e33d80fa9 | |||
| 4417dd5951 | |||
| c8e566fe42 | |||
| 8ff0c8b02a | |||
| 68f67c54b6 | |||
| abc13c5a92 | |||
| a6ea99541e | |||
| d44876382d | |||
| 885d5f2098 | |||
| 0c042dc249 | |||
| 23295f7f07 | |||
| db7ed4d019 | |||
| be974cf280 | |||
| 7496c3da81 | |||
| 3976994781 | |||
| da3681246e | |||
| e181007de1 | |||
| 95a24cb43a | |||
| d6c1c49868 | |||
| 548de7d6f3 | |||
| 2a95917557 | |||
| 65d77383d0 | |||
| e35a4fdcf0 | |||
| 44f68b5942 | |||
| 3151befb38 | |||
| 98e46cdd2a | |||
| db1127def1 | |||
| 301451be40 | |||
| 6c9c1298e4 | |||
| 5141d6f970 | |||
| c35be02a7e | |||
| 8165a6ef75 | |||
| a68b076b96 | |||
| b9933d493a | |||
| 5ce06769cd | |||
| 04985a1754 | |||
| 89aa39b5c8 | |||
| 97e07a49e9 | |||
| 73b8a5a929 | |||
| c8246e3e8a | |||
| 118a47e4e1 | |||
| f46b4cf3da | |||
| 1df943e010 | |||
| d202f20fdb | |||
| e5a1c305d3 | |||
| 6d948ffba2 | |||
| 866205c145 | |||
| a5f36ad4e2 | |||
| 50751a2d6f | |||
| 18819e37ee | |||
| d98b7ec469 | |||
| 1df750bf1a | |||
| af672803a2 | |||
| 3311c2f65d | |||
| 9faa39aa23 | |||
| 48a6cd9cee | |||
| 37c1c6840c | |||
| f5eb8a98ff | |||
| 5e2b519b36 | |||
| 65e0533d11 | |||
| f56eb87fed | |||
| 71acf4b8a1 | |||
| e5a401fce8 | |||
| 6dedd0caac | |||
| cf8a20d6f6 | |||
| 104a3c6b9c | |||
| 148e7cddd3 | |||
| a13cceea3b | |||
| 88e30bec55 | |||
| c853eb3350 | |||
| 721cb88225 | |||
| b36ed2dec7 | |||
| 1c5557279a | |||
| 732e0f063a | |||
| 76f8ff9f21 | |||
| 82275a81c7 | |||
| f803e37505 | |||
| 7090227d38 | |||
| f3f39f3770 | |||
| 30877bb71f | |||
| 3304db08dd | |||
| fed02cdcdc | |||
| 42e9956779 | |||
| 027d89dd9b | |||
| 300c6d0824 | |||
| 5ecc8236b8 | |||
| 0536a140ed | |||
| 6edd7cb036 | |||
| 770c567123 | |||
| 103d7eab14 | |||
| fb70b3abf3 | |||
| ce4996668a | |||
| e3458277df | |||
| e8b310166f | |||
| 52271ff9f8 | |||
| d9d4599ba9 | |||
| 4f0f216015 | |||
| 63d1465019 | |||
| db9d5b7e8c | |||
| e8b1a57929 | |||
| fb9dc4f346 | |||
| 19b4323512 | |||
| 2835bb45e5 | |||
| c97180c18d | |||
| 3cb11fc906 | |||
| ffa450ddd4 | |||
| f7b72ddc7a | |||
| 0dfba86744 | |||
| 6186594332 | |||
| a7c4c059e9 | |||
| e13c38f0e4 | |||
| 78e727a1c4 | |||
| 8c26d934b7 | |||
| b3f69a8d1d | |||
| 99a685b7df | |||
| 9474f66d27 | |||
| 3f21ea472f | |||
| 4c1ef38280 | |||
| 0a2903c5c5 | |||
| b317f9a83a | |||
| cbd1c3e0be | |||
| 35a0acc9c6 | |||
| 53db17803a | |||
| cb5b228a21 | |||
| 0bf9dee7e3 | |||
| 94ab6f3d8e | |||
| 38f074254b | |||
| b7d7e19606 | |||
| c6a0078c35 | |||
| 75ef1f4b26 | |||
| 17848b3b86 | |||
| 18595791c0 | |||
| 2e5859f226 | |||
| 313b51d3fb | |||
| 90388a38f3 | |||
| 2ca725386f | |||
| ce7af872ff | |||
| 14dec177a4 | |||
| 770d212094 | |||
| 23f989127d | |||
| c1ff537beb | |||
| 579fd4bc89 | |||
| 6d40f34057 | |||
| 1224a34abd | |||
| 5782879f2f | |||
| eefca43064 |
@@ -0,0 +1,16 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
|
||||
[*.{md,json,yaml,tf,tfvars}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[coderd/database/dump.sql]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
+1
-1
@@ -1 +1 @@
|
||||
site @coder/frontend
|
||||
site/ @coder/frontend
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: Bug report
|
||||
about: Report a bug
|
||||
title: "Bug: "
|
||||
labels: "bug 🐛"
|
||||
labels: ["bug :bug:", "needs grooming :razor:"]
|
||||
---
|
||||
|
||||
## OS Information
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: Documentation improvement
|
||||
about: Suggest a documentation improvement
|
||||
title: "Docs: "
|
||||
labels: "documentation 📝"
|
||||
labels: ["documentation :memo:", "needs grooming :razor:"]
|
||||
---
|
||||
|
||||
## What is your suggestion?
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea to improve coder
|
||||
title: "Feat: "
|
||||
labels: ["new feature :sparkles:", "needs grooming :razor:"]
|
||||
---
|
||||
|
||||
## What is your suggestion?
|
||||
|
||||
## Why do you want this feature?
|
||||
|
||||
## Are there any workarounds to get this functionality today?
|
||||
|
||||
## Are you interested in submitting a PR for this?
|
||||
@@ -1,18 +1,12 @@
|
||||
codecov:
|
||||
require_ci_to_pass: false
|
||||
|
||||
comment:
|
||||
show_carryforward_flags: yes
|
||||
comment: false
|
||||
|
||||
github_checks:
|
||||
annotations: false
|
||||
|
||||
coverage:
|
||||
notify:
|
||||
slack:
|
||||
default:
|
||||
url: secret:v1::ALa1/e2X+k36fPseab5D7+kBFc9bJyIoIQioD0IMA5jr+0HXVpBRNDCHZhHjCdGc67yff6PPixPEOLwEZpxC37rM23RBZOYlqAq9A5e0MeZVlEoVq19aOYN4Xel17hMJ6GGm7n17wrYpCpcvlVSqNrN0+cr3guVDyG10kQyfh2Y=
|
||||
threshold: 1%
|
||||
only_pulls: false
|
||||
branches:
|
||||
- "main"
|
||||
status:
|
||||
patch:
|
||||
default:
|
||||
@@ -25,7 +19,7 @@ coverage:
|
||||
ignore:
|
||||
# This is generated code.
|
||||
- coderd/database/models.go
|
||||
- coderd/database/query.sql.go
|
||||
- coderd/database/queries.sql.go
|
||||
- coderd/database/databasefake
|
||||
# These are generated or don't require tests.
|
||||
- cmd
|
||||
@@ -35,6 +29,6 @@ ignore:
|
||||
- peerbroker/proto
|
||||
- provisionerd/proto
|
||||
- provisionersdk/proto
|
||||
- scripts/datadog-cireport
|
||||
- scripts
|
||||
- site/.storybook
|
||||
- rules.go
|
||||
@@ -32,6 +32,9 @@ updates:
|
||||
timezone: "America/Chicago"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "go"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/site/"
|
||||
@@ -41,9 +44,27 @@ updates:
|
||||
timezone: "America/Chicago"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "typescript/js"
|
||||
ignore:
|
||||
# Ignore major updates to Node.js types, because they need to
|
||||
# correspond to the Node.js engine version
|
||||
- dependency-name: "@types/node"
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
|
||||
- package-ecosystem: "terraform"
|
||||
directory: "/examples/templates"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "terraform"
|
||||
ignore:
|
||||
# We likely want to update this ourselves.
|
||||
- dependency-name: "coder/coder"
|
||||
@@ -0,0 +1,13 @@
|
||||
<!-- Help reviewers by listing the subtasks in this PR
|
||||
|
||||
Here's an example:
|
||||
|
||||
This PR adds a new feature to the CLI.
|
||||
|
||||
## Subtasks
|
||||
|
||||
- [x] added a test for feature
|
||||
|
||||
Fixes #345
|
||||
|
||||
-->
|
||||
+8
-12
@@ -25,26 +25,22 @@ types:
|
||||
# A build of any kind.
|
||||
- build
|
||||
|
||||
# A RELEASED fix that will NOT be back-ported. The originating issue may have
|
||||
# been discovered internally or externally to Coder.
|
||||
- fix
|
||||
|
||||
# Any code task that is ignored for changelog purposes. Examples include
|
||||
# devbin scripts and internal-only configurations.
|
||||
# Any code task that operates outside of CI, docs, or the product. Examples
|
||||
# include configurations, linters etc.
|
||||
- chore
|
||||
|
||||
# Any work performed on CI.
|
||||
- ci
|
||||
|
||||
# An UNRELEASED correction. For example, features are often built
|
||||
# incrementally and sometimes introduce minor flaws during a release cycle.
|
||||
# Corrections address those increments and flaws.
|
||||
- correct
|
||||
|
||||
- example
|
||||
|
||||
# Work that directly implements or supports the implementation of a feature.
|
||||
- feat
|
||||
|
||||
# A fix for a RELEASED bug (regression fix) that is intended for patch-release
|
||||
# A fix for either a released or unrelesed bug.
|
||||
- fix
|
||||
|
||||
# A fix for a released bug (regression fix) that is intended for patch-release
|
||||
# purposes.
|
||||
- hotfix
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 14
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 5
|
||||
# Only apply the stale logic to pulls, since we are using issues to manage work
|
||||
only: pulls
|
||||
daysUntilClose: 7
|
||||
# Label to apply when stale.
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
@@ -0,0 +1,68 @@
|
||||
# Note: Chromatic is a separate workflow for coder.yaml as suggested by the
|
||||
# chromatic docs. Explicitly, Chromatic works best on 'push' instead of other
|
||||
# event types (like pull request), keep in mind that it works build-over-build
|
||||
# by storing snapshots.
|
||||
#
|
||||
# SEE: https://www.chromatic.com/docs/ci
|
||||
name: chromatic
|
||||
|
||||
# REMARK: We want Chromatic to run whenever anything in the FE or its deps
|
||||
# change, including node_modules and generated code. Currently, all
|
||||
# node_modules and generated code live in site. If any of these are
|
||||
# hoisted, we'll want to adjust the paths filter to account for them.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- site/**
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- site/**
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# REMARK: this is only used to build storybook and deploy it to Chromatic.
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
# Required by Chromatic for build-over-build history, otherwise we
|
||||
# only get 1 commit on shallow checkout.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd site && yarn
|
||||
|
||||
# This step is not meant for mainline because any detected changes to
|
||||
# storybook snapshots will require manual approval/review in order for
|
||||
# the check to pass. This is desired in PRs, but not in mainline.
|
||||
- name: Publish to Chromatic (non-mainline)
|
||||
if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@v1
|
||||
with:
|
||||
buildScriptName: "storybook:build"
|
||||
exitOnceUploaded: true
|
||||
# Chromatic states its fine to make this token public. See:
|
||||
# https://www.chromatic.com/docs/github-actions#forked-repositories
|
||||
projectToken: 695c25b6cb65
|
||||
workingDir: "./site"
|
||||
|
||||
# This is a separate step for mainline only that auto accepts and changes
|
||||
# instead of holding CI up. Since we squash/merge, this is defensive to
|
||||
# avoid the same changeset from requiring review once squashed into
|
||||
# main. Chromatic is supposed to be able to detect that we use squash
|
||||
# commits, but it's good to be defensive in case, otherwise CI remains
|
||||
# infinitely "in progress" in mainline unless we re-review each build.
|
||||
- name: Publish to Chromatic (mainline)
|
||||
if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@v1
|
||||
with:
|
||||
autoAcceptChanges: true
|
||||
buildScriptName: "storybook:build"
|
||||
projectToken: 695c25b6cb65
|
||||
workingDir: "./site"
|
||||
@@ -1,98 +0,0 @@
|
||||
# This workflow (aka The Gauntlet) is a high-iteration run of our tests,
|
||||
# used to evaluate stability and shake out intermittent failures.
|
||||
name: coder-test-stability
|
||||
on:
|
||||
schedule:
|
||||
# Run everyday around midnight Central.
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/coder-test-stability.yaml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
iterationCount:
|
||||
description: "Iteration Count"
|
||||
required: false
|
||||
default: "10"
|
||||
|
||||
# Cancel in-progress runs for pull requests when developers push
|
||||
# additional changes, and serialize builds in branches.
|
||||
# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-concurrency-to-cancel-any-in-progress-job-or-run
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
coder-test-stability:
|
||||
name: "test/go/stability/${{ matrix.os }}/${{ matrix.instance }}"
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-2022
|
||||
instance:
|
||||
- 1
|
||||
- 2
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
# Go mod cache, Linux build cache, Mac build cache, Windows build cache
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
~/Library/Caches/go-build
|
||||
%LocalAppData%\go-build
|
||||
key: ${{ matrix.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ matrix.os }}-go-
|
||||
|
||||
- run: go install gotest.tools/gotestsum@latest
|
||||
|
||||
- uses: hashicorp/setup-terraform@v1
|
||||
with:
|
||||
terraform_version: 1.1.2
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Test with Mock Database
|
||||
shell: bash
|
||||
env:
|
||||
GOCOUNT: ${{ github.event.inputs.iterationCount || 10 }}
|
||||
GOMAXPROCS: ${{ runner.os == 'Windows' && 1 || 2 }}
|
||||
run: gotestsum --junitfile="gotests.xml" --packages="./..." --
|
||||
-covermode=atomic -coverprofile="gotests.coverage"
|
||||
-timeout=15m -count=$GOCOUNT -race -short -failfast
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: (success() || failure()) && github.actor != 'dependabot[bot]'
|
||||
env:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
DD_DATABASE: fake
|
||||
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: go run scripts/datadog-cireport/main.go gotests.xml
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
GOCOUNT: ${{ github.event.inputs.iterationCount || 10 }}
|
||||
run: DB=true gotestsum --junitfile="gotests.xml" --packages="./..." --
|
||||
-covermode=atomic -coverprofile="gotests.coverage" -timeout=30m
|
||||
-count=$GOCOUNT -race -parallel=2 -failfast
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: (success() || failure()) && github.actor != 'dependabot[bot]' && runner.os == 'Linux'
|
||||
env:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
DD_DATABASE: postgresql
|
||||
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: go run scripts/datadog-cireport/main.go gotests.xml
|
||||
@@ -4,13 +4,10 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release/*"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -35,19 +32,34 @@ concurrency:
|
||||
jobs:
|
||||
style-lint-golangci:
|
||||
name: style/lint/golangci
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3.1.0
|
||||
uses: golangci/golangci-lint-action@v3.2.0
|
||||
with:
|
||||
version: v1.45.2
|
||||
version: v1.46.0
|
||||
|
||||
style-lint-shellcheck:
|
||||
name: style/lint/shellcheck
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Run ShellCheck
|
||||
uses: ludeeus/action-shellcheck@1.1.0
|
||||
env:
|
||||
SHELLCHECK_OPTS: --external-sources
|
||||
with:
|
||||
ignore: node_modules
|
||||
|
||||
style-lint-typescript:
|
||||
name: "style/lint/typescript"
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -73,29 +85,46 @@ jobs:
|
||||
|
||||
gen:
|
||||
name: "style/gen"
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Cache Node
|
||||
id: cache-node
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
.eslintcache
|
||||
key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
|
||||
- name: Install node_modules
|
||||
run: ./scripts/yarn_install.sh
|
||||
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v1
|
||||
with:
|
||||
version: "3.19.4"
|
||||
- uses: actions/setup-go@v2
|
||||
version: "3.20.0"
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
- run: curl -sSL
|
||||
https://github.com/kyleconroy/sqlc/releases/download/v1.11.0/sqlc_1.11.0_linux_amd64.tar.gz
|
||||
https://github.com/kyleconroy/sqlc/releases/download/v1.13.0/sqlc_1.13.0_linux_amd64.tar.gz
|
||||
| sudo tar -C /usr/bin -xz sqlc
|
||||
|
||||
- run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
|
||||
- run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.26
|
||||
- run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
- run: "make --output-sync -j gen"
|
||||
- run: "make --output-sync -j -B gen"
|
||||
- run: ./scripts/check_unstaged.sh
|
||||
|
||||
style-fmt:
|
||||
name: "style/fmt"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
@@ -117,12 +146,17 @@ jobs:
|
||||
- name: Install node_modules
|
||||
run: ./scripts/yarn_install.sh
|
||||
|
||||
- name: "make fmt"
|
||||
run: "make --output-sync -j fmt"
|
||||
- name: Install shfmt
|
||||
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.5.0
|
||||
|
||||
- run: |
|
||||
export PATH=${PATH}:$(go env GOPATH)/bin
|
||||
make --output-sync -j -B fmt
|
||||
|
||||
test-go:
|
||||
name: "test/go"
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
@@ -132,7 +166,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
|
||||
@@ -155,14 +189,14 @@ jobs:
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install goreleaser
|
||||
uses: jaxxstorm/action-install-gh-release@v1.4.0
|
||||
uses: jaxxstorm/action-install-gh-release@v1.7.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
repo: gotestyourself/gotestsum
|
||||
tag: v1.7.0
|
||||
|
||||
- uses: hashicorp/setup-terraform@v1
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
terraform_version: 1.1.2
|
||||
terraform_wrapper: false
|
||||
@@ -178,7 +212,7 @@ jobs:
|
||||
-timeout=3m -count=$GOCOUNT -short -failfast
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: (success() || failure()) && github.actor != 'dependabot[bot]'
|
||||
if: always() && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
env:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
DD_DATABASE: fake
|
||||
@@ -186,21 +220,23 @@ jobs:
|
||||
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: go run scripts/datadog-cireport/main.go gotests.xml
|
||||
|
||||
- uses: codecov/codecov-action@v2
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
- uses: codecov/codecov-action@v3
|
||||
if: github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./gotests.coverage
|
||||
flags: unittest-go-${{ matrix.os }}
|
||||
fail_ci_if_error: true
|
||||
# this flakes and sometimes fails the build
|
||||
fail_ci_if_error: false
|
||||
|
||||
test-go-postgres:
|
||||
name: "test/go/postgres"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
|
||||
@@ -223,14 +259,14 @@ jobs:
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install goreleaser
|
||||
uses: jaxxstorm/action-install-gh-release@v1.4.0
|
||||
uses: jaxxstorm/action-install-gh-release@v1.7.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
repo: gotestyourself/gotestsum
|
||||
tag: v1.7.0
|
||||
|
||||
- uses: hashicorp/setup-terraform@v1
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
terraform_version: 1.1.2
|
||||
terraform_wrapper: false
|
||||
@@ -258,31 +294,30 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
run: DB=ci gotestsum --junitfile="gotests.xml" --packages="./..." --
|
||||
-covermode=atomic -coverprofile="gotests.coverage" -timeout=3m
|
||||
-coverpkg=./...,github.com/coder/coder/codersdk
|
||||
-count=1 -parallel=2 -race -failfast
|
||||
run: "make test-postgres"
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: (success() || failure()) && github.actor != 'dependabot[bot]'
|
||||
if: always() && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
env:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
DD_DATABASE: postgresql
|
||||
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: go run scripts/datadog-cireport/main.go gotests.xml
|
||||
|
||||
- uses: codecov/codecov-action@v2
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
- uses: codecov/codecov-action@v3
|
||||
if: github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./gotests.coverage
|
||||
flags: unittest-go-${{ matrix.os }}
|
||||
fail_ci_if_error: true
|
||||
flags: unittest-go-postgres-${{ matrix.os }}
|
||||
# this flakes and sometimes fails the build
|
||||
fail_ci_if_error: false
|
||||
|
||||
deploy:
|
||||
name: "deploy"
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 20
|
||||
if: github.ref == 'refs/heads/main' && !github.event.pull_request.head.repo.fork
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -298,7 +333,7 @@ jobs:
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
|
||||
@@ -320,7 +355,7 @@ jobs:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ runner.os }}-release-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v2
|
||||
- uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
install-only: true
|
||||
|
||||
@@ -335,13 +370,26 @@ jobs:
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
|
||||
- name: Build site
|
||||
run: make -B site/out/index.html
|
||||
|
||||
- name: Build Release
|
||||
run: make release
|
||||
uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
version: latest
|
||||
args: release --snapshot --rm-dist --skip-sign
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coder_linux_amd64.deb
|
||||
path: ./dist/coder_*_linux_amd64.deb
|
||||
name: coder_windows_amd64.zip
|
||||
path: ./dist/coder_*_windows_amd64.zip
|
||||
retention-days: 7
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coder_linux_amd64.tar.gz
|
||||
path: ./dist/coder_*_linux_amd64.tar.gz
|
||||
retention-days: 7
|
||||
|
||||
- name: Install Release
|
||||
run: |
|
||||
@@ -357,6 +405,7 @@ jobs:
|
||||
test-js:
|
||||
name: "test/js"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -372,7 +421,7 @@ jobs:
|
||||
js-${{ runner.os }}-
|
||||
|
||||
# Go is required for uploading the test results to datadog
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
|
||||
@@ -383,27 +432,20 @@ jobs:
|
||||
- name: Install node_modules
|
||||
run: ./scripts/yarn_install.sh
|
||||
|
||||
- name: Build frontend
|
||||
run: yarn build
|
||||
working-directory: site
|
||||
|
||||
- name: Build Storybook
|
||||
run: yarn storybook:build
|
||||
working-directory: site
|
||||
|
||||
- run: yarn test:coverage
|
||||
working-directory: site
|
||||
|
||||
- uses: codecov/codecov-action@v2
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
- uses: codecov/codecov-action@v3
|
||||
if: github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./site/coverage/lcov.info
|
||||
flags: unittest-js
|
||||
fail_ci_if_error: true
|
||||
# this flakes and sometimes fails the build
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: (success() || failure()) && github.actor != 'dependabot[bot]'
|
||||
if: always() && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
env:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
DD_CATEGORY: unit
|
||||
@@ -413,14 +455,11 @@ jobs:
|
||||
test-e2e:
|
||||
name: "test/e2e/${{ matrix.os }}"
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
# TODO: Get `make build` running on Windows 2022
|
||||
# https://github.com/coder/coder/issues/384
|
||||
# - windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -436,11 +475,11 @@ jobs:
|
||||
js-${{ runner.os }}-
|
||||
|
||||
# Go is required for uploading the test results to datadog
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
|
||||
- uses: hashicorp/setup-terraform@v1
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
terraform_version: 1.1.2
|
||||
terraform_wrapper: false
|
||||
@@ -449,7 +488,7 @@ jobs:
|
||||
with:
|
||||
node-version: "14"
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v2
|
||||
- uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
install-only: true
|
||||
|
||||
@@ -473,7 +512,7 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
make site/out
|
||||
make -B site/out/index.html
|
||||
|
||||
- run: yarn playwright:install
|
||||
working-directory: site
|
||||
@@ -487,7 +526,7 @@ jobs:
|
||||
working-directory: site
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: (success() || failure()) && github.actor != 'dependabot[bot]' && runner.os == 'Linux'
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
env:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
DD_CATEGORY: e2e
|
||||
|
||||
@@ -3,17 +3,47 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
# Necessary for Docker manifest
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
steps:
|
||||
# Docker is not included on macos-latest
|
||||
- uses: docker-practice/actions-setup-docker@1.0.10
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
|
||||
- name: Install Gon
|
||||
run: |
|
||||
brew tap mitchellh/gon
|
||||
brew install mitchellh/gon/gon
|
||||
|
||||
- name: Import Signing Certificates
|
||||
uses: Apple-Actions/import-codesign-certs@v1
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.AC_CERTIFICATE_P12_BASE64 }}
|
||||
p12-password: ${{ secrets.AC_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
@@ -43,13 +73,18 @@ jobs:
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
|
||||
- name: Install make
|
||||
run: brew install make
|
||||
|
||||
- name: Build Site
|
||||
run: make site/out
|
||||
run: make site/out/index.html
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v2.9.1
|
||||
uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
version: latest
|
||||
args: release --rm-dist
|
||||
args: release --rm-dist --timeout 60m
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AC_USERNAME: ${{ secrets.AC_USERNAME }}
|
||||
AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
|
||||
|
||||
@@ -14,6 +14,7 @@ vendor
|
||||
.eslintcache
|
||||
yarn-error.log
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
# Front-end ignore
|
||||
.next/
|
||||
@@ -25,6 +26,7 @@ site/test-results/
|
||||
site/yarn-error.log
|
||||
coverage/
|
||||
site/**/*.typegen.ts
|
||||
site/build-storybook.log
|
||||
|
||||
# Build
|
||||
dist/
|
||||
@@ -34,3 +36,6 @@ site/out/
|
||||
*.tfplan
|
||||
*.lock.hcl
|
||||
.terraform/
|
||||
|
||||
.vscode/*.log
|
||||
**/*.swp
|
||||
|
||||
@@ -77,7 +77,7 @@ linters-settings:
|
||||
# - sloppyReassign
|
||||
- sloppyTypeAssert
|
||||
- sortSlice
|
||||
# - sprintfQuotedString
|
||||
- sprintfQuotedString
|
||||
- sqlQuery
|
||||
# - stringConcatSimplify
|
||||
# - stringXbytes
|
||||
@@ -103,7 +103,14 @@ linters-settings:
|
||||
settings:
|
||||
ruleguard:
|
||||
failOn: all
|
||||
rules: rules.go
|
||||
rules: '${configDir}/scripts/rules.go'
|
||||
|
||||
staticcheck:
|
||||
# https://staticcheck.io/docs/options#checks
|
||||
# We disable SA1019 because it gets angry about our usage of xerrors. We
|
||||
# intentionally xerrors because stack frame support didn't make it into the
|
||||
# stdlib port.
|
||||
checks: ["all", "-SA1019"]
|
||||
|
||||
goimports:
|
||||
local-prefixes: coder.com,cdr.dev,go.coder.com,github.com/cdr,github.com/coder
|
||||
@@ -194,6 +201,8 @@ run:
|
||||
concurrency: 4
|
||||
skip-dirs:
|
||||
- node_modules
|
||||
skip-files:
|
||||
- scripts/rules.go
|
||||
timeout: 5m
|
||||
|
||||
# Over time, add more and more linters from
|
||||
@@ -235,7 +244,7 @@ linters:
|
||||
# without testing any exported functions. This is enabled to promote
|
||||
# decomposing a package before testing it's internals. A function caller
|
||||
# should be able to test most of the functionality from exported functions.
|
||||
#
|
||||
#
|
||||
# There are edge-cases to this rule, but they should be carefully considered
|
||||
# to avoid structural inconsistency.
|
||||
- testpackage
|
||||
@@ -0,0 +1,159 @@
|
||||
archives:
|
||||
- id: coder-linux
|
||||
builds: [coder-linux]
|
||||
format: tar.gz
|
||||
|
||||
- id: coder-darwin
|
||||
builds: [coder-darwin]
|
||||
format: zip
|
||||
|
||||
- id: coder-windows
|
||||
builds: [coder-windows]
|
||||
format: zip
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- rm -f site/out/bin/coder*
|
||||
|
||||
builds:
|
||||
- id: coder-slim
|
||||
dir: cmd/coder
|
||||
ldflags: ["-s -w -X github.com/coder/coder/buildinfo.tag={{ .Version }}"]
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [darwin, linux, windows]
|
||||
goarch: [amd64, arm, arm64]
|
||||
goarm: ["7"]
|
||||
# Only build arm 7 for Linux
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarm: "7"
|
||||
- goos: darwin
|
||||
goarm: "7"
|
||||
hooks:
|
||||
# The "trimprefix" appends ".exe" on Windows.
|
||||
post: |
|
||||
cp {{.Path}} site/out/bin/coder-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ trimprefix .Name "coder" }}
|
||||
|
||||
- id: coder-linux
|
||||
dir: cmd/coder
|
||||
flags: [-tags=embed]
|
||||
ldflags: ["-s -w -X github.com/coder/coder/buildinfo.tag={{ .Version }}"]
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm, arm64]
|
||||
goarm: ["7"]
|
||||
|
||||
- id: coder-windows
|
||||
dir: cmd/coder
|
||||
flags: [-tags=embed]
|
||||
ldflags: ["-s -w -X github.com/coder/coder/buildinfo.tag={{ .Version }}"]
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [windows]
|
||||
goarch: [amd64, arm64]
|
||||
|
||||
- id: coder-darwin
|
||||
dir: cmd/coder
|
||||
flags: [-tags=embed]
|
||||
ldflags: ["-s -w -X github.com/coder/coder/buildinfo.tag={{ .Version }}"]
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [darwin]
|
||||
goarch: [amd64, arm64]
|
||||
hooks:
|
||||
# This signs the binary that will be located inside the zip.
|
||||
# MacOS requires the binary to be signed for notarization.
|
||||
#
|
||||
# If it doesn't successfully sign, the zip sign step will error.
|
||||
post: |
|
||||
sh -c 'codesign -s {{.Env.AC_APPLICATION_IDENTITY}} -f -v --timestamp --options runtime {{.Path}} || true'
|
||||
|
||||
env:
|
||||
# Apple identity for signing!
|
||||
- AC_APPLICATION_IDENTITY=BDB050EB749EDD6A80C6F119BF1382ECA119CCCC
|
||||
|
||||
nfpms:
|
||||
- id: packages
|
||||
vendor: Coder
|
||||
homepage: https://coder.com
|
||||
maintainer: Coder <support@coder.com>
|
||||
description: |
|
||||
Provision development environments with infrastructure with code
|
||||
formats:
|
||||
- apk
|
||||
- deb
|
||||
- rpm
|
||||
suggests:
|
||||
- postgresql
|
||||
builds:
|
||||
- coder-linux
|
||||
bindir: /usr/bin
|
||||
contents:
|
||||
- src: coder.env
|
||||
dst: /etc/coder.d/coder.env
|
||||
type: "config|noreplace"
|
||||
- src: coder.service
|
||||
dst: /usr/lib/systemd/system/coder.service
|
||||
|
||||
dockers:
|
||||
- image_templates: ["ghcr.io/coder/coder:{{ .Tag }}-amd64"]
|
||||
id: coder-linux
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- --platform=linux/amd64
|
||||
- --label=org.opencontainers.image.title=Coder
|
||||
- --label=org.opencontainers.image.description=A tool for provisioning self-hosted development environments with Terraform.
|
||||
- --label=org.opencontainers.image.url=https://github.com/coder/coder
|
||||
- --label=org.opencontainers.image.source=https://github.com/coder/coder
|
||||
- --label=org.opencontainers.image.version={{ .Version }}
|
||||
- --label=org.opencontainers.image.revision={{ .FullCommit }}
|
||||
- --label=org.opencontainers.image.licenses=AGPL-3.0
|
||||
- image_templates: ["ghcr.io/coder/coder:{{ .Tag }}-arm64"]
|
||||
goarch: arm64
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- --platform=linux/arm64/v8
|
||||
- --label=org.opencontainers.image.title=coder
|
||||
- --label=org.opencontainers.image.description=A tool for provisioning self-hosted development environments with Terraform.
|
||||
- --label=org.opencontainers.image.url=https://github.com/coder/coder
|
||||
- --label=org.opencontainers.image.source=https://github.com/coder/coder
|
||||
- --label=org.opencontainers.image.version={{ .Tag }}
|
||||
- --label=org.opencontainers.image.revision={{ .FullCommit }}
|
||||
- --label=org.opencontainers.image.licenses=AGPL-3.0
|
||||
- image_templates: ["ghcr.io/coder/coder:{{ .Tag }}-armv7"]
|
||||
goarch: arm
|
||||
goarm: "7"
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- --platform=linux/arm/v7
|
||||
- --label=org.opencontainers.image.title=Coder
|
||||
- --label=org.opencontainers.image.description=A tool for provisioning self-hosted development environments with Terraform.
|
||||
- --label=org.opencontainers.image.url=https://github.com/coder/coder
|
||||
- --label=org.opencontainers.image.source=https://github.com/coder/coder
|
||||
- --label=org.opencontainers.image.version={{ .Tag }}
|
||||
- --label=org.opencontainers.image.revision={{ .FullCommit }}
|
||||
- --label=org.opencontainers.image.licenses=AGPL-3.0
|
||||
docker_manifests:
|
||||
- name_template: ghcr.io/coder/coder:{{ .Tag }}
|
||||
image_templates:
|
||||
- ghcr.io/coder/coder:{{ .Tag }}-amd64
|
||||
- ghcr.io/coder/coder:{{ .Tag }}-arm64
|
||||
- ghcr.io/coder/coder:{{ .Tag }}-armv7
|
||||
|
||||
release:
|
||||
ids: [coder-linux, coder-darwin, coder-windows, packages]
|
||||
footer: |
|
||||
## Container Image
|
||||
- `docker pull ghcr.io/coder/coder:{{ .Tag }}`
|
||||
|
||||
signs:
|
||||
- ids: [coder-darwin]
|
||||
artifacts: archive
|
||||
cmd: ./scripts/sign_macos.sh
|
||||
args: ["${artifact}"]
|
||||
output: true
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ .Version }}-devel+{{ .ShortCommit }}"
|
||||
@@ -1,64 +0,0 @@
|
||||
archives:
|
||||
- id: coder
|
||||
builds:
|
||||
- coder
|
||||
files:
|
||||
- src: docs/README.md
|
||||
dst: README.md
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- rm -f site/out/bin/coder*
|
||||
|
||||
builds:
|
||||
- id: coder-slim
|
||||
dir: cmd/coder
|
||||
ldflags: ["-s -w -X github.com/coder/coder/cli/buildinfo.tag={{ .Version }}"]
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [darwin, linux, windows]
|
||||
goarch: [amd64]
|
||||
hooks:
|
||||
# The "trimprefix" appends ".exe" on Windows.
|
||||
post: |
|
||||
cp {{.Path}} site/out/bin/coder-{{ .Os }}-{{ .Arch }}{{ trimprefix .Name "coder" }}
|
||||
|
||||
- id: coder
|
||||
dir: cmd/coder
|
||||
flags: [-tags=embed]
|
||||
ldflags: ["-s -w -X github.com/coder/coder/cli/buildinfo.tag={{ .Version }}"]
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [darwin, linux, windows]
|
||||
goarch: [amd64, arm64]
|
||||
|
||||
nfpms:
|
||||
- id: packages
|
||||
vendor: Coder
|
||||
homepage: https://coder.com
|
||||
maintainer: Coder <support@coder.com>
|
||||
description: |
|
||||
Provision development environments with infrastructure with code
|
||||
formats:
|
||||
- apk
|
||||
- deb
|
||||
- rpm
|
||||
suggests:
|
||||
- postgresql
|
||||
builds:
|
||||
- coder
|
||||
bindir: /usr/bin
|
||||
contents:
|
||||
- src: coder.env
|
||||
dst: /etc/coder.d/coder.env
|
||||
type: "config|noreplace"
|
||||
- src: coder.service
|
||||
dst: /usr/lib/systemd/system/coder.service
|
||||
|
||||
release:
|
||||
ids: [coder, packages]
|
||||
|
||||
snapshot:
|
||||
name_template: '{{ .Version }}-devel+{{ .ShortCommit }}'
|
||||
Vendored
+2
-1
@@ -7,6 +7,7 @@
|
||||
"emeraldwalk.runonsave",
|
||||
"zxh404.vscode-proto3",
|
||||
"redhat.vscode-yaml",
|
||||
"streetsidesoftware.code-spell-checker"
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+24
-2
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"buildname",
|
||||
"circbuf",
|
||||
"cliflag",
|
||||
"cliui",
|
||||
"coderd",
|
||||
"coderdtest",
|
||||
"codersdk",
|
||||
"cronstrue",
|
||||
"devel",
|
||||
"drpc",
|
||||
"drpcconn",
|
||||
@@ -12,12 +15,15 @@
|
||||
"drpcserver",
|
||||
"Dsts",
|
||||
"fatih",
|
||||
"Formik",
|
||||
"goarch",
|
||||
"gographviz",
|
||||
"goleak",
|
||||
"gossh",
|
||||
"gsyslog",
|
||||
"hashicorp",
|
||||
"hclsyntax",
|
||||
"httpapi",
|
||||
"httpmw",
|
||||
"idtoken",
|
||||
"Iflag",
|
||||
@@ -35,6 +41,7 @@
|
||||
"nolint",
|
||||
"nosec",
|
||||
"ntqry",
|
||||
"OIDC",
|
||||
"oneof",
|
||||
"parameterscopeid",
|
||||
"pqtype",
|
||||
@@ -45,25 +52,40 @@
|
||||
"ptty",
|
||||
"ptytest",
|
||||
"retrier",
|
||||
"rpty",
|
||||
"sdkproto",
|
||||
"Signup",
|
||||
"sourcemapped",
|
||||
"stretchr",
|
||||
"TCGETS",
|
||||
"tcpip",
|
||||
"TCSETS",
|
||||
"templateversions",
|
||||
"testid",
|
||||
"tfexec",
|
||||
"tfjson",
|
||||
"tfstate",
|
||||
"trimprefix",
|
||||
"typegen",
|
||||
"unconvert",
|
||||
"Untar",
|
||||
"VMID",
|
||||
"weblinks",
|
||||
"webrtc",
|
||||
"workspacebuilds",
|
||||
"xerrors",
|
||||
"xstate",
|
||||
"yamux"
|
||||
],
|
||||
"emeraldwalk.runonsave": {
|
||||
"commands": [
|
||||
{
|
||||
"match": "database/query.sql",
|
||||
"match": "database/queries/*.sql",
|
||||
"cmd": "make gen"
|
||||
},
|
||||
{
|
||||
"match": "provisionerd/proto/provisionerd.proto",
|
||||
"cmd": "make provisionerd/proto/provisionerd.pb.go"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -91,5 +113,5 @@
|
||||
},
|
||||
// We often use a version of TypeScript that's ahead of the version shipped
|
||||
// with VS Code.
|
||||
"typescript.tsdk": "./site/node_modules/typescript/lib",
|
||||
"typescript.tsdk": "./site/node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM alpine
|
||||
|
||||
# Generated by goreleaser on `goreleaser release`
|
||||
ADD coder /opt/coder
|
||||
|
||||
ENTRYPOINT [ "/opt/coder", "server" ]
|
||||
@@ -1,23 +1,31 @@
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
INSTALL_DIR=$(shell go env GOPATH)/bin
|
||||
GOOS=$(shell go env GOOS)
|
||||
GOARCH=$(shell go env GOARCH)
|
||||
|
||||
bin:
|
||||
goreleaser build --snapshot --rm-dist
|
||||
.PHONY: bin
|
||||
bin: $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum $(shell find ./examples/templates)
|
||||
@echo "== This builds binaries for command-line usage."
|
||||
@echo "== Use \"make build\" to embed the site."
|
||||
goreleaser build --snapshot --rm-dist --single-target
|
||||
|
||||
build: site/out bin
|
||||
build: dist/artifacts.json
|
||||
.PHONY: build
|
||||
|
||||
# Runs migrations to output a dump of the database.
|
||||
coderd/database/dump.sql: $(wildcard coderd/database/migrations/*.sql)
|
||||
go run coderd/database/dump/main.go
|
||||
.PHONY: coderd/database/dump.sql
|
||||
|
||||
# Generates Go code for querying the database.
|
||||
coderd/database/generate: fmt/sql coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql)
|
||||
coderd/database/querier.go: coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql)
|
||||
coderd/database/generate.sh
|
||||
.PHONY: coderd/database/generate
|
||||
|
||||
dev:
|
||||
./scripts/develop.sh
|
||||
.PHONY: dev
|
||||
|
||||
dist/artifacts.json: site/out/index.html $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum $(shell find ./examples/templates)
|
||||
goreleaser release --snapshot --rm-dist --skip-sign
|
||||
|
||||
fmt/prettier:
|
||||
@echo "--- prettier"
|
||||
@@ -29,74 +37,104 @@ else
|
||||
endif
|
||||
.PHONY: fmt/prettier
|
||||
|
||||
fmt/sql: $(wildcard coderd/database/queries/*.sql)
|
||||
# TODO: this is slightly slow
|
||||
for fi in coderd/database/queries/*.sql; do \
|
||||
npx sql-formatter \
|
||||
--language postgresql \
|
||||
--lines-between-queries 2 \
|
||||
--tab-indent \
|
||||
$$fi \
|
||||
--output $$fi; \
|
||||
done
|
||||
fmt/terraform: $(wildcard *.tf)
|
||||
terraform fmt -recursive
|
||||
.PHONY: fmt/terraform
|
||||
|
||||
sed -i 's/@ /@/g' ./coderd/database/queries/*.sql
|
||||
fmt/shfmt: $(shell shfmt -f .)
|
||||
@echo "--- shfmt"
|
||||
# Only do diff check in CI, errors on diff.
|
||||
ifdef CI
|
||||
shfmt -d $(shell shfmt -f .)
|
||||
else
|
||||
shfmt -w $(shell shfmt -f .)
|
||||
endif
|
||||
|
||||
fmt: fmt/prettier fmt/sql
|
||||
fmt: fmt/prettier fmt/terraform fmt/shfmt
|
||||
.PHONY: fmt
|
||||
|
||||
gen: coderd/database/generate peerbroker/proto provisionersdk/proto provisionerd/proto
|
||||
.PHONY: gen
|
||||
gen: coderd/database/querier.go peerbroker/proto/peerbroker.pb.go provisionersdk/proto/provisioner.pb.go provisionerd/proto/provisionerd.pb.go site/src/api/typesGenerated.ts
|
||||
|
||||
install: bin
|
||||
install: build
|
||||
mkdir -p $(INSTALL_DIR)
|
||||
@echo "--- Copying from bin to $(INSTALL_DIR)"
|
||||
cp -r ./dist/coder_$(GOOS)_$(GOARCH)/* $(INSTALL_DIR)
|
||||
cp -r ./dist/coder-$(GOOS)_$(GOOS)_$(GOARCH)*/* $(INSTALL_DIR)
|
||||
@echo "-- CLI available at $(shell ls $(INSTALL_DIR)/coder*)"
|
||||
.PHONY: install
|
||||
|
||||
lint:
|
||||
golangci-lint run
|
||||
.PHONY: lint
|
||||
lint: lint/shellcheck lint/go
|
||||
|
||||
peerbroker/proto: peerbroker/proto/peerbroker.proto
|
||||
lint/go:
|
||||
golangci-lint run
|
||||
.PHONY: lint/go
|
||||
|
||||
# Use shfmt to determine the shell files, takes editorconfig into consideration.
|
||||
lint/shellcheck: $(shell shfmt -f .)
|
||||
@echo "--- shellcheck"
|
||||
shellcheck --external-sources $(shell shfmt -f .)
|
||||
|
||||
peerbroker/proto/peerbroker.pb.go: peerbroker/proto/peerbroker.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./peerbroker/proto/peerbroker.proto
|
||||
.PHONY: peerbroker/proto
|
||||
|
||||
provisionerd/proto: provisionerd/proto/provisionerd.proto
|
||||
provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./provisionerd/proto/provisionerd.proto
|
||||
.PHONY: provisionerd/proto
|
||||
|
||||
provisionersdk/proto: provisionersdk/proto/provisioner.proto
|
||||
provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./provisionersdk/proto/provisioner.proto
|
||||
.PHONY: provisionersdk/proto
|
||||
|
||||
release: site/out
|
||||
goreleaser release --snapshot --rm-dist
|
||||
.PHONY: release
|
||||
|
||||
site/out:
|
||||
site/out/index.html: $(shell find ./site -not -path './site/node_modules/*' -type f -name '*.tsx') $(shell find ./site -not -path './site/node_modules/*' -type f -name '*.ts') site/package.json
|
||||
./scripts/yarn_install.sh
|
||||
cd site && yarn typegen
|
||||
cd site && yarn build
|
||||
# Restores GITKEEP files!
|
||||
git checkout HEAD site/out
|
||||
.PHONY: site/out
|
||||
|
||||
test:
|
||||
site/src/api/typesGenerated.ts: scripts/apitypings/main.go $(shell find codersdk -type f -name '*.go')
|
||||
go run scripts/apitypings/main.go > site/src/api/typesGenerated.ts
|
||||
cd site && yarn run format:types
|
||||
|
||||
.PHONY: test
|
||||
test: test-clean
|
||||
gotestsum -- -v -short ./...
|
||||
|
||||
.PHONY: test-postgres
|
||||
test-postgres: test-clean
|
||||
DB=ci gotestsum --junitfile="gotests.xml" --packages="./..." -- \
|
||||
-covermode=atomic -coverprofile="gotests.coverage" -timeout=5m \
|
||||
-coverpkg=./...,github.com/coder/coder/codersdk \
|
||||
-count=1 -parallel=1 -race -failfast
|
||||
|
||||
|
||||
.PHONY: test-postgres-docker
|
||||
test-postgres-docker:
|
||||
docker run \
|
||||
--env POSTGRES_PASSWORD=postgres \
|
||||
--env POSTGRES_USER=postgres \
|
||||
--env POSTGRES_DB=postgres \
|
||||
--env PGDATA=/tmp \
|
||||
--publish 5432:5432 \
|
||||
--name test-postgres-docker \
|
||||
--restart unless-stopped \
|
||||
--detach \
|
||||
postgres:11 \
|
||||
-c shared_buffers=1GB \
|
||||
-c max_connections=1000
|
||||
|
||||
.PHONY: test-clean
|
||||
test-clean:
|
||||
go clean -testcache
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Coder
|
||||
|
||||
[](https://github.com/coder/coder/discussions)
|
||||
[](https://discord.gg/coder)
|
||||
[](https://twitter.com/coderhq)
|
||||
[](https://codecov.io/gh/coder/coder)
|
||||
|
||||
## Run Coder *now*
|
||||
|
||||
```curl -L https://coder.com/install.sh | sh```
|
||||
|
||||
## What Coder does
|
||||
Coder creates remote development machines so you can develop your code from anywhere. #coder
|
||||
|
||||
> **Note**:
|
||||
> Coder is in an alpha state, but any serious bugs are P1 for us so [please report them](https://github.com/coder/coder/issues/new/choose).
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/images/hero-image.png">
|
||||
</p>
|
||||
|
||||
**Code more**
|
||||
|
||||
- Build and test faster
|
||||
- Leveraging cloud CPUs, RAM, network speeds, etc.
|
||||
- Access your environment from any place on any client (even an iPad)
|
||||
- Onboard instantly then stay up to date continuously
|
||||
|
||||
**Manage less**
|
||||
|
||||
- Ensure your entire team is using the same tools and resources
|
||||
- Rollout critical updates to your developers with one command
|
||||
- Automatically shut down expensive cloud resources
|
||||
- Keep your source code and data behind your firewall
|
||||
|
||||
## How it works
|
||||
|
||||
Coder workspaces are represented with Terraform. But, no Terraform knowledge is
|
||||
required to get started. We have a database of pre-made templates built into the
|
||||
product.
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/images/providers-compute.png">
|
||||
</p>
|
||||
|
||||
Coder workspaces don't stop at compute. You can add storage buckets, secrets, sidecars
|
||||
and whatever else Terraform lets you dream up.
|
||||
|
||||
[Learn more about managing infrastructure.](./docs/templates.md)
|
||||
|
||||
## IDE Support
|
||||
|
||||
You can use any Web IDE ([code-server](https://github.com/coder/code-server), [projector](https://github.com/JetBrains/projector-server), [Jupyter](https://jupyter.org/), etc.), [JetBrains Gateway](https://www.jetbrains.com/remote-development/gateway/), [VS Code Remote](https://code.visualstudio.com/docs/remote/ssh-tutorial) or even a file sync such as [mutagen](https://mutagen.io/).
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/images/ide-icons.svg" height=72>
|
||||
</p>
|
||||
|
||||
## Installing Coder
|
||||
|
||||
There are a few ways to install Coder: [install script](./docs/install.md#installsh) (macOS, Linux), [docker-compose](./docs/install.md#docker-compose), or [manually](./docs/install.md#manual) via the latest release (macOS, Windows, and Linux).
|
||||
|
||||
If you use the install script, you can preview what occurs during the install process:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://coder.com/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
To install, run:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://coder.com/install.sh | sh
|
||||
```
|
||||
|
||||
Once installed, you can run a temporary deployment in dev mode (all data is in-memory and destroyed on exit):
|
||||
|
||||
```sh
|
||||
coder server --dev
|
||||
```
|
||||
|
||||
Use `coder --help` to get a complete list of flags and environment variables.
|
||||
|
||||
## Creating your first template and workspace
|
||||
|
||||
In a new terminal window, run the following to copy a sample template:
|
||||
|
||||
```bash
|
||||
coder templates init
|
||||
```
|
||||
|
||||
Follow the CLI instructions to modify and create the template specific for your
|
||||
usage (e.g., a template to **Develop in Linux on Google Cloud**).
|
||||
|
||||
Create a workspace using your template:
|
||||
|
||||
```bash
|
||||
coder create --template="yourTemplate" <workspaceName>
|
||||
```
|
||||
|
||||
Connect to your workspace via SSH:
|
||||
|
||||
```bash
|
||||
coder ssh <workspaceName>
|
||||
```
|
||||
|
||||
## Modifying templates
|
||||
|
||||
You can edit the Terraform template using a sample template:
|
||||
|
||||
```sh
|
||||
coder templates init
|
||||
cd gcp-linux/
|
||||
vim main.tf
|
||||
coder templates update gcp-linux
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [About Coder](./docs/about.md#about-coder)
|
||||
- [Why remote development](./docs/about.md#why-remote-development)
|
||||
- [Why Coder](./docs/about.md#why-coder)
|
||||
- [What Coder is not](./docs/about.md#what-coder-is-not)
|
||||
- [Comparison: Coder vs. [product]](./docs/about.md#comparison)
|
||||
- [Templates](./docs/templates.md)
|
||||
- [Manage templates](./docs/templates.md#manage-templates)
|
||||
- [Persistent and ephemeral
|
||||
resources](./docs/templates.md#persistent-and-ephemeral-resources)
|
||||
- [Parameters](./docs/templates.md#parameters)
|
||||
- [Workspaces](./docs/workspaces.md)
|
||||
- [Create workspaces](./docs/workspaces.md#create-workspaces)
|
||||
- [Connect with SSH](./docs/workspaces.md#connect-with-ssh)
|
||||
- [Editors and IDEs](./docs/workspaces.md#editors-and-ides)
|
||||
- [Workspace lifecycle](./docs/workspaces.md#workspace-lifecycle)
|
||||
- [Updating workspaces](./docs/workspaces.md#updating-workspaces)
|
||||
|
||||
## Community
|
||||
|
||||
Join the community on [Discord](https://discord.gg/coder) and [Twitter](https://twitter.com/coderhq) #coder!
|
||||
|
||||
[Suggest improvements and report problems](https://github.com/coder/coder/issues/new/choose)
|
||||
|
||||
## Comparison
|
||||
|
||||
Please file [an issue](https://github.com/coder/coder/issues/new) if any information is out of date. Also refer to: [What Coder is not](./docs/about.md#what-coder-is-not).
|
||||
|
||||
| Tool | Type | Delivery Model | Cost | Environments |
|
||||
| :---------------------------------------------------------- | :------- | :----------------- | :---------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Coder](https://github.com/coder/coder) | Platform | OSS + Self-Managed | Pay your cloud | All [Terraform](https://www.terraform.io/registry/providers) resources, all clouds, multi-architecture: Linux, Mac, Windows, containers, VMs, amd64, arm64 |
|
||||
| [code-server](https://github.com/cdr/code-server) | Web IDE | OSS + Self-Managed | Pay your cloud | Linux, Mac, Windows, containers, VMs, amd64, arm64 |
|
||||
| [Coder (Classic)](https://coder.com/docs) | Platform | Self-Managed | Pay your cloud + license fees | Kubernetes Linux Containers |
|
||||
| [GitHub Codespaces](https://github.com/features/codespaces) | Platform | SaaS | 2x Azure Compute | Linux containers |
|
||||
|
||||
---
|
||||
|
||||
_As of 5/27/22_
|
||||
|
||||
## Contributing
|
||||
|
||||
Read the [contributing docs](./docs/CONTRIBUTING.md).
|
||||
|
||||
Find our list of contributors [here](./docs/CONTRIBUTORS.md).
|
||||
+568
-67
@@ -4,65 +4,107 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/armon/circbuf"
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/sftp"
|
||||
"go.uber.org/atomic"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/agent/usershell"
|
||||
"github.com/coder/coder/peer"
|
||||
"github.com/coder/coder/peerbroker"
|
||||
"github.com/coder/coder/pty"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
const (
|
||||
ProtocolReconnectingPTY = "reconnecting-pty"
|
||||
ProtocolSSH = "ssh"
|
||||
ProtocolDial = "dial"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Logger slog.Logger
|
||||
ReconnectingPTYTimeout time.Duration
|
||||
EnvironmentVariables map[string]string
|
||||
Logger slog.Logger
|
||||
}
|
||||
|
||||
type Dialer func(ctx context.Context, options *peer.ConnOptions) (*peerbroker.Listener, error)
|
||||
type Metadata struct {
|
||||
OwnerEmail string `json:"owner_email"`
|
||||
OwnerUsername string `json:"owner_username"`
|
||||
EnvironmentVariables map[string]string `json:"environment_variables"`
|
||||
StartupScript string `json:"startup_script"`
|
||||
Directory string `json:"directory"`
|
||||
}
|
||||
|
||||
func New(dialer Dialer, options *peer.ConnOptions) io.Closer {
|
||||
type Dialer func(ctx context.Context, logger slog.Logger) (Metadata, *peerbroker.Listener, error)
|
||||
|
||||
func New(dialer Dialer, options *Options) io.Closer {
|
||||
if options == nil {
|
||||
options = &Options{}
|
||||
}
|
||||
if options.ReconnectingPTYTimeout == 0 {
|
||||
options.ReconnectingPTYTimeout = 5 * time.Minute
|
||||
}
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
server := &agent{
|
||||
clientDialer: dialer,
|
||||
options: options,
|
||||
closeCancel: cancelFunc,
|
||||
closed: make(chan struct{}),
|
||||
dialer: dialer,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
closeCancel: cancelFunc,
|
||||
closed: make(chan struct{}),
|
||||
envVars: options.EnvironmentVariables,
|
||||
}
|
||||
server.init(ctx)
|
||||
return server
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
clientDialer Dialer
|
||||
options *peer.ConnOptions
|
||||
dialer Dialer
|
||||
logger slog.Logger
|
||||
|
||||
reconnectingPTYs sync.Map
|
||||
reconnectingPTYTimeout time.Duration
|
||||
|
||||
connCloseWait sync.WaitGroup
|
||||
closeCancel context.CancelFunc
|
||||
closeMutex sync.Mutex
|
||||
closed chan struct{}
|
||||
|
||||
sshServer *ssh.Server
|
||||
envVars map[string]string
|
||||
// metadata is atomic because values can change after reconnection.
|
||||
metadata atomic.Value
|
||||
startupScript atomic.Bool
|
||||
sshServer *ssh.Server
|
||||
}
|
||||
|
||||
func (a *agent) run(ctx context.Context) {
|
||||
var metadata Metadata
|
||||
var peerListener *peerbroker.Listener
|
||||
var err error
|
||||
// An exponential back-off occurs when the connection is failing to dial.
|
||||
// This is to prevent server spam in case of a coderd outage.
|
||||
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
|
||||
peerListener, err = a.clientDialer(ctx, a.options)
|
||||
metadata, peerListener, err = a.dialer(ctx, a.logger)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
@@ -70,10 +112,10 @@ func (a *agent) run(ctx context.Context) {
|
||||
if a.isClosed() {
|
||||
return
|
||||
}
|
||||
a.options.Logger.Warn(context.Background(), "failed to dial", slog.Error(err))
|
||||
a.logger.Warn(context.Background(), "failed to dial", slog.Error(err))
|
||||
continue
|
||||
}
|
||||
a.options.Logger.Info(context.Background(), "connected")
|
||||
a.logger.Info(context.Background(), "connected")
|
||||
break
|
||||
}
|
||||
select {
|
||||
@@ -81,6 +123,20 @@ func (a *agent) run(ctx context.Context) {
|
||||
return
|
||||
default:
|
||||
}
|
||||
a.metadata.Store(metadata)
|
||||
|
||||
if a.startupScript.CAS(false, true) {
|
||||
// The startup script has not ran yet!
|
||||
go func() {
|
||||
err := a.runStartupScript(ctx, metadata.StartupScript)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "agent script failed", slog.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for {
|
||||
conn, err := peerListener.Accept()
|
||||
@@ -88,7 +144,7 @@ func (a *agent) run(ctx context.Context) {
|
||||
if a.isClosed() {
|
||||
return
|
||||
}
|
||||
a.options.Logger.Debug(ctx, "peer listener accept exited; restarting connection", slog.Error(err))
|
||||
a.logger.Debug(ctx, "peer listener accept exited; restarting connection", slog.Error(err))
|
||||
a.run(ctx)
|
||||
return
|
||||
}
|
||||
@@ -99,9 +155,57 @@ func (a *agent) run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (*agent) runStartupScript(ctx context.Context, script string) error {
|
||||
if script == "" {
|
||||
return nil
|
||||
}
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current user: %w", err)
|
||||
}
|
||||
username := currentUser.Username
|
||||
|
||||
shell, err := usershell.Get(username)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get user shell: %w", err)
|
||||
}
|
||||
|
||||
writer, err := os.OpenFile(filepath.Join(os.TempDir(), "coder-startup-script.log"), os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("open startup script log file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = writer.Close()
|
||||
}()
|
||||
|
||||
caller := "-c"
|
||||
if runtime.GOOS == "windows" {
|
||||
caller = "/c"
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, shell, caller, script)
|
||||
cmd.Stdout = writer
|
||||
cmd.Stderr = writer
|
||||
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("run: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) {
|
||||
go func() {
|
||||
<-conn.Closed()
|
||||
select {
|
||||
case <-a.closed:
|
||||
case <-conn.Closed():
|
||||
}
|
||||
_ = conn.Close()
|
||||
a.connCloseWait.Done()
|
||||
}()
|
||||
for {
|
||||
@@ -110,15 +214,19 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) {
|
||||
if errors.Is(err, peer.ErrClosed) || a.isClosed() {
|
||||
return
|
||||
}
|
||||
a.options.Logger.Debug(ctx, "accept channel from peer connection", slog.Error(err))
|
||||
a.logger.Debug(ctx, "accept channel from peer connection", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
switch channel.Protocol() {
|
||||
case "ssh":
|
||||
a.sshServer.HandleConn(channel.NetConn())
|
||||
case ProtocolSSH:
|
||||
go a.sshServer.HandleConn(channel.NetConn())
|
||||
case ProtocolReconnectingPTY:
|
||||
go a.handleReconnectingPTY(ctx, channel.Label(), channel.NetConn())
|
||||
case ProtocolDial:
|
||||
go a.handleDial(ctx, channel.Label(), channel.NetConn())
|
||||
default:
|
||||
a.options.Logger.Warn(ctx, "unhandled protocol from channel",
|
||||
a.logger.Warn(ctx, "unhandled protocol from channel",
|
||||
slog.F("protocol", channel.Protocol()),
|
||||
slog.F("label", channel.Label()),
|
||||
)
|
||||
@@ -138,17 +246,20 @@ func (a *agent) init(ctx context.Context) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
sshLogger := a.options.Logger.Named("ssh-server")
|
||||
sshLogger := a.logger.Named("ssh-server")
|
||||
forwardHandler := &ssh.ForwardedTCPHandler{}
|
||||
a.sshServer = &ssh.Server{
|
||||
ChannelHandlers: ssh.DefaultChannelHandlers,
|
||||
ChannelHandlers: map[string]ssh.ChannelHandler{
|
||||
"direct-tcpip": ssh.DirectTCPIPHandler,
|
||||
"session": ssh.DefaultSessionHandler,
|
||||
},
|
||||
ConnectionFailedCallback: func(conn net.Conn, err error) {
|
||||
sshLogger.Info(ctx, "ssh connection ended", slog.Error(err))
|
||||
},
|
||||
Handler: func(session ssh.Session) {
|
||||
err := a.handleSSHSession(session)
|
||||
if err != nil {
|
||||
a.options.Logger.Warn(ctx, "ssh session failed", slog.Error(err))
|
||||
a.logger.Warn(ctx, "ssh session failed", slog.Error(err))
|
||||
_ = session.Exit(1)
|
||||
return
|
||||
}
|
||||
@@ -180,56 +291,117 @@ func (a *agent) init(ctx context.Context) {
|
||||
NoClientAuth: true,
|
||||
}
|
||||
},
|
||||
SubsystemHandlers: map[string]ssh.SubsystemHandler{
|
||||
"sftp": func(session ssh.Session) {
|
||||
server, err := sftp.NewServer(session)
|
||||
if err != nil {
|
||||
a.logger.Debug(session.Context(), "initialize sftp server", slog.Error(err))
|
||||
return
|
||||
}
|
||||
defer server.Close()
|
||||
err = server.Serve()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return
|
||||
}
|
||||
a.logger.Debug(session.Context(), "sftp server exited with error", slog.Error(err))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
go a.run(ctx)
|
||||
}
|
||||
|
||||
func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
var (
|
||||
command string
|
||||
args = []string{}
|
||||
err error
|
||||
)
|
||||
|
||||
// createCommand processes raw command input with OpenSSH-like behavior.
|
||||
// If the rawCommand provided is empty, it will default to the users shell.
|
||||
// This injects environment variables specified by the user at launch too.
|
||||
func (a *agent) createCommand(ctx context.Context, rawCommand string, env []string) (*exec.Cmd, error) {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current user: %w", err)
|
||||
return nil, xerrors.Errorf("get current user: %w", err)
|
||||
}
|
||||
username := currentUser.Username
|
||||
|
||||
// gliderlabs/ssh returns a command slice of zero
|
||||
// when a shell is requested.
|
||||
if len(session.Command()) == 0 {
|
||||
command, err = usershell.Get(username)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get user shell: %w", err)
|
||||
}
|
||||
} else {
|
||||
command = session.Command()[0]
|
||||
if len(session.Command()) > 1 {
|
||||
args = session.Command()[1:]
|
||||
}
|
||||
shell, err := usershell.Get(username)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get user shell: %w", err)
|
||||
}
|
||||
|
||||
signals := make(chan ssh.Signal)
|
||||
breaks := make(chan bool)
|
||||
defer close(signals)
|
||||
defer close(breaks)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-session.Context().Done():
|
||||
return
|
||||
// Ignore signals and breaks for now!
|
||||
case <-signals:
|
||||
case <-breaks:
|
||||
}
|
||||
}
|
||||
}()
|
||||
rawMetadata := a.metadata.Load()
|
||||
if rawMetadata == nil {
|
||||
return nil, xerrors.Errorf("no metadata was provided: %w", err)
|
||||
}
|
||||
metadata, valid := rawMetadata.(Metadata)
|
||||
if !valid {
|
||||
return nil, xerrors.Errorf("metadata is the wrong type: %T", metadata)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(session.Context(), command, args...)
|
||||
cmd.Env = append(os.Environ(), session.Environ()...)
|
||||
// gliderlabs/ssh returns a command slice of zero
|
||||
// when a shell is requested.
|
||||
command := rawCommand
|
||||
if len(command) == 0 {
|
||||
command = shell
|
||||
}
|
||||
|
||||
// OpenSSH executes all commands with the users current shell.
|
||||
// We replicate that behavior for IDE support.
|
||||
caller := "-c"
|
||||
if runtime.GOOS == "windows" {
|
||||
caller = "/c"
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, shell, caller, command)
|
||||
cmd.Dir = metadata.Directory
|
||||
if cmd.Dir == "" {
|
||||
// Default to $HOME if a directory is not set!
|
||||
cmd.Dir = os.Getenv("HOME")
|
||||
}
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
executablePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("getting os executable: %w", err)
|
||||
}
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username))
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf(`PATH=%s%c%s`, os.Getenv("PATH"), filepath.ListSeparator, filepath.Dir(executablePath)))
|
||||
// Git on Windows resolves with UNIX-style paths.
|
||||
// If using backslashes, it's unable to find the executable.
|
||||
unixExecutablePath := strings.ReplaceAll(executablePath, "\\", "/")
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_SSH_COMMAND=%s gitssh --`, unixExecutablePath))
|
||||
// These prevent the user from having to specify _anything_ to successfully commit.
|
||||
// Both author and committer must be set!
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_AUTHOR_EMAIL=%s`, metadata.OwnerEmail))
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_COMMITTER_EMAIL=%s`, metadata.OwnerEmail))
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_AUTHOR_NAME=%s`, metadata.OwnerUsername))
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_COMMITTER_NAME=%s`, metadata.OwnerUsername))
|
||||
|
||||
// Load environment variables passed via the agent.
|
||||
// These should override all variables we manually specify.
|
||||
for key, value := range metadata.EnvironmentVariables {
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
|
||||
// Agent-level environment variables should take over all!
|
||||
// This is used for setting agent-specific variables like "CODER_AGENT_TOKEN".
|
||||
for key, value := range a.envVars {
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
cmd, err := a.createCommand(session.Context(), session.RawCommand(), session.Environ())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ssh.AgentRequested(session) {
|
||||
l, err := ssh.NewAgentListener()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("new agent listener: %w", err)
|
||||
}
|
||||
defer l.Close()
|
||||
go ssh.ForwardAgentConnections(l, session)
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", "SSH_AUTH_SOCK", l.Addr().String()))
|
||||
}
|
||||
|
||||
sshPty, windowSize, isPty := session.Pty()
|
||||
if isPty {
|
||||
@@ -238,11 +410,15 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("start command: %w", err)
|
||||
}
|
||||
err = ptty.Resize(uint16(sshPty.Window.Height), uint16(sshPty.Window.Width))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resize ptty: %w", err)
|
||||
}
|
||||
go func() {
|
||||
for win := range windowSize {
|
||||
err = ptty.Resize(uint16(win.Width), uint16(win.Height))
|
||||
err = ptty.Resize(uint16(win.Height), uint16(win.Width))
|
||||
if err != nil {
|
||||
a.options.Logger.Warn(context.Background(), "failed to resize tty", slog.Error(err))
|
||||
a.logger.Warn(context.Background(), "failed to resize tty", slog.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -258,7 +434,7 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
}
|
||||
|
||||
cmd.Stdout = session
|
||||
cmd.Stderr = session
|
||||
cmd.Stderr = session.Stderr()
|
||||
// This blocks forever until stdin is received if we don't
|
||||
// use StdinPipe. It's unknown what causes this.
|
||||
stdinPipe, err := cmd.StdinPipe()
|
||||
@@ -272,8 +448,263 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("start: %w", err)
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
// The ID format is referenced in conn.go.
|
||||
// <uuid>:<height>:<width>
|
||||
idParts := strings.SplitN(rawID, ":", 4)
|
||||
if len(idParts) != 4 {
|
||||
a.logger.Warn(ctx, "client sent invalid id format", slog.F("raw-id", rawID))
|
||||
return
|
||||
}
|
||||
id := idParts[0]
|
||||
// Enforce a consistent format for IDs.
|
||||
_, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "client sent reconnection token that isn't a uuid", slog.F("id", id), slog.Error(err))
|
||||
return
|
||||
}
|
||||
// Parse the initial terminal dimensions.
|
||||
height, err := strconv.Atoi(idParts[1])
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "client sent invalid height", slog.F("id", id), slog.F("height", idParts[1]))
|
||||
return
|
||||
}
|
||||
width, err := strconv.Atoi(idParts[2])
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "client sent invalid width", slog.F("id", id), slog.F("width", idParts[2]))
|
||||
return
|
||||
}
|
||||
|
||||
var rpty *reconnectingPTY
|
||||
rawRPTY, ok := a.reconnectingPTYs.Load(id)
|
||||
if ok {
|
||||
rpty, ok = rawRPTY.(*reconnectingPTY)
|
||||
if !ok {
|
||||
a.logger.Warn(ctx, "found invalid type in reconnecting pty map", slog.F("id", id))
|
||||
}
|
||||
} else {
|
||||
// Empty command will default to the users shell!
|
||||
cmd, err := a.createCommand(ctx, idParts[3], nil)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "create reconnecting pty command", slog.Error(err))
|
||||
return
|
||||
}
|
||||
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
|
||||
|
||||
ptty, process, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "start reconnecting pty command", slog.F("id", id))
|
||||
}
|
||||
|
||||
// Default to buffer 64KiB.
|
||||
circularBuffer, err := circbuf.NewBuffer(64 << 10)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "create circular buffer", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
a.closeMutex.Lock()
|
||||
a.connCloseWait.Add(1)
|
||||
a.closeMutex.Unlock()
|
||||
ctx, cancelFunc := context.WithCancel(ctx)
|
||||
rpty = &reconnectingPTY{
|
||||
activeConns: make(map[string]net.Conn),
|
||||
ptty: ptty,
|
||||
// Timeouts created with an after func can be reset!
|
||||
timeout: time.AfterFunc(a.reconnectingPTYTimeout, cancelFunc),
|
||||
circularBuffer: circularBuffer,
|
||||
}
|
||||
a.reconnectingPTYs.Store(id, rpty)
|
||||
go func() {
|
||||
// CommandContext isn't respected for Windows PTYs right now,
|
||||
// so we need to manually track the lifecycle.
|
||||
// When the context has been completed either:
|
||||
// 1. The timeout completed.
|
||||
// 2. The parent context was canceled.
|
||||
<-ctx.Done()
|
||||
_ = process.Kill()
|
||||
}()
|
||||
go func() {
|
||||
// If the process dies randomly, we should
|
||||
// close the pty.
|
||||
_, _ = process.Wait()
|
||||
rpty.Close()
|
||||
}()
|
||||
go func() {
|
||||
buffer := make([]byte, 1024)
|
||||
for {
|
||||
read, err := rpty.ptty.Output().Read(buffer)
|
||||
if err != nil {
|
||||
// When the PTY is closed, this is triggered.
|
||||
break
|
||||
}
|
||||
part := buffer[:read]
|
||||
rpty.circularBufferMutex.Lock()
|
||||
_, err = rpty.circularBuffer.Write(part)
|
||||
rpty.circularBufferMutex.Unlock()
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "reconnecting pty write buffer", slog.Error(err), slog.F("id", id))
|
||||
break
|
||||
}
|
||||
rpty.activeConnsMutex.Lock()
|
||||
for _, conn := range rpty.activeConns {
|
||||
_, _ = conn.Write(part)
|
||||
}
|
||||
rpty.activeConnsMutex.Unlock()
|
||||
}
|
||||
|
||||
// Cleanup the process, PTY, and delete it's
|
||||
// ID from memory.
|
||||
_ = process.Kill()
|
||||
rpty.Close()
|
||||
a.reconnectingPTYs.Delete(id)
|
||||
a.connCloseWait.Done()
|
||||
}()
|
||||
}
|
||||
// Resize the PTY to initial height + width.
|
||||
err = rpty.ptty.Resize(uint16(height), uint16(width))
|
||||
if err != nil {
|
||||
// We can continue after this, it's not fatal!
|
||||
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err))
|
||||
}
|
||||
// Write any previously stored data for the TTY.
|
||||
rpty.circularBufferMutex.RLock()
|
||||
_, err = conn.Write(rpty.circularBuffer.Bytes())
|
||||
rpty.circularBufferMutex.RUnlock()
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "write reconnecting pty buffer", slog.F("id", id), slog.Error(err))
|
||||
return
|
||||
}
|
||||
connectionID := uuid.NewString()
|
||||
// Multiple connections to the same TTY are permitted.
|
||||
// This could easily be used for terminal sharing, but
|
||||
// we do it because it's a nice user experience to
|
||||
// copy/paste a terminal URL and have it _just work_.
|
||||
rpty.activeConnsMutex.Lock()
|
||||
rpty.activeConns[connectionID] = conn
|
||||
rpty.activeConnsMutex.Unlock()
|
||||
// Resetting this timeout prevents the PTY from exiting.
|
||||
rpty.timeout.Reset(a.reconnectingPTYTimeout)
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(ctx)
|
||||
defer cancelFunc()
|
||||
heartbeat := time.NewTicker(a.reconnectingPTYTimeout / 2)
|
||||
defer heartbeat.Stop()
|
||||
go func() {
|
||||
// Keep updating the activity while this
|
||||
// connection is alive!
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
}
|
||||
rpty.timeout.Reset(a.reconnectingPTYTimeout)
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
// After this connection ends, remove it from
|
||||
// the PTYs active connections. If it isn't
|
||||
// removed, all PTY data will be sent to it.
|
||||
rpty.activeConnsMutex.Lock()
|
||||
delete(rpty.activeConns, connectionID)
|
||||
rpty.activeConnsMutex.Unlock()
|
||||
}()
|
||||
decoder := json.NewDecoder(conn)
|
||||
var req ReconnectingPTYRequest
|
||||
for {
|
||||
err = decoder.Decode(&req)
|
||||
if xerrors.Is(err, io.EOF) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "reconnecting pty buffer read error", slog.F("id", id), slog.Error(err))
|
||||
return
|
||||
}
|
||||
_, err = rpty.ptty.Input().Write([]byte(req.Data))
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "write to reconnecting pty", slog.F("id", id), slog.Error(err))
|
||||
return
|
||||
}
|
||||
// Check if a resize needs to happen!
|
||||
if req.Height == 0 || req.Width == 0 {
|
||||
continue
|
||||
}
|
||||
err = rpty.ptty.Resize(req.Height, req.Width)
|
||||
if err != nil {
|
||||
// We can continue after this, it's not fatal!
|
||||
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dialResponse is written to datachannels with protocol "dial" by the agent as
|
||||
// the first packet to signify whether the dial succeeded or failed.
|
||||
type dialResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (a *agent) handleDial(ctx context.Context, label string, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
writeError := func(responseError error) error {
|
||||
msg := ""
|
||||
if responseError != nil {
|
||||
msg = responseError.Error()
|
||||
if !xerrors.Is(responseError, io.EOF) {
|
||||
a.logger.Warn(ctx, "handle dial", slog.F("label", label), slog.Error(responseError))
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(dialResponse{
|
||||
Error: msg,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "write dial response", slog.F("label", label), slog.Error(err))
|
||||
return xerrors.Errorf("marshal agent webrtc dial response: %w", err)
|
||||
}
|
||||
|
||||
_, err = conn.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
u, err := url.Parse(label)
|
||||
if err != nil {
|
||||
_ = writeError(xerrors.Errorf("parse URL %q: %w", label, err))
|
||||
return
|
||||
}
|
||||
|
||||
network := u.Scheme
|
||||
addr := u.Host + u.Path
|
||||
if strings.HasPrefix(network, "unix") {
|
||||
if runtime.GOOS == "windows" {
|
||||
_ = writeError(xerrors.New("Unix forwarding is not supported from Windows workspaces"))
|
||||
return
|
||||
}
|
||||
addr, err = ExpandRelativeHomePath(addr)
|
||||
if err != nil {
|
||||
_ = writeError(xerrors.Errorf("expand path %q: %w", addr, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
nconn, err := d.DialContext(ctx, network, addr)
|
||||
if err != nil {
|
||||
_ = writeError(xerrors.Errorf("dial '%v://%v': %w", network, addr, err))
|
||||
return
|
||||
}
|
||||
|
||||
err = writeError(nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
Bicopy(ctx, conn, nconn)
|
||||
}
|
||||
|
||||
// isClosed returns whether the API is closed or not.
|
||||
@@ -298,3 +729,73 @@ func (a *agent) Close() error {
|
||||
a.connCloseWait.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
type reconnectingPTY struct {
|
||||
activeConnsMutex sync.Mutex
|
||||
activeConns map[string]net.Conn
|
||||
|
||||
circularBuffer *circbuf.Buffer
|
||||
circularBufferMutex sync.RWMutex
|
||||
timeout *time.Timer
|
||||
ptty pty.PTY
|
||||
}
|
||||
|
||||
// Close ends all connections to the reconnecting
|
||||
// PTY and clear the circular buffer.
|
||||
func (r *reconnectingPTY) Close() {
|
||||
r.activeConnsMutex.Lock()
|
||||
defer r.activeConnsMutex.Unlock()
|
||||
for _, conn := range r.activeConns {
|
||||
_ = conn.Close()
|
||||
}
|
||||
_ = r.ptty.Close()
|
||||
r.circularBuffer.Reset()
|
||||
r.timeout.Stop()
|
||||
}
|
||||
|
||||
// Bicopy copies all of the data between the two connections and will close them
|
||||
// after one or both of them are done writing. If the context is canceled, both
|
||||
// of the connections will be closed.
|
||||
func Bicopy(ctx context.Context, c1, c2 io.ReadWriteCloser) {
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
copyFunc := func(dst io.WriteCloser, src io.Reader) {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(dst, src)
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go copyFunc(c1, c2)
|
||||
go copyFunc(c2, c1)
|
||||
|
||||
// Convert waitgroup to a channel so we can also wait on the context.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
|
||||
// ExpandRelativeHomePath expands the tilde at the beginning of a path to the
|
||||
// current user's home directory and returns a full absolute path.
|
||||
func ExpandRelativeHomePath(in string) (string, error) {
|
||||
usr, err := user.Current()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get current user details: %w", err)
|
||||
}
|
||||
|
||||
if in == "~" {
|
||||
in = usr.HomeDir
|
||||
} else if strings.HasPrefix(in, "~/") {
|
||||
in = filepath.Join(usr.HomeDir, in[2:])
|
||||
}
|
||||
|
||||
return filepath.Abs(in)
|
||||
}
|
||||
|
||||
+404
-45
@@ -1,15 +1,31 @@
|
||||
package agent_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pion/udp"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
@@ -29,24 +45,8 @@ func TestAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("SessionExec", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
api := setup(t)
|
||||
stream, err := api.NegotiateConnection(context.Background())
|
||||
require.NoError(t, err)
|
||||
conn, err := peerbroker.Dial(stream, []webrtc.ICEServer{}, &peer.ConnOptions{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = conn.Close()
|
||||
})
|
||||
client := agent.Conn{
|
||||
Negotiator: api,
|
||||
Conn: conn,
|
||||
}
|
||||
sshClient, err := client.SSHClient()
|
||||
require.NoError(t, err)
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
session := setupSSHSession(t, agent.Metadata{})
|
||||
|
||||
command := "echo test"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo test"
|
||||
@@ -56,33 +56,47 @@ func TestAgent(t *testing.T) {
|
||||
require.Equal(t, "test", strings.TrimSpace(string(output)))
|
||||
})
|
||||
|
||||
t.Run("GitSSH", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
session := setupSSHSession(t, agent.Metadata{})
|
||||
command := "sh -c 'echo $GIT_SSH_COMMAND'"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo %GIT_SSH_COMMAND%"
|
||||
}
|
||||
output, err := session.Output(command)
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasSuffix(strings.TrimSpace(string(output)), "gitssh --"))
|
||||
})
|
||||
|
||||
t.Run("PATHHasCoder", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
session := setupSSHSession(t, agent.Metadata{})
|
||||
command := "sh -c 'echo $PATH'"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo %PATH%"
|
||||
}
|
||||
output, err := session.Output(command)
|
||||
require.NoError(t, err)
|
||||
ex, err := os.Executable()
|
||||
t.Log(ex)
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.Contains(strings.TrimSpace(string(output)), filepath.Dir(ex)))
|
||||
})
|
||||
|
||||
t.Run("SessionTTY", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
api := setup(t)
|
||||
stream, err := api.NegotiateConnection(context.Background())
|
||||
require.NoError(t, err)
|
||||
conn, err := peerbroker.Dial(stream, []webrtc.ICEServer{}, &peer.ConnOptions{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = conn.Close()
|
||||
})
|
||||
client := &agent.Conn{
|
||||
Negotiator: api,
|
||||
Conn: conn,
|
||||
if runtime.GOOS == "windows" {
|
||||
// This might be our implementation, or ConPTY itself.
|
||||
// It's difficult to find extensive tests for it, so
|
||||
// it seems like it could be either.
|
||||
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
||||
}
|
||||
sshClient, err := client.SSHClient()
|
||||
require.NoError(t, err)
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
prompt := "$"
|
||||
session := setupSSHSession(t, agent.Metadata{})
|
||||
command := "bash"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe"
|
||||
prompt = ">"
|
||||
}
|
||||
err = session.RequestPty("xterm", 128, 128, ssh.TerminalModes{})
|
||||
err := session.RequestPty("xterm", 128, 128, ssh.TerminalModes{})
|
||||
require.NoError(t, err)
|
||||
ptty := ptytest.New(t)
|
||||
require.NoError(t, err)
|
||||
@@ -91,26 +105,371 @@ func TestAgent(t *testing.T) {
|
||||
session.Stdin = ptty.Input()
|
||||
err = session.Start(command)
|
||||
require.NoError(t, err)
|
||||
ptty.ExpectMatch(prompt)
|
||||
caret := "$"
|
||||
if runtime.GOOS == "windows" {
|
||||
caret = ">"
|
||||
}
|
||||
ptty.ExpectMatch(caret)
|
||||
ptty.WriteLine("echo test")
|
||||
ptty.ExpectMatch("test")
|
||||
ptty.WriteLine("exit")
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("LocalForwarding", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
random, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
_ = random.Close()
|
||||
tcpAddr, valid := random.Addr().(*net.TCPAddr)
|
||||
require.True(t, valid)
|
||||
randomPort := tcpAddr.Port
|
||||
|
||||
local, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer local.Close()
|
||||
tcpAddr, valid = local.Addr().(*net.TCPAddr)
|
||||
require.True(t, valid)
|
||||
localPort := tcpAddr.Port
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
conn, err := local.Accept()
|
||||
assert.NoError(t, err)
|
||||
_ = conn.Close()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err = setupSSHCommand(t, []string{"-L", fmt.Sprintf("%d:127.0.0.1:%d", randomPort, localPort)}, []string{"echo", "test"}).Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
conn, err := net.Dial("tcp", "127.0.0.1:"+strconv.Itoa(localPort))
|
||||
require.NoError(t, err)
|
||||
conn.Close()
|
||||
<-done
|
||||
})
|
||||
|
||||
t.Run("SFTP", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sshClient, err := setupAgent(t, agent.Metadata{}, 0).SSHClient()
|
||||
require.NoError(t, err)
|
||||
client, err := sftp.NewClient(sshClient)
|
||||
require.NoError(t, err)
|
||||
tempFile := filepath.Join(t.TempDir(), "sftp")
|
||||
file, err := client.Create(tempFile)
|
||||
require.NoError(t, err)
|
||||
err = file.Close()
|
||||
require.NoError(t, err)
|
||||
_, err = os.Stat(tempFile)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("EnvironmentVariables", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := "EXAMPLE"
|
||||
value := "value"
|
||||
session := setupSSHSession(t, agent.Metadata{
|
||||
EnvironmentVariables: map[string]string{
|
||||
key: value,
|
||||
},
|
||||
})
|
||||
command := "sh -c 'echo $" + key + "'"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo %" + key + "%"
|
||||
}
|
||||
output, err := session.Output(command)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, value, strings.TrimSpace(string(output)))
|
||||
})
|
||||
|
||||
t.Run("StartupScript", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempPath := filepath.Join(os.TempDir(), "content.txt")
|
||||
content := "somethingnice"
|
||||
setupAgent(t, agent.Metadata{
|
||||
StartupScript: fmt.Sprintf("echo %s > %s", content, tempPath),
|
||||
}, 0)
|
||||
|
||||
var gotContent string
|
||||
require.Eventually(t, func() bool {
|
||||
content, err := os.ReadFile(tempPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows uses UTF16! 🪟🪟🪟
|
||||
content, _, err = transform.Bytes(unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder(), content)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
gotContent = string(content)
|
||||
return true
|
||||
}, 15*time.Second, 100*time.Millisecond)
|
||||
require.Equal(t, content, strings.TrimSpace(gotContent))
|
||||
})
|
||||
|
||||
t.Run("ReconnectingPTY", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
// This might be our implementation, or ConPTY itself.
|
||||
// It's difficult to find extensive tests for it, so
|
||||
// it seems like it could be either.
|
||||
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
||||
}
|
||||
|
||||
conn := setupAgent(t, agent.Metadata{}, 0)
|
||||
id := uuid.NewString()
|
||||
netConn, err := conn.ReconnectingPTY(id, 100, 100, "/bin/bash")
|
||||
require.NoError(t, err)
|
||||
bufRead := bufio.NewReader(netConn)
|
||||
|
||||
// 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(agent.ReconnectingPTYRequest{
|
||||
Data: "echo test\r\n",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = netConn.Write(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectLine := func(matcher func(string) bool) {
|
||||
for {
|
||||
line, err := bufRead.ReadString('\n')
|
||||
require.NoError(t, err)
|
||||
if matcher(line) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matchEchoCommand := func(line string) bool {
|
||||
return strings.Contains(line, "echo test")
|
||||
}
|
||||
matchEchoOutput := func(line string) bool {
|
||||
return strings.Contains(line, "test") && !strings.Contains(line, "echo")
|
||||
}
|
||||
|
||||
// Once for typing the command...
|
||||
expectLine(matchEchoCommand)
|
||||
// And another time for the actual output.
|
||||
expectLine(matchEchoOutput)
|
||||
|
||||
_ = netConn.Close()
|
||||
netConn, err = conn.ReconnectingPTY(id, 100, 100, "/bin/bash")
|
||||
require.NoError(t, err)
|
||||
bufRead = bufio.NewReader(netConn)
|
||||
|
||||
// Same output again!
|
||||
expectLine(matchEchoCommand)
|
||||
expectLine(matchEchoOutput)
|
||||
})
|
||||
|
||||
t.Run("Dial", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T) net.Listener
|
||||
}{
|
||||
{
|
||||
name: "TCP",
|
||||
setup: func(t *testing.T) net.Listener {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "create TCP listener")
|
||||
return l
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "UDP",
|
||||
setup: func(t *testing.T) net.Listener {
|
||||
addr := net.UDPAddr{
|
||||
IP: net.ParseIP("127.0.0.1"),
|
||||
Port: 0,
|
||||
}
|
||||
l, err := udp.Listen("udp", &addr)
|
||||
require.NoError(t, err, "create UDP listener")
|
||||
return l
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unix",
|
||||
setup: func(t *testing.T) net.Listener {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix socket forwarding isn't supported on Windows")
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "coderd_agent_test_")
|
||||
require.NoError(t, err, "create temp dir for unix listener")
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
l, err := net.Listen("unix", filepath.Join(tmpDir, "test.sock"))
|
||||
require.NoError(t, err, "create UDP listener")
|
||||
return l
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Setup listener
|
||||
l := c.setup(t)
|
||||
defer l.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go testAccept(t, c)
|
||||
}
|
||||
}()
|
||||
|
||||
// Dial the listener over WebRTC twice and test out of order
|
||||
conn := setupAgent(t, agent.Metadata{}, 0)
|
||||
conn1, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String())
|
||||
require.NoError(t, err)
|
||||
defer conn1.Close()
|
||||
conn2, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String())
|
||||
require.NoError(t, err)
|
||||
defer conn2.Close()
|
||||
testDial(t, conn2)
|
||||
testDial(t, conn1)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DialError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
// This test uses Unix listeners so we can very easily ensure that
|
||||
// no other tests decide to listen on the same random port we
|
||||
// picked.
|
||||
t.Skip("this test is unsupported on Windows")
|
||||
return
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "coderd_agent_test_")
|
||||
require.NoError(t, err, "create temp dir")
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
// Try to dial the non-existent Unix socket over WebRTC
|
||||
conn := setupAgent(t, agent.Metadata{}, 0)
|
||||
netConn, err := conn.DialContext(context.Background(), "unix", filepath.Join(tmpDir, "test.sock"))
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "remote dial error")
|
||||
require.ErrorContains(t, err, "no such file")
|
||||
require.Nil(t, netConn)
|
||||
})
|
||||
}
|
||||
|
||||
func setup(t *testing.T) proto.DRPCPeerBrokerClient {
|
||||
func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd {
|
||||
agentConn := setupAgent(t, agent.Metadata{}, 0)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ssh, err := agentConn.SSH()
|
||||
assert.NoError(t, err)
|
||||
go io.Copy(conn, ssh)
|
||||
go io.Copy(ssh, conn)
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
})
|
||||
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
|
||||
require.True(t, valid)
|
||||
args := append(beforeArgs,
|
||||
"-o", "HostName "+tcpAddr.IP.String(),
|
||||
"-o", "Port "+strconv.Itoa(tcpAddr.Port),
|
||||
"-o", "StrictHostKeyChecking=no", "host")
|
||||
args = append(args, afterArgs...)
|
||||
return exec.Command("ssh", args...)
|
||||
}
|
||||
|
||||
func setupSSHSession(t *testing.T, options agent.Metadata) *ssh.Session {
|
||||
sshClient, err := setupAgent(t, options, 0).SSHClient()
|
||||
require.NoError(t, err)
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
return session
|
||||
}
|
||||
|
||||
func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) *agent.Conn {
|
||||
client, server := provisionersdk.TransportPipe()
|
||||
closer := agent.New(func(ctx context.Context, opts *peer.ConnOptions) (*peerbroker.Listener, error) {
|
||||
return peerbroker.Listen(server, nil, opts)
|
||||
}, &peer.ConnOptions{
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
closer := agent.New(func(ctx context.Context, logger slog.Logger) (agent.Metadata, *peerbroker.Listener, error) {
|
||||
listener, err := peerbroker.Listen(server, nil)
|
||||
return metadata, listener, err
|
||||
}, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
ReconnectingPTYTimeout: ptyTimeout,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
_ = server.Close()
|
||||
_ = closer.Close()
|
||||
})
|
||||
return proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client))
|
||||
api := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client))
|
||||
stream, err := api.NegotiateConnection(context.Background())
|
||||
assert.NoError(t, err)
|
||||
conn, err := peerbroker.Dial(stream, []webrtc.ICEServer{}, &peer.ConnOptions{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = conn.Close()
|
||||
})
|
||||
|
||||
return &agent.Conn{
|
||||
Negotiator: api,
|
||||
Conn: conn,
|
||||
}
|
||||
}
|
||||
|
||||
var dialTestPayload = []byte("dean-was-here123")
|
||||
|
||||
func testDial(t *testing.T, c net.Conn) {
|
||||
t.Helper()
|
||||
|
||||
assertWritePayload(t, c, dialTestPayload)
|
||||
assertReadPayload(t, c, dialTestPayload)
|
||||
}
|
||||
|
||||
func testAccept(t *testing.T, c net.Conn) {
|
||||
t.Helper()
|
||||
defer c.Close()
|
||||
|
||||
assertReadPayload(t, c, dialTestPayload)
|
||||
assertWritePayload(t, c, dialTestPayload)
|
||||
}
|
||||
|
||||
func assertReadPayload(t *testing.T, r io.Reader, payload []byte) {
|
||||
b := make([]byte, len(payload)+16)
|
||||
n, err := r.Read(b)
|
||||
assert.NoError(t, err, "read payload")
|
||||
assert.Equal(t, len(payload), n, "read payload length does not match")
|
||||
assert.Equal(t, payload, b[:n])
|
||||
}
|
||||
|
||||
func assertWritePayload(t *testing.T, w io.Writer, payload []byte) {
|
||||
n, err := w.Write(payload)
|
||||
assert.NoError(t, err, "write payload")
|
||||
assert.Equal(t, len(payload), n, "payload length does not match")
|
||||
}
|
||||
|
||||
+64
-2
@@ -2,7 +2,11 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -11,6 +15,14 @@ import (
|
||||
"github.com/coder/coder/peerbroker/proto"
|
||||
)
|
||||
|
||||
// ReconnectingPTYRequest is sent from the client to the server
|
||||
// to pipe data to a PTY.
|
||||
type ReconnectingPTYRequest struct {
|
||||
Data string `json:"data"`
|
||||
Height uint16 `json:"height"`
|
||||
Width uint16 `json:"width"`
|
||||
}
|
||||
|
||||
// Conn wraps a peer connection with helper functions to
|
||||
// communicate with the agent.
|
||||
type Conn struct {
|
||||
@@ -20,10 +32,24 @@ type Conn struct {
|
||||
*peer.Conn
|
||||
}
|
||||
|
||||
// ReconnectingPTY returns a connection serving a TTY that can
|
||||
// be reconnected to via ID.
|
||||
//
|
||||
// The command is optional and defaults to start a shell.
|
||||
func (c *Conn) ReconnectingPTY(id string, height, width uint16, command string) (net.Conn, error) {
|
||||
channel, err := c.CreateChannel(context.Background(), fmt.Sprintf("%s:%d:%d:%s", id, height, width, command), &peer.ChannelOptions{
|
||||
Protocol: ProtocolReconnectingPTY,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("pty: %w", err)
|
||||
}
|
||||
return channel.NetConn(), nil
|
||||
}
|
||||
|
||||
// SSH dials the built-in SSH server.
|
||||
func (c *Conn) SSH() (net.Conn, error) {
|
||||
channel, err := c.Dial(context.Background(), "ssh", &peer.ChannelOptions{
|
||||
Protocol: "ssh",
|
||||
channel, err := c.CreateChannel(context.Background(), "ssh", &peer.ChannelOptions{
|
||||
Protocol: ProtocolSSH,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dial: %w", err)
|
||||
@@ -50,6 +76,42 @@ func (c *Conn) SSHClient() (*ssh.Client, error) {
|
||||
return ssh.NewClient(sshConn, channels, requests), nil
|
||||
}
|
||||
|
||||
// DialContext dials an arbitrary protocol+address from inside the workspace and
|
||||
// proxies it through the provided net.Conn.
|
||||
func (c *Conn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) {
|
||||
u := &url.URL{
|
||||
Scheme: network,
|
||||
}
|
||||
if strings.HasPrefix(network, "unix") {
|
||||
u.Path = addr
|
||||
} else {
|
||||
u.Host = addr
|
||||
}
|
||||
|
||||
channel, err := c.CreateChannel(ctx, u.String(), &peer.ChannelOptions{
|
||||
Protocol: ProtocolDial,
|
||||
Unordered: strings.HasPrefix(network, "udp"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create datachannel: %w", err)
|
||||
}
|
||||
|
||||
// The first message written from the other side is a JSON payload
|
||||
// containing the dial error.
|
||||
dec := json.NewDecoder(channel)
|
||||
var res dialResponse
|
||||
err = dec.Decode(&res)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to decode initial packet: %w", err)
|
||||
}
|
||||
if res.Error != "" {
|
||||
_ = channel.Close()
|
||||
return nil, xerrors.Errorf("remote dial error: %v", res.Error)
|
||||
}
|
||||
|
||||
return channel.NetConn(), nil
|
||||
}
|
||||
|
||||
func (c *Conn) Close() error {
|
||||
_ = c.Negotiator.DRPCConn().Close()
|
||||
return c.Conn.Close()
|
||||
|
||||
@@ -3,8 +3,6 @@ package usershell
|
||||
import "os"
|
||||
|
||||
// Get returns the $SHELL environment variable.
|
||||
// TODO: This should use "dscl" to fetch the proper value. See:
|
||||
// https://stackoverflow.com/questions/16375519/how-to-get-the-default-shell
|
||||
func Get(username string) (string, error) {
|
||||
func Get(_ string) (string, error) {
|
||||
return os.Getenv("SHELL"), nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package buildinfo
|
||||
|
||||
import (
|
||||
"path"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -14,6 +14,12 @@ var (
|
||||
buildInfoValid bool
|
||||
readBuildInfo sync.Once
|
||||
|
||||
externalURL string
|
||||
readExternalURL sync.Once
|
||||
|
||||
version string
|
||||
readVersion sync.Once
|
||||
|
||||
// Injected with ldflags at build!
|
||||
tag string
|
||||
)
|
||||
@@ -21,29 +27,41 @@ var (
|
||||
// Version returns the semantic version of the build.
|
||||
// Use golang.org/x/mod/semver to compare versions.
|
||||
func Version() string {
|
||||
revision, valid := revision()
|
||||
if valid {
|
||||
revision = "+" + revision[:7]
|
||||
}
|
||||
if tag == "" {
|
||||
return "v0.0.0-devel" + revision
|
||||
}
|
||||
if semver.Build(tag) == "" {
|
||||
tag += revision
|
||||
}
|
||||
return "v" + tag
|
||||
readVersion.Do(func() {
|
||||
revision, valid := revision()
|
||||
if valid {
|
||||
revision = "+" + revision[:7]
|
||||
}
|
||||
if tag == "" {
|
||||
// This occurs when the tag hasn't been injected,
|
||||
// like when using "go run".
|
||||
version = "v0.0.0-devel" + revision
|
||||
return
|
||||
}
|
||||
version = "v" + tag
|
||||
// The tag must be prefixed with "v" otherwise the
|
||||
// semver library will return an empty string.
|
||||
if semver.Build(version) == "" {
|
||||
version += revision
|
||||
}
|
||||
})
|
||||
return version
|
||||
}
|
||||
|
||||
// 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.
|
||||
func ExternalURL() string {
|
||||
repo := "https://github.com/coder/coder"
|
||||
revision, valid := revision()
|
||||
if !valid {
|
||||
return repo
|
||||
}
|
||||
return path.Join(repo, "commit", revision)
|
||||
readExternalURL.Do(func() {
|
||||
repo := "https://github.com/coder/coder"
|
||||
revision, valid := revision()
|
||||
if !valid {
|
||||
externalURL = repo
|
||||
return
|
||||
}
|
||||
externalURL = fmt.Sprintf("%s/commit/%s", repo, revision)
|
||||
})
|
||||
return externalURL
|
||||
}
|
||||
|
||||
// Time returns when the Git revision was published.
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"github.com/coder/coder/cli/buildinfo"
|
||||
"github.com/coder/coder/buildinfo"
|
||||
)
|
||||
|
||||
func TestBuildInfo(t *testing.T) {
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/compute/metadata"
|
||||
@@ -16,29 +18,35 @@ import (
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/peer"
|
||||
"github.com/coder/retry"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
func workspaceAgent() *cobra.Command {
|
||||
var (
|
||||
rawURL string
|
||||
auth string
|
||||
token string
|
||||
auth string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
// This command isn't useful to manually execute.
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if rawURL == "" {
|
||||
return xerrors.New("CODER_URL must be set")
|
||||
rawURL, err := cmd.Flags().GetString(varAgentURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("CODER_AGENT_URL must be set: %w", err)
|
||||
}
|
||||
coderURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse %q: %w", rawURL, err)
|
||||
}
|
||||
logger := slog.Make(sloghuman.Sink(cmd.OutOrStdout())).Leveled(slog.LevelDebug)
|
||||
|
||||
logWriter := &lumberjack.Logger{
|
||||
Filename: filepath.Join(os.TempDir(), "coder-agent.log"),
|
||||
MaxSize: 5, // MB
|
||||
}
|
||||
defer logWriter.Close()
|
||||
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()), sloghuman.Sink(logWriter)).Leveled(slog.LevelDebug)
|
||||
client := codersdk.New(coderURL)
|
||||
|
||||
// exchangeToken returns a session token.
|
||||
@@ -47,8 +55,9 @@ func workspaceAgent() *cobra.Command {
|
||||
var exchangeToken func(context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error)
|
||||
switch auth {
|
||||
case "token":
|
||||
if token == "" {
|
||||
return xerrors.Errorf("CODER_TOKEN must be set for token auth")
|
||||
token, err := cmd.Flags().GetString(varAgentToken)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("CODER_AGENT_TOKEN must be set for token auth: %w", err)
|
||||
}
|
||||
client.SessionToken = token
|
||||
case "google-instance-identity":
|
||||
@@ -77,7 +86,19 @@ func workspaceAgent() *cobra.Command {
|
||||
return client.AuthWorkspaceAWSInstanceIdentity(ctx)
|
||||
}
|
||||
case "azure-instance-identity":
|
||||
return xerrors.Errorf("not implemented")
|
||||
// This is *only* done for testing to mock client authentication.
|
||||
// This will never be set in a production scenario.
|
||||
var azureClient *http.Client
|
||||
azureClientRaw := cmd.Context().Value("azure-client")
|
||||
if azureClientRaw != nil {
|
||||
azureClient, _ = azureClientRaw.(*http.Client)
|
||||
if azureClient != nil {
|
||||
client.HTTPClient = azureClient
|
||||
}
|
||||
}
|
||||
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
|
||||
return client.AuthWorkspaceAzureInstanceIdentity(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if exchangeToken != nil {
|
||||
@@ -104,17 +125,19 @@ func workspaceAgent() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
closer := agent.New(client.ListenWorkspaceAgent, &peer.ConnOptions{
|
||||
closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: logger,
|
||||
EnvironmentVariables: map[string]string{
|
||||
// Override the "CODER_AGENT_TOKEN" variable in all
|
||||
// shells so "gitssh" works!
|
||||
"CODER_AGENT_TOKEN": client.SessionToken,
|
||||
},
|
||||
})
|
||||
<-cmd.Context().Done()
|
||||
return closer.Close()
|
||||
},
|
||||
}
|
||||
|
||||
cliflag.StringVarP(cmd.Flags(), &auth, "auth", "", "CODER_AUTH", "token", "Specify the authentication type to use for the agent")
|
||||
cliflag.StringVarP(cmd.Flags(), &rawURL, "url", "", "CODER_URL", "", "Specify the URL to access Coder")
|
||||
cliflag.StringVarP(cmd.Flags(), &token, "token", "", "CODER_TOKEN", "", "Specifies the authentication token to access Coder")
|
||||
|
||||
cliflag.StringVarP(cmd.Flags(), &auth, "auth", "", "CODER_AGENT_AUTH", "token", "Specify the authentication type to use for the agent")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func TestWorkspaceAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Azure", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
instanceID := "instanceidentifier"
|
||||
certificates, metadataClient := coderdtest.NewAzureInstanceIdentity(t, instanceID)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
AzureCertificates: certificates,
|
||||
IncludeProvisionerD: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Auth: &proto.Agent_InstanceId{
|
||||
InstanceId: instanceID,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
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)
|
||||
|
||||
cmd, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--agent-url", client.URL.String())
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
// A linting error occurs for weakly typing the context value here.
|
||||
//nolint // The above seems reasonable for a one-off test.
|
||||
ctx := context.WithValue(ctx, "azure-client", metadataClient)
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
_, err = dialer.Ping()
|
||||
require.NoError(t, err)
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("AWS", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
instanceID := "instanceidentifier"
|
||||
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
AWSCertificates: certificates,
|
||||
IncludeProvisionerD: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Auth: &proto.Agent_InstanceId{
|
||||
InstanceId: instanceID,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
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)
|
||||
|
||||
cmd, _ := clitest.New(t, "agent", "--auth", "aws-instance-identity", "--agent-url", client.URL.String())
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
// A linting error occurs for weakly typing the context value here.
|
||||
//nolint // The above seems reasonable for a one-off test.
|
||||
ctx := context.WithValue(ctx, "aws-client", metadataClient)
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
_, err = dialer.Ping()
|
||||
require.NoError(t, err)
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("GoogleCloud", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
instanceID := "instanceidentifier"
|
||||
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
GoogleTokenValidator: validator,
|
||||
IncludeProvisionerD: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Auth: &proto.Agent_InstanceId{
|
||||
InstanceId: instanceID,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
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)
|
||||
|
||||
cmd, _ := clitest.New(t, "agent", "--auth", "google-instance-identity", "--agent-url", client.URL.String())
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
// A linting error occurs for weakly typing the context value here.
|
||||
//nolint // The above seems reasonable for a one-off test.
|
||||
ctx := context.WithValue(ctx, "gcp-client", metadata)
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
_, err = dialer.Ping()
|
||||
require.NoError(t, err)
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart.
|
||||
When enabling autostart, provide the minute, hour, and day(s) of week.
|
||||
The default schedule is at 09:00 in your local timezone (TZ env, UTC by default).
|
||||
`
|
||||
|
||||
func autostart() *cobra.Command {
|
||||
autostartCmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "autostart enable <workspace>",
|
||||
Short: "schedule a workspace to automatically start at a regular time",
|
||||
Long: autostartDescriptionLong,
|
||||
Example: "coder autostart enable my-workspace --minute 30 --hour 9 --days 1-5 --tz Europe/Dublin",
|
||||
}
|
||||
|
||||
autostartCmd.AddCommand(autostartShow())
|
||||
autostartCmd.AddCommand(autostartEnable())
|
||||
autostartCmd.AddCommand(autostartDisable())
|
||||
|
||||
return autostartCmd
|
||||
}
|
||||
|
||||
func autostartShow() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show <workspace_name>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if workspace.AutostartSchedule == nil || *workspace.AutostartSchedule == "" {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "not enabled\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
validSchedule, err := schedule.Weekly(*workspace.AutostartSchedule)
|
||||
if err != nil {
|
||||
// This should never happen.
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "invalid autostart schedule %q for workspace %s: %s\n", *workspace.AutostartSchedule, workspace.Name, err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
next := validSchedule.Next(time.Now())
|
||||
loc, _ := time.LoadLocation(validSchedule.Timezone())
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
|
||||
"schedule: %s\ntimezone: %s\nnext: %s\n",
|
||||
validSchedule.Cron(),
|
||||
validSchedule.Timezone(),
|
||||
next.In(loc),
|
||||
)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func autostartEnable() *cobra.Command {
|
||||
// yes some of these are technically numbers but the cron library will do that work
|
||||
var autostartMinute string
|
||||
var autostartHour string
|
||||
var autostartDayOfWeek string
|
||||
var autostartTimezone string
|
||||
cmd := &cobra.Command{
|
||||
Use: "enable <workspace_name> <schedule>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", autostartTimezone, autostartMinute, autostartHour, autostartDayOfWeek)
|
||||
validSchedule, err := schedule.Weekly(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
Schedule: &spec,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically start at %s.\n\n", workspace.Name, validSchedule.Next(time.Now()))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&autostartMinute, "minute", "0", "autostart minute")
|
||||
cmd.Flags().StringVar(&autostartHour, "hour", "9", "autostart hour")
|
||||
cmd.Flags().StringVar(&autostartDayOfWeek, "days", "1-5", "autostart day(s) of week")
|
||||
tzEnv := os.Getenv("TZ")
|
||||
if tzEnv == "" {
|
||||
tzEnv = "UTC"
|
||||
}
|
||||
cmd.Flags().StringVar(&autostartTimezone, "tz", tzEnv, "autostart timezone")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func autostartDisable() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "disable <workspace_name>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
Schedule: nil,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will no longer automatically start.\n\n", workspace.Name)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func TestAutostart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ShowOK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||
cmdArgs = []string{"autostart", "show", workspace.Name}
|
||||
sched = "CRON_TZ=Europe/Dublin 30 17 * * 1-5"
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
Schedule: ptr.Ref(sched),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
err = cmd.Execute()
|
||||
require.NoError(t, err, "unexpected error")
|
||||
// CRON_TZ gets stripped
|
||||
require.Contains(t, stdoutBuf.String(), "schedule: 30 17 * * 1-5")
|
||||
})
|
||||
|
||||
t.Run("EnableDisableOK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||
tz = "Europe/Dublin"
|
||||
cmdArgs = []string{"autostart", "enable", workspace.Name, "--minute", "30", "--hour", "9", "--days", "1-5", "--tz", tz}
|
||||
sched = "CRON_TZ=Europe/Dublin 30 9 * * 1-5"
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err, "unexpected error")
|
||||
require.Contains(t, stdoutBuf.String(), "will automatically start at", "unexpected output")
|
||||
|
||||
// Ensure autostart schedule updated
|
||||
updated, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err, "fetch updated workspace")
|
||||
require.Equal(t, sched, *updated.AutostartSchedule, "expected autostart schedule to be set")
|
||||
|
||||
// Disable schedule
|
||||
cmd, root = clitest.New(t, "autostart", "disable", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
err = cmd.Execute()
|
||||
require.NoError(t, err, "unexpected error")
|
||||
require.Contains(t, stdoutBuf.String(), "will no longer automatically start", "unexpected output")
|
||||
|
||||
// Ensure autostart schedule updated
|
||||
updated, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err, "fetch updated workspace")
|
||||
require.Nil(t, updated.AutostartSchedule, "expected autostart schedule to not be set")
|
||||
})
|
||||
|
||||
t.Run("Enable_NotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
)
|
||||
|
||||
cmd, root := clitest.New(t, "autostart", "enable", "doesnotexist")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error")
|
||||
})
|
||||
|
||||
t.Run("Disable_NotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
)
|
||||
|
||||
cmd, root := clitest.New(t, "autostart", "disable", "doesnotexist")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error")
|
||||
})
|
||||
|
||||
t.Run("Enable_DefaultSchedule", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||
)
|
||||
|
||||
// check current TZ env var
|
||||
currTz := os.Getenv("TZ")
|
||||
if currTz == "" {
|
||||
currTz = "UTC"
|
||||
}
|
||||
expectedSchedule := fmt.Sprintf("CRON_TZ=%s 0 9 * * 1-5", currTz)
|
||||
cmd, root := clitest.New(t, "autostart", "enable", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err, "unexpected error")
|
||||
|
||||
// Ensure nothing happened
|
||||
updated, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err, "fetch updated workspace")
|
||||
require.Equal(t, expectedSchedule, *updated.AutostartSchedule, "expected default autostart schedule")
|
||||
})
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
bumpDescriptionLong = `To extend the autostop deadline for a workspace.
|
||||
If no unit is specified in the duration, we assume minutes.`
|
||||
defaultBumpDuration = 90 * time.Minute
|
||||
)
|
||||
|
||||
func bump() *cobra.Command {
|
||||
bumpCmd := &cobra.Command{
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
Annotations: workspaceCommand,
|
||||
Use: "bump <workspace-name> [duration]",
|
||||
Short: "Extend the autostop deadline for a workspace.",
|
||||
Long: bumpDescriptionLong,
|
||||
Example: "coder bump my-workspace 90m",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
bumpDuration := defaultBumpDuration
|
||||
if len(args) > 1 {
|
||||
d, err := tryParseDuration(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bumpDuration = d
|
||||
}
|
||||
|
||||
if bumpDuration < time.Minute {
|
||||
return xerrors.New("minimum bump duration is 1 minute")
|
||||
}
|
||||
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create client: %w", err)
|
||||
}
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace: %w", err)
|
||||
}
|
||||
|
||||
if workspace.LatestBuild.Deadline.IsZero() {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "no deadline set\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
newDeadline := workspace.LatestBuild.Deadline.Add(bumpDuration)
|
||||
if err := client.PutExtendWorkspace(cmd.Context(), workspace.ID, codersdk.PutExtendWorkspaceRequest{
|
||||
Deadline: newDeadline,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Workspace %q will now stop at %s\n", workspace.Name, newDeadline.Format(time.RFC3339))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return bumpCmd
|
||||
}
|
||||
|
||||
func tryParseDuration(raw string) (time.Duration, error) {
|
||||
// If the user input a raw number, assume minutes
|
||||
if isDigit(raw) {
|
||||
raw = raw + "m"
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func isDigit(s string) bool {
|
||||
return strings.IndexFunc(s, func(c rune) bool {
|
||||
return c < '0' || c > '9'
|
||||
}) == -1
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func TestBump(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("BumpOKDefault", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: we have a workspace
|
||||
var (
|
||||
err error
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||
cmdArgs = []string{"bump", workspace.Name}
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
// Given: we wait for the workspace to be built
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
expectedDeadline := workspace.LatestBuild.Deadline.Add(90 * time.Minute)
|
||||
|
||||
// Assert test invariant: workspace build has a deadline set equal to now plus ttl
|
||||
initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond)
|
||||
require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline, time.Minute)
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
// When: we execute `coder bump <workspace>`
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err, "unexpected error")
|
||||
|
||||
// Then: the deadline of the latest build is updated
|
||||
updated, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute)
|
||||
})
|
||||
|
||||
t.Run("BumpSpecificDuration", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: we have a workspace
|
||||
var (
|
||||
err error
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||
cmdArgs = []string{"bump", workspace.Name, "30"}
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
// Given: we wait for the workspace to be built
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
expectedDeadline := workspace.LatestBuild.Deadline.Add(30 * time.Minute)
|
||||
|
||||
// Assert test invariant: workspace build has a deadline set equal to now plus ttl
|
||||
initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond)
|
||||
require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline, time.Minute)
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
// When: we execute `coder bump workspace <number without units>`
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: the deadline of the latest build is updated assuming the units are minutes
|
||||
updated, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute)
|
||||
})
|
||||
|
||||
t.Run("BumpInvalidDuration", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: we have a workspace
|
||||
var (
|
||||
err error
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||
cmdArgs = []string{"bump", workspace.Name, "kwyjibo"}
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
// Given: we wait for the workspace to be built
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Assert test invariant: workspace build has a deadline set equal to now plus ttl
|
||||
initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond)
|
||||
require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline, time.Minute)
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
// When: we execute `coder bump workspace <not a number>`
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
// Then: the command fails
|
||||
require.ErrorContains(t, err, "invalid duration")
|
||||
})
|
||||
|
||||
t.Run("BumpNoDeadline", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: we have a workspace with no deadline set
|
||||
var (
|
||||
err error
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
cwr.TTLMillis = nil
|
||||
})
|
||||
cmdArgs = []string{"bump", workspace.Name}
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
// Given: we wait for the workspace to build
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Assert test invariant: workspace has no TTL set
|
||||
require.Zero(t, workspace.LatestBuild.Deadline)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
// When: we execute `coder bump workspace``
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: nothing happens and the deadline remains unset
|
||||
updated, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, updated.LatestBuild.Deadline)
|
||||
})
|
||||
|
||||
t.Run("BumpMinimumDuration", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given: we have a workspace with no deadline set
|
||||
var (
|
||||
err error
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||
cmdArgs = []string{"bump", workspace.Name, "59s"}
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
// Given: we wait for the workspace to build
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Assert test invariant: workspace build has a deadline set equal to now plus ttl
|
||||
initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond)
|
||||
require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline, time.Minute)
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
// When: we execute `coder bump workspace 59s`
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
require.ErrorContains(t, err, "minimum bump duration is 1 minute")
|
||||
|
||||
// Then: an error is reported and the deadline remains as before
|
||||
updated, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, workspace.LatestBuild.Deadline, updated.LatestBuild.Deadline, time.Minute)
|
||||
})
|
||||
}
|
||||
@@ -14,10 +14,21 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// String sets a string flag on the given flag set.
|
||||
func String(flagset *pflag.FlagSet, name, shorthand, env, def, usage string) {
|
||||
v, ok := os.LookupEnv(env)
|
||||
if !ok || v == "" {
|
||||
v = def
|
||||
}
|
||||
flagset.StringP(name, shorthand, v, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
// StringVarP sets a string flag on the given flag set.
|
||||
func StringVarP(flagset *pflag.FlagSet, p *string, name string, shorthand string, env string, def string, usage string) {
|
||||
v, ok := os.LookupEnv(env)
|
||||
@@ -27,6 +38,18 @@ func StringVarP(flagset *pflag.FlagSet, p *string, name string, shorthand string
|
||||
flagset.StringVarP(p, name, shorthand, v, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
func StringArrayVarP(flagset *pflag.FlagSet, ptr *[]string, name string, shorthand string, env string, def []string, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if ok {
|
||||
if val == "" {
|
||||
def = []string{}
|
||||
} else {
|
||||
def = strings.Split(val, ",")
|
||||
}
|
||||
}
|
||||
flagset.StringArrayVarP(ptr, name, shorthand, def, usage)
|
||||
}
|
||||
|
||||
// Uint8VarP sets a uint8 flag on the given flag set.
|
||||
func Uint8VarP(flagset *pflag.FlagSet, ptr *uint8, name string, shorthand string, env string, def uint8, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
@@ -61,6 +84,23 @@ func BoolVarP(flagset *pflag.FlagSet, ptr *bool, name string, shorthand string,
|
||||
flagset.BoolVarP(ptr, name, shorthand, valb, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
// DurationVarP sets a time.Duration flag on the given flag set.
|
||||
func DurationVarP(flagset *pflag.FlagSet, ptr *time.Duration, name string, shorthand string, env string, def time.Duration, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if !ok || val == "" {
|
||||
flagset.DurationVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
valb, err := time.ParseDuration(val)
|
||||
if err != nil {
|
||||
flagset.DurationVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
flagset.DurationVarP(ptr, name, shorthand, valb, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
func fmtUsage(u string, env string) string {
|
||||
if env == "" {
|
||||
return fmt.Sprintf("%s.", u)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -16,6 +17,28 @@ import (
|
||||
//nolint:paralleltest
|
||||
func TestCliflag(t *testing.T) {
|
||||
t.Run("StringDefault", func(t *testing.T) {
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.String(10)
|
||||
cliflag.String(flagset, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetString(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf(" - consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("StringEnvVar", func(t *testing.T) {
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.String(10)
|
||||
cliflag.String(flagset, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetString(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, envValue, got)
|
||||
})
|
||||
|
||||
t.Run("StringVarPDefault", func(t *testing.T) {
|
||||
var ptr string
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.String(10)
|
||||
@@ -28,7 +51,7 @@ func TestCliflag(t *testing.T) {
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf(" - consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("StringEnvVar", func(t *testing.T) {
|
||||
t.Run("StringVarPEnvVar", func(t *testing.T) {
|
||||
var ptr string
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
@@ -54,6 +77,36 @@ func TestCliflag(t *testing.T) {
|
||||
require.NotContains(t, flagset.FlagUsages(), " - consumes")
|
||||
})
|
||||
|
||||
t.Run("StringArrayDefault", func(t *testing.T) {
|
||||
var ptr []string
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def := []string{"hello"}
|
||||
cliflag.StringArrayVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetStringArray(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
})
|
||||
|
||||
t.Run("StringArrayEnvVar", func(t *testing.T) {
|
||||
var ptr []string
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
t.Setenv(env, "wow,test")
|
||||
cliflag.StringArrayVarP(flagset, &ptr, name, shorthand, env, nil, usage)
|
||||
got, err := flagset.GetStringArray(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"wow", "test"}, got)
|
||||
})
|
||||
|
||||
t.Run("StringArrayEnvVarEmpty", func(t *testing.T) {
|
||||
var ptr []string
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
t.Setenv(env, "")
|
||||
cliflag.StringArrayVarP(flagset, &ptr, name, shorthand, env, nil, usage)
|
||||
got, err := flagset.GetStringArray(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{}, got)
|
||||
})
|
||||
|
||||
t.Run("IntDefault", func(t *testing.T) {
|
||||
var ptr uint8
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
@@ -131,6 +184,45 @@ func TestCliflag(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
})
|
||||
|
||||
t.Run("DurationDefault", func(t *testing.T) {
|
||||
var ptr time.Duration
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.Duration()
|
||||
|
||||
cliflag.DurationVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetDuration(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf(" - consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("DurationEnvVar", func(t *testing.T) {
|
||||
var ptr time.Duration
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.Duration()
|
||||
t.Setenv(env, envValue.String())
|
||||
def, _ := cryptorand.Duration()
|
||||
|
||||
cliflag.DurationVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetDuration(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, envValue, got)
|
||||
})
|
||||
|
||||
t.Run("DurationFailParse", func(t *testing.T) {
|
||||
var ptr time.Duration
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.Duration()
|
||||
|
||||
cliflag.DurationVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetDuration(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
})
|
||||
}
|
||||
|
||||
func randomFlag() (*pflag.FlagSet, string, string, string, string) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package clitest_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
@@ -25,8 +24,7 @@ func TestCli(t *testing.T) {
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
_ = cmd.Execute()
|
||||
}()
|
||||
pty.ExpectMatch("coder")
|
||||
}
|
||||
|
||||
+28
-9
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -15,7 +17,7 @@ import (
|
||||
|
||||
type AgentOptions struct {
|
||||
WorkspaceName string
|
||||
Fetch func(context.Context) (codersdk.WorkspaceResource, error)
|
||||
Fetch func(context.Context) (codersdk.WorkspaceAgent, error)
|
||||
FetchInterval time.Duration
|
||||
WarnInterval time.Duration
|
||||
}
|
||||
@@ -29,23 +31,40 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||
opts.WarnInterval = 30 * time.Second
|
||||
}
|
||||
var resourceMutex sync.Mutex
|
||||
resource, err := opts.Fetch(ctx)
|
||||
agent, err := opts.Fetch(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch: %w", err)
|
||||
}
|
||||
if resource.Agent.Status == codersdk.WorkspaceAgentConnected {
|
||||
if agent.Status == codersdk.WorkspaceAgentConnected {
|
||||
return nil
|
||||
}
|
||||
if resource.Agent.Status == codersdk.WorkspaceAgentDisconnected {
|
||||
if agent.Status == codersdk.WorkspaceAgentDisconnected {
|
||||
opts.WarnInterval = 0
|
||||
}
|
||||
spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen"))
|
||||
spin.Writer = writer
|
||||
spin.ForceOutput = true
|
||||
spin.Suffix = " Waiting for connection from " + Styles.Field.Render(resource.Type+"."+resource.Name) + "..."
|
||||
spin.Suffix = " Waiting for connection from " + Styles.Field.Render(agent.Name) + "..."
|
||||
spin.Start()
|
||||
defer spin.Stop()
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(ctx)
|
||||
defer cancelFunc()
|
||||
stopSpin := make(chan os.Signal, 1)
|
||||
signal.Notify(stopSpin, os.Interrupt)
|
||||
defer signal.Stop(stopSpin)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-stopSpin:
|
||||
}
|
||||
signal.Stop(stopSpin)
|
||||
spin.Stop()
|
||||
// nolint:revive
|
||||
os.Exit(1)
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(opts.FetchInterval)
|
||||
defer ticker.Stop()
|
||||
timer := time.NewTimer(opts.WarnInterval)
|
||||
@@ -59,8 +78,8 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||
resourceMutex.Lock()
|
||||
defer resourceMutex.Unlock()
|
||||
message := "Don't panic, your workspace is booting up!"
|
||||
if resource.Agent.Status == codersdk.WorkspaceAgentDisconnected {
|
||||
message = "The workspace agent lost connection! Wait for it to reconnect or run: " + Styles.Code.Render("coder workspaces rebuild "+opts.WorkspaceName)
|
||||
if agent.Status == codersdk.WorkspaceAgentDisconnected {
|
||||
message = "The workspace agent lost connection! Wait for it to reconnect or run: " + Styles.Code.Render("coder rebuild "+opts.WorkspaceName)
|
||||
}
|
||||
// This saves the cursor position, then defers clearing from the cursor
|
||||
// position to the end of the screen.
|
||||
@@ -74,11 +93,11 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||
case <-ticker.C:
|
||||
}
|
||||
resourceMutex.Lock()
|
||||
resource, err = opts.Fetch(ctx)
|
||||
agent, err = opts.Fetch(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch: %w", err)
|
||||
}
|
||||
if resource.Agent.Status != codersdk.WorkspaceAgentConnected {
|
||||
if agent.Status != codersdk.WorkspaceAgentConnected {
|
||||
resourceMutex.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
@@ -22,16 +22,14 @@ func TestAgent(t *testing.T) {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||
WorkspaceName: "example",
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) {
|
||||
resource := codersdk.WorkspaceResource{
|
||||
Agent: &codersdk.WorkspaceAgent{
|
||||
Status: codersdk.WorkspaceAgentDisconnected,
|
||||
},
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
agent := codersdk.WorkspaceAgent{
|
||||
Status: codersdk.WorkspaceAgentDisconnected,
|
||||
}
|
||||
if disconnected.Load() {
|
||||
resource.Agent.Status = codersdk.WorkspaceAgentConnected
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
}
|
||||
return resource, nil
|
||||
return agent, nil
|
||||
},
|
||||
FetchInterval: time.Millisecond,
|
||||
WarnInterval: 10 * time.Millisecond,
|
||||
@@ -45,7 +43,7 @@ func TestAgent(t *testing.T) {
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
ptty.ExpectMatch("lost connection")
|
||||
disconnected.Store(true)
|
||||
|
||||
@@ -26,6 +26,7 @@ var Styles = struct {
|
||||
Checkmark,
|
||||
Code,
|
||||
Crossmark,
|
||||
Error,
|
||||
Field,
|
||||
Keyword,
|
||||
Paragraph,
|
||||
@@ -41,6 +42,7 @@ var Styles = struct {
|
||||
Checkmark: defaultStyles.Checkmark,
|
||||
Code: defaultStyles.Code,
|
||||
Crossmark: defaultStyles.Error.Copy().SetString("✘"),
|
||||
Error: defaultStyles.Error,
|
||||
Field: defaultStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}),
|
||||
Keyword: defaultStyles.Keyword,
|
||||
Paragraph: defaultStyles.Paragraph,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func removeLineLengthLimit(inputFD int) (func(), error) {
|
||||
termios, err := unix.IoctlGetTermios(inputFD, unix.TIOCGETA)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get termios: %w", err)
|
||||
}
|
||||
newState := *termios
|
||||
// MacOS has a default line limit of 1024. See:
|
||||
// https://unix.stackexchange.com/questions/204815/terminal-does-not-accept-pasted-or-typed-lines-of-more-than-1024-characters
|
||||
//
|
||||
// This removes canonical input processing, so deletes will not function
|
||||
// as expected. This _seems_ fine for most use-cases, but is unfortunate.
|
||||
newState.Lflag &^= unix.ICANON
|
||||
err = unix.IoctlSetTermios(inputFD, unix.TIOCSETA, &newState)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("set termios: %w", err)
|
||||
}
|
||||
return func() {
|
||||
_ = unix.IoctlSetTermios(inputFD, unix.TIOCSETA, termios)
|
||||
}, nil
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build !darwin
|
||||
// +build !darwin
|
||||
|
||||
package cliui
|
||||
|
||||
import "golang.org/x/xerrors"
|
||||
|
||||
func removeLineLengthLimit(_ int) (func(), error) {
|
||||
return nil, xerrors.New("not implemented")
|
||||
}
|
||||
+19
-3
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.TemplateVersionParameterSchema) (string, error) {
|
||||
func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ParameterSchema) (string, error) {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), Styles.Bold.Render("var."+parameterSchema.Name))
|
||||
if parameterSchema.Description != "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.TrimSpace(strings.Join(strings.Split(parameterSchema.Description, "\n"), "\n "))+"\n")
|
||||
@@ -30,6 +30,7 @@ func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.TemplateVersio
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
|
||||
value, err = Select(cmd, SelectOptions{
|
||||
Options: options,
|
||||
Default: parameterSchema.DefaultSourceValue,
|
||||
HideSearch: true,
|
||||
})
|
||||
if err == nil {
|
||||
@@ -37,9 +38,24 @@ func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.TemplateVersio
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+Styles.Prompt.String()+Styles.Field.Render(value))
|
||||
}
|
||||
} else {
|
||||
text := "Enter a value"
|
||||
if parameterSchema.DefaultSourceValue != "" {
|
||||
text += fmt.Sprintf(" (default: %q)", parameterSchema.DefaultSourceValue)
|
||||
}
|
||||
text += ":"
|
||||
|
||||
value, err = Prompt(cmd, PromptOptions{
|
||||
Text: Styles.Bold.Render("Enter a value:"),
|
||||
Text: Styles.Bold.Render(text),
|
||||
})
|
||||
}
|
||||
return value, err
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If they didn't specify anything, use the default value if set.
|
||||
if len(options) == 0 && value == "" {
|
||||
value = parameterSchema.DefaultSourceValue
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
+57
-32
@@ -5,15 +5,14 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/bgentry/speakeasy"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// PromptOptions supply a set of options to the prompt.
|
||||
@@ -25,8 +24,21 @@ type PromptOptions struct {
|
||||
Validate func(string) error
|
||||
}
|
||||
|
||||
func AllowSkipPrompt(cmd *cobra.Command) {
|
||||
cmd.Flags().BoolP("yes", "y", false, "Bypass prompts")
|
||||
}
|
||||
|
||||
// Prompt asks the user for input.
|
||||
func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
// If the cmd has a "yes" flag for skipping confirm prompts, honor it.
|
||||
// If it's not a "Confirm" prompt, then don't skip. As the default value of
|
||||
// "yes" makes no sense.
|
||||
if opts.IsConfirm && cmd.Flags().Lookup("yes") != nil {
|
||||
if skip, _ := cmd.Flags().GetBool("yes"); skip {
|
||||
return "yes", nil
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.FocusedPrompt.String()+opts.Text+" ")
|
||||
if opts.IsConfirm {
|
||||
opts.Default = "yes"
|
||||
@@ -35,8 +47,6 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+opts.Default+") "))
|
||||
}
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
defer signal.Stop(interrupt)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
lineCh := make(chan string)
|
||||
@@ -44,19 +54,13 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
var line string
|
||||
var err error
|
||||
|
||||
inFile, valid := cmd.InOrStdin().(*os.File)
|
||||
if opts.Secret && valid && isatty.IsTerminal(inFile.Fd()) {
|
||||
inFile, isInputFile := cmd.InOrStdin().(*os.File)
|
||||
if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) {
|
||||
// we don't install a signal handler here because speakeasy has its own
|
||||
line, err = speakeasy.Ask("")
|
||||
} else {
|
||||
if runtime.GOOS == "darwin" && valid {
|
||||
var restore func()
|
||||
restore, err = removeLineLengthLimit(int(inFile.Fd()))
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
defer restore()
|
||||
}
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
defer signal.Stop(interrupt)
|
||||
|
||||
reader := bufio.NewReader(cmd.InOrStdin())
|
||||
line, err = reader.ReadString('\n')
|
||||
@@ -65,22 +69,7 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
// This enables multiline JSON to be pasted into an input, and have
|
||||
// it parse properly.
|
||||
if err == nil && (strings.HasPrefix(line, "{") || strings.HasPrefix(line, "[")) {
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
defer pipeWriter.Close()
|
||||
defer pipeReader.Close()
|
||||
go func() {
|
||||
_, _ = pipeWriter.Write([]byte(line))
|
||||
_, _ = reader.WriteTo(pipeWriter)
|
||||
}()
|
||||
var rawMessage json.RawMessage
|
||||
err := json.NewDecoder(pipeReader).Decode(&rawMessage)
|
||||
if err == nil {
|
||||
var buf bytes.Buffer
|
||||
err = json.Compact(&buf, rawMessage)
|
||||
if err == nil {
|
||||
line = buf.String()
|
||||
}
|
||||
}
|
||||
line, err = promptJSON(reader, line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -99,7 +88,7 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
return "", err
|
||||
case line := <-lineCh:
|
||||
if opts.IsConfirm && line != "yes" && line != "y" {
|
||||
return line, Canceled
|
||||
return line, xerrors.Errorf("got %q: %w", line, Canceled)
|
||||
}
|
||||
if opts.Validate != nil {
|
||||
err := opts.Validate(line)
|
||||
@@ -117,3 +106,39 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
return "", Canceled
|
||||
}
|
||||
}
|
||||
|
||||
func promptJSON(reader *bufio.Reader, line string) (string, error) {
|
||||
var data bytes.Buffer
|
||||
for {
|
||||
_, _ = data.WriteString(line)
|
||||
var rawMessage json.RawMessage
|
||||
err := json.Unmarshal(data.Bytes(), &rawMessage)
|
||||
if err != nil {
|
||||
if err.Error() != "unexpected end of JSON input" {
|
||||
// If a real syntax error occurs in JSON,
|
||||
// we want to return that partial line to the user.
|
||||
err = nil
|
||||
line = data.String()
|
||||
break
|
||||
}
|
||||
|
||||
// Read line-by-line. We can't use a JSON decoder
|
||||
// here because it doesn't work by newline, so
|
||||
// reads will block.
|
||||
line, err = reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Compacting the JSON makes it easier for parsing and testing.
|
||||
rawJSON := data.Bytes()
|
||||
data.Reset()
|
||||
err = json.Compact(&data, rawJSON)
|
||||
if err != nil {
|
||||
return line, xerrors.Errorf("compact json: %w", err)
|
||||
}
|
||||
return data.String(), nil
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
+118
-12
@@ -1,13 +1,20 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/pty"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
@@ -20,8 +27,8 @@ func TestPrompt(t *testing.T) {
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
msgChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
@@ -37,8 +44,8 @@ func TestPrompt(t *testing.T) {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
IsConfirm: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
doneChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
@@ -46,6 +53,47 @@ func TestPrompt(t *testing.T) {
|
||||
require.Equal(t, "yes", <-doneChan)
|
||||
})
|
||||
|
||||
t.Run("Skip", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Copy all data written out to a buffer. When we close the ptty, we can
|
||||
// no longer read from the ptty.Output(), but we can read what was
|
||||
// written to the buffer.
|
||||
dataRead, doneReading := context.WithTimeout(context.Background(), time.Second*2)
|
||||
go func() {
|
||||
// This will throw an error sometimes. The underlying ptty
|
||||
// has its own cleanup routines in t.Cleanup. Instead of
|
||||
// trying to control the close perfectly, just let the ptty
|
||||
// double close. This error isn't important, we just
|
||||
// want to know the ptty is done sending output.
|
||||
_, _ = io.Copy(&buf, ptty.Output())
|
||||
doneReading()
|
||||
}()
|
||||
|
||||
doneChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "ShouldNotSeeThis",
|
||||
IsConfirm: true,
|
||||
}, func(cmd *cobra.Command) {
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
cmd.SetArgs([]string{"-y"})
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
doneChan <- resp
|
||||
}()
|
||||
|
||||
require.Equal(t, "yes", <-doneChan)
|
||||
// Close the reader to end the io.Copy
|
||||
require.NoError(t, ptty.Close(), "close eof reader")
|
||||
// Wait for the IO copy to finish
|
||||
<-dataRead.Done()
|
||||
// Timeout error means the output was hanging
|
||||
require.ErrorIs(t, dataRead.Err(), context.Canceled, "should be canceled")
|
||||
require.Len(t, buf.Bytes(), 0, "expect no output")
|
||||
})
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
@@ -53,8 +101,8 @@ func TestPrompt(t *testing.T) {
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
doneChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
@@ -69,8 +117,8 @@ func TestPrompt(t *testing.T) {
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
doneChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
@@ -85,8 +133,8 @@ func TestPrompt(t *testing.T) {
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
doneChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
@@ -97,7 +145,7 @@ func TestPrompt(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions) (string, error) {
|
||||
func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions, cmdOpt func(cmd *cobra.Command)) (string, error) {
|
||||
value := ""
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -106,7 +154,65 @@ func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions) (string, error) {
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.SetOutput(ptty.Output())
|
||||
// Optionally modify the cmd
|
||||
if cmdOpt != nil {
|
||||
cmdOpt(cmd)
|
||||
}
|
||||
cmd.SetOut(ptty.Output())
|
||||
cmd.SetErr(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
return value, cmd.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func TestPasswordTerminalState(t *testing.T) {
|
||||
if os.Getenv("TEST_SUBPROCESS") == "1" {
|
||||
passwordHelper()
|
||||
return
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
ptty := ptytest.New(t)
|
||||
ptyWithFlags, ok := ptty.PTY.(pty.WithFlags)
|
||||
if !ok {
|
||||
t.Skip("unable to check PTY local echo on this platform")
|
||||
}
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestPasswordTerminalState") //nolint:gosec
|
||||
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1")
|
||||
// connect the child process's stdio to the PTY directly, not via a pipe
|
||||
cmd.Stdin = ptty.Input().Reader
|
||||
cmd.Stdout = ptty.Output().Writer
|
||||
cmd.Stderr = os.Stderr
|
||||
err := cmd.Start()
|
||||
require.NoError(t, err)
|
||||
process := cmd.Process
|
||||
defer process.Kill()
|
||||
|
||||
ptty.ExpectMatch("Password: ")
|
||||
time.Sleep(100 * time.Millisecond) // wait for child process to turn off echo and start reading input
|
||||
|
||||
echo, err := ptyWithFlags.EchoEnabled()
|
||||
require.NoError(t, err)
|
||||
require.False(t, echo, "echo is on while reading password")
|
||||
|
||||
err = process.Signal(os.Interrupt)
|
||||
require.NoError(t, err)
|
||||
_, err = process.Wait()
|
||||
require.NoError(t, err)
|
||||
|
||||
echo, err = ptyWithFlags.EchoEnabled()
|
||||
require.NoError(t, err)
|
||||
require.True(t, echo, "echo is off after reading password")
|
||||
}
|
||||
|
||||
func passwordHelper() {
|
||||
cmd := &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Password:",
|
||||
Secret: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
@@ -36,6 +36,9 @@ type ProvisionerJobOptions struct {
|
||||
FetchInterval time.Duration
|
||||
// Verbose determines whether debug and trace logs will be shown.
|
||||
Verbose bool
|
||||
// Silent determines whether log output will be shown unless there is an
|
||||
// error.
|
||||
Silent bool
|
||||
}
|
||||
|
||||
// ProvisionerJob renders a provisioner job with interactive cancellation.
|
||||
@@ -134,12 +137,30 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
|
||||
return xerrors.Errorf("logs: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
// logOutput is where log output is written
|
||||
logOutput = writer
|
||||
// logBuffer is where logs are buffered if opts.Silent is true
|
||||
logBuffer = &bytes.Buffer{}
|
||||
)
|
||||
if opts.Silent {
|
||||
logOutput = logBuffer
|
||||
}
|
||||
flushLogBuffer := func() {
|
||||
if opts.Silent {
|
||||
_, _ = io.Copy(writer, logBuffer)
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(opts.FetchInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case err = <-errChan:
|
||||
flushLogBuffer()
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
flushLogBuffer()
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
updateJob()
|
||||
@@ -161,30 +182,35 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
|
||||
}
|
||||
err = xerrors.New(job.Error)
|
||||
jobMutex.Unlock()
|
||||
flushLogBuffer()
|
||||
return err
|
||||
}
|
||||
|
||||
output := ""
|
||||
switch log.Level {
|
||||
case database.LogLevelTrace, database.LogLevelDebug:
|
||||
case codersdk.LogLevelTrace, codersdk.LogLevelDebug:
|
||||
if !opts.Verbose {
|
||||
continue
|
||||
}
|
||||
output = Styles.Placeholder.Render(log.Output)
|
||||
case database.LogLevelError:
|
||||
case codersdk.LogLevelError:
|
||||
output = defaultStyles.Error.Render(log.Output)
|
||||
case database.LogLevelWarn:
|
||||
case codersdk.LogLevelWarn:
|
||||
output = Styles.Warn.Render(log.Output)
|
||||
case database.LogLevelInfo:
|
||||
case codersdk.LogLevelInfo:
|
||||
output = log.Output
|
||||
}
|
||||
|
||||
jobMutex.Lock()
|
||||
if log.Stage != currentStage && log.Stage != "" {
|
||||
updateStage(log.Stage, log.CreatedAt)
|
||||
jobMutex.Unlock()
|
||||
continue
|
||||
}
|
||||
_, _ = fmt.Fprintf(writer, "%s %s\n", Styles.Placeholder.Render(" "), output)
|
||||
didLogBetweenStage = true
|
||||
_, _ = fmt.Fprintf(logOutput, "%s %s\n", Styles.Placeholder.Render(" "), output)
|
||||
if !opts.Silent {
|
||||
didLogBetweenStage = true
|
||||
}
|
||||
jobMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
@@ -90,9 +90,9 @@ func TestProvisionerJob(t *testing.T) {
|
||||
go func() {
|
||||
<-test.Next
|
||||
currentProcess, err := os.FindProcess(os.Getpid())
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
err = currentProcess.Signal(os.Interrupt)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
<-test.Next
|
||||
test.JobMutex.Lock()
|
||||
test.Job.Status = codersdk.ProvisionerJobCanceled
|
||||
@@ -150,7 +150,7 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
|
||||
defer close(done)
|
||||
err := cmd.ExecuteContext(context.Background())
|
||||
if err != nil {
|
||||
require.ErrorIs(t, err, cliui.Canceled)
|
||||
assert.ErrorIs(t, err, cliui.Canceled)
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
type WorkspaceResourcesOptions struct {
|
||||
WorkspaceName string
|
||||
HideAgentState bool
|
||||
HideAccess bool
|
||||
Title string
|
||||
}
|
||||
|
||||
// WorkspaceResources displays the connection status and tree-view of provided resources.
|
||||
// ┌────────────────────────────────────────────────────────────────────────────┐
|
||||
// │ RESOURCE STATUS ACCESS │
|
||||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||||
// │ google_compute_disk.root persistent │
|
||||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||||
// │ google_compute_instance.dev ephemeral │
|
||||
// │ └─ dev (linux, amd64) ⦾ connecting [10s] coder ssh dev.dev │
|
||||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||||
// │ kubernetes_pod.dev ephemeral │
|
||||
// │ ├─ go (linux, amd64) ⦿ connected coder ssh dev.go │
|
||||
// │ └─ postgres (linux, amd64) ⦾ disconnected [4s] coder ssh dev.postgres │
|
||||
// └────────────────────────────────────────────────────────────────────────────┘
|
||||
func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource, options WorkspaceResourcesOptions) error {
|
||||
// Sort resources by type for consistent output.
|
||||
sort.Slice(resources, func(i, j int) bool {
|
||||
return resources[i].Type < resources[j].Type
|
||||
})
|
||||
|
||||
// Address on stop indexes whether a resource still exists when in the stopped transition.
|
||||
addressOnStop := map[string]codersdk.WorkspaceResource{}
|
||||
for _, resource := range resources {
|
||||
if resource.Transition != codersdk.WorkspaceTransitionStop {
|
||||
continue
|
||||
}
|
||||
addressOnStop[resource.Type+"."+resource.Name] = resource
|
||||
}
|
||||
// Displayed stores whether a resource has already been shown.
|
||||
// Resources can be stored with numerous states, which we
|
||||
// process prior to display.
|
||||
displayed := map[string]struct{}{}
|
||||
|
||||
tableWriter := table.NewWriter()
|
||||
if options.Title != "" {
|
||||
tableWriter.SetTitle(options.Title)
|
||||
}
|
||||
tableWriter.SetStyle(table.StyleLight)
|
||||
tableWriter.Style().Options.SeparateColumns = false
|
||||
row := table.Row{"Resource", "Status"}
|
||||
if !options.HideAccess {
|
||||
row = append(row, "Access")
|
||||
}
|
||||
tableWriter.AppendHeader(row)
|
||||
|
||||
totalAgents := 0
|
||||
for _, resource := range resources {
|
||||
totalAgents += len(resource.Agents)
|
||||
}
|
||||
|
||||
for _, resource := range resources {
|
||||
if resource.Type == "random_string" {
|
||||
// Hide resources that aren't substantial to a user!
|
||||
// This is an unfortunate case, and we should allow
|
||||
// callers to hide resources eventually.
|
||||
continue
|
||||
}
|
||||
resourceAddress := resource.Type + "." + resource.Name
|
||||
if _, shown := displayed[resourceAddress]; shown {
|
||||
// The same resource can have multiple transitions.
|
||||
continue
|
||||
}
|
||||
displayed[resourceAddress] = struct{}{}
|
||||
|
||||
// Sort agents by name for consistent output.
|
||||
sort.Slice(resource.Agents, func(i, j int) bool {
|
||||
return resource.Agents[i].Name < resource.Agents[j].Name
|
||||
})
|
||||
_, existsOnStop := addressOnStop[resourceAddress]
|
||||
resourceState := "ephemeral"
|
||||
if existsOnStop {
|
||||
resourceState = "persistent"
|
||||
}
|
||||
// Display a line for the resource.
|
||||
tableWriter.AppendRow(table.Row{
|
||||
Styles.Bold.Render(resourceAddress),
|
||||
Styles.Placeholder.Render(resourceState),
|
||||
"",
|
||||
})
|
||||
// Display all agents associated with the resource.
|
||||
for index, agent := range resource.Agents {
|
||||
sshCommand := "coder ssh " + options.WorkspaceName
|
||||
if totalAgents > 1 {
|
||||
sshCommand += "." + agent.Name
|
||||
}
|
||||
sshCommand = Styles.Code.Render(sshCommand)
|
||||
var agentStatus string
|
||||
if !options.HideAgentState {
|
||||
switch agent.Status {
|
||||
case codersdk.WorkspaceAgentConnecting:
|
||||
since := database.Now().Sub(agent.CreatedAt)
|
||||
agentStatus = Styles.Warn.Render("⦾ connecting") + " " +
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentDisconnected:
|
||||
since := database.Now().Sub(*agent.DisconnectedAt)
|
||||
agentStatus = Styles.Error.Render("⦾ disconnected") + " " +
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentConnected:
|
||||
agentStatus = Styles.Keyword.Render("⦿ connected")
|
||||
}
|
||||
}
|
||||
|
||||
pipe := "├"
|
||||
if index == len(resource.Agents)-1 {
|
||||
pipe = "└"
|
||||
}
|
||||
row := table.Row{
|
||||
// These tree from a resource!
|
||||
fmt.Sprintf("%s─ %s (%s, %s)", pipe, agent.Name, agent.OperatingSystem, agent.Architecture),
|
||||
agentStatus,
|
||||
}
|
||||
if !options.HideAccess {
|
||||
row = append(row, sshCommand)
|
||||
}
|
||||
tableWriter.AppendRow(row)
|
||||
}
|
||||
tableWriter.AppendSeparator()
|
||||
}
|
||||
_, err := fmt.Fprintln(writer, tableWriter.Render())
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestWorkspaceResources(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("SingleAgentSSH", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{
|
||||
Type: "google_compute_instance",
|
||||
Name: "dev",
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
Agents: []codersdk.WorkspaceAgent{{
|
||||
Name: "dev",
|
||||
Status: codersdk.WorkspaceAgentConnected,
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
}},
|
||||
}}, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: "example",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
close(done)
|
||||
}()
|
||||
ptty.ExpectMatch("coder ssh example")
|
||||
<-done
|
||||
})
|
||||
|
||||
t.Run("MultipleStates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
disconnected := database.Now().Add(-4 * time.Second)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
Type: "google_compute_disk",
|
||||
Name: "root",
|
||||
}, {
|
||||
Transition: codersdk.WorkspaceTransitionStop,
|
||||
Type: "google_compute_disk",
|
||||
Name: "root",
|
||||
}, {
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
Type: "google_compute_instance",
|
||||
Name: "dev",
|
||||
Agents: []codersdk.WorkspaceAgent{{
|
||||
CreatedAt: database.Now().Add(-10 * time.Second),
|
||||
Status: codersdk.WorkspaceAgentConnecting,
|
||||
Name: "dev",
|
||||
OperatingSystem: "linux",
|
||||
Architecture: "amd64",
|
||||
}},
|
||||
}, {
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
Type: "kubernetes_pod",
|
||||
Name: "dev",
|
||||
Agents: []codersdk.WorkspaceAgent{{
|
||||
Status: codersdk.WorkspaceAgentConnected,
|
||||
Name: "go",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
}, {
|
||||
DisconnectedAt: &disconnected,
|
||||
Status: codersdk.WorkspaceAgentDisconnected,
|
||||
Name: "postgres",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
}},
|
||||
}}, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: "dev",
|
||||
HideAgentState: false,
|
||||
HideAccess: false,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
close(done)
|
||||
}()
|
||||
ptty.ExpectMatch("google_compute_disk.root")
|
||||
ptty.ExpectMatch("google_compute_instance.dev")
|
||||
ptty.ExpectMatch("coder ssh dev.postgres")
|
||||
<-done
|
||||
})
|
||||
}
|
||||
+15
-2
@@ -1,11 +1,13 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -33,7 +35,9 @@ func init() {
|
||||
}
|
||||
|
||||
type SelectOptions struct {
|
||||
Options []string
|
||||
Options []string
|
||||
// Default will be highlighted first if it's a valid option.
|
||||
Default string
|
||||
Size int
|
||||
HideSearch bool
|
||||
}
|
||||
@@ -48,10 +52,16 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
|
||||
if flag.Lookup("test.v") != nil {
|
||||
return opts.Options[0], nil
|
||||
}
|
||||
opts.HideSearch = false
|
||||
|
||||
var defaultOption interface{}
|
||||
if opts.Default != "" {
|
||||
defaultOption = opts.Default
|
||||
}
|
||||
|
||||
var value string
|
||||
err := survey.AskOne(&survey.Select{
|
||||
Options: opts.Options,
|
||||
Default: defaultOption,
|
||||
PageSize: opts.Size,
|
||||
}, &value, survey.WithIcons(func(is *survey.IconSet) {
|
||||
is.Help.Text = "Type to search"
|
||||
@@ -63,6 +73,9 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
|
||||
}, fileReadWriter{
|
||||
Writer: cmd.OutOrStdout(),
|
||||
}, cmd.OutOrStdout()))
|
||||
if errors.Is(err, terminal.InterruptErr) {
|
||||
return value, Canceled
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
@@ -21,7 +22,7 @@ func TestSelect(t *testing.T) {
|
||||
resp, err := newSelect(ptty, cliui.SelectOptions{
|
||||
Options: []string{"First", "Second"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
msgChan <- resp
|
||||
}()
|
||||
require.Equal(t, "First", <-msgChan)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
)
|
||||
|
||||
// Table creates a new table with standardized styles.
|
||||
func Table() table.Writer {
|
||||
tableWriter := table.NewWriter()
|
||||
tableWriter.Style().Box.PaddingLeft = ""
|
||||
tableWriter.Style().Box.PaddingRight = " "
|
||||
tableWriter.Style().Options.DrawBorder = false
|
||||
tableWriter.Style().Options.SeparateHeader = false
|
||||
tableWriter.Style().Options.SeparateColumns = false
|
||||
return tableWriter
|
||||
}
|
||||
|
||||
// FilterTableColumns returns configurations to hide columns
|
||||
// that are not provided in the array. If the array is empty,
|
||||
// no filtering will occur!
|
||||
func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig {
|
||||
if len(columns) == 0 {
|
||||
return nil
|
||||
}
|
||||
columnConfigs := make([]table.ColumnConfig, 0)
|
||||
for _, headerTextRaw := range header {
|
||||
headerText, _ := headerTextRaw.(string)
|
||||
hidden := true
|
||||
for _, column := range columns {
|
||||
if strings.EqualFold(strings.ReplaceAll(column, "_", " "), headerText) {
|
||||
hidden = false
|
||||
break
|
||||
}
|
||||
}
|
||||
columnConfigs = append(columnConfigs, table.ColumnConfig{
|
||||
Name: headerText,
|
||||
Hidden: hidden,
|
||||
})
|
||||
}
|
||||
return columnConfigs
|
||||
}
|
||||
@@ -21,6 +21,10 @@ func (r Root) Organization() File {
|
||||
return File(filepath.Join(string(r), "organization"))
|
||||
}
|
||||
|
||||
func (r Root) DotfilesURL() File {
|
||||
return File(filepath.Join(string(r), "dotfilesurl"))
|
||||
}
|
||||
|
||||
// File provides convenience methods for interacting with *os.File.
|
||||
type File string
|
||||
|
||||
|
||||
+51
-20
@@ -30,10 +30,19 @@ const sshEndToken = "# ------------END-CODER------------"
|
||||
|
||||
func configSSH() *cobra.Command {
|
||||
var (
|
||||
sshConfigFile string
|
||||
sshConfigFile string
|
||||
sshOptions []string
|
||||
skipProxyCommand bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "config-ssh",
|
||||
Annotations: workspaceCommand,
|
||||
Use: "config-ssh",
|
||||
Short: "Populate your SSH config with Host entries for all of your workspaces",
|
||||
Example: `
|
||||
- You can use -o (or --ssh-option) so set SSH options to be used for all your
|
||||
workspaces.
|
||||
|
||||
` + cliui.Styles.Code.Render("$ coder config-ssh -o ForwardAgent=yes"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
@@ -52,47 +61,66 @@ func configSSH() *cobra.Command {
|
||||
sshConfigContent = sshConfigContent[:startIndex-1] + sshConfigContent[endIndex+len(sshEndToken):]
|
||||
}
|
||||
|
||||
workspaces, err := client.WorkspacesByUser(cmd.Context(), codersdk.Me)
|
||||
workspaces, err := client.Workspaces(cmd.Context(), codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(workspaces) == 0 {
|
||||
return xerrors.New("You don't have any workspaces!")
|
||||
}
|
||||
binPath, err := currentBinPath(cmd)
|
||||
|
||||
binaryFile, err := currentBinPath(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root := createConfig(cmd)
|
||||
sshConfigContent += "\n" + sshStartToken + "\n" + sshStartMessage + "\n\n"
|
||||
sshConfigContentMutex := sync.Mutex{}
|
||||
var errGroup errgroup.Group
|
||||
for _, workspace := range workspaces {
|
||||
workspace := workspace
|
||||
errGroup.Go(func() error {
|
||||
resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
|
||||
resources, err := client.TemplateVersionResources(cmd.Context(), workspace.LatestBuild.TemplateVersionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourcesWithAgents := make([]codersdk.WorkspaceResource, 0)
|
||||
for _, resource := range resources {
|
||||
if resource.Agent == nil {
|
||||
if resource.Transition != codersdk.WorkspaceTransitionStart {
|
||||
continue
|
||||
}
|
||||
resourcesWithAgents = append(resourcesWithAgents, resource)
|
||||
for _, agent := range resource.Agents {
|
||||
sshConfigContentMutex.Lock()
|
||||
hostname := workspace.Name
|
||||
if len(resource.Agents) > 1 {
|
||||
hostname += "." + agent.Name
|
||||
}
|
||||
configOptions := []string{
|
||||
"Host coder." + hostname,
|
||||
}
|
||||
for _, option := range sshOptions {
|
||||
configOptions = append(configOptions, "\t"+option)
|
||||
}
|
||||
configOptions = append(configOptions,
|
||||
"\tHostName coder."+hostname,
|
||||
"\tConnectTimeout=0",
|
||||
"\tStrictHostKeyChecking=no",
|
||||
// Without this, the "REMOTE HOST IDENTITY CHANGED"
|
||||
// message will appear.
|
||||
"\tUserKnownHostsFile=/dev/null",
|
||||
// This disables the "Warning: Permanently added 'hostname' (RSA) to the list of known hosts."
|
||||
// message from appearing on every SSH. This happens because we ignore the known hosts.
|
||||
"\tLogLevel ERROR",
|
||||
)
|
||||
if !skipProxyCommand {
|
||||
configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname))
|
||||
}
|
||||
sshConfigContent += strings.Join(configOptions, "\n") + "\n"
|
||||
sshConfigContentMutex.Unlock()
|
||||
}
|
||||
}
|
||||
sshConfigContentMutex.Lock()
|
||||
defer sshConfigContentMutex.Unlock()
|
||||
if len(resourcesWithAgents) == 1 {
|
||||
sshConfigContent += strings.Join([]string{
|
||||
"Host coder." + workspace.Name,
|
||||
"\tHostName coder." + workspace.Name,
|
||||
fmt.Sprintf("\tProxyCommand %q ssh --stdio %s", binPath, workspace.Name),
|
||||
"\tConnectTimeout=0",
|
||||
"\tStrictHostKeyChecking=no",
|
||||
}, "\n") + "\n"
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -116,6 +144,9 @@ func configSSH() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cliflag.StringVarP(cmd.Flags(), &sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", "~/.ssh/config", "Specifies the path to an SSH config.")
|
||||
cmd.Flags().StringArrayVarP(&sshOptions, "ssh-option", "o", []string{}, "Specifies additional SSH options to embed in each host stanza.")
|
||||
cmd.Flags().BoolVarP(&skipProxyCommand, "skip-proxy-command", "", false, "Specifies whether the ProxyCommand option should be skipped. Useful for testing.")
|
||||
_ = cmd.Flags().MarkHidden("skip-proxy-command")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
+117
-25
@@ -1,43 +1,135 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestConfigSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdtest.NewProvisionerDaemon(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, codersdk.Me, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
tempFile, err := os.CreateTemp(t.TempDir(), "")
|
||||
require.NoError(t, err)
|
||||
_ = tempFile.Close()
|
||||
cmd, root := clitest.New(t, "config-ssh", "--ssh-config-file", tempFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
<-doneChan
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Name: "example",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Name: "example",
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: 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 := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
tempFile, err := os.CreateTemp(t.TempDir(), "")
|
||||
require.NoError(t, err)
|
||||
_ = tempFile.Close()
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
agentConn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer agentConn.Close()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
})
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ssh, err := agentConn.SSH()
|
||||
assert.NoError(t, err)
|
||||
go io.Copy(conn, ssh)
|
||||
go io.Copy(ssh, conn)
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
})
|
||||
|
||||
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
|
||||
require.True(t, valid)
|
||||
cmd, root := clitest.New(t, "config-ssh",
|
||||
"--ssh-option", "HostName "+tcpAddr.IP.String(),
|
||||
"--ssh-option", "Port "+strconv.Itoa(tcpAddr.Port),
|
||||
"--ssh-config-file", tempFile.Name(),
|
||||
"--skip-proxy-command")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
<-doneChan
|
||||
|
||||
t.Log(tempFile.Name())
|
||||
// #nosec
|
||||
sshCmd := exec.Command("ssh", "-F", tempFile.Name(), "coder."+workspace.Name, "echo", "test")
|
||||
sshCmd.Stderr = os.Stderr
|
||||
data, err := sshCmd.Output()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func create() *cobra.Command {
|
||||
var (
|
||||
autostartMinute string
|
||||
autostartHour string
|
||||
autostartDow string
|
||||
parameterFile string
|
||||
templateName string
|
||||
ttl time.Duration
|
||||
tzName string
|
||||
workspaceName string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "create [name]",
|
||||
Short: "Create a workspace from a template",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(args) >= 1 {
|
||||
workspaceName = args[0]
|
||||
}
|
||||
|
||||
if workspaceName == "" {
|
||||
workspaceName, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Specify a name for your workspace:",
|
||||
Validate: func(workspaceName string) error {
|
||||
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName)
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tz, err := time.LoadLocation(tzName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("Invalid workspace autostart timezone: %w", err)
|
||||
}
|
||||
schedSpec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", tz.String(), autostartMinute, autostartHour, autostartDow)
|
||||
_, err = schedule.Weekly(schedSpec)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("invalid workspace autostart schedule: %w", err)
|
||||
}
|
||||
|
||||
if ttl == 0 {
|
||||
return xerrors.Errorf("TTL must be at least 1 minute")
|
||||
}
|
||||
|
||||
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName)
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
||||
}
|
||||
|
||||
var template codersdk.Template
|
||||
if templateName == "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render("Select a template below to preview the provisioned infrastructure:"))
|
||||
|
||||
templates, err := client.TemplatesByOrganization(cmd.Context(), organization.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slices.SortFunc(templates, func(a, b codersdk.Template) bool {
|
||||
return a.WorkspaceOwnerCount > b.WorkspaceOwnerCount
|
||||
})
|
||||
|
||||
templateNames := make([]string, 0, len(templates))
|
||||
templateByName := make(map[string]codersdk.Template, len(templates))
|
||||
|
||||
for _, template := range templates {
|
||||
templateName := template.Name
|
||||
|
||||
if template.WorkspaceOwnerCount > 0 {
|
||||
developerText := "developer"
|
||||
if template.WorkspaceOwnerCount != 1 {
|
||||
developerText = "developers"
|
||||
}
|
||||
|
||||
templateName += cliui.Styles.Placeholder.Render(fmt.Sprintf(" (used by %d %s)", template.WorkspaceOwnerCount, developerText))
|
||||
}
|
||||
|
||||
templateNames = append(templateNames, templateName)
|
||||
templateByName[templateName] = template
|
||||
}
|
||||
|
||||
// Move the cursor up a single line for nicer display!
|
||||
option, err := cliui.Select(cmd, cliui.SelectOptions{
|
||||
Options: templateNames,
|
||||
HideSearch: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
template = templateByName[option]
|
||||
} else {
|
||||
template, err = client.TemplateByName(cmd.Context(), organization.ID, templateName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template by name: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
templateVersion, err := client.TemplateVersion(cmd.Context(), template.ActiveVersionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parameterSchemas, err := client.TemplateVersionSchema(cmd.Context(), templateVersion.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// parameterMapFromFile can be nil if parameter file is not specified
|
||||
var parameterMapFromFile map[string]string
|
||||
if parameterFile != "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
|
||||
parameterMapFromFile, err = createParameterMapFromFile(parameterFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
disclaimerPrinted := false
|
||||
parameters := make([]codersdk.CreateParameterRequest, 0)
|
||||
for _, parameterSchema := range parameterSchemas {
|
||||
if !parameterSchema.AllowOverrideSource {
|
||||
continue
|
||||
}
|
||||
if !disclaimerPrinted {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n")
|
||||
disclaimerPrinted = true
|
||||
}
|
||||
parameterValue, err := getParameterValueFromMapOrInput(cmd, parameterMapFromFile, parameterSchema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parameters = append(parameters, codersdk.CreateParameterRequest{
|
||||
Name: parameterSchema.Name,
|
||||
SourceValue: parameterValue,
|
||||
SourceScheme: codersdk.ParameterSourceSchemeData,
|
||||
DestinationScheme: parameterSchema.DefaultDestinationScheme,
|
||||
})
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
|
||||
// Run a dry-run with the given parameters to check correctness
|
||||
after := time.Now()
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(cmd.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: workspaceName,
|
||||
ParameterValues: parameters,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("begin workspace dry-run: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
return client.TemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelTemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
return client.TemplateVersionDryRunLogsAfter(cmd.Context(), templateVersion.ID, dryRun.ID, after)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
})
|
||||
if err != nil {
|
||||
// TODO (Dean): reprompt for parameter values if we deem it to
|
||||
// be a validation error
|
||||
return xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
|
||||
resources, err := client.TemplateVersionDryRunResources(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace dry-run resources: %w", err)
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: workspaceName,
|
||||
// Since agent's haven't connected yet, hiding this makes more sense.
|
||||
HideAgentState: true,
|
||||
Title: "Workspace Preview",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm create?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: workspaceName,
|
||||
AutostartSchedule: &schedSpec,
|
||||
TTLMillis: ptr.Ref(ttl.Milliseconds()),
|
||||
ParameterValues: parameters,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, workspace.LatestBuild.ID, after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resources, err = client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: workspaceName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "The %s workspace has been created!\n", cliui.Styles.Keyword.Render(workspace.Name))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
cliflag.StringVarP(cmd.Flags(), &templateName, "template", "t", "CODER_TEMPLATE_NAME", "", "Specify a template name.")
|
||||
cliflag.StringVarP(cmd.Flags(), ¶meterFile, "parameter-file", "", "CODER_PARAMETER_FILE", "", "Specify a file path with parameter values.")
|
||||
cliflag.StringVarP(cmd.Flags(), &autostartMinute, "autostart-minute", "", "CODER_WORKSPACE_AUTOSTART_MINUTE", "0", "Specify the minute(s) at which the workspace should autostart (e.g. 0).")
|
||||
cliflag.StringVarP(cmd.Flags(), &autostartHour, "autostart-hour", "", "CODER_WORKSPACE_AUTOSTART_HOUR", "9", "Specify the hour(s) at which the workspace should autostart (e.g. 9).")
|
||||
cliflag.StringVarP(cmd.Flags(), &autostartDow, "autostart-day-of-week", "", "CODER_WORKSPACE_AUTOSTART_DOW", "MON-FRI", "Specify the days(s) on which the workspace should autostart (e.g. MON,TUE,WED,THU,FRI)")
|
||||
cliflag.StringVarP(cmd.Flags(), &tzName, "tz", "", "TZ", "", "Specify your timezone location for workspace autostart (e.g. US/Central).")
|
||||
cliflag.DurationVarP(cmd.Flags(), &ttl, "ttl", "", "CODER_WORKSPACE_TTL", 8*time.Hour, "Specify a time-to-live (TTL) for the workspace (e.g. 8h).")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: 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)
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"--template", template.Name,
|
||||
"--tz", "US/Central",
|
||||
"--autostart-minute", "0",
|
||||
"--autostart-hour", "*/2",
|
||||
"--autostart-day-of-week", "MON-FRI",
|
||||
"--ttl", "8h",
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
matches := []string{
|
||||
"Confirm create", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("CreateErrInvalidTz", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: 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)
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"--template", template.Name,
|
||||
"--tz", "invalid",
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.EqualError(t, err, "Invalid workspace autostart timezone: unknown time zone invalid")
|
||||
}()
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("CreateErrInvalidTTL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: 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)
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"--template", template.Name,
|
||||
"--ttl", "0s",
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.EqualError(t, err, "TTL must be at least 1 minute")
|
||||
}()
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("CreateFromListWithSkip", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "-y")
|
||||
|
||||
member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
cmdCtx, done := context.WithTimeout(context.Background(), time.Second*3)
|
||||
go func() {
|
||||
defer done()
|
||||
err := cmd.ExecuteContext(cmdCtx)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
// No pty interaction needed since we use the -y skip prompt flag
|
||||
<-cmdCtx.Done()
|
||||
require.ErrorIs(t, cmdCtx.Err(), context.Canceled)
|
||||
})
|
||||
|
||||
t.Run("FromNothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "create", "")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
matches := []string{
|
||||
"Specify a name", "my-workspace",
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("WithParameter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
defaultValue := "something"
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: createTestParseResponseWithDefault(defaultValue),
|
||||
Provision: echo.ProvisionComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
})
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "create", "")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"Specify a name", "my-workspace",
|
||||
fmt.Sprintf("Enter a value (default: %q):", defaultValue), "bingo",
|
||||
"Enter a value:", "boingo",
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("WithParameterFileContainingTheValue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
defaultValue := "something"
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: createTestParseResponseWithDefault(defaultValue),
|
||||
Provision: echo.ProvisionComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
})
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
tempDir := t.TempDir()
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("region: \"bingo\"\nusername: \"boingo\"")
|
||||
cmd, root := clitest.New(t, "create", "", "--parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"Specify a name", "my-workspace",
|
||||
"Confirm create?", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
removeTmpDirUntilSuccess(t, tempDir)
|
||||
})
|
||||
|
||||
t.Run("WithParameterFileNotContainingTheValue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
defaultValue := "something"
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: createTestParseResponseWithDefault(defaultValue),
|
||||
Provision: echo.ProvisionComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
tempDir := t.TempDir()
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("zone: \"bananas\"")
|
||||
cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.EqualError(t, err, "Parameter value absent in parameter file for \"region\"!")
|
||||
}()
|
||||
<-doneChan
|
||||
removeTmpDirUntilSuccess(t, tempDir)
|
||||
})
|
||||
|
||||
t.Run("FailedDryRun", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: []*proto.Provision_Response{
|
||||
{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Error: "test error",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// The template import job should end up failed, but we need it to be
|
||||
// succeeded so the dry-run can begin.
|
||||
version = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
require.Equal(t, codersdk.ProvisionerJobFailed, version.Job.Status, "job is not failed")
|
||||
err := api.Database.UpdateProvisionerJobWithCompleteByID(context.Background(), database.UpdateProvisionerJobWithCompleteByIDParams{
|
||||
ID: version.Job.ID,
|
||||
CompletedAt: sql.NullTime{
|
||||
Time: time.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
UpdatedAt: time.Now(),
|
||||
Error: sql.NullString{},
|
||||
})
|
||||
require.NoError(t, err, "update provisioner job")
|
||||
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "create", "test")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
err = cmd.Execute()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "dry-run workspace")
|
||||
})
|
||||
}
|
||||
|
||||
func createTestParseResponseWithDefault(defaultValue string) []*proto.Parse_Response {
|
||||
return []*proto.Parse_Response{{
|
||||
Type: &proto.Parse_Response_Complete{
|
||||
Complete: &proto.Parse_Complete{
|
||||
ParameterSchemas: []*proto.ParameterSchema{
|
||||
{
|
||||
AllowOverrideSource: true,
|
||||
Name: "region",
|
||||
Description: "description 1",
|
||||
DefaultSource: &proto.ParameterSource{
|
||||
Scheme: proto.ParameterSource_DATA,
|
||||
Value: defaultValue,
|
||||
},
|
||||
DefaultDestination: &proto.ParameterDestination{
|
||||
Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE,
|
||||
},
|
||||
},
|
||||
{
|
||||
AllowOverrideSource: true,
|
||||
Name: "username",
|
||||
Description: "description 2",
|
||||
DefaultSource: &proto.ParameterSource{
|
||||
Scheme: proto.ParameterSource_DATA,
|
||||
// No default value
|
||||
Value: "",
|
||||
},
|
||||
DefaultDestination: &proto.ParameterDestination{
|
||||
Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// nolint
|
||||
func delete() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "delete <workspace>",
|
||||
Short: "Delete a workspace",
|
||||
Aliases: []string{"rm"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm delete workspace?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionDelete,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
},
|
||||
}
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
t.Run("WithParameter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: 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)
|
||||
cmd, root := clitest.New(t, "delete", workspace.Name, "-y")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
// When running with the race detector on, we sometimes get an EOF.
|
||||
if err != nil {
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
}()
|
||||
pty.ExpectMatch("Cleaning Up")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("DifferentUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
adminUser := coderdtest.CreateFirstUser(t, adminClient)
|
||||
orgID := adminUser.OrganizationID
|
||||
client := coderdtest.CreateAnotherUser(t, adminClient, orgID)
|
||||
user, err := client.User(context.Background(), codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(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)
|
||||
|
||||
cmd, root := clitest.New(t, "delete", user.Username+"/"+workspace.Name, "-y")
|
||||
clitest.SetupConfig(t, adminClient, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
// When running with the race detector on, we sometimes get an EOF.
|
||||
if err != nil {
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Cleaning Up")
|
||||
<-doneChan
|
||||
|
||||
workspace, err = client.Workspace(context.Background(), workspace.ID)
|
||||
require.ErrorContains(t, err, "was deleted")
|
||||
})
|
||||
|
||||
t.Run("InvalidWorkspaceIdentifier", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
cmd, root := clitest.New(t, "delete", "a/b/c", "-y")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.ErrorContains(t, err, "invalid workspace name: \"a/b/c\"")
|
||||
}()
|
||||
<-doneChan
|
||||
})
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
func dotfiles() *cobra.Command {
|
||||
var (
|
||||
symlinkDir string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "dotfiles [git_repo_url]",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Checkout and install a dotfiles repository.",
|
||||
Example: "coder dotfiles [-y] git@github.com:example/dotfiles.git",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var (
|
||||
dotfilesRepoDir = "dotfiles"
|
||||
gitRepo = args[0]
|
||||
cfg = createConfig(cmd)
|
||||
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{
|
||||
"install.sh",
|
||||
"install",
|
||||
"bootstrap.sh",
|
||||
"bootstrap",
|
||||
"script/bootstrap",
|
||||
"setup.sh",
|
||||
"setup",
|
||||
"script/setup",
|
||||
}
|
||||
)
|
||||
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Checking if dotfiles repository already exists...\n")
|
||||
dotfilesExists, err := dirExists(dotfilesDir)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("checking dir %s: %w", dotfilesDir, err)
|
||||
}
|
||||
|
||||
moved := false
|
||||
if dotfilesExists {
|
||||
du, err := cfg.DotfilesURL().Read()
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return xerrors.Errorf("reading dotfiles url config: %w", err)
|
||||
}
|
||||
// if the git url has changed we create a backup and clone fresh
|
||||
if gitRepo != du {
|
||||
backupDir := fmt.Sprintf("%s_backup_%s", dotfilesDir, time.Now().Format(time.RFC3339))
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("The dotfiles URL has changed from %q to %q.\n Coder will backup the existing repo to %s.\n\n Continue?", du, gitRepo, backupDir),
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.Rename(dotfilesDir, backupDir)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("renaming dir %s: %w", dotfilesDir, err)
|
||||
}
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Done backup up dotfiles.\n")
|
||||
dotfilesExists = false
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
gitCmdDir string
|
||||
subcommands []string
|
||||
promptText string
|
||||
)
|
||||
if dotfilesExists {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Found dotfiles repository at %s\n", dotfilesDir)
|
||||
gitCmdDir = dotfilesDir
|
||||
subcommands = []string{"pull", "--ff-only"}
|
||||
promptText = fmt.Sprintf("Pulling latest from %s into directory %s.\n Continue?", gitRepo, dotfilesDir)
|
||||
} else {
|
||||
if !moved {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Did not find dotfiles repository at %s\n", dotfilesDir)
|
||||
}
|
||||
gitCmdDir = cfgDir
|
||||
subcommands = []string{"clone", args[0], dotfilesRepoDir}
|
||||
promptText = fmt.Sprintf("Cloning %s into directory %s.\n\n Continue?", gitRepo, dotfilesDir)
|
||||
}
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: promptText,
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ensure command dir exists
|
||||
err = os.MkdirAll(gitCmdDir, 0750)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("ensuring dir at %s: %w", gitCmdDir, err)
|
||||
}
|
||||
|
||||
// check if git ssh command already exists so we can just wrap it
|
||||
gitsshCmd := os.Getenv("GIT_SSH_COMMAND")
|
||||
if gitsshCmd == "" {
|
||||
gitsshCmd = "ssh"
|
||||
}
|
||||
|
||||
// clone or pull repo
|
||||
c := exec.CommandContext(cmd.Context(), "git", subcommands...)
|
||||
c.Dir = gitCmdDir
|
||||
c.Env = append(os.Environ(), fmt.Sprintf(`GIT_SSH_COMMAND=%s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`, gitsshCmd))
|
||||
c.Stdout = cmd.OutOrStdout()
|
||||
c.Stderr = cmd.ErrOrStderr()
|
||||
err = c.Run()
|
||||
if err != nil {
|
||||
if !dotfilesExists {
|
||||
return err
|
||||
}
|
||||
// if the repo exists we soft fail the update operation and try to continue
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Error.Render("Failed to update repo, continuing..."))
|
||||
}
|
||||
|
||||
// save git repo url so we can detect changes next time
|
||||
err = cfg.DotfilesURL().Write(gitRepo)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("writing dotfiles url config: %w", err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(dotfilesDir)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading files in dir %s: %w", dotfilesDir, err)
|
||||
}
|
||||
|
||||
var dotfiles []string
|
||||
for _, f := range files {
|
||||
// make sure we do not copy `.git*` files
|
||||
if strings.HasPrefix(f.Name(), ".") && !strings.HasPrefix(f.Name(), ".git") {
|
||||
dotfiles = append(dotfiles, f.Name())
|
||||
}
|
||||
}
|
||||
|
||||
script := findScript(installScriptSet, files)
|
||||
if script != "" {
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Running install script %s.\n\n Continue?", script),
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Running %s...\n", script)
|
||||
// it is safe to use a variable command here because it's from
|
||||
// a filtered list of pre-approved install scripts
|
||||
// nolint:gosec
|
||||
scriptCmd := exec.CommandContext(cmd.Context(), filepath.Join(dotfilesDir, script))
|
||||
scriptCmd.Dir = dotfilesDir
|
||||
scriptCmd.Stdout = cmd.OutOrStdout()
|
||||
scriptCmd.Stderr = cmd.ErrOrStderr()
|
||||
err = scriptCmd.Run()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("running %s: %w", script, err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(dotfiles) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No install scripts or dotfiles found, nothing to do.")
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "No install scripts found, symlinking dotfiles to home directory.\n\n Continue?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if symlinkDir == "" {
|
||||
symlinkDir, err = os.UserHomeDir()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("getting user home: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, df := range dotfiles {
|
||||
from := filepath.Join(dotfilesDir, df)
|
||||
to := filepath.Join(symlinkDir, df)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Symlinking %s to %s...\n", from, to)
|
||||
|
||||
isRegular, err := isRegular(to)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("checking symlink for %s: %w", to, err)
|
||||
}
|
||||
// move conflicting non-symlink files to file.ext.bak
|
||||
if isRegular {
|
||||
backup := fmt.Sprintf("%s.bak", to)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Moving %s to %s...\n", to, backup)
|
||||
err = os.Rename(to, backup)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("renaming dir %s: %w", to, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = os.Symlink(from, to)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("symlinking %s to %s: %w", from, to, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
cliflag.StringVarP(cmd.Flags(), &symlinkDir, "symlink-dir", "", "CODER_SYMLINK_DIR", "", "Specifies the directory for the dotfiles symlink destinations. If empty will use $HOME.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// dirExists checks if the path exists and is a directory.
|
||||
func dirExists(name string) (bool, error) {
|
||||
fi, err := os.Stat(name)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, xerrors.Errorf("stat dir: %w", err)
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return false, xerrors.New("exists but not a directory")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// findScript will find the first file that matches the script set.
|
||||
func findScript(scriptSet []string, files []fs.DirEntry) string {
|
||||
for _, i := range scriptSet {
|
||||
for _, f := range files {
|
||||
if f.Name() == i {
|
||||
return f.Name()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isRegular detects if the file exists and is not a symlink.
|
||||
func isRegular(to string) (bool, error) {
|
||||
fi, err := os.Lstat(to)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, xerrors.Errorf("lstat %s: %w", to, err)
|
||||
}
|
||||
|
||||
return fi.Mode().IsRegular(), nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
)
|
||||
|
||||
// nolint:paralleltest
|
||||
func TestDotfiles(t *testing.T) {
|
||||
t.Run("MissingArg", func(t *testing.T) {
|
||||
cmd, _ := clitest.New(t, "dotfiles")
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("NoInstallScript", func(t *testing.T) {
|
||||
_, root := clitest.New(t)
|
||||
testRepo := testGitRepo(t, root)
|
||||
|
||||
// nolint:gosec
|
||||
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0750)
|
||||
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))
|
||||
|
||||
cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
|
||||
err = cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(b), "wow")
|
||||
})
|
||||
t.Run("InstallScript", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("install scripts on windows require sh and aren't very practical")
|
||||
}
|
||||
_, root := clitest.New(t)
|
||||
testRepo := testGitRepo(t, root)
|
||||
|
||||
// nolint:gosec
|
||||
err := os.WriteFile(filepath.Join(testRepo, "install.sh"), []byte("#!/bin/bash\necho wow > "+filepath.Join(string(root), ".bashrc")), 0750)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := exec.Command("git", "add", "install.sh")
|
||||
c.Dir = testRepo
|
||||
err = c.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
c = exec.Command("git", "commit", "-m", `"add install.sh"`)
|
||||
c.Dir = testRepo
|
||||
err = c.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
|
||||
err = cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(b), "wow\n")
|
||||
})
|
||||
t.Run("SymlinkBackup", func(t *testing.T) {
|
||||
_, root := clitest.New(t)
|
||||
testRepo := testGitRepo(t, root)
|
||||
|
||||
// nolint:gosec
|
||||
err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0750)
|
||||
require.NoError(t, err)
|
||||
|
||||
// add a conflicting file at destination
|
||||
// nolint:gosec
|
||||
err = os.WriteFile(filepath.Join(string(root), ".bashrc"), []byte("backup"), 0750)
|
||||
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))
|
||||
|
||||
cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
|
||||
err = cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(b), "wow")
|
||||
|
||||
// check for backup file
|
||||
b, err = os.ReadFile(filepath.Join(string(root), ".bashrc.bak"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(b), "backup")
|
||||
})
|
||||
}
|
||||
|
||||
func testGitRepo(t *testing.T, root config.Root) string {
|
||||
r, err := cryptorand.String(8)
|
||||
require.NoError(t, err)
|
||||
dir := filepath.Join(string(root), fmt.Sprintf("test-repo-%s", r))
|
||||
err = os.MkdirAll(dir, 0750)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := exec.Command("git", "init")
|
||||
c.Dir = dir
|
||||
err = c.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
c = exec.Command("git", "config", "user.email", "ci@coder.com")
|
||||
c.Dir = dir
|
||||
err = c.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
c = exec.Command("git", "config", "user.name", "C I")
|
||||
c.Dir = dir
|
||||
err = c.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
return dir
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
func gitssh() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "gitssh",
|
||||
Hidden: true,
|
||||
Short: `Wraps the "ssh" command and uses the coder gitssh key for authentication`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createAgentClient(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create agent client: %w", err)
|
||||
}
|
||||
key, err := client.AgentGitSSHKey(cmd.Context())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get agent git ssh token: %w", err)
|
||||
}
|
||||
|
||||
privateKeyFile, err := os.CreateTemp("", "coder-gitsshkey-*")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create temp gitsshkey file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = privateKeyFile.Close()
|
||||
_ = os.Remove(privateKeyFile.Name())
|
||||
}()
|
||||
_, err = privateKeyFile.WriteString(key.PrivateKey)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write to temp gitsshkey file: %w", err)
|
||||
}
|
||||
err = privateKeyFile.Close()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("close temp gitsshkey file: %w", err)
|
||||
}
|
||||
|
||||
args = append([]string{"-i", privateKeyFile.Name()}, args...)
|
||||
c := exec.CommandContext(cmd.Context(), "ssh", args...)
|
||||
c.Stderr = cmd.ErrOrStderr()
|
||||
c.Stdout = cmd.OutOrStdout()
|
||||
c.Stdin = cmd.InOrStdin()
|
||||
err = c.Run()
|
||||
if err != nil {
|
||||
exitErr := &exec.ExitError{}
|
||||
if xerrors.As(err, &exitErr) && exitErr.ExitCode() == 255 {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(),
|
||||
"\n"+cliui.Styles.Wrap.Render("Coder authenticates with "+cliui.Styles.Field.Render("git")+
|
||||
" using the public key below. All clones with SSH are authenticated automatically 🪄.")+"\n")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Add to GitHub and GitLab:")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"https://github.com/settings/ssh/new")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"https://gitlab.com/-/profile/keys")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
return err
|
||||
}
|
||||
return xerrors.Errorf("run ssh command: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func TestGitSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Dial", func(t *testing.T) {
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// get user public key
|
||||
keypair, err := client.GitSSHKey(context.Background(), codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
publicKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keypair.PublicKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
// setup template
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: agentToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
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)
|
||||
|
||||
// start workspace agent
|
||||
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
agentClient := client
|
||||
clitest.SetupConfig(t, agentClient, root)
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
agentErrC := make(chan error)
|
||||
go func() {
|
||||
agentErrC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
dialer, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
_, err = dialer.Ping()
|
||||
require.NoError(t, err)
|
||||
|
||||
// start ssh server
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
publicKeyOption := ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||
return ssh.KeysEqual(publicKey, key)
|
||||
})
|
||||
var inc int64
|
||||
sshErrC := make(chan error)
|
||||
go func() {
|
||||
// as long as we get a successful session we don't care if the server errors
|
||||
_ = ssh.Serve(l, func(s ssh.Session) {
|
||||
atomic.AddInt64(&inc, 1)
|
||||
t.Log("got authenticated session")
|
||||
sshErrC <- s.Exit(0)
|
||||
}, publicKeyOption)
|
||||
}()
|
||||
|
||||
// start ssh session
|
||||
addr, ok := l.Addr().(*net.TCPAddr)
|
||||
require.True(t, ok)
|
||||
// set to agent config dir
|
||||
gitsshCmd, _ := clitest.New(t, "gitssh", "--agent-url", agentClient.URL.String(), "--agent-token", agentToken, "--", fmt.Sprintf("-p%d", addr.Port), "-o", "StrictHostKeyChecking=no", "-o", "IdentitiesOnly=yes", "127.0.0.1")
|
||||
err = gitsshCmd.ExecuteContext(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, inc)
|
||||
|
||||
err = <-sshErrC
|
||||
require.NoError(t, err, "error in ssh session exit")
|
||||
|
||||
cancelFunc()
|
||||
err = <-agentErrC
|
||||
require.NoError(t, err, "error in agent execute")
|
||||
})
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func list() *cobra.Command {
|
||||
var (
|
||||
columns []string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "list",
|
||||
Short: "List all workspaces",
|
||||
Aliases: []string{"ls"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspaces, err := client.Workspaces(cmd.Context(), codersdk.WorkspaceFilter{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(workspaces) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"No workspaces found! Create one:")
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder create <name>"))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
return nil
|
||||
}
|
||||
users, err := client.Users(cmd.Context(), codersdk.UsersRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usersByID := map[uuid.UUID]codersdk.User{}
|
||||
for _, user := range users {
|
||||
usersByID[user.ID] = user
|
||||
}
|
||||
|
||||
tableWriter := cliui.Table()
|
||||
header := table.Row{"workspace", "template", "status", "last built", "outdated", "autostart", "ttl"}
|
||||
tableWriter.AppendHeader(header)
|
||||
tableWriter.SortBy([]table.SortBy{{
|
||||
Name: "workspace",
|
||||
}})
|
||||
tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, columns))
|
||||
|
||||
for _, workspace := range workspaces {
|
||||
status := ""
|
||||
inProgress := false
|
||||
if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobRunning ||
|
||||
workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobCanceling {
|
||||
inProgress = true
|
||||
}
|
||||
|
||||
switch workspace.LatestBuild.Transition {
|
||||
case codersdk.WorkspaceTransitionStart:
|
||||
status = "Running"
|
||||
if inProgress {
|
||||
status = "Starting"
|
||||
}
|
||||
case codersdk.WorkspaceTransitionStop:
|
||||
status = "Stopped"
|
||||
if inProgress {
|
||||
status = "Stopping"
|
||||
}
|
||||
case codersdk.WorkspaceTransitionDelete:
|
||||
status = "Deleted"
|
||||
if inProgress {
|
||||
status = "Deleting"
|
||||
}
|
||||
}
|
||||
if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobFailed {
|
||||
status = "Failed"
|
||||
}
|
||||
|
||||
duration := time.Now().UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second)
|
||||
autostartDisplay := "-"
|
||||
if !ptr.NilOrEmpty(workspace.AutostartSchedule) {
|
||||
if sched, err := schedule.Weekly(*workspace.AutostartSchedule); err == nil {
|
||||
autostartDisplay = sched.Cron()
|
||||
}
|
||||
}
|
||||
|
||||
autostopDisplay := "-"
|
||||
if !ptr.NilOrZero(workspace.TTLMillis) {
|
||||
dur := time.Duration(*workspace.TTLMillis) * time.Millisecond
|
||||
autostopDisplay = durationDisplay(dur)
|
||||
if has, ext := hasExtension(workspace); has {
|
||||
autostopDisplay += fmt.Sprintf(" (+%s)", durationDisplay(ext.Round(time.Minute)))
|
||||
}
|
||||
}
|
||||
|
||||
user := usersByID[workspace.OwnerID]
|
||||
tableWriter.AppendRow(table.Row{
|
||||
user.Username + "/" + workspace.Name,
|
||||
workspace.TemplateName,
|
||||
status,
|
||||
durationDisplay(duration),
|
||||
workspace.Outdated,
|
||||
autostartDisplay,
|
||||
autostopDisplay,
|
||||
})
|
||||
}
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), tableWriter.Render())
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArrayVarP(&columns, "column", "c", nil,
|
||||
"Specify a column to filter in the table.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func hasExtension(ws codersdk.Workspace) (bool, time.Duration) {
|
||||
if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart {
|
||||
return false, 0
|
||||
}
|
||||
if ws.LatestBuild.Deadline.IsZero() {
|
||||
return false, 0
|
||||
}
|
||||
if ws.TTLMillis == nil {
|
||||
return false, 0
|
||||
}
|
||||
ttl := time.Duration(*ws.TTLMillis) * time.Millisecond
|
||||
delta := ws.LatestBuild.Deadline.Add(-ttl).Sub(ws.LatestBuild.CreatedAt)
|
||||
if delta < time.Minute {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
return true, delta
|
||||
}
|
||||
|
||||
func durationDisplay(d time.Duration) string {
|
||||
duration := d
|
||||
if duration > time.Hour {
|
||||
duration = duration.Truncate(time.Hour)
|
||||
}
|
||||
if duration > time.Minute {
|
||||
duration = duration.Truncate(time.Minute)
|
||||
}
|
||||
days := 0
|
||||
for duration.Hours() > 24 {
|
||||
days++
|
||||
duration -= 24 * time.Hour
|
||||
}
|
||||
durationDisplay := duration.String()
|
||||
if days > 0 {
|
||||
durationDisplay = fmt.Sprintf("%dd%s", days, durationDisplay)
|
||||
}
|
||||
if strings.HasSuffix(durationDisplay, "m0s") {
|
||||
durationDisplay = durationDisplay[:len(durationDisplay)-2]
|
||||
}
|
||||
if strings.HasSuffix(durationDisplay, "h0m") {
|
||||
durationDisplay = durationDisplay[:len(durationDisplay)-2]
|
||||
}
|
||||
return durationDisplay
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Single", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancelFunc()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: 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)
|
||||
cmd, root := clitest.New(t, "ls")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
pty.ExpectMatch(workspace.Name)
|
||||
pty.ExpectMatch("Running")
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
}
|
||||
+44
-24
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
@@ -37,8 +38,9 @@ func init() {
|
||||
|
||||
func login() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "login <url>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "login <url>",
|
||||
Short: "Authenticate with a Coder deployment",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rawURL := args[0]
|
||||
|
||||
@@ -117,6 +119,19 @@ func login() *cobra.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("specify password prompt: %w", err)
|
||||
}
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: func(s string) error {
|
||||
if s != password {
|
||||
return xerrors.Errorf("Passwords do not match")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("confirm password prompt: %w", err)
|
||||
}
|
||||
|
||||
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
@@ -150,32 +165,37 @@ func login() *cobra.Command {
|
||||
cliui.Styles.Paragraph.Render(fmt.Sprintf("Welcome to Coder, %s! You're authenticated.", cliui.Styles.Keyword.Render(username)))+"\n")
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
|
||||
cliui.Styles.Paragraph.Render("Get started by creating a template: "+cliui.Styles.Code.Render("coder templates create"))+"\n")
|
||||
cliui.Styles.Paragraph.Render("Get started by creating a template: "+cliui.Styles.Code.Render("coder templates init"))+"\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
authURL := *serverURL
|
||||
authURL.Path = serverURL.Path + "/cli-auth"
|
||||
if err := openURL(cmd, authURL.String()); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Open the following in your browser:\n\n\t%s\n\n", authURL.String())
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String())
|
||||
}
|
||||
sessionToken, _ := cmd.Flags().GetString(varToken)
|
||||
if sessionToken == "" {
|
||||
authURL := *serverURL
|
||||
// Don't use filepath.Join, we don't want to use the os separator
|
||||
// for a url.
|
||||
authURL.Path = path.Join(serverURL.Path, "/cli-auth")
|
||||
if err := openURL(cmd, authURL.String()); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Open the following in your browser:\n\n\t%s\n\n", authURL.String())
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String())
|
||||
}
|
||||
|
||||
sessionToken, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Paste your token here:",
|
||||
Secret: true,
|
||||
Validate: func(token string) error {
|
||||
client.SessionToken = token
|
||||
_, err := client.User(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.New("That's not a valid token!")
|
||||
}
|
||||
return err
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("paste token prompt: %w", err)
|
||||
sessionToken, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Paste your token here:",
|
||||
Secret: true,
|
||||
Validate: func(token string) error {
|
||||
client.SessionToken = token
|
||||
_, err := client.User(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.New("That's not a valid token!")
|
||||
}
|
||||
return err
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("paste token prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Login to get user data - verify it is OK before persisting
|
||||
|
||||
+55
-3
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
@@ -35,7 +36,7 @@ func TestLogin(t *testing.T) {
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
@@ -43,6 +44,7 @@ func TestLogin(t *testing.T) {
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "password",
|
||||
"password", "password", // Confirm.
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
@@ -54,6 +56,44 @@ func TestLogin(t *testing.T) {
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
client := coderdtest.New(t, nil)
|
||||
// The --force-tty flag is required on Windows, because the `isatty` library does not
|
||||
// accurately detect Windows ptys when they are not attached to a process:
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String())
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.ExecuteContext(ctx)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "mypass",
|
||||
"password", "wrongpass", // Confirm.
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
pty.ExpectMatch("Passwords do not match")
|
||||
pty.ExpectMatch("password") // Re-prompt password.
|
||||
cancel()
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("ExistingUserValidTokenTTY", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
@@ -67,7 +107,7 @@ func TestLogin(t *testing.T) {
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
@@ -92,7 +132,7 @@ func TestLogin(t *testing.T) {
|
||||
defer close(doneChan)
|
||||
err := root.ExecuteContext(ctx)
|
||||
// An error is expected in this case, since the login wasn't successful:
|
||||
require.Error(t, err)
|
||||
assert.Error(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
@@ -101,4 +141,16 @@ func TestLogin(t *testing.T) {
|
||||
cancelFunc()
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("TokenFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
root, cfg := clitest.New(t, "login", client.URL.String(), "--token", client.SessionToken)
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
sessionFile, err := cfg.Session().Read()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, client.SessionToken, sessionFile)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
func logout() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Remove the local authenticated session",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errors []error
|
||||
|
||||
config := createConfig(cmd)
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Are you sure you want to logout?",
|
||||
IsConfirm: true,
|
||||
Default: "yes",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.Logout(cmd.Context())
|
||||
if err != nil {
|
||||
errors = append(errors, xerrors.Errorf("logout api: %w", err))
|
||||
}
|
||||
|
||||
err = config.URL().Delete()
|
||||
// Only throw error if the URL configuration file is present,
|
||||
// otherwise the user is already logged out, and we proceed
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
errors = append(errors, xerrors.Errorf("remove URL file: %w", err))
|
||||
}
|
||||
|
||||
err = config.Session().Delete()
|
||||
// Only throw error if the session configuration file is present,
|
||||
// otherwise the user is already logged out, and we proceed
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
errors = append(errors, xerrors.Errorf("remove session file: %w", err))
|
||||
}
|
||||
|
||||
err = config.Organization().Delete()
|
||||
// If the organization configuration file is absent, we still proceed
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
errors = append(errors, xerrors.Errorf("remove organization file: %w", err))
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
var errorStringBuilder strings.Builder
|
||||
for _, err := range errors {
|
||||
_, _ = fmt.Fprint(&errorStringBuilder, "\t"+err.Error()+"\n")
|
||||
}
|
||||
errorString := strings.TrimRight(errorStringBuilder.String(), "\n")
|
||||
return xerrors.New("Failed to log out.\n" + errorString)
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"You are no longer logged in. You can log in using 'coder login <url>'.\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Logout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pty := ptytest.New(t)
|
||||
config := login(t, pty)
|
||||
|
||||
// Ensure session files exist.
|
||||
require.FileExists(t, string(config.URL()))
|
||||
require.FileExists(t, string(config.Session()))
|
||||
|
||||
logoutChan := make(chan struct{})
|
||||
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
|
||||
logout.SetIn(pty.Input())
|
||||
logout.SetOut(pty.Output())
|
||||
|
||||
go func() {
|
||||
defer close(logoutChan)
|
||||
err := logout.Execute()
|
||||
assert.NoError(t, err)
|
||||
assert.NoFileExists(t, string(config.URL()))
|
||||
assert.NoFileExists(t, string(config.Session()))
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Are you sure you want to logout?")
|
||||
pty.WriteLine("yes")
|
||||
pty.ExpectMatch("You are no longer logged in. You can log in using 'coder login <url>'.")
|
||||
<-logoutChan
|
||||
})
|
||||
t.Run("SkipPrompt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pty := ptytest.New(t)
|
||||
config := login(t, pty)
|
||||
|
||||
// Ensure session files exist.
|
||||
require.FileExists(t, string(config.URL()))
|
||||
require.FileExists(t, string(config.Session()))
|
||||
|
||||
logoutChan := make(chan struct{})
|
||||
logout, _ := clitest.New(t, "logout", "--global-config", string(config), "-y")
|
||||
logout.SetIn(pty.Input())
|
||||
logout.SetOut(pty.Output())
|
||||
|
||||
go func() {
|
||||
defer close(logoutChan)
|
||||
err := logout.Execute()
|
||||
assert.NoError(t, err)
|
||||
assert.NoFileExists(t, string(config.URL()))
|
||||
assert.NoFileExists(t, string(config.Session()))
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("You are no longer logged in. You can log in using 'coder login <url>'.")
|
||||
<-logoutChan
|
||||
})
|
||||
t.Run("NoURLFile", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pty := ptytest.New(t)
|
||||
config := login(t, pty)
|
||||
|
||||
// Ensure session files exist.
|
||||
require.FileExists(t, string(config.URL()))
|
||||
require.FileExists(t, string(config.Session()))
|
||||
|
||||
err := os.Remove(string(config.URL()))
|
||||
require.NoError(t, err)
|
||||
|
||||
logoutChan := make(chan struct{})
|
||||
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
|
||||
|
||||
logout.SetIn(pty.Input())
|
||||
logout.SetOut(pty.Output())
|
||||
|
||||
go func() {
|
||||
defer close(logoutChan)
|
||||
err := logout.Execute()
|
||||
assert.EqualError(t, err, "You are not logged in. Try logging in using 'coder login <url>'.")
|
||||
}()
|
||||
|
||||
<-logoutChan
|
||||
})
|
||||
t.Run("NoSessionFile", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pty := ptytest.New(t)
|
||||
config := login(t, pty)
|
||||
|
||||
// Ensure session files exist.
|
||||
require.FileExists(t, string(config.URL()))
|
||||
require.FileExists(t, string(config.Session()))
|
||||
|
||||
err := os.Remove(string(config.Session()))
|
||||
require.NoError(t, err)
|
||||
|
||||
logoutChan := make(chan struct{})
|
||||
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
|
||||
|
||||
logout.SetIn(pty.Input())
|
||||
logout.SetOut(pty.Output())
|
||||
|
||||
go func() {
|
||||
defer close(logoutChan)
|
||||
err = logout.Execute()
|
||||
assert.EqualError(t, err, "You are not logged in. Try logging in using 'coder login <url>'.")
|
||||
}()
|
||||
|
||||
<-logoutChan
|
||||
})
|
||||
t.Run("CannotDeleteFiles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pty := ptytest.New(t)
|
||||
config := login(t, pty)
|
||||
|
||||
// Ensure session files exist.
|
||||
require.FileExists(t, string(config.URL()))
|
||||
require.FileExists(t, string(config.Session()))
|
||||
|
||||
var (
|
||||
err error
|
||||
urlFile *os.File
|
||||
sessionFile *os.File
|
||||
)
|
||||
if runtime.GOOS == "windows" {
|
||||
// Opening the files so Windows does not allow deleting them.
|
||||
urlFile, err = os.Open(string(config.URL()))
|
||||
require.NoError(t, err)
|
||||
sessionFile, err = os.Open(string(config.Session()))
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
// Changing the permissions to throw error during deletion.
|
||||
err = os.Chmod(string(config), 0500)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Closing the opened files for cleanup.
|
||||
err = urlFile.Close()
|
||||
require.NoError(t, err)
|
||||
err = sessionFile.Close()
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
// Setting the permissions back for cleanup.
|
||||
err = os.Chmod(string(config), 0700)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
logoutChan := make(chan struct{})
|
||||
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
|
||||
|
||||
logout.SetIn(pty.Input())
|
||||
logout.SetOut(pty.Output())
|
||||
|
||||
go func() {
|
||||
defer close(logoutChan)
|
||||
err := logout.Execute()
|
||||
assert.NotNil(t, err)
|
||||
var errorMessage string
|
||||
if runtime.GOOS == "windows" {
|
||||
errorMessage = "The process cannot access the file because it is being used by another process."
|
||||
} else {
|
||||
errorMessage = "permission denied"
|
||||
}
|
||||
errRegex := regexp.MustCompile(fmt.Sprintf("Failed to log out.\n\tremove URL file: .+: %s\n\tremove session file: .+: %s", errorMessage, errorMessage))
|
||||
assert.Regexp(t, errRegex, err.Error())
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Are you sure you want to logout?")
|
||||
pty.WriteLine("yes")
|
||||
<-logoutChan
|
||||
})
|
||||
}
|
||||
|
||||
func login(t *testing.T, pty *ptytest.PTY) config.Root {
|
||||
t.Helper()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
root, cfg := clitest.New(t, "login", "--force-tty", client.URL.String(), "--no-open")
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken)
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
return cfg
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// Reads a YAML file and populates a string -> string map.
|
||||
// Throws an error if the file name is empty.
|
||||
func createParameterMapFromFile(parameterFile string) (map[string]string, error) {
|
||||
if parameterFile != "" {
|
||||
parameterMap := make(map[string]string)
|
||||
|
||||
parameterFileContents, err := os.ReadFile(parameterFile)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(parameterFileContents, ¶meterMap)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parameterMap, nil
|
||||
}
|
||||
|
||||
return nil, xerrors.Errorf("Parameter file name is not specified")
|
||||
}
|
||||
|
||||
// Returns a parameter value from a given map, if the map exists, else takes input from the user.
|
||||
// Throws an error if the map exists but does not include a value for the parameter.
|
||||
func getParameterValueFromMapOrInput(cmd *cobra.Command, parameterMap map[string]string, parameterSchema codersdk.ParameterSchema) (string, error) {
|
||||
var parameterValue string
|
||||
if parameterMap != nil {
|
||||
var ok bool
|
||||
parameterValue, ok = parameterMap[parameterSchema.Name]
|
||||
if !ok {
|
||||
return "", xerrors.Errorf("Parameter value absent in parameter file for %q!", parameterSchema.Name)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
parameterValue, err = cliui.ParameterSchema(cmd, parameterSchema)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return parameterValue, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateParameterMapFromFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("CreateParameterMapFromFile", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("region: \"bananas\"\ndisk: \"20\"\n")
|
||||
|
||||
parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name())
|
||||
|
||||
expectedMap := map[string]string{
|
||||
"region": "bananas",
|
||||
"disk": "20",
|
||||
}
|
||||
|
||||
assert.Equal(t, expectedMap, parameterMapFromFile)
|
||||
assert.Nil(t, err)
|
||||
|
||||
removeTmpDirUntilSuccess(t, tempDir)
|
||||
})
|
||||
t.Run("WithEmptyFilename", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parameterMapFromFile, err := createParameterMapFromFile("")
|
||||
|
||||
assert.Nil(t, parameterMapFromFile)
|
||||
assert.EqualError(t, err, "Parameter file name is not specified")
|
||||
})
|
||||
t.Run("WithInvalidFilename", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parameterMapFromFile, err := createParameterMapFromFile("invalidFile.yaml")
|
||||
|
||||
assert.Nil(t, parameterMapFromFile)
|
||||
|
||||
// On Unix based systems, it is: `open invalidFile.yaml: no such file or directory`
|
||||
// On Windows, it is `open invalidFile.yaml: The system cannot find the file specified.`
|
||||
if runtime.GOOS == "windows" {
|
||||
assert.EqualError(t, err, "open invalidFile.yaml: The system cannot find the file specified.")
|
||||
} else {
|
||||
assert.EqualError(t, err, "open invalidFile.yaml: no such file or directory")
|
||||
}
|
||||
})
|
||||
t.Run("WithInvalidYAML", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("region = \"bananas\"\ndisk = \"20\"\n")
|
||||
|
||||
parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name())
|
||||
|
||||
assert.Nil(t, parameterMapFromFile)
|
||||
assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]string")
|
||||
|
||||
removeTmpDirUntilSuccess(t, tempDir)
|
||||
})
|
||||
}
|
||||
|
||||
// Need this for Windows because of a known issue with Go:
|
||||
// https://github.com/golang/go/issues/52986
|
||||
func removeTmpDirUntilSuccess(t *testing.T, tempDir string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
err := os.RemoveAll(tempDir)
|
||||
for err != nil {
|
||||
err = os.RemoveAll(tempDir)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func parameterCreate() *cobra.Command {
|
||||
var (
|
||||
name string
|
||||
value string
|
||||
scheme string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <scope> [name]",
|
||||
Aliases: []string{"mk"},
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scopeName := ""
|
||||
if len(args) >= 2 {
|
||||
scopeName = args[1]
|
||||
}
|
||||
scope, scopeID, err := parseScopeAndID(cmd.Context(), client, organization, args[0], scopeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scheme, err := parseParameterScheme(scheme)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.CreateParameter(cmd.Context(), scope, scopeID, codersdk.CreateParameterRequest{
|
||||
Name: name,
|
||||
SourceValue: value,
|
||||
SourceScheme: database.ParameterSourceSchemeData,
|
||||
DestinationScheme: scheme,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Printf("Created!\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&name, "name", "n", "", "Name for a parameter.")
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
cmd.Flags().StringVarP(&value, "value", "v", "", "Value for a parameter.")
|
||||
_ = cmd.MarkFlagRequired("value")
|
||||
cmd.Flags().StringVarP(&scheme, "scheme", "s", "var", `Scheme for the parameter ("var" or "env").`)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseParameterScheme(scheme string) (database.ParameterDestinationScheme, error) {
|
||||
switch scheme {
|
||||
case "env":
|
||||
return database.ParameterDestinationSchemeEnvironmentVariable, nil
|
||||
case "var":
|
||||
return database.ParameterDestinationSchemeProvisionerVariable, nil
|
||||
}
|
||||
return database.ParameterDestinationSchemeNone, xerrors.Errorf("scheme %q not recognized", scheme)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package cli
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func parameterDelete() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete",
|
||||
Aliases: []string{"rm"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func parameterList() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <scope> <scope-id>",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := ""
|
||||
if len(args) >= 2 {
|
||||
name = args[1]
|
||||
}
|
||||
scope, scopeID, err := parseScopeAndID(cmd.Context(), client, organization, args[0], name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params, err := client.Parameters(cmd.Context(), scope, scopeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0)
|
||||
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\n",
|
||||
color.HiBlackString("Parameter"),
|
||||
color.HiBlackString("Created"),
|
||||
color.HiBlackString("Scheme"))
|
||||
for _, param := range params {
|
||||
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\n",
|
||||
color.New(color.FgHiCyan).Sprint(param.Name),
|
||||
color.WhiteString(param.UpdatedAt.Format("January 2, 2006")),
|
||||
color.New(color.FgHiWhite).Sprint(param.DestinationScheme))
|
||||
}
|
||||
return writer.Flush()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func parameters() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "parameters",
|
||||
Aliases: []string{"params"},
|
||||
}
|
||||
|
||||
cmd.AddCommand(parameterCreate(), parameterList(), parameterDelete())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseScopeAndID(ctx context.Context, client *codersdk.Client, organization codersdk.Organization, rawScope string, name string) (codersdk.ParameterScope, uuid.UUID, error) {
|
||||
scope, err := parseParameterScope(rawScope)
|
||||
if err != nil {
|
||||
return scope, uuid.Nil, err
|
||||
}
|
||||
|
||||
var scopeID uuid.UUID
|
||||
switch scope {
|
||||
case codersdk.ParameterOrganization:
|
||||
if name == "" {
|
||||
scopeID = organization.ID
|
||||
} else {
|
||||
org, err := client.OrganizationByName(ctx, codersdk.Me, name)
|
||||
if err != nil {
|
||||
return scope, uuid.Nil, err
|
||||
}
|
||||
scopeID = org.ID
|
||||
}
|
||||
case codersdk.ParameterTemplate:
|
||||
template, err := client.TemplateByName(ctx, organization.ID, name)
|
||||
if err != nil {
|
||||
return scope, uuid.Nil, err
|
||||
}
|
||||
scopeID = template.ID
|
||||
case codersdk.ParameterUser:
|
||||
uid, _ := uuid.Parse(name)
|
||||
user, err := client.User(ctx, uid)
|
||||
if err != nil {
|
||||
return scope, uuid.Nil, err
|
||||
}
|
||||
scopeID = user.ID
|
||||
case codersdk.ParameterWorkspace:
|
||||
workspace, err := client.WorkspaceByName(ctx, codersdk.Me, name)
|
||||
if err != nil {
|
||||
return scope, uuid.Nil, err
|
||||
}
|
||||
scopeID = workspace.ID
|
||||
}
|
||||
|
||||
return scope, scopeID, nil
|
||||
}
|
||||
|
||||
func parseParameterScope(scope string) (codersdk.ParameterScope, error) {
|
||||
switch scope {
|
||||
case string(codersdk.ParameterOrganization):
|
||||
return codersdk.ParameterOrganization, nil
|
||||
case string(codersdk.ParameterTemplate):
|
||||
return codersdk.ParameterTemplate, nil
|
||||
case string(codersdk.ParameterUser):
|
||||
return codersdk.ParameterUser, nil
|
||||
case string(codersdk.ParameterWorkspace):
|
||||
return codersdk.ParameterWorkspace, nil
|
||||
}
|
||||
return codersdk.ParameterOrganization, xerrors.Errorf("no scope found by name %q", scope)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/pion/udp"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
coderagent "github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func portForward() *cobra.Command {
|
||||
var (
|
||||
tcpForwards []string // <port>:<port>
|
||||
udpForwards []string // <port>:<port>
|
||||
unixForwards []string // <path>:<path> OR <port>:<path>
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "port-forward <workspace>",
|
||||
Aliases: []string{"tunnel"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: `
|
||||
- Port forward a single TCP port from 1234 in the workspace to port 5678 on
|
||||
your local machine
|
||||
|
||||
` + cliui.Styles.Code.Render("$ coder port-forward <workspace> --tcp 5678:1234") + `
|
||||
|
||||
- Port forward a single UDP port from port 9000 to port 9000 on your local
|
||||
machine
|
||||
|
||||
` + cliui.Styles.Code.Render("$ coder port-forward <workspace> --udp 9000") + `
|
||||
|
||||
- Forward a Unix socket in the workspace to a local Unix socket
|
||||
|
||||
` + cliui.Styles.Code.Render("$ coder port-forward <workspace> --unix ./local.sock:~/remote.sock") + `
|
||||
|
||||
- Forward a Unix socket in the workspace to a local TCP port
|
||||
|
||||
` + cliui.Styles.Code.Render("$ coder port-forward <workspace> --unix 8080:~/remote.sock") + `
|
||||
|
||||
- Port forward multiple TCP ports and a UDP port
|
||||
|
||||
` + cliui.Styles.Code.Render("$ coder port-forward <workspace> --tcp 8080:8080 --tcp 9000:3000 --udp 5353:53"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
specs, err := parsePortForwards(tcpForwards, udpForwards, unixForwards)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse port-forward specs: %w", err)
|
||||
}
|
||||
if len(specs) == 0 {
|
||||
err = cmd.Help()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("generate help output: %w", err)
|
||||
}
|
||||
return xerrors.New("no port-forwards requested")
|
||||
}
|
||||
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, agent, err := getWorkspaceAndAgent(cmd, client, codersdk.Me, args[0], false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if workspace.LatestBuild.Transition != codersdk.WorkspaceTransitionStart {
|
||||
return xerrors.New("workspace must be in start transition to port-forward")
|
||||
}
|
||||
if workspace.LatestBuild.Job.CompletedAt == nil {
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = cliui.Agent(cmd.Context(), cmd.ErrOrStderr(), cliui.AgentOptions{
|
||||
WorkspaceName: workspace.Name,
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
return client.WorkspaceAgent(ctx, agent.ID)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
|
||||
conn, err := client.DialWorkspaceAgent(cmd.Context(), agent.ID, nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dial workspace agent: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Start all listeners.
|
||||
var (
|
||||
ctx, cancel = context.WithCancel(cmd.Context())
|
||||
wg = new(sync.WaitGroup)
|
||||
listeners = make([]net.Listener, len(specs))
|
||||
closeAllListeners = func() {
|
||||
for _, l := range listeners {
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
_ = l.Close()
|
||||
}
|
||||
}
|
||||
)
|
||||
defer cancel()
|
||||
for i, spec := range specs {
|
||||
l, err := listenAndPortForward(ctx, cmd, conn, wg, spec)
|
||||
if err != nil {
|
||||
closeAllListeners()
|
||||
return err
|
||||
}
|
||||
listeners[i] = l
|
||||
}
|
||||
|
||||
// Wait for the context to be canceled or for a signal and close
|
||||
// all listeners.
|
||||
var closeErr error
|
||||
go func() {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
closeErr = ctx.Err()
|
||||
case <-sigs:
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Received signal, closing all listeners and active connections")
|
||||
closeErr = xerrors.New("signal received")
|
||||
}
|
||||
|
||||
cancel()
|
||||
closeAllListeners()
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Ready!")
|
||||
wg.Wait()
|
||||
return closeErr
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringArrayVarP(&tcpForwards, "tcp", "p", []string{}, "Forward a TCP port from the workspace to the local machine")
|
||||
cmd.Flags().StringArrayVar(&udpForwards, "udp", []string{}, "Forward a UDP port from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols")
|
||||
cmd.Flags().StringArrayVar(&unixForwards, "unix", []string{}, "Forward a Unix socket in the workspace to a local Unix socket or TCP port")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *coderagent.Conn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Forwarding '%v://%v' locally to '%v://%v' in the workspace\n", spec.listenNetwork, spec.listenAddress, spec.dialNetwork, spec.dialAddress)
|
||||
|
||||
var (
|
||||
l net.Listener
|
||||
err error
|
||||
)
|
||||
switch spec.listenNetwork {
|
||||
case "tcp":
|
||||
l, err = net.Listen(spec.listenNetwork, spec.listenAddress)
|
||||
case "udp":
|
||||
var host, port string
|
||||
host, port, err = net.SplitHostPort(spec.listenAddress)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("split %q: %w", spec.listenAddress, err)
|
||||
}
|
||||
|
||||
var portInt int
|
||||
portInt, err = strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse port %v from %q as int: %w", port, spec.listenAddress, err)
|
||||
}
|
||||
|
||||
l, err = udp.Listen(spec.listenNetwork, &net.UDPAddr{
|
||||
IP: net.ParseIP(host),
|
||||
Port: portInt,
|
||||
})
|
||||
case "unix":
|
||||
l, err = net.Listen(spec.listenNetwork, spec.listenAddress)
|
||||
default:
|
||||
return nil, xerrors.Errorf("unknown listen network %q", spec.listenNetwork)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen '%v://%v': %w", spec.listenNetwork, spec.listenAddress, err)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(spec portForwardSpec) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
netConn, err := l.Accept()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error accepting connection from '%v://%v': %+v\n", spec.listenNetwork, spec.listenAddress, err)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Killing listener")
|
||||
return
|
||||
}
|
||||
|
||||
go func(netConn net.Conn) {
|
||||
defer netConn.Close()
|
||||
remoteConn, err := conn.DialContext(ctx, spec.dialNetwork, spec.dialAddress)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Failed to dial '%v://%v' in workspace: %s\n", spec.dialNetwork, spec.dialAddress, err)
|
||||
return
|
||||
}
|
||||
defer remoteConn.Close()
|
||||
|
||||
coderagent.Bicopy(ctx, netConn, remoteConn)
|
||||
}(netConn)
|
||||
}
|
||||
}(spec)
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
type portForwardSpec struct {
|
||||
listenNetwork string // tcp, udp, unix
|
||||
listenAddress string // <ip>:<port> or path
|
||||
|
||||
dialNetwork string // tcp, udp, unix
|
||||
dialAddress string // <ip>:<port> or path
|
||||
}
|
||||
|
||||
func parsePortForwards(tcpSpecs, udpSpecs, unixSpecs []string) ([]portForwardSpec, error) {
|
||||
specs := []portForwardSpec{}
|
||||
|
||||
for _, spec := range tcpSpecs {
|
||||
local, remote, err := parsePortPort(spec)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to parse TCP port-forward specification %q: %w", spec, err)
|
||||
}
|
||||
|
||||
specs = append(specs, portForwardSpec{
|
||||
listenNetwork: "tcp",
|
||||
listenAddress: fmt.Sprintf("127.0.0.1:%v", local),
|
||||
dialNetwork: "tcp",
|
||||
dialAddress: fmt.Sprintf("127.0.0.1:%v", remote),
|
||||
})
|
||||
}
|
||||
|
||||
for _, spec := range udpSpecs {
|
||||
local, remote, err := parsePortPort(spec)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to parse UDP port-forward specification %q: %w", spec, err)
|
||||
}
|
||||
|
||||
specs = append(specs, portForwardSpec{
|
||||
listenNetwork: "udp",
|
||||
listenAddress: fmt.Sprintf("127.0.0.1:%v", local),
|
||||
dialNetwork: "udp",
|
||||
dialAddress: fmt.Sprintf("127.0.0.1:%v", remote),
|
||||
})
|
||||
}
|
||||
|
||||
for _, specStr := range unixSpecs {
|
||||
localPath, localTCP, remotePath, err := parseUnixUnix(specStr)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to parse Unix port-forward specification %q: %w", specStr, err)
|
||||
}
|
||||
|
||||
spec := portForwardSpec{
|
||||
dialNetwork: "unix",
|
||||
dialAddress: remotePath,
|
||||
}
|
||||
if localPath == "" {
|
||||
spec.listenNetwork = "tcp"
|
||||
spec.listenAddress = fmt.Sprintf("127.0.0.1:%v", localTCP)
|
||||
} else {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil, xerrors.Errorf("Unix port-forwarding is not supported on Windows")
|
||||
}
|
||||
spec.listenNetwork = "unix"
|
||||
spec.listenAddress = localPath
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
|
||||
// Check for duplicate entries.
|
||||
locals := map[string]struct{}{}
|
||||
for _, spec := range specs {
|
||||
localStr := fmt.Sprintf("%v:%v", spec.listenNetwork, spec.listenAddress)
|
||||
if _, ok := locals[localStr]; ok {
|
||||
return nil, xerrors.Errorf("local %v %v is specified twice", spec.listenNetwork, spec.listenAddress)
|
||||
}
|
||||
locals[localStr] = struct{}{}
|
||||
}
|
||||
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
func parsePort(in string) (uint16, error) {
|
||||
port, err := strconv.ParseUint(strings.TrimSpace(in), 10, 16)
|
||||
if err != nil {
|
||||
return 0, xerrors.Errorf("parse port %q: %w", in, err)
|
||||
}
|
||||
if port == 0 {
|
||||
return 0, xerrors.New("port cannot be 0")
|
||||
}
|
||||
|
||||
return uint16(port), nil
|
||||
}
|
||||
|
||||
func parseUnixPath(in string) (string, error) {
|
||||
path, err := coderagent.ExpandRelativeHomePath(strings.TrimSpace(in))
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("tidy path %q: %w", in, err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func parsePortPort(in string) (local uint16, remote uint16, err error) {
|
||||
parts := strings.Split(in, ":")
|
||||
if len(parts) > 2 {
|
||||
return 0, 0, xerrors.Errorf("invalid port specification %q", in)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
// Duplicate the single part
|
||||
parts = append(parts, parts[0])
|
||||
}
|
||||
|
||||
local, err = parsePort(parts[0])
|
||||
if err != nil {
|
||||
return 0, 0, xerrors.Errorf("parse local port from %q: %w", in, err)
|
||||
}
|
||||
remote, err = parsePort(parts[1])
|
||||
if err != nil {
|
||||
return 0, 0, xerrors.Errorf("parse remote port from %q: %w", in, err)
|
||||
}
|
||||
|
||||
return local, remote, nil
|
||||
}
|
||||
|
||||
func parsePortOrUnixPath(in string) (string, uint16, error) {
|
||||
port, err := parsePort(in)
|
||||
if err == nil {
|
||||
return "", port, nil
|
||||
}
|
||||
|
||||
path, err := parseUnixPath(in)
|
||||
if err != nil {
|
||||
return "", 0, xerrors.Errorf("could not parse port or unix path %q: %w", in, err)
|
||||
}
|
||||
|
||||
return path, 0, nil
|
||||
}
|
||||
|
||||
func parseUnixUnix(in string) (string, uint16, string, error) {
|
||||
parts := strings.Split(in, ":")
|
||||
if len(parts) > 2 {
|
||||
return "", 0, "", xerrors.Errorf("invalid port-forward specification %q", in)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
// Duplicate the single part
|
||||
parts = append(parts, parts[0])
|
||||
}
|
||||
|
||||
localPath, localPort, err := parsePortOrUnixPath(parts[0])
|
||||
if err != nil {
|
||||
return "", 0, "", xerrors.Errorf("parse local part of spec %q: %w", in, err)
|
||||
}
|
||||
|
||||
// We don't really touch the remote path at all since it gets cleaned
|
||||
// up/expanded on the remote.
|
||||
return localPath, localPort, parts[1], nil
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"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/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func TestPortForward(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("None", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
cmd, root := clitest.New(t, "port-forward", "blah")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(buf)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "no port-forwards")
|
||||
|
||||
// Check that the help was printed.
|
||||
require.Contains(t, buf.String(), "port-forward <workspace>")
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
network string
|
||||
// The flag to pass to `coder port-forward X` to port-forward this type
|
||||
// of connection. Has two format args (both strings), the first is the
|
||||
// local address and the second is the remote address.
|
||||
flag string
|
||||
// setupRemote creates a "remote" listener to emulate a service in the
|
||||
// workspace.
|
||||
setupRemote func(t *testing.T) net.Listener
|
||||
// setupLocal returns an available port or Unix socket path that the
|
||||
// port-forward command will listen on "locally". Returns the address
|
||||
// you pass to net.Dial, and the port/path you pass to `coder
|
||||
// port-forward`.
|
||||
setupLocal func(t *testing.T) (string, string)
|
||||
}{
|
||||
{
|
||||
name: "TCP",
|
||||
network: "tcp",
|
||||
flag: "--tcp=%v:%v",
|
||||
setupRemote: func(t *testing.T) net.Listener {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "create TCP listener")
|
||||
return l
|
||||
},
|
||||
setupLocal: func(t *testing.T) (string, string) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "create TCP listener to generate random port")
|
||||
defer l.Close()
|
||||
|
||||
_, port, err := net.SplitHostPort(l.Addr().String())
|
||||
require.NoErrorf(t, err, "split TCP address %q", l.Addr().String())
|
||||
return l.Addr().String(), port
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "UDP",
|
||||
network: "udp",
|
||||
flag: "--udp=%v:%v",
|
||||
setupRemote: func(t *testing.T) net.Listener {
|
||||
addr := net.UDPAddr{
|
||||
IP: net.ParseIP("127.0.0.1"),
|
||||
Port: 0,
|
||||
}
|
||||
l, err := udp.Listen("udp", &addr)
|
||||
require.NoError(t, err, "create UDP listener")
|
||||
return l
|
||||
},
|
||||
setupLocal: func(t *testing.T) (string, string) {
|
||||
addr := net.UDPAddr{
|
||||
IP: net.ParseIP("127.0.0.1"),
|
||||
Port: 0,
|
||||
}
|
||||
l, err := udp.Listen("udp", &addr)
|
||||
require.NoError(t, err, "create UDP listener to generate random port")
|
||||
defer l.Close()
|
||||
|
||||
_, port, err := net.SplitHostPort(l.Addr().String())
|
||||
require.NoErrorf(t, err, "split UDP address %q", l.Addr().String())
|
||||
return l.Addr().String(), port
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unix",
|
||||
network: "unix",
|
||||
flag: "--unix=%v:%v",
|
||||
setupRemote: func(t *testing.T) net.Listener {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix socket forwarding isn't supported on Windows")
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "coderd_agent_test_")
|
||||
require.NoError(t, err, "create temp dir for unix listener")
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
l, err := net.Listen("unix", filepath.Join(tmpDir, "test.sock"))
|
||||
require.NoError(t, err, "create UDP listener")
|
||||
return l
|
||||
},
|
||||
setupLocal: func(t *testing.T) (string, string) {
|
||||
tmpDir, err := os.MkdirTemp("", "coderd_agent_test_")
|
||||
require.NoError(t, err, "create temp dir for unix listener")
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
path := filepath.Join(tmpDir, "test.sock")
|
||||
return path, path
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases { //nolint:paralleltest // the `c := c` confuses the linter
|
||||
c := c
|
||||
// Avoid parallel test here because setupLocal reserves
|
||||
// a free open port which is not guaranteed to be free
|
||||
// after the listener closes.
|
||||
//nolint:paralleltest
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
//nolint:paralleltest
|
||||
t.Run("OnePort", func(t *testing.T) {
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
_, workspace = runAgent(t, client, user.UserID)
|
||||
p1 = setupTestListener(t, c.setupRemote(t))
|
||||
)
|
||||
|
||||
// Create a flag that forwards from local to listener 1.
|
||||
localAddress, localFlag := c.setupLocal(t)
|
||||
flag := fmt.Sprintf(c.flag, localFlag, p1)
|
||||
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listener.
|
||||
cmd, root := clitest.New(t, "port-forward", workspace.Name, flag)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(io.MultiWriter(buf, os.Stderr))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
|
||||
// Open two connections simultaneously and test them out of
|
||||
// sync.
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
c1, err := d.DialContext(ctx, c.network, localAddress)
|
||||
require.NoError(t, err, "open connection 1 to 'local' listener")
|
||||
defer c1.Close()
|
||||
c2, err := d.DialContext(ctx, c.network, localAddress)
|
||||
require.NoError(t, err, "open connection 2 to 'local' listener")
|
||||
defer c2.Close()
|
||||
testDial(t, c2)
|
||||
testDial(t, c1)
|
||||
|
||||
cancel()
|
||||
err = <-errC
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
})
|
||||
|
||||
//nolint:paralleltest
|
||||
t.Run("TwoPorts", func(t *testing.T) {
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
_, workspace = runAgent(t, client, user.UserID)
|
||||
p1 = setupTestListener(t, c.setupRemote(t))
|
||||
p2 = setupTestListener(t, c.setupRemote(t))
|
||||
)
|
||||
|
||||
// Create a flags for listener 1 and listener 2.
|
||||
localAddress1, localFlag1 := c.setupLocal(t)
|
||||
localAddress2, localFlag2 := c.setupLocal(t)
|
||||
flag1 := fmt.Sprintf(c.flag, localFlag1, p1)
|
||||
flag2 := fmt.Sprintf(c.flag, localFlag2, p2)
|
||||
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listeners.
|
||||
cmd, root := clitest.New(t, "port-forward", workspace.Name, flag1, flag2)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(io.MultiWriter(buf, os.Stderr))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
|
||||
// Open a connection to both listener 1 and 2 simultaneously and
|
||||
// then test them out of order.
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
c1, err := d.DialContext(ctx, c.network, localAddress1)
|
||||
require.NoError(t, err, "open connection 1 to 'local' listener 1")
|
||||
defer c1.Close()
|
||||
c2, err := d.DialContext(ctx, c.network, localAddress2)
|
||||
require.NoError(t, err, "open connection 2 to 'local' listener 2")
|
||||
defer c2.Close()
|
||||
testDial(t, c2)
|
||||
testDial(t, c1)
|
||||
|
||||
cancel()
|
||||
err = <-errC
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Test doing a TCP -> Unix forward.
|
||||
//nolint:paralleltest
|
||||
t.Run("TCP2Unix", func(t *testing.T) {
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
_, workspace = runAgent(t, client, user.UserID)
|
||||
|
||||
// Find the TCP and Unix cases so we can use their setupLocal and
|
||||
// setupRemote methods respectively.
|
||||
tcpCase = cases[0]
|
||||
unixCase = cases[2]
|
||||
|
||||
// Setup remote Unix listener.
|
||||
p1 = setupTestListener(t, unixCase.setupRemote(t))
|
||||
)
|
||||
|
||||
// Create a flag that forwards from local TCP to Unix listener 1.
|
||||
// Notably this is a --unix flag.
|
||||
localAddress, localFlag := tcpCase.setupLocal(t)
|
||||
flag := fmt.Sprintf(unixCase.flag, localFlag, p1)
|
||||
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listener.
|
||||
cmd, root := clitest.New(t, "port-forward", workspace.Name, flag)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(io.MultiWriter(buf, os.Stderr))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
|
||||
// Open two connections simultaneously and test them out of
|
||||
// sync.
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
c1, err := d.DialContext(ctx, tcpCase.network, localAddress)
|
||||
require.NoError(t, err, "open connection 1 to 'local' listener")
|
||||
defer c1.Close()
|
||||
c2, err := d.DialContext(ctx, tcpCase.network, localAddress)
|
||||
require.NoError(t, err, "open connection 2 to 'local' listener")
|
||||
defer c2.Close()
|
||||
testDial(t, c2)
|
||||
testDial(t, c1)
|
||||
|
||||
cancel()
|
||||
err = <-errC
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
})
|
||||
|
||||
// Test doing TCP, UDP and Unix at the same time.
|
||||
//nolint:paralleltest
|
||||
t.Run("All", func(t *testing.T) {
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
_, workspace = runAgent(t, client, user.UserID)
|
||||
// These aren't fixed size because we exclude Unix on Windows.
|
||||
dials = []addr{}
|
||||
flags = []string{}
|
||||
)
|
||||
|
||||
// Start listeners and populate arrays with the cases.
|
||||
for _, c := range cases {
|
||||
if strings.HasPrefix(c.network, "unix") && runtime.GOOS == "windows" {
|
||||
// Unix isn't supported on Windows, but we can still
|
||||
// test other protocols together.
|
||||
continue
|
||||
}
|
||||
|
||||
p := setupTestListener(t, c.setupRemote(t))
|
||||
|
||||
localAddress, localFlag := c.setupLocal(t)
|
||||
dials = append(dials, addr{
|
||||
network: c.network,
|
||||
addr: localAddress,
|
||||
})
|
||||
flags = append(flags, fmt.Sprintf(c.flag, localFlag, p))
|
||||
}
|
||||
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listeners.
|
||||
cmd, root := clitest.New(t, append([]string{"port-forward", workspace.Name}, flags...)...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(io.MultiWriter(buf, os.Stderr))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
|
||||
// Open connections to all items in the "dial" array.
|
||||
var (
|
||||
d = net.Dialer{Timeout: 3 * time.Second}
|
||||
conns = make([]net.Conn, len(dials))
|
||||
)
|
||||
for i, a := range dials {
|
||||
c, err := d.DialContext(ctx, a.network, a.addr)
|
||||
require.NoErrorf(t, err, "open connection %v to 'local' listener %v", i+1, i+1)
|
||||
t.Cleanup(func() {
|
||||
_ = c.Close()
|
||||
})
|
||||
conns[i] = c
|
||||
}
|
||||
|
||||
// Test each connection in reverse order.
|
||||
for i := len(conns) - 1; i >= 0; i-- {
|
||||
testDial(t, conns[i])
|
||||
}
|
||||
|
||||
cancel()
|
||||
err := <-errC
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
})
|
||||
}
|
||||
|
||||
// runAgent creates a fake workspace and starts an agent locally for that
|
||||
// workspace. The agent will be cleaned up on test completion.
|
||||
func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]codersdk.WorkspaceResource, codersdk.Workspace) {
|
||||
ctx := context.Background()
|
||||
user, err := client.User(ctx, userID.String())
|
||||
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{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: 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)
|
||||
|
||||
// Start workspace agent in a goroutine
|
||||
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
errC := make(chan error)
|
||||
agentCtx, agentCancel := context.WithCancel(ctx)
|
||||
t.Cleanup(func() {
|
||||
agentCancel()
|
||||
err := <-errC
|
||||
require.NoError(t, err)
|
||||
})
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(agentCtx)
|
||||
}()
|
||||
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
return resources, workspace
|
||||
}
|
||||
|
||||
// setupTestListener starts accepting connections and echoing a single packet.
|
||||
// Returns the listener and the listen port or Unix path.
|
||||
func setupTestListener(t *testing.T, l net.Listener) string {
|
||||
// Wait for listener to completely exit before releasing.
|
||||
done := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
_ = l.Close()
|
||||
<-done
|
||||
})
|
||||
go func() {
|
||||
defer close(done)
|
||||
// Guard against testAccept running require after test completion.
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
|
||||
for {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
testAccept(t, c)
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
addr := l.Addr().String()
|
||||
if !strings.HasPrefix(l.Addr().Network(), "unix") {
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
require.NoErrorf(t, err, "split non-Unix listen path %q", addr)
|
||||
addr = port
|
||||
}
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
var dialTestPayload = []byte("dean-was-here123")
|
||||
|
||||
func testDial(t *testing.T, c net.Conn) {
|
||||
t.Helper()
|
||||
|
||||
assertWritePayload(t, c, dialTestPayload)
|
||||
assertReadPayload(t, c, dialTestPayload)
|
||||
}
|
||||
|
||||
func testAccept(t *testing.T, c net.Conn) {
|
||||
t.Helper()
|
||||
defer c.Close()
|
||||
|
||||
assertReadPayload(t, c, dialTestPayload)
|
||||
assertWritePayload(t, c, dialTestPayload)
|
||||
}
|
||||
|
||||
func assertReadPayload(t *testing.T, r io.Reader, payload []byte) {
|
||||
b := make([]byte, len(payload)+16)
|
||||
n, err := r.Read(b)
|
||||
assert.NoError(t, err, "read payload")
|
||||
assert.Equal(t, len(payload), n, "read payload length does not match")
|
||||
assert.Equal(t, payload, b[:n])
|
||||
}
|
||||
|
||||
func assertWritePayload(t *testing.T, w io.Writer, payload []byte) {
|
||||
n, err := w.Write(payload)
|
||||
assert.NoError(t, err, "write payload")
|
||||
assert.Equal(t, len(payload), n, "payload length does not match")
|
||||
}
|
||||
|
||||
func waitForPortForwardReady(t *testing.T, output *threadSafeBuffer) {
|
||||
for i := 0; i < 100; i++ {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
data := output.String()
|
||||
if strings.Contains(data, "Ready!") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatal("port-forward command did not become ready in time")
|
||||
}
|
||||
|
||||
type addr struct {
|
||||
network string
|
||||
addr string
|
||||
}
|
||||
|
||||
type threadSafeBuffer struct {
|
||||
b *bytes.Buffer
|
||||
mut *sync.RWMutex
|
||||
}
|
||||
|
||||
func newThreadSafeBuffer() *threadSafeBuffer {
|
||||
return &threadSafeBuffer{
|
||||
b: bytes.NewBuffer(nil),
|
||||
mut: new(sync.RWMutex),
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ io.Reader = &threadSafeBuffer{}
|
||||
_ io.Writer = &threadSafeBuffer{}
|
||||
)
|
||||
|
||||
// Read implements io.Reader.
|
||||
func (b *threadSafeBuffer) Read(p []byte) (int, error) {
|
||||
b.mut.RLock()
|
||||
defer b.mut.RUnlock()
|
||||
|
||||
return b.b.Read(p)
|
||||
}
|
||||
|
||||
// Write implements io.Writer.
|
||||
func (b *threadSafeBuffer) Write(p []byte) (int, error) {
|
||||
b.mut.Lock()
|
||||
defer b.mut.Unlock()
|
||||
|
||||
return b.b.Write(p)
|
||||
}
|
||||
|
||||
func (b *threadSafeBuffer) String() string {
|
||||
b.mut.RLock()
|
||||
defer b.mut.RUnlock()
|
||||
|
||||
return b.b.String()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func publickey() *cobra.Command {
|
||||
var (
|
||||
reset bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "publickey",
|
||||
Aliases: []string{"pubkey"},
|
||||
Short: "Output your public key for Git operations",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create codersdk client: %w", err)
|
||||
}
|
||||
|
||||
if reset {
|
||||
// Confirm prompt if using --reset. We don't want to accidentally
|
||||
// reset our public key.
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm regenerate a new sshkey for your workspaces? This will require updating the key " +
|
||||
"on any services it is registered with. This action cannot be reverted.",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reset the public key, let the retrieve re-read it.
|
||||
_, err = client.RegenerateGitSSHKey(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
key, err := client.GitSSHKey(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create codersdk client: %w", err)
|
||||
}
|
||||
|
||||
cmd.Println(cliui.Styles.Wrap.Render(
|
||||
"This is your public key for using " + cliui.Styles.Field.Render("git") + " in " +
|
||||
"Coder. All clones with SSH will be authenticated automatically 🪄.",
|
||||
))
|
||||
cmd.Println()
|
||||
cmd.Println(cliui.Styles.Code.Render(strings.TrimSpace(key.PublicKey)))
|
||||
cmd.Println()
|
||||
cmd.Println("Add to GitHub and GitLab:")
|
||||
cmd.Println(cliui.Styles.Prompt.String() + "https://github.com/settings/ssh/new")
|
||||
cmd.Println(cliui.Styles.Prompt.String() + "https://gitlab.com/-/profile/keys")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&reset, "reset", false, "Regenerate your public key. This will require updating the key on any services it's registered with.")
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
)
|
||||
|
||||
func TestPublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
cmd, root := clitest.New(t, "publickey")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
publicKey := buf.String()
|
||||
require.NotEmpty(t, publicKey)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/userpassword"
|
||||
)
|
||||
|
||||
func resetPassword() *cobra.Command {
|
||||
var (
|
||||
postgresURL string
|
||||
)
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "reset-password <username>",
|
||||
Short: "Reset a user's password by directly updating the database",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
username := args[0]
|
||||
|
||||
sqlDB, err := sql.Open("postgres", postgresURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dial postgres: %w", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
err = sqlDB.Ping()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
|
||||
err = database.EnsureClean(sqlDB)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("database needs migration: %w", err)
|
||||
}
|
||||
db := database.New(sqlDB)
|
||||
|
||||
user, err := db.GetUserByEmailOrUsername(cmd.Context(), database.GetUserByEmailOrUsernameParams{
|
||||
Username: username,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("retrieving user: %w", err)
|
||||
}
|
||||
|
||||
password, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Enter new " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: cliui.ValidateNotEmpty,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("password prompt: %w", err)
|
||||
}
|
||||
confirmedPassword, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: cliui.ValidateNotEmpty,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("confirm password prompt: %w", err)
|
||||
}
|
||||
if password != confirmedPassword {
|
||||
return xerrors.New("Passwords do not match")
|
||||
}
|
||||
|
||||
hashedPassword, err := userpassword.Hash(password)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
err = db.UpdateUserHashedPassword(cmd.Context(), database.UpdateUserHashedPasswordParams{
|
||||
ID: user.ID,
|
||||
HashedPassword: []byte(hashedPassword),
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("updating password: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cliflag.StringVarP(root.Flags(), &postgresURL, "postgres-url", "", "CODER_PG_CONNECTION_URL", "", "URL of a PostgreSQL database to connect to")
|
||||
|
||||
return root
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/database/postgres"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
// nolint:paralleltest
|
||||
func TestResetPassword(t *testing.T) {
|
||||
// postgres.Open() seems to be creating race conditions when run in parallel.
|
||||
// t.Parallel()
|
||||
|
||||
if runtime.GOOS != "linux" || testing.Short() {
|
||||
// Skip on non-Linux because it spawns a PostgreSQL instance.
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
const email = "some@one.com"
|
||||
const username = "example"
|
||||
const oldPassword = "password"
|
||||
const newPassword = "password2"
|
||||
|
||||
// start postgres and coder server processes
|
||||
|
||||
connectionURL, closeFunc, err := postgres.Open()
|
||||
require.NoError(t, err)
|
||||
defer closeFunc()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
serverDone := make(chan struct{})
|
||||
serverCmd, cfg := clitest.New(t, "server", "--address", ":0", "--postgres-url", connectionURL)
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
err = serverCmd.ExecuteContext(ctx)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
}()
|
||||
var client *codersdk.Client
|
||||
require.Eventually(t, func() bool {
|
||||
rawURL, err := cfg.URL().Read()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
accessURL, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
client = codersdk.New(accessURL)
|
||||
return true
|
||||
}, 15*time.Second, 25*time.Millisecond)
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Password: oldPassword,
|
||||
OrganizationName: "example",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// reset the password
|
||||
|
||||
resetCmd, cmdCfg := clitest.New(t, "reset-password", "--postgres-url", connectionURL, username)
|
||||
clitest.SetupConfig(t, client, cmdCfg)
|
||||
cmdDone := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
resetCmd.SetIn(pty.Input())
|
||||
resetCmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(cmdDone)
|
||||
err = resetCmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []struct {
|
||||
output string
|
||||
input string
|
||||
}{
|
||||
{"Enter new", newPassword},
|
||||
{"Confirm", newPassword},
|
||||
}
|
||||
for _, match := range matches {
|
||||
pty.ExpectMatch(match.output)
|
||||
pty.WriteLine(match.input)
|
||||
}
|
||||
<-cmdDone
|
||||
|
||||
// now try logging in
|
||||
|
||||
_, err = client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: email,
|
||||
Password: oldPassword,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: email,
|
||||
Password: newPassword,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
<-serverDone
|
||||
}
|
||||
+197
-52
@@ -1,17 +1,20 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/kirsle/configdir"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/buildinfo"
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/codersdk"
|
||||
@@ -19,80 +22,133 @@ import (
|
||||
|
||||
var (
|
||||
caret = cliui.Styles.Prompt.String()
|
||||
|
||||
// Applied as annotations to workspace commands
|
||||
// so they display in a separated "help" section.
|
||||
workspaceCommand = map[string]string{
|
||||
"workspaces": " ",
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
varGlobalConfig = "global-config"
|
||||
varNoOpen = "no-open"
|
||||
varForceTty = "force-tty"
|
||||
varURL = "url"
|
||||
varToken = "token"
|
||||
varAgentToken = "agent-token"
|
||||
varAgentURL = "agent-url"
|
||||
varGlobalConfig = "global-config"
|
||||
varNoOpen = "no-open"
|
||||
varForceTty = "force-tty"
|
||||
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Customizes the color of headings to make subcommands more visually
|
||||
// appealing.
|
||||
header := cliui.Styles.Placeholder
|
||||
cobra.AddTemplateFunc("usageHeader", func(s string) string {
|
||||
return header.Render(s)
|
||||
})
|
||||
}
|
||||
|
||||
func Root() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "coder",
|
||||
Version: buildinfo.Version(),
|
||||
SilenceUsage: true,
|
||||
Long: ` ▄█▀ ▀█▄
|
||||
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
|
||||
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
|
||||
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
|
||||
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
|
||||
` + lipgloss.NewStyle().Underline(true).Render("Self-hosted developer workspaces on your infra") + `
|
||||
|
||||
Use: "coder",
|
||||
Version: buildinfo.Version(),
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
Long: `Coder — A tool for provisioning self-hosted development environments.
|
||||
`,
|
||||
Example: cliui.Styles.Paragraph.Render(`Start Coder in "dev" mode. This dev-mode requires no further setup, and your local `+cliui.Styles.Code.Render("coder")+` CLI will be authenticated to talk to it. This makes it easy to experiment with Coder.`) + `
|
||||
Example: ` Start Coder in "dev" mode. This dev-mode requires no further setup, and your local ` + cliui.Styles.Code.Render("coder") + ` CLI will be authenticated to talk to it. This makes it easy to experiment with Coder.
|
||||
` + cliui.Styles.Code.Render("$ coder server --dev") + `
|
||||
|
||||
` + cliui.Styles.Code.Render("$ coder start --dev") + `
|
||||
` + cliui.Styles.Paragraph.Render("Get started by creating a template from an example.") + `
|
||||
|
||||
` + cliui.Styles.Code.Render("$ coder templates init"),
|
||||
Get started by creating a template from an example.
|
||||
` + cliui.Styles.Code.Render("$ coder templates init"),
|
||||
}
|
||||
// Customizes the color of headings to make subcommands
|
||||
// more visually appealing.
|
||||
header := cliui.Styles.Placeholder
|
||||
cmd.SetUsageTemplate(strings.NewReplacer(
|
||||
`Usage:`, header.Render("Usage:"),
|
||||
`Examples:`, header.Render("Examples:"),
|
||||
`Available Commands:`, header.Render("Commands:"),
|
||||
`Global Flags:`, header.Render("Global Flags:"),
|
||||
`Flags:`, header.Render("Flags:"),
|
||||
`Additional help topics:`, header.Render("Additional help:"),
|
||||
).Replace(cmd.UsageTemplate()))
|
||||
cmd.SetVersionTemplate(versionTemplate())
|
||||
|
||||
cmd.AddCommand(
|
||||
autostart(),
|
||||
bump(),
|
||||
configSSH(),
|
||||
start(),
|
||||
create(),
|
||||
delete(),
|
||||
dotfiles(),
|
||||
gitssh(),
|
||||
list(),
|
||||
login(),
|
||||
parameters(),
|
||||
templates(),
|
||||
users(),
|
||||
workspaces(),
|
||||
logout(),
|
||||
publickey(),
|
||||
resetPassword(),
|
||||
server(),
|
||||
show(),
|
||||
start(),
|
||||
state(),
|
||||
stop(),
|
||||
ssh(),
|
||||
workspaceTunnel(),
|
||||
templates(),
|
||||
ttl(),
|
||||
update(),
|
||||
users(),
|
||||
portForward(),
|
||||
workspaceAgent(),
|
||||
)
|
||||
|
||||
cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coderv2"), "Path to the global `coder` config directory")
|
||||
cmd.PersistentFlags().Bool(varForceTty, false, "Force the `coder` command to run as if connected to a TTY")
|
||||
err := cmd.PersistentFlags().MarkHidden(varForceTty)
|
||||
if err != nil {
|
||||
// This should never return an error, because we just added the `--force-tty`` flag prior to calling MarkHidden.
|
||||
panic(err)
|
||||
}
|
||||
cmd.SetUsageTemplate(usageTemplate())
|
||||
cmd.SetVersionTemplate(versionTemplate())
|
||||
|
||||
cmd.PersistentFlags().String(varURL, "", "Specify the URL to your deployment.")
|
||||
cmd.PersistentFlags().String(varToken, "", "Specify an authentication token.")
|
||||
cliflag.String(cmd.PersistentFlags(), varAgentToken, "", "CODER_AGENT_TOKEN", "", "Specify an agent authentication token.")
|
||||
_ = cmd.PersistentFlags().MarkHidden(varAgentToken)
|
||||
cliflag.String(cmd.PersistentFlags(), varAgentURL, "", "CODER_AGENT_URL", "", "Specify the URL for an agent to access your deployment.")
|
||||
_ = cmd.PersistentFlags().MarkHidden(varAgentURL)
|
||||
cliflag.String(cmd.PersistentFlags(), varGlobalConfig, "", "CODER_CONFIG_DIR", configdir.LocalConfig("coderv2"), "Specify the path to the global `coder` config directory.")
|
||||
cmd.PersistentFlags().Bool(varForceTty, false, "Force the `coder` command to run as if connected to a TTY.")
|
||||
_ = cmd.PersistentFlags().MarkHidden(varForceTty)
|
||||
cmd.PersistentFlags().Bool(varNoOpen, false, "Block automatically opening URLs in the browser.")
|
||||
err = cmd.PersistentFlags().MarkHidden(varNoOpen)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = cmd.PersistentFlags().MarkHidden(varNoOpen)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// createClient returns a new client from the command context.
|
||||
// The configuration directory will be read from the global flag.
|
||||
// It reads from global configuration files if flags are not set.
|
||||
func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
root := createConfig(cmd)
|
||||
rawURL, err := root.URL().Read()
|
||||
rawURL, err := cmd.Flags().GetString(varURL)
|
||||
if err != nil || rawURL == "" {
|
||||
rawURL, err = root.URL().Read()
|
||||
if err != nil {
|
||||
// If the configuration files are absent, the user is logged out
|
||||
if os.IsNotExist(err) {
|
||||
return nil, xerrors.New(notLoggedInMessage)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
serverURL, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := cmd.Flags().GetString(varToken)
|
||||
if err != nil || token == "" {
|
||||
token, err = root.Session().Read()
|
||||
if err != nil {
|
||||
// If the configuration files are absent, the user is logged out
|
||||
if os.IsNotExist(err) {
|
||||
return nil, xerrors.New(notLoggedInMessage)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
client := codersdk.New(serverURL)
|
||||
client.SessionToken = strings.TrimSpace(token)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// createAgentClient returns a new client from the command context.
|
||||
// It works just like createClient, but uses the agent token and URL instead.
|
||||
func createAgentClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
rawURL, err := cmd.Flags().GetString(varAgentURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -100,7 +156,7 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := root.Session().Read()
|
||||
token, err := cmd.Flags().GetString(varAgentToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -120,6 +176,27 @@ func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (codersdk.
|
||||
return orgs[0], nil
|
||||
}
|
||||
|
||||
// namedWorkspace fetches and returns a workspace by an identifier, which may be either
|
||||
// a bare name (for a workspace owned by the current user) or a "user/workspace" combination,
|
||||
// where user is either a username or UUID.
|
||||
func namedWorkspace(cmd *cobra.Command, client *codersdk.Client, identifier string) (codersdk.Workspace, error) {
|
||||
parts := strings.Split(identifier, "/")
|
||||
|
||||
var owner, name string
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
owner = codersdk.Me
|
||||
name = parts[0]
|
||||
case 2:
|
||||
owner = parts[0]
|
||||
name = parts[1]
|
||||
default:
|
||||
return codersdk.Workspace{}, xerrors.Errorf("invalid workspace name: %q", identifier)
|
||||
}
|
||||
|
||||
return client.WorkspaceByOwnerAndName(cmd.Context(), owner, name)
|
||||
}
|
||||
|
||||
// createConfig consumes the global configuration flag to produce a config root.
|
||||
func createConfig(cmd *cobra.Command) config.Root {
|
||||
globalRoot, err := cmd.Flags().GetString(varGlobalConfig)
|
||||
@@ -147,6 +224,68 @@ func isTTY(cmd *cobra.Command) bool {
|
||||
return isatty.IsTerminal(file.Fd())
|
||||
}
|
||||
|
||||
func usageTemplate() string {
|
||||
// usageHeader is defined in init().
|
||||
return `{{usageHeader "Usage:"}}
|
||||
{{- if .Runnable}}
|
||||
{{.UseLine}}
|
||||
{{end}}
|
||||
{{- if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]
|
||||
{{end}}
|
||||
|
||||
{{- if gt (len .Aliases) 0}}
|
||||
{{usageHeader "Aliases:"}}
|
||||
{{.NameAndAliases}}
|
||||
{{end}}
|
||||
|
||||
{{- if .HasExample}}
|
||||
{{usageHeader "Get Started:"}}
|
||||
{{.Example}}
|
||||
{{end}}
|
||||
|
||||
{{- if .HasAvailableSubCommands}}
|
||||
{{usageHeader "Commands:"}}
|
||||
{{- range .Commands}}
|
||||
{{- if (or (and .IsAvailableCommand (eq (len .Annotations) 0)) (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
{{- if and (not .HasParent) .HasAvailableSubCommands}}
|
||||
{{usageHeader "Workspace Commands:"}}
|
||||
{{- range .Commands}}
|
||||
{{- if (and .IsAvailableCommand (ne (index .Annotations "workspaces") ""))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
{{- if .HasAvailableLocalFlags}}
|
||||
{{usageHeader "Flags:"}}
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}
|
||||
{{end}}
|
||||
|
||||
{{- if .HasAvailableInheritedFlags}}
|
||||
{{usageHeader "Global Flags:"}}
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}
|
||||
{{end}}
|
||||
|
||||
{{- if .HasHelpSubCommands}}
|
||||
{{usageHeader "Additional help topics:"}}
|
||||
{{- range .Commands}}
|
||||
{{- if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
{{- if .HasAvailableSubCommands}}
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.
|
||||
{{end}}`
|
||||
}
|
||||
|
||||
func versionTemplate() string {
|
||||
template := `Coder {{printf "%s" .Version}}`
|
||||
buildTime, valid := buildinfo.Time()
|
||||
@@ -157,3 +296,9 @@ func versionTemplate() string {
|
||||
template += "\r\n"
|
||||
return template
|
||||
}
|
||||
|
||||
// FormatCobraError colorizes and adds "--help" docs to cobra commands.
|
||||
func FormatCobraError(err error, cmd *cobra.Command) string {
|
||||
helpErrMsg := fmt.Sprintf("Run '%s --help' for usage.", cmd.CommandPath())
|
||||
return cliui.Styles.Error.Render(err.Error() + "\n" + helpErrMsg)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
)
|
||||
|
||||
func TestRoot(t *testing.T) {
|
||||
t.Run("FormatCobraError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, _ := clitest.New(t, "delete")
|
||||
|
||||
cmd, err := cmd.ExecuteC()
|
||||
errStr := cli.FormatCobraError(err, cmd)
|
||||
require.Contains(t, errStr, "Run 'coder delete --help' for usage.")
|
||||
})
|
||||
}
|
||||
+801
@@ -0,0 +1,801 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/coreos/go-systemd/daemon"
|
||||
"github.com/google/go-github/v43/github"
|
||||
"github.com/pion/turn/v2"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/spf13/cobra"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"golang.org/x/mod/semver"
|
||||
"golang.org/x/oauth2"
|
||||
xgithub "golang.org/x/oauth2/github"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/api/idtoken"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/autobuild/executor"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/databasefake"
|
||||
"github.com/coder/coder/coderd/devtunnel"
|
||||
"github.com/coder/coder/coderd/gitsshkey"
|
||||
"github.com/coder/coder/coderd/tracing"
|
||||
"github.com/coder/coder/coderd/turnconn"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
"github.com/coder/coder/provisioner/terraform"
|
||||
"github.com/coder/coder/provisionerd"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
||||
// nolint:gocyclo
|
||||
func server() *cobra.Command {
|
||||
var (
|
||||
accessURL string
|
||||
address string
|
||||
autobuildPollInterval time.Duration
|
||||
promEnabled bool
|
||||
promAddress string
|
||||
pprofEnabled bool
|
||||
pprofAddress string
|
||||
cacheDir string
|
||||
dev bool
|
||||
devUserEmail string
|
||||
devUserPassword string
|
||||
postgresURL string
|
||||
// provisionerDaemonCount is a uint8 to ensure a number > 0.
|
||||
provisionerDaemonCount uint8
|
||||
oauth2GithubClientID string
|
||||
oauth2GithubClientSecret string
|
||||
oauth2GithubAllowedOrganizations []string
|
||||
oauth2GithubAllowSignups bool
|
||||
tlsCertFile string
|
||||
tlsClientCAFile string
|
||||
tlsClientAuth string
|
||||
tlsEnable bool
|
||||
tlsKeyFile string
|
||||
tlsMinVersion string
|
||||
turnRelayAddress string
|
||||
tunnel bool
|
||||
stunServers []string
|
||||
trace bool
|
||||
secureAuthCookie bool
|
||||
sshKeygenAlgorithmRaw string
|
||||
spooky bool
|
||||
verbose bool
|
||||
)
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Start a Coder server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := slog.Make(sloghuman.Sink(os.Stderr))
|
||||
buildModeDev := semver.Prerelease(buildinfo.Version()) == "-devel"
|
||||
if verbose || buildModeDev {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
var (
|
||||
tracerProvider *sdktrace.TracerProvider
|
||||
err error
|
||||
sqlDriver = "postgres"
|
||||
)
|
||||
if trace {
|
||||
tracerProvider, err = tracing.TracerProvider(cmd.Context(), "coderd")
|
||||
if err != nil {
|
||||
logger.Warn(cmd.Context(), "failed to start telemetry exporter", slog.Error(err))
|
||||
} else {
|
||||
defer func() {
|
||||
// allow time for traces to flush even if command context is canceled
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = tracerProvider.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
d, err := tracing.PostgresDriver(tracerProvider, "coderd.database")
|
||||
if err != nil {
|
||||
logger.Warn(cmd.Context(), "failed to start postgres tracing driver", slog.Error(err))
|
||||
} else {
|
||||
sqlDriver = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printLogo(cmd, spooky)
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listen %q: %w", address, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
if tlsEnable {
|
||||
listener, err = configureTLS(listener, tlsMinVersion, tlsClientAuth, tlsCertFile, tlsKeyFile, tlsClientCAFile)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("configure tls: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
|
||||
if !valid {
|
||||
return xerrors.New("must be listening on tcp")
|
||||
}
|
||||
// If just a port is specified, assume localhost.
|
||||
if tcpAddr.IP.IsUnspecified() {
|
||||
tcpAddr.IP = net.IPv4(127, 0, 0, 1)
|
||||
}
|
||||
|
||||
localURL := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: tcpAddr.String(),
|
||||
}
|
||||
if tlsEnable {
|
||||
localURL.Scheme = "https"
|
||||
}
|
||||
if accessURL == "" {
|
||||
accessURL = localURL.String()
|
||||
} else {
|
||||
// If an access URL is specified, always skip tunneling.
|
||||
tunnel = false
|
||||
}
|
||||
|
||||
var (
|
||||
tunnelErrChan <-chan error
|
||||
ctxTunnel, closeTunnel = context.WithCancel(cmd.Context())
|
||||
)
|
||||
defer closeTunnel()
|
||||
|
||||
// If we're attempting to tunnel in dev-mode, the access URL
|
||||
// needs to be changed to use the tunnel.
|
||||
if dev && tunnel {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Wrap.Render(
|
||||
"Coder requires a URL accessible by workspaces you provision. "+
|
||||
"A free tunnel can be created for simple setup. This will "+
|
||||
"expose your Coder deployment to a publicly accessible URL. "+
|
||||
cliui.Styles.Field.Render("--access-url")+" can be specified instead.\n",
|
||||
))
|
||||
|
||||
// This skips the prompt if the flag is explicitly specified.
|
||||
if !cmd.Flags().Changed("tunnel") {
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Would you like to start a tunnel for simple setup?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
accessURL, tunnelErrChan, err = devtunnel.New(ctxTunnel, localURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tunnel: %w", err)
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
}
|
||||
|
||||
validator, err := idtoken.NewValidator(cmd.Context(), option.WithoutAuthentication())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessURLParsed, err := url.Parse(accessURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse access url %q: %w", accessURL, err)
|
||||
}
|
||||
|
||||
sshKeygenAlgorithm, err := gitsshkey.ParseAlgorithm(sshKeygenAlgorithmRaw)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse ssh keygen algorithm %s: %w", sshKeygenAlgorithmRaw, err)
|
||||
}
|
||||
|
||||
turnServer, err := turnconn.New(&turn.RelayAddressGeneratorStatic{
|
||||
RelayAddress: net.ParseIP(turnRelayAddress),
|
||||
Address: turnRelayAddress,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create turn server: %w", err)
|
||||
}
|
||||
|
||||
iceServers := make([]webrtc.ICEServer, 0)
|
||||
for _, stunServer := range stunServers {
|
||||
iceServers = append(iceServers, webrtc.ICEServer{
|
||||
URLs: []string{stunServer},
|
||||
})
|
||||
}
|
||||
options := &coderd.Options{
|
||||
AccessURL: accessURLParsed,
|
||||
ICEServers: iceServers,
|
||||
Logger: logger.Named("coderd"),
|
||||
Database: databasefake.New(),
|
||||
Pubsub: database.NewPubsubInMemory(),
|
||||
GoogleTokenValidator: validator,
|
||||
SecureAuthCookie: secureAuthCookie,
|
||||
SSHKeygenAlgorithm: sshKeygenAlgorithm,
|
||||
TURNServer: turnServer,
|
||||
TracerProvider: tracerProvider,
|
||||
}
|
||||
|
||||
if oauth2GithubClientSecret != "" {
|
||||
options.GithubOAuth2Config, err = configureGithubOAuth2(accessURLParsed, oauth2GithubClientID, oauth2GithubClientSecret, oauth2GithubAllowSignups, oauth2GithubAllowedOrganizations)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("configure github oauth2: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "access-url: %s\n", accessURL)
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "provisioner-daemons: %d\n", provisionerDaemonCount)
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
|
||||
if !dev {
|
||||
sqlDB, err := sql.Open(sqlDriver, postgresURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dial postgres: %w", err)
|
||||
}
|
||||
err = sqlDB.Ping()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
err = database.MigrateUp(sqlDB)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("migrate up: %w", err)
|
||||
}
|
||||
options.Database = database.New(sqlDB)
|
||||
options.Pubsub, err = database.NewPubsub(cmd.Context(), sqlDB, postgresURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create pubsub: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
coderAPI := coderd.New(options)
|
||||
client := codersdk.New(localURL)
|
||||
if tlsEnable {
|
||||
// Secure transport isn't needed for locally communicating!
|
||||
client.HTTPClient.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// This prevents the pprof import from being accidentally deleted.
|
||||
var _ = pprof.Handler
|
||||
if pprofEnabled {
|
||||
//nolint:revive
|
||||
defer serveHandler(cmd.Context(), logger, nil, pprofAddress, "pprof")()
|
||||
}
|
||||
if promEnabled {
|
||||
//nolint:revive
|
||||
defer serveHandler(cmd.Context(), logger, promhttp.Handler(), promAddress, "prometheus")()
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
provisionerDaemons := make([]*provisionerd.Server, 0)
|
||||
for i := 0; uint8(i) < provisionerDaemonCount; i++ {
|
||||
daemonClose, err := newProvisionerDaemon(cmd.Context(), coderAPI, logger, cacheDir, errCh, dev)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create provisioner daemon: %w", err)
|
||||
}
|
||||
provisionerDaemons = append(provisionerDaemons, daemonClose)
|
||||
}
|
||||
defer func() {
|
||||
for _, provisionerDaemon := range provisionerDaemons {
|
||||
_ = provisionerDaemon.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
shutdownConnsCtx, shutdownConns := context.WithCancel(cmd.Context())
|
||||
defer shutdownConns()
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
server := http.Server{
|
||||
// These errors are typically noise like "TLS: EOF". Vault does similar:
|
||||
// https://github.com/hashicorp/vault/blob/e2490059d0711635e529a4efcbaa1b26998d6e1c/command/server.go#L2714
|
||||
ErrorLog: log.New(io.Discard, "", 0),
|
||||
Handler: coderAPI.Handler,
|
||||
BaseContext: func(_ net.Listener) context.Context {
|
||||
return shutdownConnsCtx
|
||||
},
|
||||
}
|
||||
errCh <- server.Serve(listener)
|
||||
}()
|
||||
|
||||
config := createConfig(cmd)
|
||||
|
||||
if dev {
|
||||
if devUserPassword == "" {
|
||||
devUserPassword, err = cryptorand.String(10)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("generate random admin password for dev: %w", err)
|
||||
}
|
||||
}
|
||||
restorePreviousSession, err := createFirstUser(logger, cmd, client, config, devUserEmail, devUserPassword)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create first user: %w", err)
|
||||
}
|
||||
defer restorePreviousSession()
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "email: %s\n", devUserEmail)
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "password: %s\n", devUserPassword)
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), cliui.Styles.Wrap.Render(`Started in dev mode. All data is in-memory! `+cliui.Styles.Bold.Render("Do not use in production")+`. Press `+
|
||||
cliui.Styles.Field.Render("ctrl+c")+` to clean up provisioned infrastructure.`)+"\n\n")
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), cliui.Styles.Wrap.Render(`Run `+cliui.Styles.Code.Render("coder templates init")+
|
||||
" in a new terminal to start creating workspaces.")+"\n")
|
||||
} else {
|
||||
// This is helpful for tests, but can be silently ignored.
|
||||
// Coder may be ran as users that don't have permission to write in the homedir,
|
||||
// such as via the systemd service.
|
||||
_ = config.URL().Write(client.URL.String())
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+
|
||||
cliui.Styles.Field.Render("production")+` mode. All data is stored in the PostgreSQL provided! Press `+cliui.Styles.Field.Render("ctrl+c")+` to gracefully shutdown.`))+"\n")
|
||||
|
||||
hasFirstUser, err := client.HasFirstUser(cmd.Context())
|
||||
if !hasFirstUser && err == nil {
|
||||
// This could fail for a variety of TLS-related reasons.
|
||||
// This is a helpful starter message, and not critical for user interaction.
|
||||
_, _ = fmt.Fprint(cmd.ErrOrStderr(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.FocusedPrompt.String()+`Run `+cliui.Styles.Code.Render("coder login "+accessURL)+" in a new terminal to get started.\n")))
|
||||
}
|
||||
}
|
||||
|
||||
// Updates the systemd status from activating to activated.
|
||||
_, err = daemon.SdNotify(false, daemon.SdNotifyReady)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("notify systemd: %w", err)
|
||||
}
|
||||
|
||||
autobuildPoller := time.NewTicker(autobuildPollInterval)
|
||||
defer autobuildPoller.Stop()
|
||||
autobuildExecutor := executor.New(cmd.Context(), options.Database, logger, autobuildPoller.C)
|
||||
autobuildExecutor.Run()
|
||||
|
||||
// Because the graceful shutdown includes cleaning up workspaces in dev mode, we're
|
||||
// going to make it harder to accidentally skip the graceful shutdown by hitting ctrl+c
|
||||
// two or more times. So the stopChan is unlimited in size and we don't call
|
||||
// signal.Stop() until graceful shutdown finished--this means we swallow additional
|
||||
// SIGINT after the first. To get out of a graceful shutdown, the user can send SIGQUIT
|
||||
// with ctrl+\ or SIGTERM with `kill`.
|
||||
stopChan := make(chan os.Signal, 1)
|
||||
defer signal.Stop(stopChan)
|
||||
signal.Notify(stopChan, os.Interrupt)
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
coderAPI.Close()
|
||||
return cmd.Context().Err()
|
||||
case err := <-tunnelErrChan:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case err := <-errCh:
|
||||
shutdownConns()
|
||||
coderAPI.Close()
|
||||
return err
|
||||
case <-stopChan:
|
||||
}
|
||||
_, err = daemon.SdNotify(false, daemon.SdNotifyStopping)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("notify systemd: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n\n"+
|
||||
cliui.Styles.Bold.Render(
|
||||
"Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit"))
|
||||
|
||||
if dev {
|
||||
workspaces, err := client.Workspaces(cmd.Context(), codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspaces: %w", err)
|
||||
}
|
||||
for _, workspace := range workspaces {
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionDelete,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete workspace: %w", err)
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete workspace %s: %w", workspace.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, provisionerDaemon := range provisionerDaemons {
|
||||
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
spin.Suffix = cliui.Styles.Keyword.Render(" Shutting down provisioner daemon...")
|
||||
spin.Start()
|
||||
err = provisionerDaemon.Shutdown(cmd.Context())
|
||||
if err != nil {
|
||||
spin.FinalMSG = cliui.Styles.Prompt.String() + "Failed to shutdown provisioner daemon: " + err.Error()
|
||||
spin.Stop()
|
||||
}
|
||||
err = provisionerDaemon.Close()
|
||||
if err != nil {
|
||||
spin.Stop()
|
||||
return xerrors.Errorf("close provisioner daemon: %w", err)
|
||||
}
|
||||
spin.FinalMSG = cliui.Styles.Prompt.String() + "Gracefully shut down provisioner daemon!\n"
|
||||
spin.Stop()
|
||||
}
|
||||
|
||||
if dev && tunnel {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Waiting for dev tunnel to close...\n")
|
||||
closeTunnel()
|
||||
<-tunnelErrChan
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Waiting for WebSocket connections to close...\n")
|
||||
shutdownConns()
|
||||
coderAPI.Close()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cliflag.DurationVarP(root.Flags(), &autobuildPollInterval, "autobuild-poll-interval", "", "CODER_AUTOBUILD_POLL_INTERVAL", time.Minute, "Specifies the interval at which to poll for and execute automated workspace build operations.")
|
||||
cliflag.StringVarP(root.Flags(), &accessURL, "access-url", "", "CODER_ACCESS_URL", "", "Specifies the external URL to access Coder.")
|
||||
cliflag.StringVarP(root.Flags(), &address, "address", "a", "CODER_ADDRESS", "127.0.0.1:3000", "The address to serve the API and dashboard.")
|
||||
cliflag.BoolVarP(root.Flags(), &promEnabled, "prometheus-enable", "", "CODER_PROMETHEUS_ENABLE", false, "Enable serving prometheus metrics on the addressdefined by --prometheus-address.")
|
||||
cliflag.StringVarP(root.Flags(), &promAddress, "prometheus-address", "", "CODER_PROMETHEUS_ADDRESS", "127.0.0.1:2112", "The address to serve prometheus metrics.")
|
||||
cliflag.BoolVarP(root.Flags(), &pprofEnabled, "pprof-enable", "", "CODER_PPROF_ENABLE", false, "Enable serving pprof metrics on the address defined by --pprof-address.")
|
||||
cliflag.StringVarP(root.Flags(), &pprofAddress, "pprof-address", "", "CODER_PPROF_ADDRESS", "127.0.0.1:6060", "The address to serve pprof.")
|
||||
// systemd uses the CACHE_DIRECTORY environment variable!
|
||||
cliflag.StringVarP(root.Flags(), &cacheDir, "cache-dir", "", "CACHE_DIRECTORY", filepath.Join(os.TempDir(), "coder-cache"), "Specifies a directory to cache binaries for provision operations.")
|
||||
cliflag.BoolVarP(root.Flags(), &dev, "dev", "", "CODER_DEV_MODE", false, "Serve Coder in dev mode for tinkering")
|
||||
cliflag.StringVarP(root.Flags(), &devUserEmail, "dev-admin-email", "", "CODER_DEV_ADMIN_EMAIL", "admin@coder.com", "Specifies the admin email to be used in dev mode (--dev)")
|
||||
cliflag.StringVarP(root.Flags(), &devUserPassword, "dev-admin-password", "", "CODER_DEV_ADMIN_PASSWORD", "", "Specifies the admin password to be used in dev mode (--dev) instead of a randomly generated one")
|
||||
cliflag.StringVarP(root.Flags(), &postgresURL, "postgres-url", "", "CODER_PG_CONNECTION_URL", "", "URL of a PostgreSQL database to connect to")
|
||||
cliflag.Uint8VarP(root.Flags(), &provisionerDaemonCount, "provisioner-daemons", "", "CODER_PROVISIONER_DAEMONS", 3, "The amount of provisioner daemons to create on start.")
|
||||
cliflag.StringVarP(root.Flags(), &oauth2GithubClientID, "oauth2-github-client-id", "", "CODER_OAUTH2_GITHUB_CLIENT_ID", "",
|
||||
"Specifies a client ID to use for oauth2 with GitHub.")
|
||||
cliflag.StringVarP(root.Flags(), &oauth2GithubClientSecret, "oauth2-github-client-secret", "", "CODER_OAUTH2_GITHUB_CLIENT_SECRET", "",
|
||||
"Specifies a client secret to use for oauth2 with GitHub.")
|
||||
cliflag.StringArrayVarP(root.Flags(), &oauth2GithubAllowedOrganizations, "oauth2-github-allowed-orgs", "", "CODER_OAUTH2_GITHUB_ALLOWED_ORGS", nil,
|
||||
"Specifies organizations the user must be a member of to authenticate with GitHub.")
|
||||
cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false,
|
||||
"Specifies whether new users can sign up with GitHub.")
|
||||
cliflag.BoolVarP(root.Flags(), &tlsEnable, "tls-enable", "", "CODER_TLS_ENABLE", false, "Specifies if TLS will be enabled")
|
||||
cliflag.StringVarP(root.Flags(), &tlsCertFile, "tls-cert-file", "", "CODER_TLS_CERT_FILE", "",
|
||||
"Specifies the path to the certificate for TLS. It requires a PEM-encoded file. "+
|
||||
"To configure the listener to use a CA certificate, concatenate the primary certificate "+
|
||||
"and the CA certificate together. The primary certificate should appear first in the combined file")
|
||||
cliflag.StringVarP(root.Flags(), &tlsClientCAFile, "tls-client-ca-file", "", "CODER_TLS_CLIENT_CA_FILE", "",
|
||||
"PEM-encoded Certificate Authority file used for checking the authenticity of client")
|
||||
cliflag.StringVarP(root.Flags(), &tlsClientAuth, "tls-client-auth", "", "CODER_TLS_CLIENT_AUTH", "request",
|
||||
`Specifies the policy the server will follow for TLS Client Authentication. `+
|
||||
`Accepted values are "none", "request", "require-any", "verify-if-given", or "require-and-verify"`)
|
||||
cliflag.StringVarP(root.Flags(), &tlsKeyFile, "tls-key-file", "", "CODER_TLS_KEY_FILE", "",
|
||||
"Specifies the path to the private key for the certificate. It requires a PEM-encoded file")
|
||||
cliflag.StringVarP(root.Flags(), &tlsMinVersion, "tls-min-version", "", "CODER_TLS_MIN_VERSION", "tls12",
|
||||
`Specifies the minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13"`)
|
||||
cliflag.BoolVarP(root.Flags(), &tunnel, "tunnel", "", "CODER_DEV_TUNNEL", true,
|
||||
"Specifies whether the dev tunnel will be enabled or not. If specified, the interactive prompt will not display.")
|
||||
cliflag.StringArrayVarP(root.Flags(), &stunServers, "stun-server", "", "CODER_STUN_SERVERS", []string{
|
||||
"stun:stun.l.google.com:19302",
|
||||
}, "Specify URLs for STUN servers to enable P2P connections.")
|
||||
cliflag.BoolVarP(root.Flags(), &trace, "trace", "", "CODER_TRACE", false, "Specifies if application tracing data is collected")
|
||||
cliflag.StringVarP(root.Flags(), &turnRelayAddress, "turn-relay-address", "", "CODER_TURN_RELAY_ADDRESS", "127.0.0.1",
|
||||
"Specifies the address to bind TURN connections.")
|
||||
cliflag.BoolVarP(root.Flags(), &secureAuthCookie, "secure-auth-cookie", "", "CODER_SECURE_AUTH_COOKIE", false, "Specifies if the 'Secure' property is set on browser session cookies")
|
||||
cliflag.StringVarP(root.Flags(), &sshKeygenAlgorithmRaw, "ssh-keygen-algorithm", "", "CODER_SSH_KEYGEN_ALGORITHM", "ed25519", "Specifies the algorithm to use for generating ssh keys. "+
|
||||
`Accepted values are "ed25519", "ecdsa", or "rsa4096"`)
|
||||
cliflag.BoolVarP(root.Flags(), &spooky, "spooky", "", "", false, "Specifies spookiness level")
|
||||
cliflag.BoolVarP(root.Flags(), &verbose, "verbose", "v", "CODER_VERBOSE", false, "Enables verbose logging.")
|
||||
_ = root.Flags().MarkHidden("spooky")
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// createFirstUser creates the first user and sets a valid session.
|
||||
// Caller must call restorePreviousSession on server exit.
|
||||
func createFirstUser(logger slog.Logger, cmd *cobra.Command, client *codersdk.Client, cfg config.Root, email, password string) (func(), error) {
|
||||
if email == "" {
|
||||
return nil, xerrors.New("email is empty")
|
||||
}
|
||||
if password == "" {
|
||||
return nil, xerrors.New("password is empty")
|
||||
}
|
||||
_, err := client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: "developer",
|
||||
Password: password,
|
||||
OrganizationName: "acme-corp",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create first user: %w", err)
|
||||
}
|
||||
token, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("login with first user: %w", err)
|
||||
}
|
||||
client.SessionToken = token.SessionToken
|
||||
|
||||
// capture the current session and if exists recover session on server exit
|
||||
restorePreviousSession := func() {}
|
||||
oldURL, _ := cfg.URL().Read()
|
||||
oldSession, _ := cfg.Session().Read()
|
||||
if oldURL != "" && oldSession != "" {
|
||||
restorePreviousSession = func() {
|
||||
currentURL, err := cfg.URL().Read()
|
||||
if err != nil {
|
||||
logger.Error(cmd.Context(), "failed to read current session url", slog.Error(err))
|
||||
return
|
||||
}
|
||||
currentSession, err := cfg.Session().Read()
|
||||
if err != nil {
|
||||
logger.Error(cmd.Context(), "failed to read current session token", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// if it's changed since we wrote to it don't restore session
|
||||
if currentURL != client.URL.String() ||
|
||||
currentSession != token.SessionToken {
|
||||
return
|
||||
}
|
||||
|
||||
err = cfg.URL().Write(oldURL)
|
||||
if err != nil {
|
||||
logger.Error(cmd.Context(), "failed to recover previous session url", slog.Error(err))
|
||||
return
|
||||
}
|
||||
err = cfg.Session().Write(oldSession)
|
||||
if err != nil {
|
||||
logger.Error(cmd.Context(), "failed to recover previous session token", slog.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = cfg.URL().Write(client.URL.String())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("write local url: %w", err)
|
||||
}
|
||||
err = cfg.Session().Write(token.SessionToken)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("write session token: %w", err)
|
||||
}
|
||||
|
||||
return restorePreviousSession, nil
|
||||
}
|
||||
|
||||
// nolint:revive
|
||||
func newProvisionerDaemon(ctx context.Context, coderAPI *coderd.API,
|
||||
logger slog.Logger, cacheDir string, errChan chan error, dev bool) (*provisionerd.Server, error) {
|
||||
err := os.MkdirAll(cacheDir, 0700)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("mkdir %q: %w", cacheDir, err)
|
||||
}
|
||||
|
||||
terraformClient, terraformServer := provisionersdk.TransportPipe()
|
||||
go func() {
|
||||
err := terraform.Serve(ctx, &terraform.ServeOptions{
|
||||
ServeOptions: &provisionersdk.ServeOptions{
|
||||
Listener: terraformServer,
|
||||
},
|
||||
CachePath: cacheDir,
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "provisionerd")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provisioners := provisionerd.Provisioners{
|
||||
string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(terraformClient)),
|
||||
}
|
||||
// include echo provisioner when in dev mode
|
||||
if dev {
|
||||
echoClient, echoServer := provisionersdk.TransportPipe()
|
||||
go func() {
|
||||
err := echo.Serve(ctx, &provisionersdk.ServeOptions{Listener: echoServer})
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
provisioners[string(database.ProvisionerTypeEcho)] = proto.NewDRPCProvisionerClient(provisionersdk.Conn(echoClient))
|
||||
}
|
||||
return provisionerd.New(coderAPI.ListenProvisionerDaemon, &provisionerd.Options{
|
||||
Logger: logger,
|
||||
PollInterval: 500 * time.Millisecond,
|
||||
UpdateInterval: 500 * time.Millisecond,
|
||||
Provisioners: provisioners,
|
||||
WorkDirectory: tempDir,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// nolint: revive
|
||||
func printLogo(cmd *cobra.Command, spooky bool) {
|
||||
if spooky {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), `
|
||||
▄████▄ ▒█████ ▓█████▄ ▓█████ ██▀███
|
||||
▒██▀ ▀█ ▒██▒ ██▒▒██▀ ██▌▓█ ▀ ▓██ ▒ ██▒
|
||||
▒▓█ ▄ ▒██░ ██▒░██ █▌▒███ ▓██ ░▄█ ▒
|
||||
▒▓▓▄ ▄██▒▒██ ██░░▓█▄ ▌▒▓█ ▄ ▒██▀▀█▄
|
||||
▒ ▓███▀ ░░ ████▓▒░░▒████▓ ░▒████▒░██▓ ▒██▒
|
||||
░ ░▒ ▒ ░░ ▒░▒░▒░ ▒▒▓ ▒ ░░ ▒░ ░░ ▒▓ ░▒▓░
|
||||
░ ▒ ░ ▒ ▒░ ░ ▒ ▒ ░ ░ ░ ░▒ ░ ▒░
|
||||
░ ░ ░ ░ ▒ ░ ░ ░ ░ ░░ ░
|
||||
░ ░ ░ ░ ░ ░ ░ ░
|
||||
░ ░
|
||||
|
||||
`)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), ` ▄█▀ ▀█▄
|
||||
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
|
||||
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
|
||||
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
|
||||
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
|
||||
|
||||
`)
|
||||
}
|
||||
|
||||
func configureTLS(listener net.Listener, tlsMinVersion, tlsClientAuth, tlsCertFile, tlsKeyFile, tlsClientCAFile string) (net.Listener, error) {
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
switch tlsMinVersion {
|
||||
case "tls10":
|
||||
tlsConfig.MinVersion = tls.VersionTLS10
|
||||
case "tls11":
|
||||
tlsConfig.MinVersion = tls.VersionTLS11
|
||||
case "tls12":
|
||||
tlsConfig.MinVersion = tls.VersionTLS12
|
||||
case "tls13":
|
||||
tlsConfig.MinVersion = tls.VersionTLS13
|
||||
default:
|
||||
return nil, xerrors.Errorf("unrecognized tls version: %q", tlsMinVersion)
|
||||
}
|
||||
|
||||
switch tlsClientAuth {
|
||||
case "none":
|
||||
tlsConfig.ClientAuth = tls.NoClientCert
|
||||
case "request":
|
||||
tlsConfig.ClientAuth = tls.RequestClientCert
|
||||
case "require-any":
|
||||
tlsConfig.ClientAuth = tls.RequireAnyClientCert
|
||||
case "verify-if-given":
|
||||
tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven
|
||||
case "require-and-verify":
|
||||
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
default:
|
||||
return nil, xerrors.Errorf("unrecognized tls client auth: %q", tlsClientAuth)
|
||||
}
|
||||
|
||||
if tlsCertFile == "" {
|
||||
return nil, xerrors.New("tls-cert-file is required when tls is enabled")
|
||||
}
|
||||
if tlsKeyFile == "" {
|
||||
return nil, xerrors.New("tls-key-file is required when tls is enabled")
|
||||
}
|
||||
|
||||
certPEMBlock, err := os.ReadFile(tlsCertFile)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read file %q: %w", tlsCertFile, err)
|
||||
}
|
||||
keyPEMBlock, err := os.ReadFile(tlsKeyFile)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read file %q: %w", tlsKeyFile, err)
|
||||
}
|
||||
keyBlock, _ := pem.Decode(keyPEMBlock)
|
||||
if keyBlock == nil {
|
||||
return nil, xerrors.New("decoded pem is blank")
|
||||
}
|
||||
cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create key pair: %w", err)
|
||||
}
|
||||
tlsConfig.GetCertificate = func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
certPool := x509.NewCertPool()
|
||||
certPool.AppendCertsFromPEM(certPEMBlock)
|
||||
tlsConfig.RootCAs = certPool
|
||||
|
||||
if tlsClientCAFile != "" {
|
||||
caPool := x509.NewCertPool()
|
||||
data, err := os.ReadFile(tlsClientCAFile)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read %q: %w", tlsClientCAFile, err)
|
||||
}
|
||||
if !caPool.AppendCertsFromPEM(data) {
|
||||
return nil, xerrors.Errorf("failed to parse CA certificate in tls-client-ca-file")
|
||||
}
|
||||
tlsConfig.ClientCAs = caPool
|
||||
}
|
||||
|
||||
return tls.NewListener(listener, tlsConfig), nil
|
||||
}
|
||||
|
||||
func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, allowSignups bool, allowOrgs []string) (*coderd.GithubOAuth2Config, error) {
|
||||
redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback")
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse github oauth callback url: %w", err)
|
||||
}
|
||||
return &coderd.GithubOAuth2Config{
|
||||
OAuth2Config: &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
Endpoint: xgithub.Endpoint,
|
||||
RedirectURL: redirectURL.String(),
|
||||
Scopes: []string{
|
||||
"read:user",
|
||||
"read:org",
|
||||
"user:email",
|
||||
},
|
||||
},
|
||||
AllowSignups: allowSignups,
|
||||
AllowOrganizations: allowOrgs,
|
||||
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
|
||||
user, _, err := github.NewClient(client).Users.Get(ctx, "")
|
||||
return user, err
|
||||
},
|
||||
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
|
||||
emails, _, err := github.NewClient(client).Users.ListEmails(ctx, &github.ListOptions{})
|
||||
return emails, err
|
||||
},
|
||||
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
|
||||
memberships, _, err := github.NewClient(client).Organizations.ListOrgMemberships(ctx, &github.ListOrgMembershipsOptions{
|
||||
State: "active",
|
||||
})
|
||||
return memberships, err
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serveHandler(ctx context.Context, logger slog.Logger, handler http.Handler, addr, name string) (closeFunc func()) {
|
||||
logger.Debug(ctx, "http server listening", slog.F("addr", addr), slog.F("name", name))
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: handler}
|
||||
go func() {
|
||||
err := srv.ListenAndServe()
|
||||
if err != nil && !xerrors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error(ctx, "http server listen", slog.F("name", name), slog.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
return func() { _ = srv.Close() }
|
||||
}
|
||||
@@ -9,15 +9,18 @@ import (
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
|
||||
@@ -28,10 +31,11 @@ import (
|
||||
)
|
||||
|
||||
// This cannot be ran in parallel because it uses a signal.
|
||||
// nolint:tparallel
|
||||
func TestStart(t *testing.T) {
|
||||
// nolint:paralleltest
|
||||
func TestServer(t *testing.T) {
|
||||
t.Run("Production", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// postgres.Open() seems to be creating race conditions when run in parallel.
|
||||
// t.Parallel()
|
||||
if runtime.GOOS != "linux" || testing.Short() {
|
||||
// Skip on non-Linux because it spawns a PostgreSQL instance.
|
||||
t.SkipNow()
|
||||
@@ -40,12 +44,11 @@ func TestStart(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer closeFunc()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
root, cfg := clitest.New(t, "start", "--address", ":0", "--postgres-url", connectionURL)
|
||||
defer cancelFunc()
|
||||
root, cfg := clitest.New(t, "server", "--address", ":0", "--postgres-url", connectionURL)
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
defer close(done)
|
||||
err = root.ExecuteContext(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
var client *codersdk.Client
|
||||
require.Eventually(t, func() bool {
|
||||
@@ -54,7 +57,7 @@ func TestStart(t *testing.T) {
|
||||
return false
|
||||
}
|
||||
accessURL, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
client = codersdk.New(accessURL)
|
||||
return true
|
||||
}, 15*time.Second, 25*time.Millisecond)
|
||||
@@ -66,17 +69,77 @@ func TestStart(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
cancelFunc()
|
||||
<-done
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
})
|
||||
|
||||
t.Run("Development", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, cfg := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0")
|
||||
|
||||
wantEmail := "admin@coder.com"
|
||||
|
||||
root, cfg := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0")
|
||||
var buf strings.Builder
|
||||
errC := make(chan error)
|
||||
root.SetOutput(&buf)
|
||||
go func() {
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
var token string
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
token, err = cfg.Session().Read()
|
||||
return err == nil && token != ""
|
||||
}, 15*time.Second, 25*time.Millisecond)
|
||||
|
||||
// Verify that authentication was properly set in dev-mode.
|
||||
accessURL, err := cfg.URL().Read()
|
||||
require.NoError(t, err)
|
||||
parsed, err := url.Parse(accessURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
client := codersdk.New(parsed)
|
||||
client.SessionToken = token
|
||||
_, err = client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err, "token:", token)
|
||||
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
|
||||
// Verify that credentials were output to the terminal.
|
||||
assert.Contains(t, buf.String(), fmt.Sprintf("email: %s", wantEmail), "expected output %q; got no match", wantEmail)
|
||||
// Check that the password line is output and that it's non-empty.
|
||||
if _, after, found := strings.Cut(buf.String(), "password: "); found {
|
||||
before, _, _ := strings.Cut(after, "\n")
|
||||
before = strings.Trim(before, "\r") // Ensure no control character is left.
|
||||
assert.NotEmpty(t, before, "expected non-empty password; got empty")
|
||||
} else {
|
||||
t.Error("expected password line output; got no match")
|
||||
}
|
||||
})
|
||||
|
||||
// Duplicated test from "Development" above to test setting email/password via env.
|
||||
// Cannot run parallel due to os.Setenv.
|
||||
//nolint:paralleltest
|
||||
t.Run("Development with email and password from env", func(t *testing.T) {
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
wantEmail := "myadmin@coder.com"
|
||||
wantPassword := "testpass42"
|
||||
t.Setenv("CODER_DEV_ADMIN_EMAIL", wantEmail)
|
||||
t.Setenv("CODER_DEV_ADMIN_PASSWORD", wantPassword)
|
||||
|
||||
root, cfg := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0")
|
||||
var buf strings.Builder
|
||||
root.SetOutput(&buf)
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
var token string
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
@@ -92,12 +155,19 @@ func TestStart(t *testing.T) {
|
||||
client.SessionToken = token
|
||||
_, err = client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
// Verify that credentials were output to the terminal.
|
||||
assert.Contains(t, buf.String(), fmt.Sprintf("email: %s", wantEmail), "expected output %q; got no match", wantEmail)
|
||||
assert.Contains(t, buf.String(), fmt.Sprintf("password: %s", wantPassword), "expected output %q; got no match", wantPassword)
|
||||
})
|
||||
|
||||
t.Run("TLSBadVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0",
|
||||
root, _ := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0",
|
||||
"--tls-enable", "--tls-min-version", "tls9")
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
@@ -106,7 +176,7 @@ func TestStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0",
|
||||
root, _ := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0",
|
||||
"--tls-enable", "--tls-client-auth", "something")
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
@@ -115,7 +185,7 @@ func TestStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0",
|
||||
root, _ := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0",
|
||||
"--tls-enable")
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
@@ -126,12 +196,14 @@ func TestStart(t *testing.T) {
|
||||
defer cancelFunc()
|
||||
|
||||
certPath, keyPath := generateTLSCertificate(t)
|
||||
root, cfg := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0",
|
||||
root, cfg := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0",
|
||||
"--tls-enable", "--tls-cert-file", certPath, "--tls-key-file", keyPath)
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
// Verify HTTPS
|
||||
var accessURLRaw string
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
@@ -152,6 +224,9 @@ func TestStart(t *testing.T) {
|
||||
}
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
})
|
||||
// This cannot be ran in parallel because it uses a signal.
|
||||
//nolint:paralleltest
|
||||
@@ -162,12 +237,11 @@ func TestStart(t *testing.T) {
|
||||
}
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, cfg := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", "--provisioner-daemons", "0")
|
||||
done := make(chan struct{})
|
||||
root, cfg := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0", "--provisioner-daemons", "1")
|
||||
serverErr := make(chan error)
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
serverErr <- err
|
||||
}()
|
||||
var token string
|
||||
require.Eventually(t, func() bool {
|
||||
@@ -184,13 +258,12 @@ func TestStart(t *testing.T) {
|
||||
client.SessionToken = token
|
||||
orgs, err := client.OrganizationsByUser(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
|
||||
// Create a workspace so the cleanup occurs!
|
||||
version := coderdtest.CreateTemplateVersion(t, client, orgs[0].ID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, orgs[0].ID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, orgs[0].ID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -198,21 +271,26 @@ func TestStart(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
err = currentProcess.Signal(os.Interrupt)
|
||||
require.NoError(t, err)
|
||||
<-done
|
||||
// Send a two more signal, which should be ignored. Send 2 because the channel has a buffer
|
||||
// of 1 and we want to make sure that nothing strange happens if we exceed the buffer.
|
||||
err = currentProcess.Signal(os.Interrupt)
|
||||
require.NoError(t, err)
|
||||
err = currentProcess.Signal(os.Interrupt)
|
||||
require.NoError(t, err)
|
||||
err = <-serverErr
|
||||
require.NoError(t, err)
|
||||
})
|
||||
t.Run("DatadogTracerNoLeak", func(t *testing.T) {
|
||||
t.Run("TracerNoLeak", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, _ := clitest.New(t, "start", "--dev", "--tunnel=false", "--address", ":0", "--trace-datadog=true")
|
||||
done := make(chan struct{})
|
||||
root, _ := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0", "--trace=true")
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
cancelFunc()
|
||||
<-done
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
require.Error(t, goleak.Find())
|
||||
})
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
func show() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "show",
|
||||
Short: "Show details of a workspace's resources and agents",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace: %w", err)
|
||||
}
|
||||
resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace resources: %w", err)
|
||||
}
|
||||
return cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: workspace.Name,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
+195
-78
@@ -2,112 +2,91 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gen2brain/beeep"
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/spf13/cobra"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
gosshagent "golang.org/x/crypto/ssh/agent"
|
||||
"golang.org/x/term"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/autobuild/notify"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
)
|
||||
|
||||
var workspacePollInterval = time.Minute
|
||||
var autostopNotifyCountdown = []time.Duration{30 * time.Minute}
|
||||
|
||||
func ssh() *cobra.Command {
|
||||
var (
|
||||
stdio bool
|
||||
stdio bool
|
||||
shuffle bool
|
||||
forwardAgent bool
|
||||
identityAgent string
|
||||
wsPollInterval time.Duration
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "ssh <workspace> [resource]",
|
||||
Annotations: workspaceCommand,
|
||||
Use: "ssh <workspace>",
|
||||
Short: "SSH into a workspace",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if workspace.LatestBuild.Transition != database.WorkspaceTransitionStart {
|
||||
return xerrors.New("workspace must be in start transition to ssh")
|
||||
}
|
||||
|
||||
if workspace.LatestBuild.Job.CompletedAt == nil {
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt)
|
||||
if shuffle {
|
||||
err := cobra.ExactArgs(0)(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
err := cobra.MinimumNArgs(1)(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if workspace.LatestBuild.Transition == database.WorkspaceTransitionDelete {
|
||||
return xerrors.New("workspace is deleting...")
|
||||
}
|
||||
|
||||
resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
|
||||
workspace, agent, err := getWorkspaceAndAgent(cmd, client, codersdk.Me, args[0], shuffle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resourceByAddress := make(map[string]codersdk.WorkspaceResource)
|
||||
for _, resource := range resources {
|
||||
if resource.Agent == nil {
|
||||
continue
|
||||
}
|
||||
resourceByAddress[resource.Address] = resource
|
||||
}
|
||||
|
||||
var resourceAddress string
|
||||
if len(args) >= 2 {
|
||||
resourceAddress = args[1]
|
||||
} else {
|
||||
// No resource name was provided!
|
||||
if len(resourceByAddress) > 1 {
|
||||
// List available resources to connect into?
|
||||
return xerrors.Errorf("multiple agents")
|
||||
}
|
||||
for _, resource := range resourceByAddress {
|
||||
resourceAddress = resource.Address
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
resource, exists := resourceByAddress[resourceAddress]
|
||||
if !exists {
|
||||
resourceKeys := make([]string, 0)
|
||||
for resourceKey := range resourceByAddress {
|
||||
resourceKeys = append(resourceKeys, resourceKey)
|
||||
}
|
||||
return xerrors.Errorf("no sshable agent with address %q: %+v", resourceAddress, resourceKeys)
|
||||
}
|
||||
// OpenSSH passes stderr directly to the calling TTY.
|
||||
// This is required in "stdio" mode so a connecting indicator can be displayed.
|
||||
err = cliui.Agent(cmd.Context(), cmd.ErrOrStderr(), cliui.AgentOptions{
|
||||
WorkspaceName: workspace.Name,
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) {
|
||||
return client.WorkspaceResource(ctx, resource.ID)
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
return client.WorkspaceAgent(ctx, agent.ID)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
|
||||
conn, err := client.DialWorkspaceAgent(cmd.Context(), resource.ID, []webrtc.ICEServer{{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
}}, nil)
|
||||
conn, err := client.DialWorkspaceAgent(cmd.Context(), agent.ID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
stopPolling := tryPollWorkspaceAutostop(cmd.Context(), client, workspace)
|
||||
defer stopPolling()
|
||||
|
||||
if stdio {
|
||||
rawSSH, err := conn.SSH()
|
||||
if err != nil {
|
||||
@@ -129,7 +108,22 @@ func ssh() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
if isatty.IsTerminal(os.Stdout.Fd()) {
|
||||
if identityAgent == "" {
|
||||
identityAgent = os.Getenv("SSH_AUTH_SOCK")
|
||||
}
|
||||
if forwardAgent && identityAgent != "" {
|
||||
err = gosshagent.ForwardToRemote(sshClient, identityAgent)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("forward agent failed: %w", err)
|
||||
}
|
||||
err = gosshagent.RequestAgentForwarding(sshSession)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("request agent forwarding failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
stdoutFile, valid := cmd.OutOrStdout().(*os.File)
|
||||
if valid && isatty.IsTerminal(stdoutFile.Fd()) {
|
||||
state, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -137,6 +131,19 @@ func ssh() *cobra.Command {
|
||||
defer func() {
|
||||
_ = term.Restore(int(os.Stdin.Fd()), state)
|
||||
}()
|
||||
|
||||
windowChange := listenWindowSize(cmd.Context())
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
return
|
||||
case <-windowChange:
|
||||
}
|
||||
width, height, _ := term.GetSize(int(stdoutFile.Fd()))
|
||||
_ = sshSession.WindowChange(height, width)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
err = sshSession.RequestPty("xterm-256color", 128, 128, gossh.TerminalModes{})
|
||||
@@ -162,35 +169,145 @@ func ssh() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cliflag.BoolVarP(cmd.Flags(), &stdio, "stdio", "", "CODER_SSH_STDIO", false, "Specifies whether to emit SSH output over stdin/stdout.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &shuffle, "shuffle", "", "CODER_SSH_SHUFFLE", false, "Specifies whether to choose a random workspace")
|
||||
_ = cmd.Flags().MarkHidden("shuffle")
|
||||
cliflag.BoolVarP(cmd.Flags(), &forwardAgent, "forward-agent", "A", "CODER_SSH_FORWARD_AGENT", false, "Specifies whether to forward the SSH agent specified in $SSH_AUTH_SOCK")
|
||||
cliflag.StringVarP(cmd.Flags(), &identityAgent, "identity-agent", "", "CODER_SSH_IDENTITY_AGENT", "", "Specifies which identity agent to use (overrides $SSH_AUTH_SOCK), forward agent must also be enabled")
|
||||
cliflag.DurationVarP(cmd.Flags(), &wsPollInterval, "workspace-poll-interval", "", "CODER_WORKSPACE_POLL_INTERVAL", workspacePollInterval, "Specifies how often to poll for workspace automated shutdown.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
type stdioConn struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
// getWorkspaceAgent returns the workspace and agent selected using either the
|
||||
// `<workspace>[.<agent>]` syntax via `in` or picks a random workspace and agent
|
||||
// if `shuffle` is true.
|
||||
func getWorkspaceAndAgent(cmd *cobra.Command, client *codersdk.Client, userID string, in string, shuffle bool) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { //nolint:revive
|
||||
ctx := cmd.Context()
|
||||
|
||||
var (
|
||||
workspace codersdk.Workspace
|
||||
workspaceParts = strings.Split(in, ".")
|
||||
err error
|
||||
)
|
||||
if shuffle {
|
||||
workspaces, err := client.Workspaces(cmd.Context(), codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
})
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
}
|
||||
if len(workspaces) == 0 {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("no workspaces to shuffle")
|
||||
}
|
||||
|
||||
workspace, err = cryptorand.Element(workspaces)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
}
|
||||
} else {
|
||||
workspace, err = namedWorkspace(cmd, client, workspaceParts[0])
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if workspace.LatestBuild.Transition != codersdk.WorkspaceTransitionStart {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("workspace must be in start transition to ssh")
|
||||
}
|
||||
if workspace.LatestBuild.Job.CompletedAt == nil {
|
||||
err := cliui.WorkspaceBuild(ctx, cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
}
|
||||
}
|
||||
if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionDelete {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q is being deleted", workspace.Name)
|
||||
}
|
||||
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("fetch workspace resources: %w", err)
|
||||
}
|
||||
|
||||
agents := make([]codersdk.WorkspaceAgent, 0)
|
||||
for _, resource := range resources {
|
||||
agents = append(agents, resource.Agents...)
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q has no agents", workspace.Name)
|
||||
}
|
||||
var agent codersdk.WorkspaceAgent
|
||||
if len(workspaceParts) >= 2 {
|
||||
for _, otherAgent := range agents {
|
||||
if otherAgent.Name != workspaceParts[1] {
|
||||
continue
|
||||
}
|
||||
agent = otherAgent
|
||||
break
|
||||
}
|
||||
if agent.ID == uuid.Nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("agent not found by name %q", workspaceParts[1])
|
||||
}
|
||||
}
|
||||
if agent.ID == uuid.Nil {
|
||||
if len(agents) > 1 {
|
||||
if !shuffle {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("you must specify the name of an agent")
|
||||
}
|
||||
agent, err = cryptorand.Element(agents)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
}
|
||||
} else {
|
||||
agent = agents[0]
|
||||
}
|
||||
}
|
||||
|
||||
return workspace, agent, nil
|
||||
}
|
||||
|
||||
func (*stdioConn) Close() (err error) {
|
||||
return nil
|
||||
// Attempt to poll workspace autostop. We write a per-workspace lockfile to
|
||||
// avoid spamming the user with notifications in case of multiple instances
|
||||
// of the CLI running simultaneously.
|
||||
func tryPollWorkspaceAutostop(ctx context.Context, client *codersdk.Client, workspace codersdk.Workspace) (stop func()) {
|
||||
lock := flock.New(filepath.Join(os.TempDir(), "coder-autostop-notify-"+workspace.ID.String()))
|
||||
condition := notifyCondition(ctx, client, workspace.ID, lock)
|
||||
return notify.Notify(condition, workspacePollInterval, autostopNotifyCountdown...)
|
||||
}
|
||||
|
||||
func (*stdioConn) LocalAddr() net.Addr {
|
||||
return nil
|
||||
}
|
||||
// Notify the user if the workspace is due to shutdown.
|
||||
func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID, lock *flock.Flock) notify.Condition {
|
||||
return func(now time.Time) (deadline time.Time, callback func()) {
|
||||
// Keep trying to regain the lock.
|
||||
locked, err := lock.TryLockContext(ctx, workspacePollInterval)
|
||||
if err != nil || !locked {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
func (*stdioConn) RemoteAddr() net.Addr {
|
||||
return nil
|
||||
}
|
||||
ws, err := client.Workspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
func (*stdioConn) SetDeadline(_ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
if ptr.NilOrZero(ws.TTLMillis) {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
func (*stdioConn) SetReadDeadline(_ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stdioConn) SetWriteDeadline(_ time.Time) error {
|
||||
return nil
|
||||
deadline = ws.LatestBuild.Deadline
|
||||
callback = func() {
|
||||
ttl := deadline.Sub(now)
|
||||
var title, body string
|
||||
if ttl > time.Minute {
|
||||
title = fmt.Sprintf(`Workspace %s stopping soon`, ws.Name)
|
||||
body = fmt.Sprintf(
|
||||
`Your Coder workspace %s is scheduled to stop in %.0f mins`, ws.Name, ttl.Minutes())
|
||||
} else {
|
||||
title = fmt.Sprintf("Workspace %s stopping!", ws.Name)
|
||||
body = fmt.Sprintf("Your Coder workspace %s is stopping any time now!", ws.Name)
|
||||
}
|
||||
// notify user with a native system notification (best effort)
|
||||
_ = beeep.Notify(title, body, "")
|
||||
}
|
||||
return deadline.Truncate(time.Minute), callback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func listenWindowSize(ctx context.Context) <-chan os.Signal {
|
||||
windowSize := make(chan os.Signal, 1)
|
||||
signal.Notify(windowSize, unix.SIGWINCH)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
signal.Stop(windowSize)
|
||||
}()
|
||||
return windowSize
|
||||
}
|
||||
+197
-87
@@ -1,145 +1,127 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/ssh"
|
||||
gosshagent "golang.org/x/crypto/ssh/agent"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/peer"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func setupWorkspaceForSSH(t *testing.T) (*codersdk.Client, codersdk.Workspace, string) {
|
||||
t.Helper()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "dev",
|
||||
Type: "google_compute_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: agentToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
|
||||
return client, workspace, agentToken
|
||||
}
|
||||
|
||||
func TestSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("ImmediateExit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "dev",
|
||||
Type: "google_compute_instance",
|
||||
Agent: &proto.Agent{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: agentToken,
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID)
|
||||
go func() {
|
||||
// Run this async so the SSH command has to wait for
|
||||
// the build and agent to connect!
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
}()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForSSH(t)
|
||||
cmd, root := clitest.New(t, "ssh", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetErr(pty.Output())
|
||||
cmd.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
pty.ExpectMatch("Waiting")
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
|
||||
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
|
||||
pty.WriteLine("exit")
|
||||
<-doneChan
|
||||
<-cmdDone
|
||||
})
|
||||
t.Run("Stdio", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "dev",
|
||||
Type: "google_compute_instance",
|
||||
Agent: &proto.Agent{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: agentToken,
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID)
|
||||
go func() {
|
||||
client, workspace, agentToken := setupWorkspaceForSSH(t)
|
||||
|
||||
_, _ = tGoContext(t, func(ctx context.Context) {
|
||||
// Run this async so the SSH command has to wait for
|
||||
// the build and agent to connect!
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
}()
|
||||
<-ctx.Done()
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
|
||||
clientOutput, clientInput := io.Pipe()
|
||||
serverOutput, serverInput := io.Pipe()
|
||||
|
||||
cmd, root := clitest.New(t, "ssh", "--stdio", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
cmd.SetIn(clientOutput)
|
||||
cmd.SetOut(serverInput)
|
||||
cmd.SetErr(io.Discard)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
conn, channels, requests, err := ssh.NewClientConn(&stdioConn{
|
||||
Reader: serverOutput,
|
||||
@@ -161,8 +143,136 @@ func TestSSH(t *testing.T) {
|
||||
err = sshClient.Close()
|
||||
require.NoError(t, err)
|
||||
_ = clientOutput.Close()
|
||||
<-doneChan
|
||||
|
||||
<-cmdDone
|
||||
})
|
||||
t.Run("ForwardAgent", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Test not supported on windows")
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForSSH(t)
|
||||
|
||||
_, _ = tGoContext(t, func(ctx context.Context) {
|
||||
// Run this async so the SSH command has to wait for
|
||||
// the build and agent to connect!
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
})
|
||||
<-ctx.Done()
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
|
||||
// Generate private key.
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
kr := gosshagent.NewKeyring()
|
||||
kr.Add(gosshagent.AddedKey{
|
||||
PrivateKey: privateKey,
|
||||
})
|
||||
|
||||
// Start up ssh agent listening on unix socket.
|
||||
tmpdir := t.TempDir()
|
||||
agentSock := filepath.Join(tmpdir, "agent.sock")
|
||||
l, err := net.Listen("unix", agentSock)
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
_ = tGo(t, func() {
|
||||
for {
|
||||
fd, err := l.Accept()
|
||||
if err != nil {
|
||||
if !errors.Is(err, net.ErrClosed) {
|
||||
t.Logf("accept error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = gosshagent.ServeAgent(kr, fd)
|
||||
if !errors.Is(err, io.EOF) {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cmd, root := clitest.New(t,
|
||||
"ssh",
|
||||
workspace.Name,
|
||||
"--forward-agent",
|
||||
"--identity-agent", agentSock, // Overrides $SSH_AUTH_SOCK.
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(io.Discard)
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
// Ensure that SSH_AUTH_SOCK is set.
|
||||
// Linux: /tmp/auth-agent3167016167/listener.sock
|
||||
// macOS: /var/folders/ng/m1q0wft14hj0t3rtjxrdnzsr0000gn/T/auth-agent3245553419/listener.sock
|
||||
pty.WriteLine("env")
|
||||
pty.ExpectMatch("SSH_AUTH_SOCK=")
|
||||
// Ensure that ssh-add lists our key.
|
||||
pty.WriteLine("ssh-add -L")
|
||||
keys, err := kr.List()
|
||||
require.NoError(t, err)
|
||||
pty.ExpectMatch(keys[0].String())
|
||||
|
||||
// And we're done.
|
||||
pty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
}
|
||||
|
||||
// tGoContext runs fn in a goroutine passing a context that will be
|
||||
// canceled on test completion and wait until fn has finished executing.
|
||||
// Done and cancel are returned for optionally waiting until completion
|
||||
// or early cancellation.
|
||||
//
|
||||
// NOTE(mafredri): This could be moved to a helper library.
|
||||
func tGoContext(t *testing.T, fn func(context.Context)) (done <-chan struct{}, cancel context.CancelFunc) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
doneC := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
<-done
|
||||
})
|
||||
go func() {
|
||||
fn(ctx)
|
||||
close(doneC)
|
||||
}()
|
||||
|
||||
return doneC, cancel
|
||||
}
|
||||
|
||||
// tGo runs fn in a goroutine and waits until fn has completed before
|
||||
// test completion. Done is returned for optionally waiting for fn to
|
||||
// exit.
|
||||
//
|
||||
// NOTE(mafredri): This could be moved to a helper library.
|
||||
func tGo(t *testing.T, fn func()) (done <-chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
doneC := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
<-doneC
|
||||
})
|
||||
go func() {
|
||||
fn()
|
||||
close(doneC)
|
||||
}()
|
||||
|
||||
return doneC
|
||||
}
|
||||
|
||||
type stdioConn struct {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func listenWindowSize(ctx context.Context) <-chan os.Signal {
|
||||
windowSize := make(chan os.Signal, 3)
|
||||
ticker := time.NewTicker(time.Second)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
windowSize <- nil
|
||||
}
|
||||
}()
|
||||
return windowSize
|
||||
}
|
||||
+21
-481
@@ -1,507 +1,47 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/coreos/go-systemd/daemon"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/api/idtoken"
|
||||
"google.golang.org/api/option"
|
||||
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/databasefake"
|
||||
"github.com/coder/coder/coderd/gitsshkey"
|
||||
"github.com/coder/coder/coderd/tunnel"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/terraform"
|
||||
"github.com/coder/coder/provisionerd"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func start() *cobra.Command {
|
||||
var (
|
||||
accessURL string
|
||||
address string
|
||||
cacheDir string
|
||||
dev bool
|
||||
postgresURL string
|
||||
// provisionerDaemonCount is a uint8 to ensure a number > 0.
|
||||
provisionerDaemonCount uint8
|
||||
tlsCertFile string
|
||||
tlsClientCAFile string
|
||||
tlsClientAuth string
|
||||
tlsEnable bool
|
||||
tlsKeyFile string
|
||||
tlsMinVersion string
|
||||
useTunnel bool
|
||||
traceDatadog bool
|
||||
secureAuthCookie bool
|
||||
sshKeygenAlgorithmRaw string
|
||||
)
|
||||
root := &cobra.Command{
|
||||
Use: "start",
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "start <workspace>",
|
||||
Short: "Build a workspace with the start state",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if traceDatadog {
|
||||
tracer.Start()
|
||||
defer tracer.Stop()
|
||||
}
|
||||
|
||||
printLogo(cmd)
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listen %q: %w", address, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
if tlsEnable {
|
||||
listener, err = configureTLS(listener, tlsMinVersion, tlsClientAuth, tlsCertFile, tlsKeyFile, tlsClientCAFile)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("configure tls: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
|
||||
if !valid {
|
||||
return xerrors.New("must be listening on tcp")
|
||||
}
|
||||
// If just a port is specified, assume localhost.
|
||||
if tcpAddr.IP.IsUnspecified() {
|
||||
tcpAddr.IP = net.IPv4(127, 0, 0, 1)
|
||||
}
|
||||
|
||||
localURL := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: tcpAddr.String(),
|
||||
}
|
||||
if tlsEnable {
|
||||
localURL.Scheme = "https"
|
||||
}
|
||||
if accessURL == "" {
|
||||
accessURL = localURL.String()
|
||||
}
|
||||
var tunnelErr <-chan error
|
||||
// If we're attempting to tunnel in dev-mode, the access URL
|
||||
// needs to be changed to use the tunnel.
|
||||
if dev && useTunnel {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Coder requires a network endpoint that can be accessed by provisioned workspaces. In dev mode, a free tunnel can be created for you. This will expose your Coder deployment to the internet.")+"\n")
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Would you like Coder to start a tunnel for simple setup?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err == nil {
|
||||
accessURL, tunnelErr, err = tunnel.New(cmd.Context(), localURL.String())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tunnel: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Tunnel started. Your deployment is accessible at:`))+"\n "+cliui.Styles.Field.Render(accessURL)+"\n")
|
||||
}
|
||||
}
|
||||
validator, err := idtoken.NewValidator(cmd.Context(), option.WithoutAuthentication())
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm start workspace?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessURLParsed, err := url.Parse(accessURL)
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse access url %q: %w", accessURL, err)
|
||||
}
|
||||
|
||||
sshKeygenAlgorithm, err := gitsshkey.ParseAlgorithm(sshKeygenAlgorithmRaw)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse ssh keygen algorithm %s: %w", sshKeygenAlgorithmRaw, err)
|
||||
}
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(os.Stderr))
|
||||
options := &coderd.Options{
|
||||
AccessURL: accessURLParsed,
|
||||
Logger: logger.Named("coderd"),
|
||||
Database: databasefake.New(),
|
||||
Pubsub: database.NewPubsubInMemory(),
|
||||
GoogleTokenValidator: validator,
|
||||
SecureAuthCookie: secureAuthCookie,
|
||||
SSHKeygenAlgorithm: sshKeygenAlgorithm,
|
||||
}
|
||||
|
||||
if !dev {
|
||||
sqlDB, err := sql.Open("postgres", postgresURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dial postgres: %w", err)
|
||||
}
|
||||
err = sqlDB.Ping()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
err = database.MigrateUp(sqlDB)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("migrate up: %w", err)
|
||||
}
|
||||
options.Database = database.New(sqlDB)
|
||||
options.Pubsub, err = database.NewPubsub(cmd.Context(), sqlDB, postgresURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create pubsub: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
handler, closeCoderd := coderd.New(options)
|
||||
client := codersdk.New(localURL)
|
||||
if tlsEnable {
|
||||
// Secure transport isn't needed for locally communicating!
|
||||
client.HTTPClient.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
provisionerDaemons := make([]*provisionerd.Server, 0)
|
||||
for i := 0; uint8(i) < provisionerDaemonCount; i++ {
|
||||
daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger, cacheDir)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create provisioner daemon: %w", err)
|
||||
}
|
||||
provisionerDaemons = append(provisionerDaemons, daemonClose)
|
||||
}
|
||||
defer func() {
|
||||
for _, provisionerDaemon := range provisionerDaemons {
|
||||
_ = provisionerDaemon.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
shutdownConnsCtx, shutdownConns := context.WithCancel(cmd.Context())
|
||||
defer shutdownConns()
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
server := http.Server{
|
||||
Handler: handler,
|
||||
BaseContext: func(_ net.Listener) context.Context {
|
||||
return shutdownConnsCtx
|
||||
},
|
||||
}
|
||||
errCh <- server.Serve(listener)
|
||||
}()
|
||||
|
||||
config := createConfig(cmd)
|
||||
|
||||
if dev {
|
||||
err = createFirstUser(cmd, client, config)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create first user: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+
|
||||
cliui.Styles.Field.Render("dev")+` mode. All data is in-memory! Do not use in production. Press `+cliui.Styles.Field.Render("ctrl+c")+` to clean up provisioned infrastructure.`))+
|
||||
`
|
||||
`+
|
||||
cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Run `+cliui.Styles.Code.Render("coder templates init")+" in a new terminal to get started.\n"))+`
|
||||
`)
|
||||
} else {
|
||||
// This is helpful for tests, but can be silently ignored.
|
||||
// Coder may be ran as users that don't have permission to write in the homedir,
|
||||
// such as via the systemd service.
|
||||
_ = config.URL().Write(client.URL.String())
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Started in `+
|
||||
cliui.Styles.Field.Render("production")+` mode. All data is stored in the PostgreSQL provided! Press `+cliui.Styles.Field.Render("ctrl+c")+` to gracefully shutdown.`))+"\n")
|
||||
|
||||
hasFirstUser, err := client.HasFirstUser(cmd.Context())
|
||||
if !hasFirstUser && err == nil {
|
||||
// This could fail for a variety of TLS-related reasons.
|
||||
// This is a helpful starter message, and not critical for user interaction.
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.FocusedPrompt.String()+`Run `+cliui.Styles.Code.Render("coder login "+client.URL.String())+" in a new terminal to get started.\n")))
|
||||
}
|
||||
}
|
||||
|
||||
// Updates the systemd status from activating to activated.
|
||||
_, err = daemon.SdNotify(false, daemon.SdNotifyReady)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("notify systemd: %w", err)
|
||||
}
|
||||
|
||||
stopChan := make(chan os.Signal, 1)
|
||||
defer signal.Stop(stopChan)
|
||||
signal.Notify(stopChan, os.Interrupt)
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
closeCoderd()
|
||||
return cmd.Context().Err()
|
||||
case err := <-tunnelErr:
|
||||
return err
|
||||
case err := <-errCh:
|
||||
closeCoderd()
|
||||
return err
|
||||
case <-stopChan:
|
||||
}
|
||||
signal.Stop(stopChan)
|
||||
_, err = daemon.SdNotify(false, daemon.SdNotifyStopping)
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return xerrors.Errorf("notify systemd: %w", err)
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n\n"+cliui.Styles.Bold.Render("Interrupt caught. Gracefully exiting..."))
|
||||
|
||||
if dev {
|
||||
workspaces, err := client.WorkspacesByUser(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspaces: %w", err)
|
||||
}
|
||||
for _, workspace := range workspaces {
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: database.WorkspaceTransitionDelete,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete workspace: %w", err)
|
||||
}
|
||||
|
||||
err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
build, err := client.WorkspaceBuild(cmd.Context(), build.ID)
|
||||
return build.Job, err
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelWorkspaceBuild(cmd.Context(), build.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
return client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete workspace %s: %w", workspace.Name, err)
|
||||
}
|
||||
}
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, provisionerDaemon := range provisionerDaemons {
|
||||
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
spin.Suffix = cliui.Styles.Keyword.Render(" Shutting down provisioner daemon...")
|
||||
spin.Start()
|
||||
err = provisionerDaemon.Shutdown(cmd.Context())
|
||||
if err != nil {
|
||||
spin.FinalMSG = cliui.Styles.Prompt.String() + "Failed to shutdown provisioner daemon: " + err.Error()
|
||||
spin.Stop()
|
||||
}
|
||||
err = provisionerDaemon.Close()
|
||||
if err != nil {
|
||||
spin.Stop()
|
||||
return xerrors.Errorf("close provisioner daemon: %w", err)
|
||||
}
|
||||
spin.FinalMSG = cliui.Styles.Prompt.String() + "Gracefully shut down provisioner daemon!\n"
|
||||
spin.Stop()
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Prompt.String()+"Waiting for WebSocket connections to close...\n")
|
||||
shutdownConns()
|
||||
closeCoderd()
|
||||
return nil
|
||||
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
},
|
||||
}
|
||||
|
||||
cliflag.StringVarP(root.Flags(), &accessURL, "access-url", "", "CODER_ACCESS_URL", "", "Specifies the external URL to access Coder")
|
||||
cliflag.StringVarP(root.Flags(), &address, "address", "a", "CODER_ADDRESS", "127.0.0.1:3000", "The address to serve the API and dashboard")
|
||||
// systemd uses the CACHE_DIRECTORY environment variable!
|
||||
cliflag.StringVarP(root.Flags(), &cacheDir, "cache-dir", "", "CACHE_DIRECTORY", filepath.Join(os.TempDir(), "coder-cache"), "Specifies a directory to cache binaries for provision operations.")
|
||||
cliflag.BoolVarP(root.Flags(), &dev, "dev", "", "CODER_DEV_MODE", false, "Serve Coder in dev mode for tinkering")
|
||||
cliflag.StringVarP(root.Flags(), &postgresURL, "postgres-url", "", "CODER_PG_CONNECTION_URL", "", "URL of a PostgreSQL database to connect to")
|
||||
cliflag.Uint8VarP(root.Flags(), &provisionerDaemonCount, "provisioner-daemons", "", "CODER_PROVISIONER_DAEMONS", 1, "The amount of provisioner daemons to create on start.")
|
||||
cliflag.BoolVarP(root.Flags(), &tlsEnable, "tls-enable", "", "CODER_TLS_ENABLE", false, "Specifies if TLS will be enabled")
|
||||
cliflag.StringVarP(root.Flags(), &tlsCertFile, "tls-cert-file", "", "CODER_TLS_CERT_FILE", "",
|
||||
"Specifies the path to the certificate for TLS. It requires a PEM-encoded file. "+
|
||||
"To configure the listener to use a CA certificate, concatenate the primary certificate "+
|
||||
"and the CA certificate together. The primary certificate should appear first in the combined file")
|
||||
cliflag.StringVarP(root.Flags(), &tlsClientCAFile, "tls-client-ca-file", "", "CODER_TLS_CLIENT_CA_FILE", "",
|
||||
"PEM-encoded Certificate Authority file used for checking the authenticity of client")
|
||||
cliflag.StringVarP(root.Flags(), &tlsClientAuth, "tls-client-auth", "", "CODER_TLS_CLIENT_AUTH", "request",
|
||||
`Specifies the policy the server will follow for TLS Client Authentication. `+
|
||||
`Accepted values are "none", "request", "require-any", "verify-if-given", or "require-and-verify"`)
|
||||
cliflag.StringVarP(root.Flags(), &tlsKeyFile, "tls-key-file", "", "CODER_TLS_KEY_FILE", "",
|
||||
"Specifies the path to the private key for the certificate. It requires a PEM-encoded file")
|
||||
cliflag.StringVarP(root.Flags(), &tlsMinVersion, "tls-min-version", "", "CODER_TLS_MIN_VERSION", "tls12",
|
||||
`Specifies the minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13"`)
|
||||
cliflag.BoolVarP(root.Flags(), &useTunnel, "tunnel", "", "CODER_DEV_TUNNEL", true, "Serve dev mode through a Cloudflare Tunnel for easy setup")
|
||||
_ = root.Flags().MarkHidden("tunnel")
|
||||
cliflag.BoolVarP(root.Flags(), &traceDatadog, "trace-datadog", "", "CODER_TRACE_DATADOG", false, "Send tracing data to a datadog agent")
|
||||
cliflag.BoolVarP(root.Flags(), &secureAuthCookie, "secure-auth-cookie", "", "CODER_SECURE_AUTH_COOKIE", false, "Specifies if the 'Secure' property is set on browser session cookies")
|
||||
cliflag.StringVarP(root.Flags(), &sshKeygenAlgorithmRaw, "ssh-keygen-algorithm", "", "CODER_SSH_KEYGEN_ALGORITHM", "ed25519", "Specifies the algorithm to use for generating ssh keys. "+
|
||||
`Accepted values are "ed25519", "ecdsa", or "rsa4096"`)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func createFirstUser(cmd *cobra.Command, client *codersdk.Client, cfg config.Root) error {
|
||||
_, err := client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
|
||||
Email: "admin@coder.com",
|
||||
Username: "developer",
|
||||
Password: "password",
|
||||
OrganizationName: "acme-corp",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create first user: %w", err)
|
||||
}
|
||||
token, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
|
||||
Email: "admin@coder.com",
|
||||
Password: "password",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("login with first user: %w", err)
|
||||
}
|
||||
client.SessionToken = token.SessionToken
|
||||
|
||||
err = cfg.URL().Write(client.URL.String())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write local url: %w", err)
|
||||
}
|
||||
err = cfg.Session().Write(token.SessionToken)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write session token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger, cacheDir string) (*provisionerd.Server, error) {
|
||||
err := os.MkdirAll(cacheDir, 0700)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("mkdir %q: %w", cacheDir, err)
|
||||
}
|
||||
|
||||
terraformClient, terraformServer := provisionersdk.TransportPipe()
|
||||
go func() {
|
||||
err := terraform.Serve(ctx, &terraform.ServeOptions{
|
||||
ServeOptions: &provisionersdk.ServeOptions{
|
||||
Listener: terraformServer,
|
||||
},
|
||||
CachePath: cacheDir,
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "provisionerd")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provisionerd.New(client.ListenProvisionerDaemon, &provisionerd.Options{
|
||||
Logger: logger,
|
||||
PollInterval: 50 * time.Millisecond,
|
||||
UpdateInterval: 50 * time.Millisecond,
|
||||
Provisioners: provisionerd.Provisioners{
|
||||
string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(terraformClient)),
|
||||
},
|
||||
WorkDirectory: tempDir,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func printLogo(cmd *cobra.Command) {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), ` ▄█▀ ▀█▄
|
||||
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
|
||||
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
|
||||
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
|
||||
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
|
||||
|
||||
`)
|
||||
}
|
||||
|
||||
func configureTLS(listener net.Listener, tlsMinVersion, tlsClientAuth, tlsCertFile, tlsKeyFile, tlsClientCAFile string) (net.Listener, error) {
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
switch tlsMinVersion {
|
||||
case "tls10":
|
||||
tlsConfig.MinVersion = tls.VersionTLS10
|
||||
case "tls11":
|
||||
tlsConfig.MinVersion = tls.VersionTLS11
|
||||
case "tls12":
|
||||
tlsConfig.MinVersion = tls.VersionTLS12
|
||||
case "tls13":
|
||||
tlsConfig.MinVersion = tls.VersionTLS13
|
||||
default:
|
||||
return nil, xerrors.Errorf("unrecognized tls version: %q", tlsMinVersion)
|
||||
}
|
||||
|
||||
switch tlsClientAuth {
|
||||
case "none":
|
||||
tlsConfig.ClientAuth = tls.NoClientCert
|
||||
case "request":
|
||||
tlsConfig.ClientAuth = tls.RequestClientCert
|
||||
case "require-any":
|
||||
tlsConfig.ClientAuth = tls.RequireAnyClientCert
|
||||
case "verify-if-given":
|
||||
tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven
|
||||
case "require-and-verify":
|
||||
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
default:
|
||||
return nil, xerrors.Errorf("unrecognized tls client auth: %q", tlsClientAuth)
|
||||
}
|
||||
|
||||
if tlsCertFile == "" {
|
||||
return nil, xerrors.New("tls-cert-file is required when tls is enabled")
|
||||
}
|
||||
if tlsKeyFile == "" {
|
||||
return nil, xerrors.New("tls-key-file is required when tls is enabled")
|
||||
}
|
||||
|
||||
certPEMBlock, err := os.ReadFile(tlsCertFile)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read file %q: %w", tlsCertFile, err)
|
||||
}
|
||||
keyPEMBlock, err := os.ReadFile(tlsKeyFile)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read file %q: %w", tlsKeyFile, err)
|
||||
}
|
||||
keyBlock, _ := pem.Decode(keyPEMBlock)
|
||||
if keyBlock == nil {
|
||||
return nil, xerrors.New("decoded pem is blank")
|
||||
}
|
||||
cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create key pair: %w", err)
|
||||
}
|
||||
tlsConfig.GetCertificate = func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
certPool := x509.NewCertPool()
|
||||
certPool.AppendCertsFromPEM(certPEMBlock)
|
||||
tlsConfig.RootCAs = certPool
|
||||
|
||||
if tlsClientCAFile != "" {
|
||||
caPool := x509.NewCertPool()
|
||||
data, err := os.ReadFile(tlsClientCAFile)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read %q: %w", tlsClientCAFile, err)
|
||||
}
|
||||
if !caPool.AppendCertsFromPEM(data) {
|
||||
return nil, xerrors.Errorf("failed to parse CA certificate in tls-client-ca-file")
|
||||
}
|
||||
tlsConfig.ClientCAs = caPool
|
||||
}
|
||||
|
||||
return tls.NewListener(listener, tlsConfig), nil
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func state() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "state",
|
||||
Short: "Manually manage Terraform state to fix broken workspaces",
|
||||
}
|
||||
cmd.AddCommand(statePull(), statePush())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func statePull() *cobra.Command {
|
||||
var buildName string
|
||||
cmd := &cobra.Command{
|
||||
Use: "pull <workspace> [file]",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var build codersdk.WorkspaceBuild
|
||||
if buildName == "latest" {
|
||||
build = workspace.LatestBuild
|
||||
} else {
|
||||
build, err = client.WorkspaceBuildByName(cmd.Context(), workspace.ID, buildName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
state, err := client.WorkspaceBuildState(cmd.Context(), build.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(args) < 2 {
|
||||
cmd.Println(string(state))
|
||||
return nil
|
||||
}
|
||||
|
||||
return os.WriteFile(args[1], state, 0600)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&buildName, "build", "b", "latest", "Specify a workspace build to target by name.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func statePush() *cobra.Command {
|
||||
var buildName string
|
||||
cmd := &cobra.Command{
|
||||
Use: "push <workspace> <file>",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var build codersdk.WorkspaceBuild
|
||||
if buildName == "latest" {
|
||||
build = workspace.LatestBuild
|
||||
} else {
|
||||
build, err = client.WorkspaceBuildByName(cmd.Context(), workspace.ID, buildName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var state []byte
|
||||
if args[1] == "-" {
|
||||
state, err = io.ReadAll(cmd.InOrStdin())
|
||||
} else {
|
||||
state, err = os.ReadFile(args[1])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
build, err = client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
TemplateVersionID: build.TemplateVersionID,
|
||||
Transition: build.Transition,
|
||||
ProvisionerState: state,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStderr(), client, build.ID, before)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&buildName, "build", "b", "latest", "Specify a workspace build to target by name.")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func TestStatePull(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("File", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
wantState := []byte("some state")
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
State: wantState,
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
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)
|
||||
statefilePath := filepath.Join(t.TempDir(), "state")
|
||||
cmd, root := clitest.New(t, "state", "pull", workspace.Name, statefilePath)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
gotState, err := os.ReadFile(statefilePath)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wantState, gotState)
|
||||
})
|
||||
t.Run("Stdout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
wantState := []byte("some state")
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
State: wantState,
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
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)
|
||||
cmd, root := clitest.New(t, "state", "pull", workspace.Name)
|
||||
var gotState bytes.Buffer
|
||||
cmd.SetOut(&gotState)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wantState, bytes.TrimSpace(gotState.Bytes()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatePush(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("File", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: echo.ProvisionComplete,
|
||||
})
|
||||
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)
|
||||
stateFile, err := os.CreateTemp(t.TempDir(), "")
|
||||
require.NoError(t, err)
|
||||
wantState := []byte("some magic state")
|
||||
_, err = stateFile.Write(wantState)
|
||||
require.NoError(t, err)
|
||||
err = stateFile.Close()
|
||||
require.NoError(t, err)
|
||||
cmd, root := clitest.New(t, "state", "push", workspace.Name, stateFile.Name())
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetOut(io.Discard)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
err = cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Stdin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: echo.ProvisionComplete,
|
||||
})
|
||||
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)
|
||||
cmd, root := clitest.New(t, "state", "push", "--build", workspace.LatestBuild.Name, workspace.Name, "-")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetIn(strings.NewReader("some magic state"))
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func stop() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "stop <workspace>",
|
||||
Short: "Build a workspace with the stop state",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm stop workspace?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStop,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
},
|
||||
}
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
return cmd
|
||||
}
|
||||
+88
-39
@@ -1,15 +1,14 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
@@ -22,18 +21,20 @@ import (
|
||||
|
||||
func templateCreate() *cobra.Command {
|
||||
var (
|
||||
yes bool
|
||||
directory string
|
||||
provisioner string
|
||||
directory string
|
||||
provisioner string
|
||||
parameterFile string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "create [name]",
|
||||
Short: "Create a template from the current directory",
|
||||
Short: "Create a template from the current directory or as specified by flag",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -45,17 +46,29 @@ func templateCreate() *cobra.Command {
|
||||
} else {
|
||||
templateName = args[0]
|
||||
}
|
||||
|
||||
_, err = client.TemplateByName(cmd.Context(), organization.ID, templateName)
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A template already exists named %q!", templateName)
|
||||
}
|
||||
|
||||
// Confirm upload of the directory.
|
||||
prettyDir := prettyDirectoryPath(directory)
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Create and upload %q?", prettyDir),
|
||||
IsConfirm: true,
|
||||
Default: "yes",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
spin.Suffix = cliui.Styles.Keyword.Render(" Uploading current directory...")
|
||||
spin.Suffix = cliui.Styles.Keyword.Render(" Uploading directory...")
|
||||
spin.Start()
|
||||
defer spin.Stop()
|
||||
archive, err := provisionersdk.Tar(directory)
|
||||
archive, err := provisionersdk.Tar(directory, provisionersdk.TemplateArchiveLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -66,26 +79,17 @@ func templateCreate() *cobra.Command {
|
||||
}
|
||||
spin.Stop()
|
||||
|
||||
spin = spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
spin.Suffix = cliui.Styles.Keyword.Render("Something")
|
||||
job, parameters, err := createValidTemplateVersion(cmd, client, organization, database.ProvisionerType(provisioner), resp.Hash)
|
||||
job, parameters, err := createValidTemplateVersion(cmd, client, organization, database.ProvisionerType(provisioner), resp.Hash, parameterFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !yes {
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Create template?",
|
||||
IsConfirm: true,
|
||||
Default: "yes",
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, promptui.ErrAbort) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm create?",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.CreateTemplate(cmd.Context(), organization.ID, codersdk.CreateTemplateRequest{
|
||||
@@ -97,28 +101,35 @@ func templateCreate() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "The %s template has been created!\n", templateName)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n"+cliui.Styles.Wrap.Render(
|
||||
"The "+cliui.Styles.Keyword.Render(templateName)+" template has been created! "+
|
||||
"Developers can provision a workspace with this template using:")+"\n")
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render(fmt.Sprintf("coder create --template=%q [workspace name]", templateName)))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
currentDirectory, _ := os.Getwd()
|
||||
cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from")
|
||||
cmd.Flags().StringVarP(&provisioner, "provisioner", "p", "terraform", "Customize the provisioner backend")
|
||||
cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend")
|
||||
cmd.Flags().StringVarP(¶meterFile, "parameter-file", "", "", "Specify a file path with parameter values.")
|
||||
// This is for testing!
|
||||
err := cmd.Flags().MarkHidden("provisioner")
|
||||
err := cmd.Flags().MarkHidden("test.provisioner")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Bypass prompts")
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, organization codersdk.Organization, provisioner database.ProvisionerType, hash string, parameters ...codersdk.CreateParameterRequest) (*codersdk.TemplateVersion, []codersdk.CreateParameterRequest, error) {
|
||||
func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, organization codersdk.Organization, provisioner database.ProvisionerType, hash string, parameterFile string, parameters ...codersdk.CreateParameterRequest) (*codersdk.TemplateVersion, []codersdk.CreateParameterRequest, error) {
|
||||
before := time.Now()
|
||||
version, err := client.CreateTemplateVersion(cmd.Context(), organization.ID, codersdk.CreateTemplateVersionRequest{
|
||||
StorageMethod: database.ProvisionerStorageMethodFile,
|
||||
StorageMethod: codersdk.ProvisionerStorageMethodFile,
|
||||
StorageSource: hash,
|
||||
Provisioner: provisioner,
|
||||
Provisioner: codersdk.ProvisionerType(provisioner),
|
||||
ParameterValues: parameters,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -163,7 +174,7 @@ func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, org
|
||||
sort.Slice(parameterSchemas, func(i, j int) bool {
|
||||
return parameterSchemas[i].Name < parameterSchemas[j].Name
|
||||
})
|
||||
missingSchemas := make([]codersdk.TemplateVersionParameterSchema, 0)
|
||||
missingSchemas := make([]codersdk.ParameterSchema, 0)
|
||||
for _, parameterSchema := range parameterSchemas {
|
||||
_, ok := valuesBySchemaID[parameterSchema.ID.String()]
|
||||
if ok {
|
||||
@@ -172,31 +183,69 @@ func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, org
|
||||
missingSchemas = append(missingSchemas, parameterSchema)
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has required variables! They are scoped to the template, and not viewable after being set.")+"\r\n")
|
||||
|
||||
// parameterMapFromFile can be nil if parameter file is not specified
|
||||
var parameterMapFromFile map[string]string
|
||||
if parameterFile != "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
|
||||
parameterMapFromFile, err = createParameterMapFromFile(parameterFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
for _, parameterSchema := range missingSchemas {
|
||||
value, err := cliui.ParameterSchema(cmd, parameterSchema)
|
||||
parameterValue, err := getParameterValueFromMapOrInput(cmd, parameterMapFromFile, parameterSchema)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
parameters = append(parameters, codersdk.CreateParameterRequest{
|
||||
Name: parameterSchema.Name,
|
||||
SourceValue: value,
|
||||
SourceScheme: database.ParameterSourceSchemeData,
|
||||
SourceValue: parameterValue,
|
||||
SourceScheme: codersdk.ParameterSourceSchemeData,
|
||||
DestinationScheme: parameterSchema.DefaultDestinationScheme,
|
||||
})
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
}
|
||||
return createValidTemplateVersion(cmd, client, organization, provisioner, hash, parameters...)
|
||||
|
||||
// This recursion is only 1 level deep in practice.
|
||||
// The first pass populates the missing parameters, so it does not enter this `if` block again.
|
||||
return createValidTemplateVersion(cmd, client, organization, provisioner, hash, parameterFile, parameters...)
|
||||
}
|
||||
|
||||
if version.Job.Status != codersdk.ProvisionerJobSucceeded {
|
||||
return nil, nil, xerrors.New(version.Job.Error)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Checkmark.String()+" Successfully imported template source!\n")
|
||||
|
||||
resources, err := client.TemplateVersionResources(cmd.Context(), version.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &version, parameters, displayTemplateVersionInfo(cmd, resources)
|
||||
|
||||
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
||||
HideAgentState: true,
|
||||
HideAccess: true,
|
||||
Title: "Template Preview",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, xerrors.Errorf("preview template resources: %w", err)
|
||||
}
|
||||
|
||||
return &version, parameters, nil
|
||||
}
|
||||
|
||||
// prettyDirectoryPath returns a prettified path when inside the users
|
||||
// home directory. Falls back to dir if the users home directory cannot
|
||||
// discerned. This function calls filepath.Clean on the result.
|
||||
func prettyDirectoryPath(dir string) string {
|
||||
dir = filepath.Clean(dir)
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return dir
|
||||
}
|
||||
pretty := dir
|
||||
if strings.HasPrefix(pretty, homeDir) {
|
||||
pretty = strings.TrimPrefix(pretty, homeDir)
|
||||
pretty = "~" + pretty
|
||||
}
|
||||
return pretty
|
||||
}
|
||||
|
||||
+161
-15
@@ -1,6 +1,7 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
@@ -16,33 +18,177 @@ func TestTemplateCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: echo.ProvisionComplete,
|
||||
})
|
||||
cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
|
||||
cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho))
|
||||
clitest.SetupConfig(t, client, root)
|
||||
_ = coderdtest.NewProvisionerDaemon(t, client)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
matches := []string{
|
||||
"Create template?", "yes",
|
||||
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Create and upload", write: "yes"},
|
||||
{match: "Confirm create?", write: "yes"},
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
})
|
||||
|
||||
t.Run("WithParameter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
|
||||
Parse: createTestParseResponse(),
|
||||
Provision: echo.ProvisionComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
})
|
||||
cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho))
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Create and upload", write: "yes"},
|
||||
{match: "Enter a value:", write: "bananas"},
|
||||
{match: "Confirm create?", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
})
|
||||
|
||||
t.Run("WithParameterFileContainingTheValue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
|
||||
Parse: createTestParseResponse(),
|
||||
Provision: echo.ProvisionComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
})
|
||||
tempDir := t.TempDir()
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("region: \"bananas\"")
|
||||
cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Create and upload", write: "yes"},
|
||||
{match: "Confirm create?", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
removeTmpDirUntilSuccess(t, tempDir)
|
||||
})
|
||||
|
||||
t.Run("WithParameterFileNotContainingTheValue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
|
||||
Parse: createTestParseResponse(),
|
||||
Provision: echo.ProvisionComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
})
|
||||
tempDir := t.TempDir()
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("zone: \"bananas\"")
|
||||
cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "Create and upload", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
require.EqualError(t, <-execDone, "Parameter value absent in parameter file for \"region\"!")
|
||||
removeTmpDirUntilSuccess(t, tempDir)
|
||||
})
|
||||
}
|
||||
|
||||
func createTestParseResponse() []*proto.Parse_Response {
|
||||
return []*proto.Parse_Response{{
|
||||
Type: &proto.Parse_Response_Complete{
|
||||
Complete: &proto.Parse_Complete{
|
||||
ParameterSchemas: []*proto.ParameterSchema{{
|
||||
AllowOverrideSource: true,
|
||||
Name: "region",
|
||||
Description: "description",
|
||||
DefaultDestination: &proto.ParameterDestination{
|
||||
Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// Need this for Windows because of a known issue with Go:
|
||||
// https://github.com/golang/go/issues/52986
|
||||
func removeTmpDirUntilSuccess(t *testing.T, tempDir string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
err := os.RemoveAll(tempDir)
|
||||
for err != nil {
|
||||
err = os.RemoveAll(tempDir)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func templateDelete() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete [name...]",
|
||||
Short: "Delete templates",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var (
|
||||
ctx = cmd.Context()
|
||||
templateNames = []string{}
|
||||
templates = []codersdk.Template{}
|
||||
)
|
||||
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
templateNames = args
|
||||
} else {
|
||||
allTemplates, err := client.TemplatesByOrganization(ctx, organization.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get templates by organization: %w", err)
|
||||
}
|
||||
|
||||
if len(allTemplates) == 0 {
|
||||
return xerrors.Errorf("no templates exist in the current organization %q", organization.Name)
|
||||
}
|
||||
|
||||
opts := make([]string, 0, len(allTemplates))
|
||||
for _, template := range allTemplates {
|
||||
opts = append(opts, template.Name)
|
||||
}
|
||||
|
||||
selection, err := cliui.Select(cmd, cliui.SelectOptions{
|
||||
Options: opts,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("select template: %w", err)
|
||||
}
|
||||
|
||||
for _, template := range allTemplates {
|
||||
if template.Name == selection {
|
||||
templates = append(templates, template)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, templateName := range templateNames {
|
||||
template, err := client.TemplateByName(ctx, organization.ID, templateName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template by name: %w", err)
|
||||
}
|
||||
|
||||
templates = append(templates, template)
|
||||
}
|
||||
|
||||
for _, template := range templates {
|
||||
err := client.DeleteTemplate(ctx, template.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete template %q: %w", template.Name, err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Deleted template "+cliui.Styles.Code.Render(template.Name)+"!")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestTemplateDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Ok", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: 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)
|
||||
|
||||
cmd, root := clitest.New(t, "templates", "delete", template.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
require.NoError(t, cmd.Execute())
|
||||
|
||||
_, err := client.Template(context.Background(), template.ID)
|
||||
require.Error(t, err, "template should not exist")
|
||||
})
|
||||
|
||||
t.Run("Multiple", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
templates := []codersdk.Template{
|
||||
coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID),
|
||||
coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID),
|
||||
coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID),
|
||||
}
|
||||
templateNames := []string{}
|
||||
for _, template := range templates {
|
||||
templateNames = append(templateNames, template.Name)
|
||||
}
|
||||
|
||||
cmd, root := clitest.New(t, append([]string{"templates", "delete"}, templateNames...)...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
require.NoError(t, cmd.Execute())
|
||||
|
||||
for _, template := range templates {
|
||||
_, err := client.Template(context.Background(), template.ID)
|
||||
require.Error(t, err, "template should not exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Selector", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: 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)
|
||||
|
||||
cmd, root := clitest.New(t, "templates", "delete")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
|
||||
pty.WriteLine("docker-local")
|
||||
require.NoError(t, <-execDone)
|
||||
|
||||
_, err := client.Template(context.Background(), template.ID)
|
||||
require.Error(t, err, "template should not exist")
|
||||
})
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package cli
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func templateEdit() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "edit",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user