Compare commits
766 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 | |||
| fe23dcd3fc | |||
| 8813788c28 | |||
| 02ad3f14f5 | |||
| 584c8b4fc3 | |||
| dc55e3525c | |||
| 32759a8714 | |||
| 9da17be61e | |||
| 1e9e5f7c76 | |||
| abddd64497 | |||
| abf6934ebb | |||
| 6b034eced3 | |||
| 29fffbe13d | |||
| c0a9eaca56 | |||
| 2f1fa153cd | |||
| 63916b6680 | |||
| 5ae71f0958 | |||
| d4e26ff8c2 | |||
| f02b8fda9e | |||
| 31536186f7 | |||
| 473aa6bd3a | |||
| 63b400627b | |||
| 8a1ae18ede | |||
| b621c59a03 | |||
| e0eae49f52 | |||
| fb4cab78e0 | |||
| 7833c24779 | |||
| 4dd3c57f6e | |||
| f2a21267b9 | |||
| c2a025d0d7 | |||
| 8e56830d15 | |||
| 1cc0a7284c | |||
| 315676b7bb | |||
| 081d43ed61 | |||
| ccba2ba99d | |||
| 2a7ab08bab | |||
| e9027b9717 | |||
| 84ede1c832 | |||
| fbcdd92fbc | |||
| fd523100bf | |||
| 2b1a0ee126 | |||
| cbb82ce017 | |||
| efec029675 | |||
| 0eacde79b1 | |||
| dc46ff407b | |||
| 4601a35c01 | |||
| b2261030e6 | |||
| 50f2fcae05 | |||
| 0d53795c0d | |||
| bb6c12ddd4 | |||
| 1b43a4ab91 | |||
| e759b4a492 | |||
| f5757ffb0d | |||
| b942c74926 | |||
| 6612e3c9c7 | |||
| 6ab1a681c4 | |||
| 7e48df8cb5 | |||
| f4ac7a3709 | |||
| 4362f5fa3d | |||
| f0dccd8c1e | |||
| 3abb87ddb6 | |||
| 8c0f109240 | |||
| b33e457f57 | |||
| 1bab7e8fd0 | |||
| 82dfd6c72f | |||
| 620c889842 | |||
| bd20d9ee7f | |||
| e0172dd4d5 | |||
| 13cef7d07c | |||
| 9485fd62da | |||
| a502a5fa14 | |||
| 01957da040 | |||
| be8389fd74 | |||
| b33dec9d38 | |||
| eb18925f11 | |||
| fe23d51edf | |||
| 2e0715375e | |||
| 3a48e4000e | |||
| 4448ba2778 | |||
| 533dab0dbd | |||
| ff2f647fd9 | |||
| 51ff7e1862 | |||
| 2a7f2d2808 | |||
| af151efc09 | |||
| 4f910b5386 | |||
| 85deeb02fb | |||
| 99e5a82e9f | |||
| 546beef673 | |||
| 591523a078 | |||
| 6be949a88e | |||
| 21fdb80825 | |||
| a06821c103 | |||
| 27c24de764 | |||
| 8c0f403546 | |||
| 39e5fcfd61 | |||
| d371a66447 | |||
| bf00487174 | |||
| 565b9403e4 | |||
| ddd86ab547 | |||
| 99ece25bb3 | |||
| 305b67c668 | |||
| d665263d37 | |||
| 6573b651b1 | |||
| 014ce11fdb | |||
| e3e4c6c2f4 | |||
| 6560f2e340 | |||
| 038dd54ab3 | |||
| f2ac81cc2e | |||
| 3bf5ceb600 | |||
| 0c1cdb4492 | |||
| d0c353be5d | |||
| 8216a782fa | |||
| 26d24f4508 | |||
| ebae1b9af9 | |||
| 5af2ab9d0e | |||
| c451f4e685 | |||
| 2818b3ce6d | |||
| 43d433cde1 | |||
| 3b29b15290 | |||
| 01ab48724e | |||
| b55a67a36c | |||
| 2c7ad59a5d | |||
| f24358e0c6 | |||
| d11079d337 | |||
| 88d0abb8e6 | |||
| 64d9d00052 | |||
| 22f820c69b | |||
| 8fde3ed52f | |||
| d875298c0e | |||
| 22a050d547 | |||
| 360e1a3ce6 | |||
| 706e60bb3b | |||
| edd8345658 | |||
| 8b12e470d9 | |||
| 810b2d23a4 | |||
| 95160d0ee8 |
@@ -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,5 +1,5 @@
|
||||
# Generated files
|
||||
database/dump.sql linguist-generated=true
|
||||
coderd/database/dump.sql linguist-generated=true
|
||||
peerbroker/proto/*.go linguist-generated=true
|
||||
provisionerd/proto/*.go linguist-generated=true
|
||||
provisionersdk/proto/*.go linguist-generated=true
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
site @coder/coder-frontend
|
||||
site/ @coder/frontend
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a bug
|
||||
title: "Bug: "
|
||||
labels: ["bug :bug:", "needs grooming :razor:"]
|
||||
---
|
||||
|
||||
## OS Information
|
||||
|
||||
- OS:
|
||||
- Browser (if applicable):
|
||||
- Architecture:
|
||||
- `coder --version`:
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
<!-- 1. -->
|
||||
<!-- 2. -->
|
||||
<!-- 3. -->
|
||||
|
||||
## Expected
|
||||
|
||||
<!-- What should happen? -->
|
||||
|
||||
## Actual
|
||||
|
||||
<!-- What actually happens? -->
|
||||
|
||||
## Logs
|
||||
|
||||
## Screenshot
|
||||
|
||||
<!-- Ideally provide a screenshot, gif, video or screen recording. -->
|
||||
|
||||
## Notes
|
||||
|
||||
<!-- Anything else you want to share -->
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Question?
|
||||
url: https://github.com/coder/coder/discussions/new?category=q-a
|
||||
about: Ask for help on our GitHub Discussions board
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Documentation improvement
|
||||
about: Suggest a documentation improvement
|
||||
title: "Docs: "
|
||||
labels: ["documentation :memo:", "needs grooming :razor:"]
|
||||
---
|
||||
|
||||
## What is your suggestion?
|
||||
|
||||
## How will this improve the docs?
|
||||
|
||||
## Are you interested in submitting a PR for this?
|
||||
@@ -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?
|
||||
@@ -0,0 +1,34 @@
|
||||
codecov:
|
||||
require_ci_to_pass: false
|
||||
|
||||
comment: false
|
||||
|
||||
github_checks:
|
||||
annotations: false
|
||||
|
||||
coverage:
|
||||
status:
|
||||
patch:
|
||||
default:
|
||||
informational: yes
|
||||
project:
|
||||
default:
|
||||
target: 70%
|
||||
informational: yes
|
||||
|
||||
ignore:
|
||||
# This is generated code.
|
||||
- coderd/database/models.go
|
||||
- coderd/database/queries.sql.go
|
||||
- coderd/database/databasefake
|
||||
# These are generated or don't require tests.
|
||||
- cmd
|
||||
- coderd/tunnel
|
||||
- coderd/database/dump
|
||||
- coderd/database/postgres
|
||||
- peerbroker/proto
|
||||
- provisionerd/proto
|
||||
- provisionersdk/proto
|
||||
- scripts
|
||||
- site/.storybook
|
||||
- rules.go
|
||||
@@ -3,7 +3,7 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
commit-message:
|
||||
@@ -27,23 +27,44 @@ updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "go"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/site/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
time: "06:00"
|
||||
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
|
||||
|
||||
-->
|
||||
+12
-12
@@ -7,6 +7,10 @@
|
||||
# https://www.notion.so/coderhq/Conventional-commits-1d51287f58b64026bb29393f277734ed
|
||||
###############################################################################
|
||||
|
||||
# We have no valid scopes right now.
|
||||
# A scope should be added when commits aren't aligning with associated change anymore.
|
||||
scopes:
|
||||
|
||||
# We only check that the PR title is semantic. The PR title is automatically
|
||||
# applied to the "Squash & Merge" flow as the suggested commit message, so this
|
||||
# should suffice unless someone drastically alters the message in that flow.
|
||||
@@ -21,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"
|
||||
+260
-99
@@ -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.17"
|
||||
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.43.0
|
||||
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
|
||||
@@ -55,7 +67,7 @@ jobs:
|
||||
|
||||
- name: Cache Node
|
||||
id: cache-node
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
@@ -73,28 +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.6.1"
|
||||
- uses: actions/setup-go@v2
|
||||
version: "3.20.0"
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
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: "make --output-sync -j gen"
|
||||
- run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
- 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
|
||||
@@ -104,7 +134,7 @@ jobs:
|
||||
|
||||
- name: Cache Node
|
||||
id: cache-node
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
@@ -116,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:
|
||||
@@ -131,25 +166,37 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
go-version: "~1.18"
|
||||
|
||||
- uses: actions/cache@v2
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-build::$(go env GOCACHE)"
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
|
||||
- name: Go Build Cache
|
||||
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-
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- run: go install gotest.tools/gotestsum@latest
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- uses: hashicorp/setup-terraform@v1
|
||||
- name: Install goreleaser
|
||||
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@v2
|
||||
with:
|
||||
terraform_version: 1.1.2
|
||||
terraform_wrapper: false
|
||||
@@ -157,15 +204,15 @@ jobs:
|
||||
- name: Test with Mock Database
|
||||
shell: bash
|
||||
env:
|
||||
GOCOUNT: ${{ runner.os == 'Windows' && 3 || 5 }}
|
||||
GOCOUNT: ${{ runner.os == 'Windows' && 1 || 2 }}
|
||||
GOMAXPROCS: ${{ runner.os == 'Windows' && 1 || 2 }}
|
||||
run: gotestsum --junitfile="gotests.xml" --packages="./..." --
|
||||
-covermode=atomic -coverprofile="gotests.coverage"
|
||||
-coverpkg=./...,github.com/coder/coder/codersdk
|
||||
-timeout=3m -count=$GOCOUNT -race -short -failfast
|
||||
-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
|
||||
@@ -173,33 +220,104 @@ jobs:
|
||||
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: go run scripts/datadog-cireport/main.go gotests.xml
|
||||
|
||||
- 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 }}
|
||||
# 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@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-build::$(go env GOCACHE)"
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
|
||||
- name: Go Build Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install goreleaser
|
||||
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@v2
|
||||
with:
|
||||
terraform_version: 1.1.2
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Start PostgreSQL Database
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: postgres
|
||||
PGDATA: /tmp
|
||||
run: |
|
||||
docker run \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_DB=postgres \
|
||||
-e PGDATA=/tmp \
|
||||
-p 5432:5432 \
|
||||
-d postgres:11 \
|
||||
-c shared_buffers=1GB \
|
||||
-c max_connections=1000
|
||||
while ! pg_isready -h 127.0.0.1
|
||||
do
|
||||
echo "$(date) - waiting for database to start"
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
if: runner.os == 'Linux'
|
||||
run: DB=true gotestsum --junitfile="gotests.xml" --packages="./..." --
|
||||
-covermode=atomic -coverprofile="gotests.coverage" -timeout=3m
|
||||
-coverpkg=./...,github.com/coder/coder/codersdk
|
||||
-count=1 -race -parallel=2 -failfast
|
||||
run: "make test-postgres"
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: (success() || failure()) && github.actor != 'dependabot[bot]' && runner.os == 'Linux'
|
||||
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.event_name != 'pull_request'
|
||||
timeout-minutes: 20
|
||||
if: github.ref == 'refs/heads/main' && !github.event.pull_request.head.repo.fork
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -209,46 +327,91 @@ jobs:
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v0
|
||||
with:
|
||||
workload_identity_provider: projects/477254869654/locations/global/workloadIdentityPools/github/providers/github
|
||||
service_account: github-coder@coder-ci.iam.gserviceaccount.com
|
||||
workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github
|
||||
service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com
|
||||
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
|
||||
- name: Configure Docker for Google Artifact Registry
|
||||
run: gcloud auth configure-docker us-docker.pkg.dev
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
node-version: "14"
|
||||
go-version: "~1.18"
|
||||
|
||||
- name: Install node_modules
|
||||
run: ./scripts/yarn_install.sh
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-build::$(go env GOCACHE)"
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
|
||||
- uses: actions/setup-go@v2
|
||||
- name: Go Build Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
key: ${{ runner.os }}-release-go-build-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v2
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ runner.os }}-release-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
install-only: true
|
||||
|
||||
- run: make docker/image/coder
|
||||
- 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 }}-
|
||||
|
||||
- run: docker push us-docker.pkg.dev/coder-blacktriangle-dev/ci/coder:latest
|
||||
- name: Build site
|
||||
run: make -B site/out/index.html
|
||||
|
||||
- name: Update coder service
|
||||
run: gcloud run services update coder --image us-docker.pkg.dev/coder-blacktriangle-dev/ci/coder:latest --project coder-blacktriangle-dev --tag "git-$(git rev-parse --short HEAD)" --region us-central1
|
||||
- name: Build Release
|
||||
uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
version: latest
|
||||
args: release --snapshot --rm-dist --skip-sign
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
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: |
|
||||
gcloud config set project coder-dogfood
|
||||
gcloud config set compute/zone us-central1-a
|
||||
gcloud compute scp ./dist/coder_*_linux_amd64.deb coder:/tmp/coder.deb
|
||||
gcloud compute ssh coder -- sudo dpkg -i --force-confdef /tmp/coder.deb
|
||||
gcloud compute ssh coder -- sudo systemctl daemon-reload
|
||||
|
||||
- name: Start
|
||||
run: gcloud compute ssh coder -- sudo service coder restart
|
||||
|
||||
test-js:
|
||||
name: "test/js"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Cache Node
|
||||
id: cache-node
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
@@ -258,9 +421,9 @@ 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.17"
|
||||
go-version: "~1.18"
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -269,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
|
||||
@@ -299,20 +455,17 @@ 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@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Cache Node
|
||||
id: cache-node
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
@@ -322,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.17"
|
||||
go-version: "~1.18"
|
||||
|
||||
- uses: hashicorp/setup-terraform@v1
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
terraform_version: 1.1.2
|
||||
terraform_wrapper: false
|
||||
@@ -335,23 +488,31 @@ jobs:
|
||||
with:
|
||||
node-version: "14"
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v2
|
||||
- uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
install-only: true
|
||||
|
||||
- uses: actions/cache@v2
|
||||
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-
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-build::$(go env GOCACHE)"
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
|
||||
- run: make build
|
||||
- name: Go Build Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
make -B site/out/index.html
|
||||
|
||||
- run: yarn playwright:install
|
||||
working-directory: site
|
||||
@@ -365,9 +526,9 @@ 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
|
||||
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: go run scripts/datadog-cireport/main.go site/test-results/junit.xml
|
||||
run: go run scripts/datadog-cireport/main.go site/test-results/junit.xml
|
||||
|
||||
@@ -3,21 +3,88 @@ 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:
|
||||
go-version: "^1.17"
|
||||
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: |
|
||||
echo "::set-output name=go-build::$(go env GOCACHE)"
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
|
||||
- name: Go Build Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
key: ${{ runner.os }}-release-go-build-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ runner.os }}-release-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- 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 make
|
||||
run: brew install make
|
||||
|
||||
- name: Build Site
|
||||
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 }}
|
||||
|
||||
+12
@@ -13,6 +13,8 @@ node_modules
|
||||
vendor
|
||||
.eslintcache
|
||||
yarn-error.log
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
# Front-end ignore
|
||||
.next/
|
||||
@@ -23,7 +25,17 @@ site/storybook-static/
|
||||
site/test-results/
|
||||
site/yarn-error.log
|
||||
coverage/
|
||||
site/**/*.typegen.ts
|
||||
site/build-storybook.log
|
||||
|
||||
# Build
|
||||
dist/
|
||||
site/out/
|
||||
|
||||
*.tfstate
|
||||
*.tfplan
|
||||
*.lock.hcl
|
||||
.terraform/
|
||||
|
||||
.vscode/*.log
|
||||
**/*.swp
|
||||
|
||||
@@ -27,7 +27,7 @@ linters-settings:
|
||||
- codegenComment
|
||||
# - commentedOutCode
|
||||
- commentedOutImport
|
||||
# - commentFormatting
|
||||
- commentFormatting
|
||||
- defaultCaseOrder
|
||||
- deferUnlambda
|
||||
# - deprecatedComment
|
||||
@@ -54,7 +54,7 @@ linters-settings:
|
||||
# - importShadow
|
||||
- indexAlloc
|
||||
- initClause
|
||||
# - ioutilDeprecated
|
||||
- ioutilDeprecated
|
||||
- mapKey
|
||||
- methodExprCall
|
||||
# - nestingReduce
|
||||
@@ -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
|
||||
@@ -154,7 +161,7 @@ linters-settings:
|
||||
- name: import-shadowing
|
||||
- name: increment-decrement
|
||||
- name: indent-error-flow
|
||||
- name: modifies-parameter
|
||||
# - name: modifies-parameter
|
||||
- name: modifies-value-receiver
|
||||
- name: package-comments
|
||||
- name: range
|
||||
@@ -177,20 +184,6 @@ linters-settings:
|
||||
- name: var-declaration
|
||||
- name: var-naming
|
||||
- name: waitgroup-by-value
|
||||
varnamelen:
|
||||
ignore-names:
|
||||
- err
|
||||
- rw
|
||||
- r
|
||||
- i
|
||||
- db
|
||||
# Optional list of variable declarations that should be ignored completely. (defaults to empty list)
|
||||
# Entries must be in the form of "<variable name> <type>" or "<variable name> *<type>" for
|
||||
# variables, or "const <name>" for constants.
|
||||
ignore-decls:
|
||||
- rw http.ResponseWriter
|
||||
- r *http.Request
|
||||
- t testing.T
|
||||
|
||||
issues:
|
||||
# Rules listed here: https://github.com/securego/gosec#available-rules
|
||||
@@ -208,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
|
||||
@@ -245,11 +240,17 @@ linters:
|
||||
- staticcheck
|
||||
- structcheck
|
||||
- tenv
|
||||
# In Go, it's possible for a package to test it's internal functionality
|
||||
# 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
|
||||
- tparallel
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- varcheck
|
||||
- varnamelen
|
||||
- wastedassign
|
||||
@@ -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,48 +0,0 @@
|
||||
archives:
|
||||
- builds:
|
||||
- coder
|
||||
files:
|
||||
- README.md
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- rm -f site/out/bin/coder*
|
||||
|
||||
builds:
|
||||
- id: coder-slim
|
||||
dir: cmd/coder
|
||||
flags: [-tags=slim]
|
||||
ldflags: ["-s -w"]
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [darwin, linux, windows]
|
||||
goarch: [amd64, arm64]
|
||||
hooks:
|
||||
# The "trimprefix" appends ".exe" on Windows.
|
||||
post: |
|
||||
cp {{.Path}} site/out/bin/coder_{{ .Os }}_{{ .Arch }}{{ trimprefix .Name "coder" }}
|
||||
|
||||
- id: coder
|
||||
dir: cmd/coder
|
||||
ldflags: ["-s -w"]
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [darwin, linux, windows]
|
||||
goarch: [amd64, arm64]
|
||||
|
||||
nfpms:
|
||||
- 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
|
||||
|
||||
release:
|
||||
ids: [coder]
|
||||
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
+65
-28
@@ -1,46 +1,32 @@
|
||||
{
|
||||
"files.exclude": {
|
||||
"**/node_modules": true
|
||||
},
|
||||
"go.lintTool": "golangci-lint",
|
||||
"go.lintFlags": ["--fast"],
|
||||
"go.lintOnSave": "package",
|
||||
"go.coverOnSave": true,
|
||||
// The codersdk is used by coderd another other packages extensively.
|
||||
// To reduce redundancy in tests, it's covered by other packages.
|
||||
"go.testFlags": ["-coverpkg=./.,github.com/coder/coder/codersdk"],
|
||||
"go.coverageDecorator": {
|
||||
"type": "gutter",
|
||||
"coveredHighlightColor": "rgba(64,128,128,0.5)",
|
||||
"uncoveredHighlightColor": "rgba(128,64,64,0.25)",
|
||||
"coveredBorderColor": "rgba(64,128,128,0.5)",
|
||||
"uncoveredBorderColor": "rgba(128,64,64,0.25)",
|
||||
"coveredGutterStyle": "blockgreen",
|
||||
"uncoveredGutterStyle": "blockred"
|
||||
},
|
||||
"emeraldwalk.runonsave": {
|
||||
"commands": [
|
||||
{
|
||||
"match": "database/query.sql",
|
||||
"cmd": "make gen"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cSpell.words": [
|
||||
"buildname",
|
||||
"circbuf",
|
||||
"cliflag",
|
||||
"cliui",
|
||||
"coderd",
|
||||
"coderdtest",
|
||||
"codersdk",
|
||||
"cronstrue",
|
||||
"devel",
|
||||
"drpc",
|
||||
"drpcconn",
|
||||
"drpcmux",
|
||||
"drpcserver",
|
||||
"Dsts",
|
||||
"fatih",
|
||||
"Formik",
|
||||
"goarch",
|
||||
"gographviz",
|
||||
"goleak",
|
||||
"gossh",
|
||||
"gsyslog",
|
||||
"hashicorp",
|
||||
"hclsyntax",
|
||||
"httpapi",
|
||||
"httpmw",
|
||||
"idtoken",
|
||||
"Iflag",
|
||||
"incpatch",
|
||||
"isatty",
|
||||
"Jobf",
|
||||
@@ -55,6 +41,7 @@
|
||||
"nolint",
|
||||
"nosec",
|
||||
"ntqry",
|
||||
"OIDC",
|
||||
"oneof",
|
||||
"parameterscopeid",
|
||||
"pqtype",
|
||||
@@ -65,16 +52,66 @@
|
||||
"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"
|
||||
],
|
||||
"eslint.workingDirectories": ["./site"]
|
||||
"emeraldwalk.runonsave": {
|
||||
"commands": [
|
||||
{
|
||||
"match": "database/queries/*.sql",
|
||||
"cmd": "make gen"
|
||||
},
|
||||
{
|
||||
"match": "provisionerd/proto/provisionerd.proto",
|
||||
"cmd": "make provisionerd/proto/provisionerd.pb.go"
|
||||
}
|
||||
]
|
||||
},
|
||||
"eslint.workingDirectories": ["./site"],
|
||||
"files.exclude": {
|
||||
"**/node_modules": true
|
||||
},
|
||||
// Ensure files always have a newline.
|
||||
"files.insertFinalNewline": true,
|
||||
"go.lintTool": "golangci-lint",
|
||||
"go.lintFlags": ["--fast"],
|
||||
"go.lintOnSave": "package",
|
||||
"go.coverOnSave": true,
|
||||
// The codersdk is used by coderd another other packages extensively.
|
||||
// To reduce redundancy in tests, it's covered by other packages.
|
||||
"go.testFlags": ["-short", "-coverpkg=./.,github.com/coder/coder/codersdk"],
|
||||
"go.coverageDecorator": {
|
||||
"type": "gutter",
|
||||
"coveredHighlightColor": "rgba(64,128,128,0.5)",
|
||||
"uncoveredHighlightColor": "rgba(128,64,64,0.25)",
|
||||
"coveredBorderColor": "rgba(64,128,128,0.5)",
|
||||
"uncoveredBorderColor": "rgba(128,64,64,0.25)",
|
||||
"coveredGutterStyle": "blockgreen",
|
||||
"uncoveredGutterStyle": "blockred"
|
||||
},
|
||||
// We often use a version of TypeScript that's ahead of the version shipped
|
||||
// with VS Code.
|
||||
"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" ]
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -1,29 +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 --single-target --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.
|
||||
database/dump.sql: $(wildcard database/migrations/*.sql)
|
||||
go run database/dump/main.go
|
||||
coderd/database/dump.sql: $(wildcard coderd/database/migrations/*.sql)
|
||||
go run coderd/database/dump/main.go
|
||||
|
||||
# Generates Go code for querying the database.
|
||||
database/generate: fmt/sql database/dump.sql database/query.sql
|
||||
cd database && sqlc generate && rm db_tmp.go
|
||||
cd database && gofmt -w -r 'Querier -> querier' *.go
|
||||
cd database && gofmt -w -r 'Queries -> sqlQuerier' *.go
|
||||
.PHONY: database/generate
|
||||
coderd/database/querier.go: coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql)
|
||||
coderd/database/generate.sh
|
||||
|
||||
docker/image/coder: build
|
||||
cp ./images/coder/run.sh ./dist/coder_$(GOOS)_$(GOARCH)
|
||||
docker build --network=host -t us-docker.pkg.dev/coder-blacktriangle-dev/ci/coder:latest -f images/coder/Dockerfile ./dist/coder_$(GOOS)_$(GOARCH)
|
||||
.PHONY: docker/build
|
||||
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"
|
||||
@@ -35,60 +37,104 @@ else
|
||||
endif
|
||||
.PHONY: fmt/prettier
|
||||
|
||||
fmt/sql: ./database/query.sql
|
||||
npx sql-formatter \
|
||||
--language postgresql \
|
||||
--lines-between-queries 2 \
|
||||
./database/query.sql \
|
||||
--output ./database/query.sql
|
||||
sed -i 's/@ /@/g' ./database/query.sql
|
||||
fmt/terraform: $(wildcard *.tf)
|
||||
terraform fmt -recursive
|
||||
.PHONY: fmt/terraform
|
||||
|
||||
fmt: fmt/prettier fmt/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/terraform fmt/shfmt
|
||||
.PHONY: fmt
|
||||
|
||||
gen: 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
|
||||
|
||||
peerbroker/proto: peerbroker/proto/peerbroker.proto
|
||||
lint: lint/shellcheck lint/go
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
snapshot:
|
||||
goreleaser release --snapshot --rm-dist
|
||||
.PHONY: snapshot
|
||||
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
|
||||
|
||||
@@ -1,78 +1,164 @@
|
||||
[](https://github.com/coder/coder/actions/workflows/coder.yaml)
|
||||
# Coder
|
||||
|
||||
[](https://github.com/coder/coder/discussions)
|
||||
[](https://discord.gg/coder)
|
||||
[](https://twitter.com/coderhq)
|
||||
[](https://codecov.io/gh/coder/coder)
|
||||
|
||||
# Coder v2
|
||||
## Run Coder *now*
|
||||
|
||||
This repository contains source code for Coder V2. Additional documentation:
|
||||
```curl -L https://coder.com/install.sh | sh```
|
||||
|
||||
- [Workspaces V2 RFC](https://www.notion.so/coderhq/b48040da8bfe46eca1f32749b69420dd?v=a4e7d23495094644b939b08caba8e381&p=e908a8cd54804ddd910367abf03c8d0a)
|
||||
## What Coder does
|
||||
Coder creates remote development machines so you can develop your code from anywhere. #coder
|
||||
|
||||
## Directory Structure
|
||||
> **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).
|
||||
|
||||
- `.github/`: Settings for [Dependabot for updating dependencies](https://docs.github.com/en/code-security/supply-chain-security/customizing-dependency-updates) and [build/deploy pipelines with GitHub Actions](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions).
|
||||
- [`semantic.yaml`](./github/semantic.yaml): Configuration for [semantic pull requests](https://github.com/apps/semantic-pull-requests)
|
||||
- `examples`: Example terraform project templates.
|
||||
- `site`: Front-end UI code.
|
||||
<p align="center">
|
||||
<img src="./docs/images/hero-image.png">
|
||||
</p>
|
||||
|
||||
## Development
|
||||
**Code more**
|
||||
|
||||
### Pre-requisites
|
||||
- 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
|
||||
|
||||
- `git`
|
||||
- `go` version 1.17, with the `GOPATH` environment variable set
|
||||
- `node`
|
||||
- `yarn`
|
||||
**Manage less**
|
||||
|
||||
### Cloning
|
||||
- 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
|
||||
|
||||
- `git clone https://github.com/coder/coder`
|
||||
- `cd coder`
|
||||
## How it works
|
||||
|
||||
### Building
|
||||
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.
|
||||
|
||||
- `make build`
|
||||
- `make install`
|
||||
<p align="center">
|
||||
<img src="./docs/images/providers-compute.png">
|
||||
</p>
|
||||
|
||||
The `coder` CLI binary will now be available at `$GOPATH/bin/coder`
|
||||
Coder workspaces don't stop at compute. You can add storage buckets, secrets, sidecars
|
||||
and whatever else Terraform lets you dream up.
|
||||
|
||||
### Running
|
||||
[Learn more about managing infrastructure.](./docs/templates.md)
|
||||
|
||||
After building, the binaries will be available at:
|
||||
- `dist/coder_{os}_{arch}/coder`
|
||||
## IDE Support
|
||||
|
||||
For the purpose of these steps, an OS of `linux` and an arch of `amd64` is assumed.
|
||||
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/).
|
||||
|
||||
To manually run the server and go through first-time set up, run the following commands in separate terminals:
|
||||
- `dist/coder_linux_amd64/coder daemon` <-- starts the Coder server on port 3000
|
||||
- `dist/coder_linux_amd64/coder login http://localhost:3000` <-- runs through first-time setup, creating a user and org
|
||||
<p align="center">
|
||||
<img src="./docs/images/ide-icons.svg" height=72>
|
||||
</p>
|
||||
|
||||
You'll now be able to login and access the server.
|
||||
## Installing Coder
|
||||
|
||||
To create a project, run:
|
||||
- `dist/coder_linux_amd64/coder projects create -d /path/to/project`
|
||||
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).
|
||||
|
||||
### Development
|
||||
If you use the install script, you can preview what occurs during the install process:
|
||||
|
||||
- `./develop.sh`
|
||||
```sh
|
||||
curl -fsSL https://coder.com/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The `develop.sh` script does three things:
|
||||
To install, run:
|
||||
|
||||
- runs `coder daemon` locally on port `3000`
|
||||
- runs `webpack-dev-server` on port `8080`
|
||||
- sets up an initial user and organization
|
||||
```sh
|
||||
curl -fsSL https://coder.com/install.sh | sh
|
||||
```
|
||||
|
||||
This is the recommend flow for working on the front-end, as hot-reload is set up as part of the webpack config.
|
||||
Once installed, you can run a temporary deployment in dev mode (all data is in-memory and destroyed on exit):
|
||||
|
||||
## Front-End Plan
|
||||
```sh
|
||||
coder server --dev
|
||||
```
|
||||
|
||||
For the front-end team, we're planning on 2 phases to the 'v2' work:
|
||||
Use `coder --help` to get a complete list of flags and environment variables.
|
||||
|
||||
### Phase 1
|
||||
## Creating your first template and workspace
|
||||
|
||||
Phase 1 is the 'new-wine-in-an-old-bottle' approach - we want to preserve the look and feel (UX) of v1, while testing and validating the market fit of our new v2 provisioner model. This means that we'll preserve Material UI and re-use components from v1 (porting them over to the v2 codebase).
|
||||
In a new terminal window, run the following to copy a sample template:
|
||||
|
||||
### Phase 2
|
||||
```bash
|
||||
coder templates init
|
||||
```
|
||||
|
||||
Phase 2 is the 'new-wine-in-a-new-bottle' - which we can do once we've successfully packaged the new wine in the old bottle.
|
||||
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**).
|
||||
|
||||
In other words, once we've validated that the new strategy fits and is desirable for our customers, we'd like to build a new, v2-native UI (leveraging designers on the team to build a first-class experience around the new provisioner model).
|
||||
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).
|
||||
|
||||
+628
-165
@@ -4,88 +4,237 @@ 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"
|
||||
)
|
||||
|
||||
func DialSSH(conn *peer.Conn) (net.Conn, error) {
|
||||
channel, err := conn.Dial(context.Background(), "ssh", &peer.ChannelOptions{
|
||||
Protocol: "ssh",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return channel.NetConn(), nil
|
||||
}
|
||||
|
||||
func DialSSHClient(conn *peer.Conn) (*gossh.Client, error) {
|
||||
netConn, err := DialSSH(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sshConn, channels, requests, err := gossh.NewClientConn(netConn, "localhost:22", &gossh.ClientConfig{
|
||||
Config: gossh.Config{
|
||||
Ciphers: []string{"arcfour"},
|
||||
},
|
||||
// SSH host validation isn't helpful, because obtaining a peer
|
||||
// connection already signifies user-intent to dial a workspace.
|
||||
// #nosec
|
||||
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gossh.NewClient(sshConn, channels, requests), nil
|
||||
}
|
||||
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 := &server{
|
||||
clientDialer: dialer,
|
||||
options: options,
|
||||
closeCancel: cancelFunc,
|
||||
closed: make(chan struct{}),
|
||||
server := &agent{
|
||||
dialer: dialer,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
closeCancel: cancelFunc,
|
||||
closed: make(chan struct{}),
|
||||
envVars: options.EnvironmentVariables,
|
||||
}
|
||||
server.init(ctx)
|
||||
return server
|
||||
}
|
||||
|
||||
type server struct {
|
||||
clientDialer Dialer
|
||||
options *peer.ConnOptions
|
||||
type agent struct {
|
||||
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 (s *server) init(ctx context.Context) {
|
||||
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); {
|
||||
metadata, peerListener, err = a.dialer(ctx, a.logger)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
if a.isClosed() {
|
||||
return
|
||||
}
|
||||
a.logger.Warn(context.Background(), "failed to dial", slog.Error(err))
|
||||
continue
|
||||
}
|
||||
a.logger.Info(context.Background(), "connected")
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
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()
|
||||
if err != nil {
|
||||
if a.isClosed() {
|
||||
return
|
||||
}
|
||||
a.logger.Debug(ctx, "peer listener accept exited; restarting connection", slog.Error(err))
|
||||
a.run(ctx)
|
||||
return
|
||||
}
|
||||
a.closeMutex.Lock()
|
||||
a.connCloseWait.Add(1)
|
||||
a.closeMutex.Unlock()
|
||||
go a.handlePeerConn(ctx, conn)
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
select {
|
||||
case <-a.closed:
|
||||
case <-conn.Closed():
|
||||
}
|
||||
_ = conn.Close()
|
||||
a.connCloseWait.Done()
|
||||
}()
|
||||
for {
|
||||
channel, err := conn.Accept(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, peer.ErrClosed) || a.isClosed() {
|
||||
return
|
||||
}
|
||||
a.logger.Debug(ctx, "accept channel from peer connection", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
switch channel.Protocol() {
|
||||
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.logger.Warn(ctx, "unhandled protocol from channel",
|
||||
slog.F("protocol", channel.Protocol()),
|
||||
slog.F("label", channel.Label()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) init(ctx context.Context) {
|
||||
// Clients' should ignore the host key when connecting.
|
||||
// The agent needs to authenticate with coderd to SSH,
|
||||
// so SSH authentication doesn't improve security.
|
||||
@@ -97,17 +246,20 @@ func (s *server) init(ctx context.Context) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
sshLogger := s.options.Logger.Named("ssh-server")
|
||||
sshLogger := a.logger.Named("ssh-server")
|
||||
forwardHandler := &ssh.ForwardedTCPHandler{}
|
||||
s.sshServer = &ssh.Server{
|
||||
ChannelHandlers: ssh.DefaultChannelHandlers,
|
||||
a.sshServer = &ssh.Server{
|
||||
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 := s.handleSSHSession(session)
|
||||
err := a.handleSSHSession(session)
|
||||
if err != nil {
|
||||
s.options.Logger.Debug(ctx, "ssh session failed", slog.Error(err))
|
||||
a.logger.Warn(ctx, "ssh session failed", slog.Error(err))
|
||||
_ = session.Exit(1)
|
||||
return
|
||||
}
|
||||
@@ -136,68 +288,120 @@ func (s *server) init(ctx context.Context) {
|
||||
},
|
||||
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
|
||||
return &gossh.ServerConfig{
|
||||
Config: gossh.Config{
|
||||
// "arcfour" is the fastest SSH cipher. We prioritize throughput
|
||||
// over encryption here, because the WebRTC connection is already
|
||||
// encrypted. If possible, we'd disable encryption entirely here.
|
||||
Ciphers: []string{"arcfour"},
|
||||
},
|
||||
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 s.run(ctx)
|
||||
go a.run(ctx)
|
||||
}
|
||||
|
||||
func (*server) 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 nil, xerrors.Errorf("get current user: %w", err)
|
||||
}
|
||||
username := currentUser.Username
|
||||
|
||||
username := session.User()
|
||||
if username == "" {
|
||||
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 nil, xerrors.Errorf("get user shell: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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:]
|
||||
}
|
||||
command := rawCommand
|
||||
if len(command) == 0 {
|
||||
command = shell
|
||||
}
|
||||
|
||||
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:
|
||||
}
|
||||
}
|
||||
}()
|
||||
// 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))
|
||||
|
||||
cmd := exec.CommandContext(session.Context(), command, args...)
|
||||
cmd.Env = session.Environ()
|
||||
// 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 {
|
||||
@@ -206,11 +410,15 @@ func (*server) 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 {
|
||||
panic(err)
|
||||
a.logger.Warn(context.Background(), "failed to resize tty", slog.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -226,7 +434,7 @@ func (*server) 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()
|
||||
@@ -240,99 +448,354 @@ func (*server) handleSSHSession(session ssh.Session) error {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("start: %w", err)
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
func (s *server) run(ctx context.Context) {
|
||||
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 = s.clientDialer(ctx, s.options)
|
||||
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 {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
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()
|
||||
}
|
||||
if s.isClosed() {
|
||||
|
||||
// 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:
|
||||
}
|
||||
s.options.Logger.Warn(context.Background(), "failed to dial", slog.Error(err))
|
||||
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
|
||||
}
|
||||
s.options.Logger.Debug(context.Background(), "connected")
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
for {
|
||||
conn, err := peerListener.Accept()
|
||||
err = rpty.ptty.Resize(req.Height, req.Width)
|
||||
if err != nil {
|
||||
if s.isClosed() {
|
||||
return
|
||||
}
|
||||
s.options.Logger.Debug(ctx, "peer listener accept exited; restarting connection", slog.Error(err))
|
||||
s.run(ctx)
|
||||
return
|
||||
// We can continue after this, it's not fatal!
|
||||
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err))
|
||||
}
|
||||
s.closeMutex.Lock()
|
||||
s.connCloseWait.Add(1)
|
||||
s.closeMutex.Unlock()
|
||||
go s.handlePeerConn(ctx, conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) handlePeerConn(ctx context.Context, conn *peer.Conn) {
|
||||
go func() {
|
||||
<-conn.Closed()
|
||||
s.connCloseWait.Done()
|
||||
}()
|
||||
for {
|
||||
channel, err := conn.Accept(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, peer.ErrClosed) || s.isClosed() {
|
||||
return
|
||||
// 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))
|
||||
}
|
||||
s.options.Logger.Debug(ctx, "accept channel from peer connection", slog.Error(err))
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
switch channel.Protocol() {
|
||||
case "ssh":
|
||||
s.sshServer.HandleConn(channel.NetConn())
|
||||
default:
|
||||
s.options.Logger.Warn(ctx, "unhandled protocol from channel",
|
||||
slog.F("protocol", channel.Protocol()),
|
||||
slog.F("label", channel.Label()),
|
||||
)
|
||||
_, 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.
|
||||
func (s *server) isClosed() bool {
|
||||
func (a *agent) isClosed() bool {
|
||||
select {
|
||||
case <-s.closed:
|
||||
case <-a.closed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) Close() error {
|
||||
s.closeMutex.Lock()
|
||||
defer s.closeMutex.Unlock()
|
||||
if s.isClosed() {
|
||||
func (a *agent) Close() error {
|
||||
a.closeMutex.Lock()
|
||||
defer a.closeMutex.Unlock()
|
||||
if a.isClosed() {
|
||||
return nil
|
||||
}
|
||||
close(s.closed)
|
||||
s.closeCancel()
|
||||
_ = s.sshServer.Close()
|
||||
s.connCloseWait.Wait()
|
||||
close(a.closed)
|
||||
a.closeCancel()
|
||||
_ = a.sshServer.Close()
|
||||
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)
|
||||
}
|
||||
|
||||
+405
-38
@@ -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,20 +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()
|
||||
})
|
||||
sshClient, err := agent.DialSSHClient(conn)
|
||||
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"
|
||||
@@ -52,29 +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()
|
||||
})
|
||||
sshClient, err := agent.DialSSHClient(conn)
|
||||
require.NoError(t, err)
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
prompt := "$"
|
||||
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.")
|
||||
}
|
||||
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)
|
||||
@@ -83,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")
|
||||
}
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/peer"
|
||||
"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 {
|
||||
// Negotiator is responsible for exchanging messages.
|
||||
Negotiator proto.DRPCPeerBrokerClient
|
||||
|
||||
*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.CreateChannel(context.Background(), "ssh", &peer.ChannelOptions{
|
||||
Protocol: ProtocolSSH,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dial: %w", err)
|
||||
}
|
||||
return channel.NetConn(), nil
|
||||
}
|
||||
|
||||
// SSHClient calls SSH to create a client that uses a weak cipher
|
||||
// for high throughput.
|
||||
func (c *Conn) SSHClient() (*ssh.Client, error) {
|
||||
netConn, err := c.SSH()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("ssh: %w", err)
|
||||
}
|
||||
sshConn, channels, requests, err := ssh.NewClientConn(netConn, "localhost:22", &ssh.ClientConfig{
|
||||
// SSH host validation isn't helpful, because obtaining a peer
|
||||
// connection already signifies user-intent to dial a workspace.
|
||||
// #nosec
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("ssh conn: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -27,5 +27,5 @@ func Get(username string) (string, error) {
|
||||
}
|
||||
return parts[6], nil
|
||||
}
|
||||
return "", xerrors.New("user not found in /etc/passwd and $SHELL not set")
|
||||
return "", xerrors.Errorf("user %q not found in /etc/passwd", username)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package usershell
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// Get returns the command prompt binary name.
|
||||
func Get(username string) (string, error) {
|
||||
_, err := exec.LookPath("powershell.exe")
|
||||
if err == nil {
|
||||
return "powershell.exe", nil
|
||||
}
|
||||
return "cmd.exe", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package buildinfo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
var (
|
||||
buildInfo *debug.BuildInfo
|
||||
buildInfoValid bool
|
||||
readBuildInfo sync.Once
|
||||
|
||||
externalURL string
|
||||
readExternalURL sync.Once
|
||||
|
||||
version string
|
||||
readVersion sync.Once
|
||||
|
||||
// Injected with ldflags at build!
|
||||
tag string
|
||||
)
|
||||
|
||||
// Version returns the semantic version of the build.
|
||||
// Use golang.org/x/mod/semver to compare versions.
|
||||
func Version() string {
|
||||
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 {
|
||||
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.
|
||||
func Time() (time.Time, bool) {
|
||||
value, valid := find("vcs.time")
|
||||
if !valid {
|
||||
return time.Time{}, false
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic("couldn't parse time: " + err.Error())
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// revision returns the Git hash of the build.
|
||||
func revision() (string, bool) {
|
||||
return find("vcs.revision")
|
||||
}
|
||||
|
||||
// find panics if a setting with the specific key was not
|
||||
// found in the build info.
|
||||
func find(key string) (string, bool) {
|
||||
readBuildInfo.Do(func() {
|
||||
buildInfo, buildInfoValid = debug.ReadBuildInfo()
|
||||
})
|
||||
if !buildInfoValid {
|
||||
panic("couldn't read build info")
|
||||
}
|
||||
for _, setting := range buildInfo.Settings {
|
||||
if setting.Key != key {
|
||||
continue
|
||||
}
|
||||
return setting.Value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package buildinfo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"github.com/coder/coder/buildinfo"
|
||||
)
|
||||
|
||||
func TestBuildInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Version", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
version := buildinfo.Version()
|
||||
require.True(t, semver.IsValid(version))
|
||||
prerelease := semver.Prerelease(version)
|
||||
require.Equal(t, "-devel", prerelease)
|
||||
require.Equal(t, "v0", semver.Major(version))
|
||||
})
|
||||
t.Run("ExternalURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, "https://github.com/coder/coder", buildinfo.ExternalURL())
|
||||
})
|
||||
// Tests don't include Go build info.
|
||||
t.Run("NoTime", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, valid := buildinfo.Time()
|
||||
require.False(t, valid)
|
||||
})
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/compute/metadata"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/retry"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
func workspaceAgent() *cobra.Command {
|
||||
var (
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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.
|
||||
// This is abstracted to allow for the same looping condition
|
||||
// regardless of instance identity auth type.
|
||||
var exchangeToken func(context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error)
|
||||
switch auth {
|
||||
case "token":
|
||||
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":
|
||||
// This is *only* done for testing to mock client authentication.
|
||||
// This will never be set in a production scenario.
|
||||
var gcpClient *metadata.Client
|
||||
gcpClientRaw := cmd.Context().Value("gcp-client")
|
||||
if gcpClientRaw != nil {
|
||||
gcpClient, _ = gcpClientRaw.(*metadata.Client)
|
||||
}
|
||||
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
|
||||
return client.AuthWorkspaceGoogleInstanceIdentity(ctx, "", gcpClient)
|
||||
}
|
||||
case "aws-instance-identity":
|
||||
// This is *only* done for testing to mock client authentication.
|
||||
// This will never be set in a production scenario.
|
||||
var awsClient *http.Client
|
||||
awsClientRaw := cmd.Context().Value("aws-client")
|
||||
if awsClientRaw != nil {
|
||||
awsClient, _ = awsClientRaw.(*http.Client)
|
||||
if awsClient != nil {
|
||||
client.HTTPClient = awsClient
|
||||
}
|
||||
}
|
||||
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
|
||||
return client.AuthWorkspaceAWSInstanceIdentity(ctx)
|
||||
}
|
||||
case "azure-instance-identity":
|
||||
// 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 {
|
||||
// Agent's can start before resources are returned from the provisioner
|
||||
// daemon. If there are many resources being provisioned, this time
|
||||
// could be significant. This is arbitrarily set at an hour to prevent
|
||||
// tons of idle agents from pinging coderd.
|
||||
ctx, cancelFunc := context.WithTimeout(cmd.Context(), time.Hour)
|
||||
defer cancelFunc()
|
||||
for retry.New(100*time.Millisecond, 5*time.Second).Wait(ctx) {
|
||||
var response codersdk.WorkspaceAgentAuthenticateResponse
|
||||
|
||||
response, err = exchangeToken(ctx)
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "authenticate workspace", slog.F("method", auth), slog.Error(err))
|
||||
continue
|
||||
}
|
||||
client.SessionToken = response.SessionToken
|
||||
logger.Info(ctx, "authenticated", slog.F("method", auth))
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return xerrors.Errorf("agent failed to authenticate in time: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
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_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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package cliflag extends flagset with environment variable defaults.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// cliflag.String(root.Flags(), &address, "address", "a", "CODER_ADDRESS", "127.0.0.1:3000", "The address to serve the API and dashboard")
|
||||
//
|
||||
// Will produce the following usage docs:
|
||||
//
|
||||
// -a, --address string The address to serve the API and dashboard (uses $CODER_ADDRESS). (default "127.0.0.1:3000")
|
||||
//
|
||||
package cliflag
|
||||
|
||||
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)
|
||||
if !ok || v == "" {
|
||||
v = def
|
||||
}
|
||||
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)
|
||||
if !ok || val == "" {
|
||||
flagset.Uint8VarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
vi64, err := strconv.ParseUint(val, 10, 8)
|
||||
if err != nil {
|
||||
flagset.Uint8VarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
flagset.Uint8VarP(ptr, name, shorthand, uint8(vi64), fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
// BoolVarP sets a bool flag on the given flag set.
|
||||
func BoolVarP(flagset *pflag.FlagSet, ptr *bool, name string, shorthand string, env string, def bool, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if !ok || val == "" {
|
||||
flagset.BoolVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
valb, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
flagset.BoolVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s - consumes $%s.", u, env)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package cliflag_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
)
|
||||
|
||||
// Testcliflag cannot run in parallel because it uses t.Setenv.
|
||||
//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)
|
||||
|
||||
cliflag.StringVarP(flagset, &ptr, 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("StringVarPEnvVar", func(t *testing.T) {
|
||||
var ptr string
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.String(10)
|
||||
|
||||
cliflag.StringVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetString(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, envValue, got)
|
||||
})
|
||||
|
||||
t.Run("EmptyEnvVar", func(t *testing.T) {
|
||||
var ptr string
|
||||
flagset, name, shorthand, _, usage := randomFlag()
|
||||
def, _ := cryptorand.String(10)
|
||||
|
||||
cliflag.StringVarP(flagset, &ptr, name, shorthand, "", def, usage)
|
||||
got, err := flagset.GetString(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
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()
|
||||
def, _ := cryptorand.Int63n(10)
|
||||
|
||||
cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage)
|
||||
got, err := flagset.GetUint8(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint8(def), got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf(" - consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("IntEnvVar", func(t *testing.T) {
|
||||
var ptr uint8
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.Int63n(10)
|
||||
t.Setenv(env, strconv.FormatUint(uint64(envValue), 10))
|
||||
def, _ := cryptorand.Int()
|
||||
|
||||
cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage)
|
||||
got, err := flagset.GetUint8(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint8(envValue), got)
|
||||
})
|
||||
|
||||
t.Run("IntFailParse", func(t *testing.T) {
|
||||
var ptr uint8
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.Int63n(10)
|
||||
|
||||
cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage)
|
||||
got, err := flagset.GetUint8(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint8(def), got)
|
||||
})
|
||||
|
||||
t.Run("BoolDefault", func(t *testing.T) {
|
||||
var ptr bool
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.Bool()
|
||||
|
||||
cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetBool(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("BoolEnvVar", func(t *testing.T) {
|
||||
var ptr bool
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.Bool()
|
||||
t.Setenv(env, strconv.FormatBool(envValue))
|
||||
def, _ := cryptorand.Bool()
|
||||
|
||||
cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetBool(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, envValue, got)
|
||||
})
|
||||
|
||||
t.Run("BoolFailParse", func(t *testing.T) {
|
||||
var ptr bool
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.Bool()
|
||||
|
||||
cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetBool(name)
|
||||
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) {
|
||||
fsname, _ := cryptorand.String(10)
|
||||
flagset := pflag.NewFlagSet(fsname, pflag.PanicOnError)
|
||||
name, _ := cryptorand.String(10)
|
||||
shorthand, _ := cryptorand.String(1)
|
||||
env, _ := cryptorand.String(10)
|
||||
usage, _ := cryptorand.String(10)
|
||||
|
||||
return flagset, name, shorthand, env, usage
|
||||
}
|
||||
@@ -36,9 +36,9 @@ func SetupConfig(t *testing.T, client *codersdk.Client, root config.Root) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// CreateProjectVersionSource writes the echo provisioner responses into a
|
||||
// CreateTemplateVersionSource writes the echo provisioner responses into a
|
||||
// new temporary testing directory.
|
||||
func CreateProjectVersionSource(t *testing.T, responses *echo.Responses) string {
|
||||
func CreateTemplateVersionSource(t *testing.T, responses *echo.Responses) string {
|
||||
directory := t.TempDir()
|
||||
data, err := echo.Tar(responses)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -3,7 +3,6 @@ package clitest_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
@@ -17,7 +16,7 @@ func TestMain(m *testing.M) {
|
||||
|
||||
func TestCli(t *testing.T) {
|
||||
t.Parallel()
|
||||
clitest.CreateProjectVersionSource(t, nil)
|
||||
clitest.CreateTemplateVersionSource(t, nil)
|
||||
client := coderdtest.New(t, nil)
|
||||
cmd, config := clitest.New(t)
|
||||
clitest.SetupConfig(t, client, config)
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
type AgentOptions struct {
|
||||
WorkspaceName string
|
||||
Fetch func(context.Context) (codersdk.WorkspaceAgent, error)
|
||||
FetchInterval time.Duration
|
||||
WarnInterval time.Duration
|
||||
}
|
||||
|
||||
// Agent displays a spinning indicator that waits for a workspace agent to connect.
|
||||
func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||
if opts.FetchInterval == 0 {
|
||||
opts.FetchInterval = 500 * time.Millisecond
|
||||
}
|
||||
if opts.WarnInterval == 0 {
|
||||
opts.WarnInterval = 30 * time.Second
|
||||
}
|
||||
var resourceMutex sync.Mutex
|
||||
agent, err := opts.Fetch(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch: %w", err)
|
||||
}
|
||||
if agent.Status == codersdk.WorkspaceAgentConnected {
|
||||
return nil
|
||||
}
|
||||
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(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)
|
||||
defer timer.Stop()
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
resourceMutex.Lock()
|
||||
defer resourceMutex.Unlock()
|
||||
message := "Don't panic, your workspace is booting up!"
|
||||
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.
|
||||
_, _ = fmt.Fprintf(writer, "\033[s\r\033[2K%s\n\n", Styles.Paragraph.Render(Styles.Prompt.String()+message))
|
||||
defer fmt.Fprintf(writer, "\033[u\033[J")
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
resourceMutex.Lock()
|
||||
agent, err = opts.Fetch(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch: %w", err)
|
||||
}
|
||||
if agent.Status != codersdk.WorkspaceAgentConnected {
|
||||
resourceMutex.Unlock()
|
||||
continue
|
||||
}
|
||||
resourceMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
var disconnected atomic.Bool
|
||||
ptty := ptytest.New(t)
|
||||
cmd := &cobra.Command{
|
||||
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.WorkspaceAgent, error) {
|
||||
agent := codersdk.WorkspaceAgent{
|
||||
Status: codersdk.WorkspaceAgentDisconnected,
|
||||
}
|
||||
if disconnected.Load() {
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
}
|
||||
return agent, nil
|
||||
},
|
||||
FetchInterval: time.Millisecond,
|
||||
WarnInterval: 10 * time.Millisecond,
|
||||
})
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
ptty.ExpectMatch("lost connection")
|
||||
disconnected.Store(true)
|
||||
<-done
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/charm/ui/common"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var (
|
||||
Canceled = xerrors.New("canceled")
|
||||
|
||||
defaultStyles = common.DefaultStyles()
|
||||
)
|
||||
|
||||
// ValidateNotEmpty is a helper function to disallow empty inputs!
|
||||
func ValidateNotEmpty(s string) error {
|
||||
if s == "" {
|
||||
return xerrors.New("Must be provided!")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Styles compose visual elements of the UI!
|
||||
var Styles = struct {
|
||||
Bold,
|
||||
Checkmark,
|
||||
Code,
|
||||
Crossmark,
|
||||
Error,
|
||||
Field,
|
||||
Keyword,
|
||||
Paragraph,
|
||||
Placeholder,
|
||||
Prompt,
|
||||
FocusedPrompt,
|
||||
Fuschia,
|
||||
Logo,
|
||||
Warn,
|
||||
Wrap lipgloss.Style
|
||||
}{
|
||||
Bold: lipgloss.NewStyle().Bold(true),
|
||||
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,
|
||||
Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
|
||||
Prompt: defaultStyles.Prompt.Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}),
|
||||
FocusedPrompt: defaultStyles.FocusedPrompt.Foreground(lipgloss.Color("#651fff")),
|
||||
Fuschia: defaultStyles.SelectedMenuItem.Copy(),
|
||||
Logo: defaultStyles.Logo.SetString("Coder"),
|
||||
Warn: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}),
|
||||
Wrap: defaultStyles.Wrap,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// cliMessage provides a human-readable message for CLI errors and messages.
|
||||
type cliMessage struct {
|
||||
Level string
|
||||
Style lipgloss.Style
|
||||
Header string
|
||||
Lines []string
|
||||
}
|
||||
|
||||
// String formats the CLI message for consumption by a human.
|
||||
func (m cliMessage) String() string {
|
||||
var str strings.Builder
|
||||
_, _ = fmt.Fprintf(&str, "%s\r\n",
|
||||
Styles.Bold.Render(m.Header))
|
||||
for _, line := range m.Lines {
|
||||
_, _ = fmt.Fprintf(&str, " %s %s\r\n", m.Style.Render("|"), line)
|
||||
}
|
||||
return str.String()
|
||||
}
|
||||
|
||||
// Warn writes a log to the writer provided.
|
||||
func Warn(wtr io.Writer, header string, lines ...string) {
|
||||
_, _ = fmt.Fprint(wtr, cliMessage{
|
||||
Level: "warning",
|
||||
Style: Styles.Warn,
|
||||
Header: header,
|
||||
Lines: lines,
|
||||
}.String())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/coderd/parameter"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
var err error
|
||||
var options []string
|
||||
if parameterSchema.ValidationCondition != "" {
|
||||
options, _, err = parameter.Contains(parameterSchema.ValidationCondition)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
var value string
|
||||
if len(options) > 0 {
|
||||
// Move the cursor up a single line for nicer display!
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\033[1A")
|
||||
value, err = Select(cmd, SelectOptions{
|
||||
Options: options,
|
||||
Default: parameterSchema.DefaultSourceValue,
|
||||
HideSearch: true,
|
||||
})
|
||||
if err == nil {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
_, _ = 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(text),
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"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.
|
||||
type PromptOptions struct {
|
||||
Text string
|
||||
Default string
|
||||
Secret bool
|
||||
IsConfirm bool
|
||||
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"
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+Styles.Bold.Render("yes")+Styles.Placeholder.Render("/no) ")))
|
||||
} else if opts.Default != "" {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+opts.Default+") "))
|
||||
}
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
lineCh := make(chan string)
|
||||
go func() {
|
||||
var line string
|
||||
var err error
|
||||
|
||||
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 {
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
defer signal.Stop(interrupt)
|
||||
|
||||
reader := bufio.NewReader(cmd.InOrStdin())
|
||||
line, err = reader.ReadString('\n')
|
||||
|
||||
// Check if the first line beings with JSON object or array chars.
|
||||
// This enables multiline JSON to be pasted into an input, and have
|
||||
// it parse properly.
|
||||
if err == nil && (strings.HasPrefix(line, "{") || strings.HasPrefix(line, "[")) {
|
||||
line, err = promptJSON(reader, line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
line = opts.Default
|
||||
}
|
||||
lineCh <- line
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return "", err
|
||||
case line := <-lineCh:
|
||||
if opts.IsConfirm && line != "yes" && line != "y" {
|
||||
return line, xerrors.Errorf("got %q: %w", line, Canceled)
|
||||
}
|
||||
if opts.Validate != nil {
|
||||
err := opts.Validate(line)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), defaultStyles.Error.Render(err.Error()))
|
||||
return Prompt(cmd, opts)
|
||||
}
|
||||
}
|
||||
return line, nil
|
||||
case <-cmd.Context().Done():
|
||||
return "", cmd.Context().Err()
|
||||
case <-interrupt:
|
||||
// Print a newline so that any further output starts properly on a new line.
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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"
|
||||
)
|
||||
|
||||
func TestPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
msgChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
msgChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
ptty.WriteLine("hello")
|
||||
require.Equal(t, "hello", <-msgChan)
|
||||
})
|
||||
|
||||
t.Run("Confirm", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
doneChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
IsConfirm: true,
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
doneChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
ptty.WriteLine("yes")
|
||||
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)
|
||||
doneChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
doneChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
ptty.WriteLine("{}")
|
||||
require.Equal(t, "{}", <-doneChan)
|
||||
})
|
||||
|
||||
t.Run("BadJSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
doneChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
doneChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
ptty.WriteLine("{a")
|
||||
require.Equal(t, "{a", <-doneChan)
|
||||
})
|
||||
|
||||
t.Run("MultilineJSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
doneChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newPrompt(ptty, cliui.PromptOptions{
|
||||
Text: "Example",
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
doneChan <- resp
|
||||
}()
|
||||
ptty.ExpectMatch("Example")
|
||||
ptty.WriteLine(`{
|
||||
"test": "wow"
|
||||
}`)
|
||||
require.Equal(t, `{"test":"wow"}`, <-doneChan)
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
var err error
|
||||
value, err = cliui.Prompt(cmd, opts)
|
||||
return err
|
||||
},
|
||||
}
|
||||
// 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())
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func WorkspaceBuild(ctx context.Context, writer io.Writer, client *codersdk.Client, build uuid.UUID, before time.Time) error {
|
||||
return ProvisionerJob(ctx, writer, ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
build, err := client.WorkspaceBuild(ctx, build)
|
||||
return build.Job, err
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
return client.WorkspaceBuildLogsAfter(ctx, build, before)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type ProvisionerJobOptions struct {
|
||||
Fetch func() (codersdk.ProvisionerJob, error)
|
||||
Cancel func() error
|
||||
Logs func() (<-chan codersdk.ProvisionerJobLog, error)
|
||||
|
||||
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.
|
||||
func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOptions) error {
|
||||
if opts.FetchInterval == 0 {
|
||||
opts.FetchInterval = time.Second
|
||||
}
|
||||
ctx, cancelFunc := context.WithCancel(ctx)
|
||||
defer cancelFunc()
|
||||
|
||||
var (
|
||||
currentStage = "Queued"
|
||||
currentStageStartedAt = time.Now().UTC()
|
||||
didLogBetweenStage = false
|
||||
|
||||
errChan = make(chan error, 1)
|
||||
job codersdk.ProvisionerJob
|
||||
jobMutex sync.Mutex
|
||||
)
|
||||
|
||||
printStage := func() {
|
||||
_, _ = fmt.Fprintf(writer, Styles.Prompt.Render("⧗")+"%s\n", Styles.Field.Render(currentStage))
|
||||
}
|
||||
|
||||
updateStage := func(stage string, startedAt time.Time) {
|
||||
if currentStage != "" {
|
||||
prefix := ""
|
||||
if !didLogBetweenStage {
|
||||
prefix = "\033[1A\r"
|
||||
}
|
||||
mark := Styles.Checkmark
|
||||
if job.CompletedAt != nil && job.Status != codersdk.ProvisionerJobSucceeded {
|
||||
mark = Styles.Crossmark
|
||||
}
|
||||
_, _ = fmt.Fprintf(writer, prefix+mark.String()+Styles.Placeholder.Render(" %s [%dms]")+"\n", currentStage, startedAt.Sub(currentStageStartedAt).Milliseconds())
|
||||
}
|
||||
if stage == "" {
|
||||
return
|
||||
}
|
||||
currentStage = stage
|
||||
currentStageStartedAt = startedAt
|
||||
didLogBetweenStage = false
|
||||
printStage()
|
||||
}
|
||||
|
||||
updateJob := func() {
|
||||
var err error
|
||||
jobMutex.Lock()
|
||||
defer jobMutex.Unlock()
|
||||
job, err = opts.Fetch()
|
||||
if err != nil {
|
||||
errChan <- xerrors.Errorf("fetch: %w", err)
|
||||
return
|
||||
}
|
||||
if job.StartedAt == nil {
|
||||
return
|
||||
}
|
||||
if currentStage != "Queued" {
|
||||
// If another stage is already running, there's no need
|
||||
// for us to notify the user we're running!
|
||||
return
|
||||
}
|
||||
updateStage("Running", *job.StartedAt)
|
||||
}
|
||||
updateJob()
|
||||
|
||||
if opts.Cancel != nil {
|
||||
// Handles ctrl+c to cancel a job.
|
||||
stopChan := make(chan os.Signal, 1)
|
||||
signal.Notify(stopChan, os.Interrupt)
|
||||
go func() {
|
||||
defer signal.Stop(stopChan)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case _, ok := <-stopChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprintf(writer, "\033[2K\r\n"+Styles.FocusedPrompt.String()+Styles.Bold.Render("Gracefully canceling...")+"\n\n")
|
||||
err := opts.Cancel()
|
||||
if err != nil {
|
||||
errChan <- xerrors.Errorf("cancel: %w", err)
|
||||
return
|
||||
}
|
||||
updateJob()
|
||||
}()
|
||||
}
|
||||
|
||||
// The initial stage needs to print after the signal handler has been registered.
|
||||
printStage()
|
||||
|
||||
logs, err := opts.Logs()
|
||||
if err != nil {
|
||||
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()
|
||||
case log, ok := <-logs:
|
||||
if !ok {
|
||||
updateJob()
|
||||
jobMutex.Lock()
|
||||
if job.CompletedAt != nil {
|
||||
updateStage("", *job.CompletedAt)
|
||||
}
|
||||
switch job.Status {
|
||||
case codersdk.ProvisionerJobCanceled:
|
||||
jobMutex.Unlock()
|
||||
return Canceled
|
||||
case codersdk.ProvisionerJobSucceeded:
|
||||
jobMutex.Unlock()
|
||||
return nil
|
||||
case codersdk.ProvisionerJobFailed:
|
||||
}
|
||||
err = xerrors.New(job.Error)
|
||||
jobMutex.Unlock()
|
||||
flushLogBuffer()
|
||||
return err
|
||||
}
|
||||
|
||||
output := ""
|
||||
switch log.Level {
|
||||
case codersdk.LogLevelTrace, codersdk.LogLevelDebug:
|
||||
if !opts.Verbose {
|
||||
continue
|
||||
}
|
||||
output = Styles.Placeholder.Render(log.Output)
|
||||
case codersdk.LogLevelError:
|
||||
output = defaultStyles.Error.Render(log.Output)
|
||||
case codersdk.LogLevelWarn:
|
||||
output = Styles.Warn.Render(log.Output)
|
||||
case codersdk.LogLevelInfo:
|
||||
output = log.Output
|
||||
}
|
||||
|
||||
jobMutex.Lock()
|
||||
if log.Stage != currentStage && log.Stage != "" {
|
||||
updateStage(log.Stage, log.CreatedAt)
|
||||
jobMutex.Unlock()
|
||||
continue
|
||||
}
|
||||
_, _ = fmt.Fprintf(logOutput, "%s %s\n", Styles.Placeholder.Render(" "), output)
|
||||
if !opts.Silent {
|
||||
didLogBetweenStage = true
|
||||
}
|
||||
jobMutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"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"
|
||||
)
|
||||
|
||||
// This cannot be ran in parallel because it uses a signal.
|
||||
// nolint:tparallel
|
||||
func TestProvisionerJob(t *testing.T) {
|
||||
t.Run("NoLogs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
test := newProvisionerJob(t)
|
||||
go func() {
|
||||
<-test.Next
|
||||
test.JobMutex.Lock()
|
||||
test.Job.Status = codersdk.ProvisionerJobRunning
|
||||
now := database.Now()
|
||||
test.Job.StartedAt = &now
|
||||
test.JobMutex.Unlock()
|
||||
<-test.Next
|
||||
test.JobMutex.Lock()
|
||||
test.Job.Status = codersdk.ProvisionerJobSucceeded
|
||||
now = database.Now()
|
||||
test.Job.CompletedAt = &now
|
||||
close(test.Logs)
|
||||
test.JobMutex.Unlock()
|
||||
}()
|
||||
test.PTY.ExpectMatch("Queued")
|
||||
test.Next <- struct{}{}
|
||||
test.PTY.ExpectMatch("Queued")
|
||||
test.PTY.ExpectMatch("Running")
|
||||
test.Next <- struct{}{}
|
||||
test.PTY.ExpectMatch("Running")
|
||||
})
|
||||
|
||||
t.Run("Stages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
test := newProvisionerJob(t)
|
||||
go func() {
|
||||
<-test.Next
|
||||
test.JobMutex.Lock()
|
||||
test.Job.Status = codersdk.ProvisionerJobRunning
|
||||
now := database.Now()
|
||||
test.Job.StartedAt = &now
|
||||
test.Logs <- codersdk.ProvisionerJobLog{
|
||||
CreatedAt: database.Now(),
|
||||
Stage: "Something",
|
||||
}
|
||||
test.JobMutex.Unlock()
|
||||
<-test.Next
|
||||
test.JobMutex.Lock()
|
||||
test.Job.Status = codersdk.ProvisionerJobSucceeded
|
||||
now = database.Now()
|
||||
test.Job.CompletedAt = &now
|
||||
close(test.Logs)
|
||||
test.JobMutex.Unlock()
|
||||
}()
|
||||
test.PTY.ExpectMatch("Queued")
|
||||
test.Next <- struct{}{}
|
||||
test.PTY.ExpectMatch("Queued")
|
||||
test.PTY.ExpectMatch("Something")
|
||||
test.Next <- struct{}{}
|
||||
test.PTY.ExpectMatch("Something")
|
||||
})
|
||||
|
||||
// This cannot be ran in parallel because it uses a signal.
|
||||
// nolint:paralleltest
|
||||
t.Run("Cancel", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Sending interrupt signal isn't supported on Windows!
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
test := newProvisionerJob(t)
|
||||
go func() {
|
||||
<-test.Next
|
||||
currentProcess, err := os.FindProcess(os.Getpid())
|
||||
assert.NoError(t, err)
|
||||
err = currentProcess.Signal(os.Interrupt)
|
||||
assert.NoError(t, err)
|
||||
<-test.Next
|
||||
test.JobMutex.Lock()
|
||||
test.Job.Status = codersdk.ProvisionerJobCanceled
|
||||
now := database.Now()
|
||||
test.Job.CompletedAt = &now
|
||||
close(test.Logs)
|
||||
test.JobMutex.Unlock()
|
||||
}()
|
||||
test.PTY.ExpectMatch("Queued")
|
||||
test.Next <- struct{}{}
|
||||
test.PTY.ExpectMatch("Gracefully canceling")
|
||||
test.Next <- struct{}{}
|
||||
test.PTY.ExpectMatch("Queued")
|
||||
})
|
||||
}
|
||||
|
||||
type provisionerJobTest struct {
|
||||
Next chan struct{}
|
||||
Job *codersdk.ProvisionerJob
|
||||
JobMutex *sync.Mutex
|
||||
Logs chan codersdk.ProvisionerJobLog
|
||||
PTY *ptytest.PTY
|
||||
}
|
||||
|
||||
func newProvisionerJob(t *testing.T) provisionerJobTest {
|
||||
job := &codersdk.ProvisionerJob{
|
||||
Status: codersdk.ProvisionerJobPending,
|
||||
CreatedAt: database.Now(),
|
||||
}
|
||||
jobLock := sync.Mutex{}
|
||||
logs := make(chan codersdk.ProvisionerJobLog, 1)
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
|
||||
FetchInterval: time.Millisecond,
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
jobLock.Lock()
|
||||
defer jobLock.Unlock()
|
||||
return *job, nil
|
||||
},
|
||||
Cancel: func() error {
|
||||
return nil
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
return logs, nil
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
ptty := ptytest.New(t)
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := cmd.ExecuteContext(context.Background())
|
||||
if err != nil {
|
||||
assert.ErrorIs(t, err, cliui.Canceled)
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
<-done
|
||||
})
|
||||
return provisionerJobTest{
|
||||
Next: make(chan struct{}),
|
||||
Job: job,
|
||||
JobMutex: &jobLock,
|
||||
Logs: logs,
|
||||
PTY: ptty,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
survey.SelectQuestionTemplate = `
|
||||
{{- define "option"}}
|
||||
{{- " " }}{{- if eq .SelectedIndex .CurrentIndex }}{{color "green" }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}}
|
||||
{{- .CurrentOpt.Value}}
|
||||
{{- color "reset"}}
|
||||
{{end}}
|
||||
|
||||
{{- if not .ShowAnswer }}
|
||||
{{- if .Config.Icons.Help.Text }}
|
||||
{{- if .FilterMessage }}{{ "Search:" }}{{ .FilterMessage }}
|
||||
{{- else }}
|
||||
{{- color "black+h"}}{{- "Type to search" }}{{color "reset"}}
|
||||
{{- end }}
|
||||
{{- "\n" }}
|
||||
{{- end }}
|
||||
{{- "\n" }}
|
||||
{{- range $ix, $option := .PageEntries}}
|
||||
{{- template "option" $.IterateOption $ix $option}}
|
||||
{{- end}}
|
||||
{{- end }}`
|
||||
}
|
||||
|
||||
type SelectOptions struct {
|
||||
Options []string
|
||||
// Default will be highlighted first if it's a valid option.
|
||||
Default string
|
||||
Size int
|
||||
HideSearch bool
|
||||
}
|
||||
|
||||
// Select displays a list of user options.
|
||||
func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
|
||||
// The survey library used *always* fails when testing on Windows,
|
||||
// as it requires a live TTY (can't be a conpty). We should fork
|
||||
// this library to add a dummy fallback, that simply reads/writes
|
||||
// to the IO provided. See:
|
||||
// https://github.com/AlecAivazis/survey/blob/master/terminal/runereader_windows.go#L94
|
||||
if flag.Lookup("test.v") != nil {
|
||||
return opts.Options[0], nil
|
||||
}
|
||||
|
||||
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"
|
||||
if opts.HideSearch {
|
||||
is.Help.Text = ""
|
||||
}
|
||||
}), survey.WithStdio(fileReadWriter{
|
||||
Reader: cmd.InOrStdin(),
|
||||
}, fileReadWriter{
|
||||
Writer: cmd.OutOrStdout(),
|
||||
}, cmd.OutOrStdout()))
|
||||
if errors.Is(err, terminal.InterruptErr) {
|
||||
return value, Canceled
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
type fileReadWriter struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (f fileReadWriter) Fd() uintptr {
|
||||
if file, ok := f.Reader.(*os.File); ok {
|
||||
return file.Fd()
|
||||
}
|
||||
if file, ok := f.Writer.(*os.File); ok {
|
||||
return file.Fd()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"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/ptytest"
|
||||
)
|
||||
|
||||
func TestSelect(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Select", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
msgChan := make(chan string)
|
||||
go func() {
|
||||
resp, err := newSelect(ptty, cliui.SelectOptions{
|
||||
Options: []string{"First", "Second"},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
msgChan <- resp
|
||||
}()
|
||||
require.Equal(t, "First", <-msgChan)
|
||||
})
|
||||
}
|
||||
|
||||
func newSelect(ptty *ptytest.PTY, opts cliui.SelectOptions) (string, error) {
|
||||
value := ""
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
value, err = cliui.Select(cmd, opts)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
return value, cmd.ExecuteContext(context.Background())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+6
-2
@@ -1,7 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -67,5 +71,5 @@ func read(path string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer fi.Close()
|
||||
return ioutil.ReadAll(fi)
|
||||
return io.ReadAll(fi)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cli/safeexec"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const sshStartToken = "# ------------START-CODER-----------"
|
||||
const sshStartMessage = `# This was generated by "coder config-ssh".
|
||||
#
|
||||
# To remove this blob, run:
|
||||
#
|
||||
# coder config-ssh --remove
|
||||
#
|
||||
# You should not hand-edit this section, unless you are deleting it.`
|
||||
const sshEndToken = "# ------------END-CODER------------"
|
||||
|
||||
func configSSH() *cobra.Command {
|
||||
var (
|
||||
sshConfigFile string
|
||||
sshOptions []string
|
||||
skipProxyCommand bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(sshConfigFile, "~/") {
|
||||
dirname, _ := os.UserHomeDir()
|
||||
sshConfigFile = filepath.Join(dirname, sshConfigFile[2:])
|
||||
}
|
||||
// Doesn't matter if this fails, because we write the file anyways.
|
||||
sshConfigContentRaw, _ := os.ReadFile(sshConfigFile)
|
||||
sshConfigContent := string(sshConfigContentRaw)
|
||||
startIndex := strings.Index(sshConfigContent, sshStartToken)
|
||||
endIndex := strings.Index(sshConfigContent, sshEndToken)
|
||||
if startIndex != -1 && endIndex != -1 {
|
||||
sshConfigContent = sshConfigContent[:startIndex-1] + sshConfigContent[endIndex+len(sshEndToken):]
|
||||
}
|
||||
|
||||
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!")
|
||||
}
|
||||
|
||||
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.TemplateVersionResources(cmd.Context(), workspace.LatestBuild.TemplateVersionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if resource.Transition != codersdk.WorkspaceTransitionStart {
|
||||
continue
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
err = errGroup.Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sshConfigContent += "\n" + sshEndToken
|
||||
err = os.MkdirAll(filepath.Dir(sshConfigFile), os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(sshConfigFile, []byte(sshConfigContent), os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "An auto-generated ssh config was written to %q\n", sshConfigFile)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "You should now be able to ssh into your workspace")
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "For example, try running\n\n\t$ ssh coder.%s\n\n", workspaces[0].Name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// currentBinPath returns the path to the coder binary suitable for use in ssh
|
||||
// ProxyCommand.
|
||||
func currentBinPath(cmd *cobra.Command) (string, error) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get executable path: %w", err)
|
||||
}
|
||||
|
||||
binName := filepath.Base(exePath)
|
||||
// We use safeexec instead of os/exec because os/exec returns paths in
|
||||
// the current working directory, which we will run into very often when
|
||||
// looking for our own path.
|
||||
pathPath, err := safeexec.LookPath(binName)
|
||||
// On Windows, the coder-cli executable must be in $PATH for both Msys2/Git
|
||||
// Bash and OpenSSH for Windows (used by Powershell and VS Code) to function
|
||||
// correctly. Check if the current executable is in $PATH, and warn the user
|
||||
// if it isn't.
|
||||
if err != nil && runtime.GOOS == "windows" {
|
||||
cliui.Warn(cmd.OutOrStdout(),
|
||||
"The current executable is not in $PATH.",
|
||||
"This may lead to problems connecting to your workspace via SSH.",
|
||||
fmt.Sprintf("Please move %q to a location in your $PATH (such as System32) and run `%s config-ssh` again.", binName, binName),
|
||||
)
|
||||
// Return the exePath so SSH at least works outside of Msys2.
|
||||
return exePath, nil
|
||||
}
|
||||
|
||||
// Warn the user if the current executable is not the same as the one in
|
||||
// $PATH.
|
||||
if filepath.Clean(pathPath) != filepath.Clean(exePath) {
|
||||
cliui.Warn(cmd.OutOrStdout(),
|
||||
"The current executable path does not match the executable path found in $PATH.",
|
||||
"This may cause issues connecting to your workspace via SSH.",
|
||||
fmt.Sprintf("\tCurrent executable path: %q", exePath),
|
||||
fmt.Sprintf("\tExecutable path in $PATH: %q", pathPath),
|
||||
)
|
||||
}
|
||||
|
||||
return binName, nil
|
||||
}
|
||||
@@ -0,0 +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()
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
"github.com/coder/coder/database/databasefake"
|
||||
"github.com/coder/coder/provisioner/terraform"
|
||||
"github.com/coder/coder/provisionerd"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
)
|
||||
|
||||
func daemon() *cobra.Command {
|
||||
var (
|
||||
address string
|
||||
)
|
||||
root := &cobra.Command{
|
||||
Use: "daemon",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := slog.Make(sloghuman.Sink(os.Stderr))
|
||||
accessURL := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: address,
|
||||
}
|
||||
handler, closeCoderd := coderd.New(&coderd.Options{
|
||||
AccessURL: accessURL,
|
||||
Logger: logger,
|
||||
Database: databasefake.New(),
|
||||
Pubsub: database.NewPubsubInMemory(),
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listen %q: %w", address, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
client := codersdk.New(accessURL)
|
||||
daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create provisioner daemon: %w", err)
|
||||
}
|
||||
defer daemonClose.Close()
|
||||
|
||||
errCh := make(chan error)
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
errCh <- http.Serve(listener, handler)
|
||||
}()
|
||||
|
||||
closeCoderd()
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
return cmd.Context().Err()
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
},
|
||||
}
|
||||
defaultAddress, ok := os.LookupEnv("ADDRESS")
|
||||
if !ok {
|
||||
defaultAddress = "127.0.0.1:3000"
|
||||
}
|
||||
root.Flags().StringVarP(&address, "address", "a", defaultAddress, "The address to serve the API and dashboard.")
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger) (io.Closer, error) {
|
||||
terraformClient, terraformServer := provisionersdk.TransportPipe()
|
||||
go func() {
|
||||
err := terraform.Serve(ctx, &terraform.ServeOptions{
|
||||
ServeOptions: &provisionersdk.ServeOptions{
|
||||
Listener: terraformServer,
|
||||
},
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
tempDir, err := ioutil.TempDir("", "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
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
)
|
||||
|
||||
func TestDaemon(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
go cancelFunc()
|
||||
root, _ := clitest.New(t, "daemon")
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+82
-59
@@ -1,22 +1,23 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/pkg/browser"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
@@ -31,14 +32,15 @@ func init() {
|
||||
// when in SSH or another type of headless session
|
||||
// NOTE: This needs to be in `init` to prevent data races
|
||||
// (multiple threads trying to set the global browser.Std* variables)
|
||||
browser.Stderr = ioutil.Discard
|
||||
browser.Stdout = ioutil.Discard
|
||||
browser.Stderr = io.Discard
|
||||
browser.Stdout = io.Discard
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
@@ -67,38 +69,36 @@ func login() *cobra.Command {
|
||||
if !isTTY(cmd) {
|
||||
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", caret)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Your Coder deployment hasn't been set up!\n")
|
||||
|
||||
_, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "Would you like to create the first user?",
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Would you like to create the first user?",
|
||||
Default: "yes",
|
||||
IsConfirm: true,
|
||||
Default: "y",
|
||||
})
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create user prompt: %w", err)
|
||||
return err
|
||||
}
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current user: %w", err)
|
||||
}
|
||||
username, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "What username would you like?",
|
||||
username, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "What " + cliui.Styles.Field.Render("username") + " would you like?",
|
||||
Default: currentUser.Username,
|
||||
})
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return xerrors.Errorf("pick username prompt: %w", err)
|
||||
}
|
||||
|
||||
organization, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "What is the name of your organization?",
|
||||
Default: "acme-corp",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("pick organization prompt: %w", err)
|
||||
}
|
||||
|
||||
email, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "What's your email?",
|
||||
email, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "What's your " + cliui.Styles.Field.Render("email") + "?",
|
||||
Validate: func(s string) error {
|
||||
err := validator.New().Var(s, "email")
|
||||
if err != nil {
|
||||
@@ -111,24 +111,38 @@ func login() *cobra.Command {
|
||||
return xerrors.Errorf("specify email prompt: %w", err)
|
||||
}
|
||||
|
||||
password, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "Enter a password:",
|
||||
Mask: '*',
|
||||
password, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Enter a " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: cliui.ValidateNotEmpty,
|
||||
})
|
||||
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(), coderd.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Organization: organization,
|
||||
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
OrganizationName: username,
|
||||
Password: password,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create initial user: %w", err)
|
||||
}
|
||||
resp, err := client.LoginWithPassword(cmd.Context(), coderd.LoginWithPasswordRequest{
|
||||
resp, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
})
|
||||
@@ -147,37 +161,46 @@ func login() *cobra.Command {
|
||||
return xerrors.Errorf("write server url: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", caret, color.HiCyanString(username))
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
|
||||
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 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 := prompt(cmd, &promptui.Prompt{
|
||||
Label: "Paste your token here:",
|
||||
Mask: '*',
|
||||
Validate: func(token string) error {
|
||||
client.SessionToken = token
|
||||
_, err := client.User(cmd.Context(), "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
|
||||
client.SessionToken = sessionToken
|
||||
resp, err := client.User(cmd.Context(), "me")
|
||||
resp, err := client.User(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get user: %w", err)
|
||||
}
|
||||
@@ -192,7 +215,7 @@ func login() *cobra.Command {
|
||||
return xerrors.Errorf("write server url: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", caret, color.HiCyanString(resp.Username))
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -203,7 +226,7 @@ func isWSL() (bool, error) {
|
||||
if runtime.GOOS == goosDarwin || runtime.GOOS == goosWindows {
|
||||
return false, nil
|
||||
}
|
||||
data, err := ioutil.ReadFile("/proc/version")
|
||||
data, err := os.ReadFile("/proc/version")
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("read /proc/version: %w", err)
|
||||
}
|
||||
|
||||
+73
-9
@@ -1,8 +1,10 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
@@ -26,21 +28,23 @@ func TestLogin(t *testing.T) {
|
||||
// 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
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty")
|
||||
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.Execute()
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"first user?", "y",
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"organization", "testorg",
|
||||
"email", "user@coder.com",
|
||||
"password", "password",
|
||||
"password", "password", // Confirm.
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
@@ -49,6 +53,45 @@ func TestLogin(t *testing.T) {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-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) {
|
||||
@@ -56,18 +99,21 @@ func TestLogin(t *testing.T) {
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty", "--no-open")
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", "--force-tty", client.URL.String(), "--no-open")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken)
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("ExistingUserInvalidTokenTTY", func(t *testing.T) {
|
||||
@@ -75,18 +121,36 @@ func TestLogin(t *testing.T) {
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty", "--no-open")
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--no-open")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
err := root.Execute()
|
||||
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:")
|
||||
pty.WriteLine("an-invalid-token")
|
||||
pty.ExpectMatch("That's not a valid token!")
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/fatih/color"
|
||||
"github.com/google/uuid"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/database"
|
||||
"github.com/coder/coder/provisionerd"
|
||||
)
|
||||
|
||||
func projectCreate() *cobra.Command {
|
||||
var (
|
||||
directory string
|
||||
provisioner string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a project from the current directory",
|
||||
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
|
||||
}
|
||||
_, err = prompt(cmd, &promptui.Prompt{
|
||||
Default: "y",
|
||||
IsConfirm: true,
|
||||
Label: fmt.Sprintf("Set up %s in your organization?", color.New(color.FgHiCyan).Sprintf("%q", directory)),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, promptui.ErrAbort) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
name, err := prompt(cmd, &promptui.Prompt{
|
||||
Default: filepath.Base(directory),
|
||||
Label: "What's your project's name?",
|
||||
Validate: func(s string) error {
|
||||
project, _ := client.ProjectByName(cmd.Context(), organization.ID, s)
|
||||
if project.ID.String() != uuid.Nil.String() {
|
||||
return xerrors.New("A project already exists with that name!")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
job, err := validateProjectVersionSource(cmd, client, organization, database.ProvisionerType(provisioner), directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := client.CreateProject(cmd.Context(), organization.ID, coderd.CreateProjectRequest{
|
||||
Name: name,
|
||||
VersionID: job.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = prompt(cmd, &promptui.Prompt{
|
||||
Label: "Create project?",
|
||||
IsConfirm: true,
|
||||
Default: "y",
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, promptui.ErrAbort) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s The %s project has been created!\n", caret, color.HiCyanString(project.Name))
|
||||
_, err = prompt(cmd, &promptui.Prompt{
|
||||
Label: "Create a new workspace?",
|
||||
IsConfirm: true,
|
||||
Default: "y",
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, promptui.ErrAbort) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
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")
|
||||
// This is for testing!
|
||||
err := cmd.Flags().MarkHidden("provisioner")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func validateProjectVersionSource(cmd *cobra.Command, client *codersdk.Client, organization coderd.Organization, provisioner database.ProvisionerType, directory string, parameters ...coderd.CreateParameterRequest) (*coderd.ProjectVersion, error) {
|
||||
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = cmd.OutOrStdout()
|
||||
spin.Suffix = " Uploading current directory..."
|
||||
err := spin.Color("fgHiGreen")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spin.Start()
|
||||
defer spin.Stop()
|
||||
|
||||
tarData, err := tarDirectory(directory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, tarData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
version, err := client.CreateProjectVersion(cmd.Context(), organization.ID, coderd.CreateProjectVersionRequest{
|
||||
StorageMethod: database.ProvisionerStorageMethodFile,
|
||||
StorageSource: resp.Hash,
|
||||
Provisioner: provisioner,
|
||||
ParameterValues: parameters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spin.Suffix = " Waiting for the import to complete..."
|
||||
logs, err := client.ProjectVersionLogsAfter(cmd.Context(), version.ID, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logBuffer := make([]coderd.ProvisionerJobLog, 0, 64)
|
||||
for {
|
||||
log, ok := <-logs
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
logBuffer = append(logBuffer, log)
|
||||
}
|
||||
|
||||
version, err = client.ProjectVersion(cmd.Context(), version.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parameterSchemas, err := client.ProjectVersionSchema(cmd.Context(), version.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parameterValues, err := client.ProjectVersionParameters(cmd.Context(), version.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spin.Stop()
|
||||
|
||||
if provisionerd.IsMissingParameterError(version.Job.Error) {
|
||||
valuesBySchemaID := map[string]coderd.ProjectVersionParameter{}
|
||||
for _, parameterValue := range parameterValues {
|
||||
valuesBySchemaID[parameterValue.SchemaID.String()] = parameterValue
|
||||
}
|
||||
for _, parameterSchema := range parameterSchemas {
|
||||
_, ok := valuesBySchemaID[parameterSchema.ID.String()]
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
value, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: fmt.Sprintf("Enter value for %s:", color.HiCyanString(parameterSchema.Name)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parameters = append(parameters, coderd.CreateParameterRequest{
|
||||
Name: parameterSchema.Name,
|
||||
SourceValue: value,
|
||||
SourceScheme: database.ParameterSourceSchemeData,
|
||||
DestinationScheme: parameterSchema.DefaultDestinationScheme,
|
||||
})
|
||||
}
|
||||
return validateProjectVersionSource(cmd, client, organization, provisioner, directory, parameters...)
|
||||
}
|
||||
|
||||
if version.Job.Status != coderd.ProvisionerJobSucceeded {
|
||||
for _, log := range logBuffer {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", color.HiGreenString("[tf]"), log.Output)
|
||||
}
|
||||
return nil, xerrors.New(version.Job.Error)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Successfully imported project source!\n", color.HiGreenString("✓"))
|
||||
|
||||
resources, err := client.ProjectVersionResources(cmd.Context(), version.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &version, displayProjectImportInfo(cmd, parameterSchemas, parameterValues, resources)
|
||||
}
|
||||
|
||||
func tarDirectory(directory string) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
tarWriter := tar.NewWriter(&buffer)
|
||||
err := filepath.Walk(directory, func(file string, fileInfo os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header, err := tar.FileInfoHeader(fileInfo, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(directory, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = rel
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(tarWriter, data); err != nil {
|
||||
return err
|
||||
}
|
||||
return data.Close()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = tarWriter.Flush()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/database"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestProjectCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("NoParameters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
source := clitest.CreateProjectVersionSource(t, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: echo.ProvisionComplete,
|
||||
})
|
||||
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
|
||||
clitest.SetupConfig(t, client, root)
|
||||
_ = coderdtest.NewProvisionerDaemon(t, client)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
close(closeChan)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"organization?", "y",
|
||||
"name?", "test-project",
|
||||
"project?", "y",
|
||||
"created!", "n",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-closeChan
|
||||
})
|
||||
|
||||
t.Run("Parameter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
source := clitest.CreateProjectVersionSource(t, &echo.Responses{
|
||||
Parse: []*proto.Parse_Response{{
|
||||
Type: &proto.Parse_Response_Complete{
|
||||
Complete: &proto.Parse_Complete{
|
||||
ParameterSchemas: []*proto.ParameterSchema{{
|
||||
Name: "somevar",
|
||||
DefaultDestination: &proto.ParameterDestination{
|
||||
Scheme: proto.ParameterDestination_PROVISIONER_VARIABLE,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
Provision: echo.ProvisionComplete,
|
||||
})
|
||||
cmd, root := clitest.New(t, "projects", "create", "--directory", source, "--provisioner", string(database.ProvisionerTypeEcho))
|
||||
clitest.SetupConfig(t, client, root)
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
close(closeChan)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"organization?", "y",
|
||||
"name?", "test-project",
|
||||
"somevar", "value",
|
||||
"project?", "y",
|
||||
"created!", "n",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-closeChan
|
||||
})
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func projectList() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projects, err := client.ProjectsByOrganization(cmd.Context(), organization.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(projects) == 0 {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s No projects found in %s! Create one:\n\n", caret, color.HiWhiteString(organization.Name))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), color.HiMagentaString(" $ coder projects create <directory>\n"))
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Projects found in %s %s\n\n",
|
||||
caret,
|
||||
color.HiWhiteString(organization.Name),
|
||||
color.HiBlackString("[%dms]",
|
||||
time.Since(start).Milliseconds()))
|
||||
|
||||
writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0)
|
||||
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n",
|
||||
color.HiBlackString("Project"),
|
||||
color.HiBlackString("Source"),
|
||||
color.HiBlackString("Last Updated"),
|
||||
color.HiBlackString("Used By"))
|
||||
for _, project := range projects {
|
||||
suffix := ""
|
||||
if project.WorkspaceOwnerCount != 1 {
|
||||
suffix = "s"
|
||||
}
|
||||
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n",
|
||||
color.New(color.FgHiCyan).Sprint(project.Name),
|
||||
color.WhiteString("Archive"),
|
||||
color.WhiteString(project.UpdatedAt.Format("January 2, 2006")),
|
||||
color.New(color.FgHiWhite).Sprintf("%d developer%s", project.WorkspaceOwnerCount, suffix))
|
||||
}
|
||||
return writer.Flush()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestProjectList(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, "projects", "list")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
close(closeChan)
|
||||
}()
|
||||
pty.ExpectMatch("No projects found")
|
||||
<-closeChan
|
||||
})
|
||||
t.Run("List", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
daemon := coderdtest.NewProvisionerDaemon(t, client)
|
||||
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
|
||||
_ = daemon.Close()
|
||||
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "projects", "list")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
close(closeChan)
|
||||
}()
|
||||
pty.ExpectMatch(project.Name)
|
||||
<-closeChan
|
||||
})
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/xlab/treeprint"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/database"
|
||||
)
|
||||
|
||||
func projects() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "projects",
|
||||
Aliases: []string{"project"},
|
||||
Example: `
|
||||
- Create a project for developers to create workspaces
|
||||
|
||||
` + color.New(color.FgHiMagenta).Sprint("$ coder projects create") + `
|
||||
|
||||
- Make changes to your project, and plan the changes
|
||||
|
||||
` + color.New(color.FgHiMagenta).Sprint("$ coder projects plan <name>") + `
|
||||
|
||||
- Update the project. Your developers can update their workspaces
|
||||
|
||||
` + color.New(color.FgHiMagenta).Sprint("$ coder projects update <name>"),
|
||||
}
|
||||
cmd.AddCommand(
|
||||
projectCreate(),
|
||||
projectList(),
|
||||
projectPlan(),
|
||||
projectUpdate(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func displayProjectImportInfo(cmd *cobra.Command, parameterSchemas []coderd.ProjectVersionParameterSchema, parameterValues []coderd.ProjectVersionParameter, resources []coderd.WorkspaceResource) error {
|
||||
schemaByID := map[string]coderd.ProjectVersionParameterSchema{}
|
||||
for _, schema := range parameterSchemas {
|
||||
schemaByID[schema.ID.String()] = schema
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n %s\n\n", color.HiBlackString("Parameters"))
|
||||
for _, value := range parameterValues {
|
||||
schema, ok := schemaByID[value.SchemaID.String()]
|
||||
if !ok {
|
||||
return xerrors.Errorf("schema not found: %s", value.Name)
|
||||
}
|
||||
displayValue := value.SourceValue
|
||||
if !schema.RedisplayValue {
|
||||
displayValue = "<redacted>"
|
||||
}
|
||||
output := fmt.Sprintf("%s %s %s", color.HiCyanString(value.Name), color.HiBlackString("="), displayValue)
|
||||
if value.DefaultSourceValue {
|
||||
output += " (default value)"
|
||||
} else if value.Scope != database.ParameterScopeImportJob {
|
||||
output += fmt.Sprintf(" (inherited from %s)", value.Scope)
|
||||
}
|
||||
|
||||
root := treeprint.NewWithRoot(output)
|
||||
if schema.Description != "" {
|
||||
root.AddBranch(fmt.Sprintf("%s\n%s", color.HiBlackString("Description"), schema.Description))
|
||||
}
|
||||
if schema.AllowOverrideSource {
|
||||
root.AddBranch(fmt.Sprintf("%s Users can customize this value!", color.HiYellowString("+")))
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+strings.Join(strings.Split(root.String(), "\n"), "\n "))
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s\n\n", color.HiBlackString("Resources"))
|
||||
for _, resource := range resources {
|
||||
transition := color.HiGreenString("start")
|
||||
if resource.Transition == database.WorkspaceTransitionStop {
|
||||
transition = color.HiRedString("stop")
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s %s on %s\n\n", color.HiCyanString(resource.Type), color.HiCyanString(resource.Name), transition)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package cli
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func projectUpdate() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "update <name>",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Short: "Update a project from the current directory",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+209
-118
@@ -2,95 +2,153 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/kirsle/configdir"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"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/coderd"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
var (
|
||||
caret = color.HiBlackString(">")
|
||||
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",
|
||||
Long: ` ▄█▀ ▀█▄
|
||||
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
|
||||
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
|
||||
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
|
||||
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
|
||||
` + color.New(color.Underline).Sprint("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: `
|
||||
- Create a project for developers to create workspaces
|
||||
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") + `
|
||||
|
||||
` + color.New(color.FgHiMagenta).Sprint("$ coder projects create <directory>") + `
|
||||
|
||||
- Create a workspace for a specific project
|
||||
|
||||
` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces create <project>") + `
|
||||
|
||||
- Maintain consistency by updating a workspace
|
||||
|
||||
` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces update <workspace>"),
|
||||
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 := color.New(color.FgHiBlack)
|
||||
cmd.SetUsageTemplate(strings.NewReplacer(
|
||||
`Usage:`, header.Sprint("Usage:"),
|
||||
`Examples:`, header.Sprint("Examples:"),
|
||||
`Available Commands:`, header.Sprint("Commands:"),
|
||||
`Global Flags:`, header.Sprint("Global Flags:"),
|
||||
`Flags:`, header.Sprint("Flags:"),
|
||||
`Additional help topics:`, header.Sprint("Additional help:"),
|
||||
).Replace(cmd.UsageTemplate()))
|
||||
|
||||
cmd.AddCommand(daemon())
|
||||
cmd.AddCommand(login())
|
||||
cmd.AddCommand(projects())
|
||||
cmd.AddCommand(workspaces())
|
||||
cmd.AddCommand(users())
|
||||
cmd.AddCommand(
|
||||
autostart(),
|
||||
bump(),
|
||||
configSSH(),
|
||||
create(),
|
||||
delete(),
|
||||
dotfiles(),
|
||||
gitssh(),
|
||||
list(),
|
||||
login(),
|
||||
logout(),
|
||||
publickey(),
|
||||
resetPassword(),
|
||||
server(),
|
||||
show(),
|
||||
start(),
|
||||
state(),
|
||||
stop(),
|
||||
ssh(),
|
||||
templates(),
|
||||
ttl(),
|
||||
update(),
|
||||
users(),
|
||||
portForward(),
|
||||
workspaceAgent(),
|
||||
)
|
||||
|
||||
cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coder"), "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
|
||||
}
|
||||
@@ -98,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
|
||||
}
|
||||
@@ -108,16 +166,37 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
}
|
||||
|
||||
// currentOrganization returns the currently active organization for the authenticated user.
|
||||
func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (coderd.Organization, error) {
|
||||
orgs, err := client.OrganizationsByUser(cmd.Context(), "me")
|
||||
func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (codersdk.Organization, error) {
|
||||
orgs, err := client.OrganizationsByUser(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return coderd.Organization{}, nil
|
||||
return codersdk.Organization{}, nil
|
||||
}
|
||||
// For now, we won't use the config to set this.
|
||||
// Eventually, we will support changing using "coder switch <org>"
|
||||
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)
|
||||
@@ -138,76 +217,88 @@ func isTTY(cmd *cobra.Command) bool {
|
||||
if forceTty && err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
reader := cmd.InOrStdin()
|
||||
file, ok := reader.(*os.File)
|
||||
file, ok := cmd.InOrStdin().(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return isatty.IsTerminal(file.Fd())
|
||||
}
|
||||
|
||||
func prompt(cmd *cobra.Command, prompt *promptui.Prompt) (string, error) {
|
||||
prompt.Stdin = io.NopCloser(cmd.InOrStdin())
|
||||
prompt.Stdout = readWriteCloser{
|
||||
Writer: cmd.OutOrStdout(),
|
||||
}
|
||||
func usageTemplate() string {
|
||||
// usageHeader is defined in init().
|
||||
return `{{usageHeader "Usage:"}}
|
||||
{{- if .Runnable}}
|
||||
{{.UseLine}}
|
||||
{{end}}
|
||||
{{- if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]
|
||||
{{end}}
|
||||
|
||||
// The prompt library displays defaults in a jarring way for the user
|
||||
// by attempting to autocomplete it. This sets no default enabling us
|
||||
// to customize the display.
|
||||
defaultValue := prompt.Default
|
||||
if !prompt.IsConfirm {
|
||||
prompt.Default = ""
|
||||
}
|
||||
{{- if gt (len .Aliases) 0}}
|
||||
{{usageHeader "Aliases:"}}
|
||||
{{.NameAndAliases}}
|
||||
{{end}}
|
||||
|
||||
// Rewrite the confirm template to remove bold, and fit to the Coder style.
|
||||
confirmEnd := fmt.Sprintf("[y/%s] ", color.New(color.Bold).Sprint("N"))
|
||||
if prompt.Default == "y" {
|
||||
confirmEnd = fmt.Sprintf("[%s/n] ", color.New(color.Bold).Sprint("Y"))
|
||||
}
|
||||
confirm := color.HiBlackString("?") + ` {{ . }} ` + confirmEnd
|
||||
{{- if .HasExample}}
|
||||
{{usageHeader "Get Started:"}}
|
||||
{{.Example}}
|
||||
{{end}}
|
||||
|
||||
// Customize to remove bold.
|
||||
valid := color.HiBlackString("?") + " {{ . }} "
|
||||
if defaultValue != "" {
|
||||
valid += fmt.Sprintf("(%s) ", defaultValue)
|
||||
}
|
||||
{{- if .HasAvailableSubCommands}}
|
||||
{{usageHeader "Commands:"}}
|
||||
{{- range .Commands}}
|
||||
{{- if (or (and .IsAvailableCommand (eq (len .Annotations) 0)) (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
success := valid
|
||||
invalid := valid
|
||||
if prompt.IsConfirm {
|
||||
success = confirm
|
||||
invalid = confirm
|
||||
}
|
||||
{{- if and (not .HasParent) .HasAvailableSubCommands}}
|
||||
{{usageHeader "Workspace Commands:"}}
|
||||
{{- range .Commands}}
|
||||
{{- if (and .IsAvailableCommand (ne (index .Annotations "workspaces") ""))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
prompt.Templates = &promptui.PromptTemplates{
|
||||
Confirm: confirm,
|
||||
Success: success,
|
||||
Invalid: invalid,
|
||||
Valid: valid,
|
||||
}
|
||||
oldValidate := prompt.Validate
|
||||
if oldValidate != nil {
|
||||
// Override the validate function to pass our default!
|
||||
prompt.Validate = func(s string) error {
|
||||
if s == "" {
|
||||
s = defaultValue
|
||||
}
|
||||
return oldValidate(s)
|
||||
}
|
||||
}
|
||||
value, err := prompt.Run()
|
||||
if value == "" && !prompt.IsConfirm {
|
||||
value = defaultValue
|
||||
}
|
||||
{{- if .HasAvailableLocalFlags}}
|
||||
{{usageHeader "Flags:"}}
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}
|
||||
{{end}}
|
||||
|
||||
return value, err
|
||||
{{- 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}}`
|
||||
}
|
||||
|
||||
// readWriteCloser fakes reads, writes, and closing!
|
||||
type readWriteCloser struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
io.Closer
|
||||
func versionTemplate() string {
|
||||
template := `Coder {{printf "%s" .Version}}`
|
||||
buildTime, valid := buildinfo.Time()
|
||||
if valid {
|
||||
template += " " + buildTime.Format(time.UnixDate)
|
||||
}
|
||||
template += "\r\n" + buildinfo.ExternalURL()
|
||||
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() }
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"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"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database/postgres"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// This cannot be ran in parallel because it uses a signal.
|
||||
// nolint:paralleltest
|
||||
func TestServer(t *testing.T) {
|
||||
t.Run("Production", func(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()
|
||||
}
|
||||
connectionURL, closeFunc, err := postgres.Open()
|
||||
require.NoError(t, err)
|
||||
defer closeFunc()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, cfg := clitest.New(t, "server", "--address", ":0", "--postgres-url", connectionURL)
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
var client *codersdk.Client
|
||||
require.Eventually(t, func() bool {
|
||||
rawURL, err := cfg.URL().Read()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
accessURL, err := url.Parse(rawURL)
|
||||
assert.NoError(t, err)
|
||||
client = codersdk.New(accessURL)
|
||||
return true
|
||||
}, 15*time.Second, 25*time.Millisecond)
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: "some@one.com",
|
||||
Username: "example",
|
||||
Password: "password",
|
||||
OrganizationName: "example",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
})
|
||||
|
||||
t.Run("Development", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
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() {
|
||||
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
|
||||
token, err = cfg.Session().Read()
|
||||
return err == nil
|
||||
}, 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)
|
||||
|
||||
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, "server", "--dev", "--tunnel=false", "--address", ":0",
|
||||
"--tls-enable", "--tls-min-version", "tls9")
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("TLSBadClientAuth", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, _ := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0",
|
||||
"--tls-enable", "--tls-client-auth", "something")
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("TLSNoCertFile", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, _ := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0",
|
||||
"--tls-enable")
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("TLSValid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
certPath, keyPath := generateTLSCertificate(t)
|
||||
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() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
// Verify HTTPS
|
||||
var accessURLRaw string
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
accessURLRaw, err = cfg.URL().Read()
|
||||
return err == nil
|
||||
}, 15*time.Second, 25*time.Millisecond)
|
||||
accessURL, err := url.Parse(accessURLRaw)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https", accessURL.Scheme)
|
||||
client := codersdk.New(accessURL)
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
_, 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
|
||||
t.Run("Shutdown", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Sending interrupt signal isn't supported on Windows!
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, cfg := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0", "--provisioner-daemons", "1")
|
||||
serverErr := make(chan error)
|
||||
go func() {
|
||||
err := root.ExecuteContext(ctx)
|
||||
serverErr <- err
|
||||
}()
|
||||
var token string
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
token, err = cfg.Session().Read()
|
||||
return err == nil
|
||||
}, 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
|
||||
orgs, err := client.OrganizationsByUser(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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, orgs[0].ID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
require.NoError(t, err)
|
||||
currentProcess, err := os.FindProcess(os.Getpid())
|
||||
require.NoError(t, err)
|
||||
err = currentProcess.Signal(os.Interrupt)
|
||||
require.NoError(t, err)
|
||||
// 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("TracerNoLeak", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, _ := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0", "--trace=true")
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
require.Error(t, goleak.Find())
|
||||
})
|
||||
}
|
||||
|
||||
func generateTLSCertificate(t testing.TB) (certPath, keyPath string) {
|
||||
dir := t.TempDir()
|
||||
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Acme Co"},
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(time.Hour * 24 * 180),
|
||||
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
require.NoError(t, err)
|
||||
certFile, err := os.CreateTemp(dir, "")
|
||||
require.NoError(t, err)
|
||||
defer certFile.Close()
|
||||
_, err = certFile.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}))
|
||||
require.NoError(t, err)
|
||||
privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
require.NoError(t, err)
|
||||
keyFile, err := os.CreateTemp(dir, "")
|
||||
require.NoError(t, err)
|
||||
defer keyFile.Close()
|
||||
err = pem.Encode(keyFile, &pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyBytes})
|
||||
require.NoError(t, err)
|
||||
return certFile.Name(), keyFile.Name()
|
||||
}
|
||||
+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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
+312
@@ -1 +1,313 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gen2brain/beeep"
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mattn/go-isatty"
|
||||
"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/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
|
||||
shuffle bool
|
||||
forwardAgent bool
|
||||
identityAgent string
|
||||
wsPollInterval time.Duration
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
workspace, agent, err := getWorkspaceAndAgent(cmd, client, codersdk.Me, args[0], shuffle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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.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 err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
stopPolling := tryPollWorkspaceAutostop(cmd.Context(), client, workspace)
|
||||
defer stopPolling()
|
||||
|
||||
if stdio {
|
||||
rawSSH, err := conn.SSH()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
_, _ = io.Copy(cmd.OutOrStdout(), rawSSH)
|
||||
}()
|
||||
_, _ = io.Copy(rawSSH, cmd.InOrStdin())
|
||||
return nil
|
||||
}
|
||||
sshClient, err := conn.SSHClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshSession, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshSession.Stdin = cmd.InOrStdin()
|
||||
sshSession.Stdout = cmd.OutOrStdout()
|
||||
sshSession.Stderr = cmd.OutOrStdout()
|
||||
|
||||
err = sshSession.Shell()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = sshSession.Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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...)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
ws, err := client.Workspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
if ptr.NilOrZero(ws.TTLMillis) {
|
||||
return time.Time{}, 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
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
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/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, workspace, agentToken := setupWorkspaceForSSH(t)
|
||||
cmd, root := clitest.New(t, "ssh", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetErr(pty.Output())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.Execute()
|
||||
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")
|
||||
<-cmdDone
|
||||
})
|
||||
t.Run("Stdio", func(t *testing.T) {
|
||||
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()
|
||||
})
|
||||
|
||||
clientOutput, clientInput := io.Pipe()
|
||||
serverOutput, serverInput := io.Pipe()
|
||||
|
||||
cmd, root := clitest.New(t, "ssh", "--stdio", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetIn(clientOutput)
|
||||
cmd.SetOut(serverInput)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
conn, channels, requests, err := ssh.NewClientConn(&stdioConn{
|
||||
Reader: serverOutput,
|
||||
Writer: clientInput,
|
||||
}, "", &ssh.ClientConfig{
|
||||
// #nosec
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
sshClient := ssh.NewClient(conn, channels, requests)
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
command := "sh -c exit"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c exit"
|
||||
}
|
||||
err = session.Run(command)
|
||||
require.NoError(t, err)
|
||||
err = sshClient.Close()
|
||||
require.NoError(t, err)
|
||||
_ = clientOutput.Close()
|
||||
|
||||
<-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 {
|
||||
io.Reader
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (*stdioConn) Close() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stdioConn) LocalAddr() net.Addr {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stdioConn) RemoteAddr() net.Addr {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stdioConn) SetDeadline(_ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stdioConn) SetReadDeadline(_ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stdioConn) SetWriteDeadline(_ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func start() *cobra.Command {
|
||||
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 {
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm start 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.WorkspaceTransitionStart,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
},
|
||||
}
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
return cmd
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user