Compare commits
711 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccabec6dd1 | |||
| 23f61fce2a | |||
| 98a6958f10 | |||
| 6a00baf235 | |||
| c8f8c95f6a | |||
| 623fc5baac | |||
| ca3811499e | |||
| 14a9576b77 | |||
| 94e96fa40b | |||
| 8a446837d4 | |||
| 7a77e55bd4 | |||
| b412cc1a4b | |||
| 78a24941fe | |||
| a21a6d2f4a | |||
| 4de1fc8339 | |||
| a05fad4efd | |||
| 6e496077ae | |||
| cf0d2c9bbc | |||
| e6b6b7f610 | |||
| 0b53b06fc6 | |||
| 076c4a0aa8 | |||
| 9e35793b43 | |||
| 254e91a08f | |||
| 5d7c4092ac | |||
| c9bce19d88 | |||
| da54874958 | |||
| 57c202d112 | |||
| 4e3b212707 | |||
| 4f8270d95b | |||
| 1400d7cd84 | |||
| ca3c0490e0 | |||
| 123fe0131e | |||
| 09142255e6 | |||
| 706bceb7e7 | |||
| eba753ba87 | |||
| 343d1184b2 | |||
| 7a71180ae6 | |||
| 253e6cbffa | |||
| 184f0625e1 | |||
| 6dacf70898 | |||
| b9dd566804 | |||
| e44f7adb7e | |||
| 9c0cd5287c | |||
| 5025fe2fa0 | |||
| 49de44c76d | |||
| f7ccfa2ab9 | |||
| 8343a4f199 | |||
| a7b49788f5 | |||
| a07ca946c3 | |||
| 8ca3fa9712 | |||
| b101a6f3f4 | |||
| 85acfdf0dc | |||
| 2ee6acb2ad | |||
| 6fde537f9c | |||
| 5e36be8cbb | |||
| 58d29264aa | |||
| 369a9fb535 | |||
| 68e17921f0 | |||
| b0fe9bcdd1 | |||
| d37fb054c8 | |||
| 54b8e794ce | |||
| a4c90c591d | |||
| 690e6c6585 | |||
| 91bfcca287 | |||
| c14a4b92ed | |||
| e938e8577f | |||
| 985eea6099 | |||
| c417115eb1 | |||
| 544bf01fbb | |||
| 80f042f01b | |||
| 57f3410009 | |||
| 3fdae47b87 | |||
| 4ba3573632 | |||
| f6b0835982 | |||
| 04c5f924d7 | |||
| 7599ad4bf6 | |||
| aabb72783c | |||
| 55890df6f1 | |||
| 3610402cd8 | |||
| c43297937b | |||
| f1423450bd | |||
| 6a0f8ae9cc | |||
| 380022fe63 | |||
| c3eea98db0 | |||
| 53d1fb36db | |||
| d6351a6b9f | |||
| 546157b63e | |||
| 4b646cc4fa | |||
| acd0cd66f6 | |||
| 5c898d0c83 | |||
| c3f946737c | |||
| 000e1a5ef2 | |||
| a872330a8d | |||
| b1b2d1b2b2 | |||
| 5817c6ac7f | |||
| 4be61d9250 | |||
| 4b6a82f92a | |||
| 01dd35f1ba | |||
| 2306d2c709 | |||
| e749070193 | |||
| 301727d1fc | |||
| 8cf82112ad | |||
| 40e68cb80b | |||
| c41261cf6e | |||
| 351d55e1f4 | |||
| 3b951f77fb | |||
| 0a46b1e59d | |||
| 010f64e8e9 | |||
| 0e8c68ebc5 | |||
| c3fcf7c953 | |||
| b3d3b8ba0f | |||
| 16c12e976e | |||
| ca342067b3 | |||
| d7b96f7d58 | |||
| 923c212960 | |||
| 3ae42f4de9 | |||
| 4a17e0d91f | |||
| 604f211674 | |||
| 6122df6f1f | |||
| 4e6645af50 | |||
| 426b30ed16 | |||
| 272962cfae | |||
| 5d40b1f0f4 | |||
| cee0d1f848 | |||
| 95f26f74b6 | |||
| d6d9cf9b30 | |||
| fd73d6dd0d | |||
| 758eb21b36 | |||
| f28cd15706 | |||
| 3ceee76784 | |||
| c73f708678 | |||
| 815bf1b668 | |||
| 88c9f31007 | |||
| fd59e2e812 | |||
| db665e7261 | |||
| ccf6f4e7ed | |||
| 690ba661a7 | |||
| 53400c6205 | |||
| e1da2b6467 | |||
| c0cc8b9935 | |||
| f62e1ede77 | |||
| 7bdb8ff9cf | |||
| e62677efab | |||
| 049e7cb5df | |||
| a848e71f58 | |||
| 42bac09c1a | |||
| d275e52a41 | |||
| eb7d947d10 | |||
| 9c12b4ed8e | |||
| 3279504cbe | |||
| 13a2014d7f | |||
| 8d4b6086f6 | |||
| 44a826dc06 | |||
| 1fb274cbda | |||
| b10a1b84e5 | |||
| f14efd1a2b | |||
| 854bb5dbeb | |||
| e7bc01383c | |||
| 01fe5e668e | |||
| 46d64c624a | |||
| fb9fca8bc9 | |||
| ad20b23178 | |||
| 303b280e0e | |||
| 075454cce8 | |||
| 9f54fa8e52 | |||
| fd4e2cc331 | |||
| 8a4438895b | |||
| b6774ead2c | |||
| 7e1caa7086 | |||
| 69664ed168 | |||
| 420fae886a | |||
| 6e426cf47d | |||
| 9a023dd63b | |||
| 1d6283bdac | |||
| 8f338782db | |||
| 81e292be44 | |||
| 8bcf23e60a | |||
| 83c63d4a63 | |||
| 5ae19f097e | |||
| bd785ddd87 | |||
| c1885dab27 | |||
| 8a2811210a | |||
| 877519232c | |||
| 66a5b0f7bc | |||
| 8f3727d05d | |||
| 80223a5e41 | |||
| 56ee105a2a | |||
| 00c5116a2e | |||
| 0d93e9bde1 | |||
| 19fcf60864 | |||
| eb514357bb | |||
| 4730c589fe | |||
| 3d0febdd90 | |||
| 8b17bf98ea | |||
| f82df1bd78 | |||
| 70bf66e030 | |||
| 921de16d98 | |||
| 16f0f1a2db | |||
| c553829fbf | |||
| 52041becf7 | |||
| beed6c7222 | |||
| c8d7b38418 | |||
| 7806f3bebe | |||
| 7367253097 | |||
| d764b3d0c3 | |||
| 09776f33dd | |||
| 6ea9298656 | |||
| 6e63487b27 | |||
| 4b9daf5777 | |||
| f49328bee5 | |||
| 9614bfea6b | |||
| 29eccbe4da | |||
| d12e6b394f | |||
| 1f2ead80c6 | |||
| 183b2e80b9 | |||
| aaa2db6f8b | |||
| b9936d2310 | |||
| e94fe20b6b | |||
| 4658b3f0d2 | |||
| 74c87664c1 | |||
| 6b82fdd0c0 | |||
| d6faf8f524 | |||
| 6d14dcb1ee | |||
| 7ba69739f6 | |||
| 736084ca5d | |||
| 29d44b6283 | |||
| 43b8cf04f0 | |||
| 73f145e45f | |||
| 1a8cce27ae | |||
| 2805d86ba9 | |||
| 663d0475b9 | |||
| 043768076f | |||
| 6230d5512e | |||
| 27ea415b6c | |||
| 36ffdce065 | |||
| a37e61a099 | |||
| 46564fb470 | |||
| a0320f455a | |||
| 6377f17fda | |||
| d27076cac7 | |||
| bb05b1f749 | |||
| cef622d77c | |||
| 5802c29c38 | |||
| f310aeb4cb | |||
| b1e0d69789 | |||
| df20dd7374 | |||
| aaf0da27ef | |||
| 6f93acd964 | |||
| 991b4f7480 | |||
| 509a601efe | |||
| 0128ca6bd1 | |||
| b19cf701c5 | |||
| d2aa75dd0d | |||
| fbd1a272fe | |||
| 8115a11e58 | |||
| c8d2254028 | |||
| f49b015fc7 | |||
| ef260faf27 | |||
| 159137dc10 | |||
| 9fe260d5ea | |||
| 8d6949a0b1 | |||
| 3f2cbc9b85 | |||
| 9a3baffe43 | |||
| 100584d95c | |||
| d1d89210b8 | |||
| 122c6f06d8 | |||
| 2c0d57e8c0 | |||
| 9a9912c8ce | |||
| 0b86c8047c | |||
| f34b5000cb | |||
| 9bf5537b0f | |||
| b0957f32e3 | |||
| 173ab297be | |||
| 92a95fbd5f | |||
| d7dee2c069 | |||
| 6c5a142674 | |||
| 1859ca568d | |||
| 1c04b20fde | |||
| 6916d34458 | |||
| c2cd51d8b8 | |||
| 456318cbd8 | |||
| 4a0b8440bc | |||
| 3c38a23e27 | |||
| 821ae5dbd7 | |||
| 4d53934eb0 | |||
| 5312296283 | |||
| f0f0aebdbb | |||
| d7ec407a7c | |||
| 233aa17848 | |||
| ad2b29a571 | |||
| 2c67a2f30b | |||
| 592340c6ce | |||
| 54547a4e9a | |||
| 60de8d0279 | |||
| 5578facf8f | |||
| ecb6301cab | |||
| e4251af8f3 | |||
| 3eb6f28d81 | |||
| d10513f43a | |||
| 1ddff0abcd | |||
| f28d14197a | |||
| 257e52e014 | |||
| 5e32468a73 | |||
| c6016d247d | |||
| ca93614c3f | |||
| 1b19a09a37 | |||
| fd4954b4e5 | |||
| 471564df7d | |||
| 2dd98c7ec8 | |||
| 51dd1fde3b | |||
| 3bb760576b | |||
| fa4361db76 | |||
| 882ee55fd0 | |||
| f43eb0e77c | |||
| 1140e29a17 | |||
| ef7d357e19 | |||
| e874d538fb | |||
| 7d07e670ca | |||
| 75ff579051 | |||
| 0aa8c2efeb | |||
| 77f4ab16a4 | |||
| 7f54628848 | |||
| c9d7cbca48 | |||
| 06e0a5b1e4 | |||
| 59b04c154e | |||
| e01905821f | |||
| 5b78251592 | |||
| e33a74975e | |||
| 62e685669f | |||
| 4a7d067c6c | |||
| 96edc8af9a | |||
| 3e5affd28a | |||
| b0c26745fb | |||
| 916c388d8d | |||
| 82f159b8c3 | |||
| cf9bc71c03 | |||
| 4fde5366be | |||
| 6199e6a060 | |||
| 0c18a2313f | |||
| 034416f141 | |||
| cd74afcccc | |||
| 87b0b4b1ea | |||
| f7ea016494 | |||
| b9847c18f4 | |||
| a69bd47b3a | |||
| caf2478cf6 | |||
| c86a623ff8 | |||
| 1830a18565 | |||
| b6ad5623a3 | |||
| a2f6b25110 | |||
| a66b852c81 | |||
| 5919e96ac2 | |||
| 54cf677e80 | |||
| 4f6b2cff83 | |||
| 3a692a6cdb | |||
| c0d19ebea2 | |||
| 6d1ec409d0 | |||
| ccdf82dd7e | |||
| 9a5fa3f050 | |||
| d04ba2cc02 | |||
| d26b3b7ba1 | |||
| 680e24a14b | |||
| 1033e02d79 | |||
| eebf0dd736 | |||
| aea3b3b83e | |||
| 6ef8a625d5 | |||
| adcd6f5cf1 | |||
| c8d04aff6b | |||
| bf1af216e1 | |||
| 8e17254785 | |||
| b5f5e909bd | |||
| b692b7ea14 | |||
| 000bc50258 | |||
| 02129332d7 | |||
| 0f5f30b6f6 | |||
| 6f34cbff1e | |||
| 8b76e40629 | |||
| 7e9819f2a8 | |||
| dde51f1caa | |||
| 5ee112bc00 | |||
| 59facdd8dc | |||
| 2d048803c8 | |||
| e035b642b8 | |||
| 5e6320163d | |||
| c07a45e610 | |||
| 61c52b3090 | |||
| b0bab3e432 | |||
| e172a40a91 | |||
| 166bc273b3 | |||
| 0645176e66 | |||
| 8df4212bbb | |||
| 18a9d070af | |||
| 919e3a5fb5 | |||
| 8acae4b5aa | |||
| 516dc190ad | |||
| 4cfa240065 | |||
| 516d955219 | |||
| 453d6ff75d | |||
| 701821ab28 | |||
| b4bee421e9 | |||
| c178f37a3e | |||
| 3070ef8903 | |||
| d497e1ce8d | |||
| 146473cafd | |||
| dcf5d57357 | |||
| 92ebdaec5a | |||
| 59de95b8bb | |||
| df13b9dfea | |||
| 2c89e07e12 | |||
| 08d90f7b4f | |||
| 00fee2e501 | |||
| 536c77af5d | |||
| fa7dcf615a | |||
| 7d8b092af9 | |||
| 312a19c270 | |||
| a585a986d8 | |||
| 420a07762a | |||
| ef691f297a | |||
| 13d7466ebc | |||
| 5eecbaa534 | |||
| 749694b7de | |||
| 50e8a27d04 | |||
| 74d484eacf | |||
| 6d0aab4d2c | |||
| 71cb223564 | |||
| daadb9a532 | |||
| 8f55254167 | |||
| 1973786335 | |||
| 3e279b6d23 | |||
| c7681370b5 | |||
| 2bf78aa548 | |||
| 41de2d8b67 | |||
| c99c15232c | |||
| 70d394f6a1 | |||
| 8a59178e7e | |||
| 8d8c1a1927 | |||
| 4f1df88529 | |||
| 08a781f401 | |||
| dff6e97f83 | |||
| c801da45f3 | |||
| 411caa20df | |||
| 52fa1f2464 | |||
| 8589eb693a | |||
| ff5930c7fe | |||
| 2609be767d | |||
| 584448e089 | |||
| ca90189a9b | |||
| c2bb5ee2b1 | |||
| 5df5507cf3 | |||
| a7b73fe001 | |||
| 7ae1878c51 | |||
| bacfd630fb | |||
| 3d40cb85b7 | |||
| dc58d1b734 | |||
| 4f1e9dae27 | |||
| 88f852b42f | |||
| b1e4cfe6c8 | |||
| c1b3080162 | |||
| ea5c2cd09b | |||
| ead3516fb5 | |||
| 2d0ea00ffd | |||
| 22febc749a | |||
| e5d5fa7706 | |||
| 554d9917c0 | |||
| 0dbfd265fb | |||
| de1fc40000 | |||
| 9776e66ff9 | |||
| e14953461c | |||
| 482feef373 | |||
| ae59f166fd | |||
| 29be359f3d | |||
| 6ad0f31687 | |||
| 64997705ab | |||
| 8ad35c7353 | |||
| 9df6bc7ba1 | |||
| 7df5827767 | |||
| 45328ec0f1 | |||
| 38fb6cb4b4 | |||
| 03fd063d20 | |||
| d9668f7a4e | |||
| 6a55889362 | |||
| baa36182c0 | |||
| 889e2e68ea | |||
| ea7f9e2d47 | |||
| a06bea7a3f | |||
| 2b6dcb842d | |||
| 7ee7be3391 | |||
| 4b6189c9e9 | |||
| 0d25e1752f | |||
| cb2d1f488a | |||
| 576aef40f2 | |||
| 09cb778620 | |||
| 37f9dffc02 | |||
| 0052e6a21b | |||
| a494489ffa | |||
| 69f27efead | |||
| abfae1b4aa | |||
| 752d6096a1 | |||
| 2353687610 | |||
| 7dfec821f5 | |||
| 2d3d822273 | |||
| 3a3aa493f1 | |||
| 6429dfee1f | |||
| d9da96cad0 | |||
| a805565cd4 | |||
| f41b50a253 | |||
| 407c47fd65 | |||
| 68b5f0a35a | |||
| 998e75feb3 | |||
| 5c8b09fee7 | |||
| 975b4f6df2 | |||
| 08f4b193e1 | |||
| 4a2d29948e | |||
| 33a04f661f | |||
| 82938944e7 | |||
| 09722ae1ef | |||
| bbbd5241c3 | |||
| f9d830a2b6 | |||
| 16ac54cbd9 | |||
| dac6838fc3 | |||
| 4851d932c4 | |||
| 545a9f3435 | |||
| 01c31b47a3 | |||
| 95e854d144 | |||
| 47796211d7 | |||
| 3312c814bd | |||
| 90815e5119 | |||
| d1c69866e8 | |||
| 6aed58f486 | |||
| 26e85b0bbc | |||
| 115730341e | |||
| 46c6b9ee27 | |||
| bd07284a68 | |||
| 05b67ab1cf | |||
| d21ab2115d | |||
| 981fb2764f | |||
| 885e7fd03e | |||
| 0bcdfd584f | |||
| a39a8563cc | |||
| 9c8079b25e | |||
| 929227d0f8 | |||
| 65870e65ce | |||
| ac557e02b8 | |||
| 4eda7034ee | |||
| b55fca4904 | |||
| c6b1daabc5 | |||
| 6a2a145545 | |||
| 97d1d2f4f0 | |||
| 7dc3f5f92b | |||
| 69b7eed7ed | |||
| a0c8e70d1b | |||
| 3f9776784c | |||
| cfbda57990 | |||
| b7eeb436ad | |||
| caf9c41a9e | |||
| 437066ce20 | |||
| f72a6d09fc | |||
| c366725472 | |||
| 11c47e0d3b | |||
| bd19fcbae1 | |||
| 92bcacebde | |||
| 34222b2260 | |||
| 1778db23cb | |||
| dc7d6def8e | |||
| 7f778316ac | |||
| 5d2368cb1e | |||
| ee5918217b | |||
| 0585372170 | |||
| 9d02a37ba9 | |||
| 06ea7c8388 | |||
| e2785ada5e | |||
| 64f0473499 | |||
| fe81b0b859 | |||
| a48a838c9e | |||
| 1ce28836d1 | |||
| 5d2579fcda | |||
| a40089c22a | |||
| f476a4ad37 | |||
| 93b78755a6 | |||
| 7a4fd12911 | |||
| 8a853a64a5 | |||
| 6d0579d6b6 | |||
| a19493bd53 | |||
| 9bdaec6a21 | |||
| 153ffc0ee9 | |||
| 97348b1c9d | |||
| 8d6faa3c1a | |||
| 4b3608b628 | |||
| dc115688b8 | |||
| 167ab281e4 | |||
| 075e891f28 | |||
| a9c166491d | |||
| 54a585dbf6 | |||
| 0aa66b4296 | |||
| 1455603505 | |||
| edd1083176 | |||
| 4616499030 | |||
| 0b6efce466 | |||
| a9d62cc992 | |||
| 0d2f0d7f8c | |||
| 17ba4c8e88 | |||
| 289b98978f | |||
| 7dcfea10dc | |||
| 64b92eea67 | |||
| 18973a65c1 | |||
| 6c1208e3db | |||
| 18b0effa83 | |||
| 7cce7a9c69 | |||
| f09ab03baf | |||
| d0aca86657 | |||
| 3415b9daef | |||
| 9fdee5d391 | |||
| b9f3fe49cb | |||
| be02d87f22 | |||
| 4cce969018 | |||
| af8a1e3fea | |||
| 40ef1546e1 | |||
| de213934d1 | |||
| 535481139a | |||
| a09d2af977 | |||
| 0c9ff3a2ac | |||
| da9009bd3e | |||
| 93b1425d85 | |||
| 552dad6919 | |||
| 82c4b80c67 | |||
| c9691eafcb | |||
| c36b0d892b | |||
| ba451b569a | |||
| c570501662 | |||
| b3f2b7c80a | |||
| edaa3f5fc3 | |||
| fda856d293 | |||
| 6c1a111fa9 | |||
| a82c0eb560 | |||
| eab5c15062 | |||
| 2d7e6d6530 | |||
| 024ab6df57 | |||
| 5e673cc544 | |||
| a95d9b17f6 | |||
| 29c9c1d928 | |||
| 10dc9e3876 | |||
| 75205f5978 | |||
| f5e558c4ec | |||
| ccd061652b | |||
| bb4ecd72c5 | |||
| 0f44048fcc | |||
| 2e625c1d9b | |||
| 961f5110ca | |||
| 45eb1b4980 | |||
| 6cf483bf37 | |||
| 9b3b6418a2 | |||
| a6a06d4e9c | |||
| d48ab96511 | |||
| 8f7dbee813 | |||
| 55e538e854 | |||
| 12a664fa9a | |||
| afa5443180 | |||
| 7808593a25 | |||
| d0794910d9 | |||
| b225953f68 | |||
| e9f87f12ec | |||
| 02ad60fd75 | |||
| 4734636b17 | |||
| c28b7ecdf2 | |||
| 251316751e | |||
| dc1de58857 | |||
| 5be52de593 | |||
| 961ddad925 | |||
| 0a949aaff2 | |||
| 9d155843dd | |||
| 1863da4ff4 | |||
| dad42fe712 | |||
| d057e8cc03 | |||
| a91482cb25 | |||
| 49f857806f | |||
| cbde8e8b91 | |||
| e3a1cd34b7 | |||
| 8415022bf9 | |||
| de6f86bf7a | |||
| ec0bb7b330 | |||
| 02d2aea7f2 | |||
| 46da59a6b5 | |||
| f562b74fa1 | |||
| 71fd19631a | |||
| f79ab7f87e | |||
| 928958c94c | |||
| 1a9e57296c | |||
| fcc52846da | |||
| b2833c694b | |||
| f9290b016e | |||
| 6bee180bb3 | |||
| 953e8c8fe6 | |||
| 0260e39d11 | |||
| 06021bdc92 | |||
| 6ea86c831b | |||
| 0df75f9176 | |||
| 92bda0d2c1 | |||
| b7234a6ce1 | |||
| 119db78bff | |||
| 411d7da661 | |||
| 377f17c292 | |||
| d04d527f2c | |||
| 0ec1e8f89b | |||
| 92db80cadc | |||
| 518495a6c5 | |||
| d0ac4d9e74 | |||
| 857d83750a | |||
| fff59ef6ad | |||
| 567e4afdfc | |||
| 2621093452 | |||
| 74fe38eb3d |
+1
-1
@@ -7,7 +7,7 @@ trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
|
||||
[*.{md,json,yaml,tf,tfvars}]
|
||||
[*.{md,json,yaml,yml,tf,tfvars}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
site/ @coder/frontend
|
||||
docs/ @ammario
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<!--- Provide a general summary of the issue in the Title above -->
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!--- Tell us what should happen -->
|
||||
|
||||
## Current Behavior
|
||||
|
||||
<!--- Tell us what happens instead of the expected behavior -->
|
||||
+9
-2
@@ -9,14 +9,17 @@ github_checks:
|
||||
annotations: false
|
||||
|
||||
coverage:
|
||||
range: 50..75
|
||||
round: down
|
||||
precision: 2
|
||||
status:
|
||||
patch:
|
||||
default:
|
||||
informational: yes
|
||||
project:
|
||||
default:
|
||||
target: 70%
|
||||
informational: yes
|
||||
target: 65%
|
||||
informational: true
|
||||
|
||||
ignore:
|
||||
# This is generated code.
|
||||
@@ -34,3 +37,7 @@ ignore:
|
||||
- scripts
|
||||
- site/.storybook
|
||||
- rules.go
|
||||
# Packages used for writing tests.
|
||||
- cli/clitest
|
||||
- coderd/coderdtest
|
||||
- pty/ptytest
|
||||
|
||||
+22
-13
@@ -3,7 +3,7 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "monthly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
labels: []
|
||||
@@ -28,23 +28,32 @@ updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels: []
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/site/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "monthly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels: []
|
||||
ignore:
|
||||
# Ignore patch updates for all dependencies
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- version-update:semver-patch
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/site/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels: []
|
||||
ignore:
|
||||
# Ignore patch updates for all dependencies
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- version-update:semver-patch
|
||||
# Ignore major updates to Node.js types, because they need to
|
||||
# correspond to the Node.js engine version
|
||||
- dependency-name: "@types/node"
|
||||
@@ -54,7 +63,7 @@ updates:
|
||||
- package-ecosystem: "terraform"
|
||||
directory: "/examples/templates"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "monthly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
commit-message:
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Label to apply when stale.
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no activity occurs in the next 5 days.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
@@ -1,68 +0,0 @@
|
||||
# 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"
|
||||
+274
-82
@@ -30,6 +30,64 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
typos:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: typos-action
|
||||
uses: crate-ci/typos@master
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
- name: Fix Helper
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
echo "::notice:: you can automatically fix typos from your CLI:
|
||||
cargo install typos-cli
|
||||
typos -c .github/workflows/typos.toml -w"
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docs-only: ${{ steps.filter.outputs.docs_count == steps.filter.outputs.all_count }}
|
||||
sh: ${{ steps.filter.outputs.sh }}
|
||||
ts: ${{ steps.filter.outputs.ts }}
|
||||
k8s: ${{ steps.filter.outputs.k8s }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# For pull requests it's not necessary to checkout the code
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
all:
|
||||
- '**'
|
||||
docs:
|
||||
- 'docs/**'
|
||||
# For testing:
|
||||
# - '.github/**'
|
||||
sh:
|
||||
- "**.sh"
|
||||
ts:
|
||||
- 'site/**'
|
||||
k8s:
|
||||
- 'helm/**'
|
||||
- Dockerfile
|
||||
- scripts/helm.sh
|
||||
- id: debug
|
||||
run: |
|
||||
echo "${{ toJSON(steps.filter )}}"
|
||||
|
||||
# Debug step
|
||||
debug-inputs:
|
||||
needs:
|
||||
- changes
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: log
|
||||
run: |
|
||||
echo "${{ toJSON(needs) }}"
|
||||
|
||||
style-lint-golangci:
|
||||
name: style/lint/golangci
|
||||
timeout-minutes: 5
|
||||
@@ -38,11 +96,20 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
go-version: "~1.19"
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3.2.0
|
||||
with:
|
||||
version: v1.46.0
|
||||
version: v1.48.0
|
||||
|
||||
check-enterprise-imports:
|
||||
name: check/enterprise-imports
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Check imports of enterprise code
|
||||
run: ./scripts/check_enterprise_imports.sh
|
||||
|
||||
style-lint-shellcheck:
|
||||
name: style/lint/shellcheck
|
||||
@@ -83,10 +150,32 @@ jobs:
|
||||
run: yarn lint
|
||||
working-directory: site
|
||||
|
||||
style-lint-k8s:
|
||||
name: "style/lint/k8s"
|
||||
timeout-minutes: 5
|
||||
needs: changes
|
||||
if: needs.changes.outputs.k8s == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install helm
|
||||
uses: azure/setup-helm@v3
|
||||
with:
|
||||
version: v3.9.2
|
||||
|
||||
- name: cd helm && make lint
|
||||
run: |
|
||||
cd helm
|
||||
make lint
|
||||
|
||||
gen:
|
||||
name: "style/gen"
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 8
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs-only == 'false'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -110,16 +199,41 @@ jobs:
|
||||
version: "3.20.0"
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
- run: curl -sSL
|
||||
https://github.com/kyleconroy/sqlc/releases/download/v1.13.0/sqlc_1.13.0_linux_amd64.tar.gz
|
||||
| sudo tar -C /usr/bin -xz sqlc
|
||||
go-version: "~1.19"
|
||||
|
||||
- run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
|
||||
- run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.26
|
||||
- run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
- run: "make --output-sync -j -B gen"
|
||||
- run: ./scripts/check_unstaged.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)"
|
||||
|
||||
- name: Go Build Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
key: ${{ github.job }}-go-build-${{ hashFiles('**/go.sum', '**/**.go') }}
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ github.job }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install sqlc
|
||||
run: |
|
||||
curl -sSL 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
|
||||
- name: Install protoc-gen-go
|
||||
run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
|
||||
- name: Install protoc-gen-go-drpc
|
||||
run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.26
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
|
||||
- name: make gen
|
||||
run: "make --output-sync -j -B gen"
|
||||
|
||||
- name: Check for unstaged files
|
||||
run: ./scripts/check_unstaged.sh
|
||||
|
||||
style-fmt:
|
||||
name: "style/fmt"
|
||||
@@ -149,7 +263,8 @@ jobs:
|
||||
- name: Install shfmt
|
||||
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.5.0
|
||||
|
||||
- run: |
|
||||
- name: make fmt
|
||||
run: |
|
||||
export PATH=${PATH}:$(go env GOPATH)/bin
|
||||
make --output-sync -j -B fmt
|
||||
|
||||
@@ -168,7 +283,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
go-version: "~1.19"
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
@@ -180,7 +295,7 @@ jobs:
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
|
||||
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.**', '**.go') }}
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
@@ -188,7 +303,7 @@ jobs:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install goreleaser
|
||||
- name: Install gotestsum
|
||||
uses: jaxxstorm/action-install-gh-release@v1.7.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -198,21 +313,31 @@ jobs:
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
terraform_version: 1.1.2
|
||||
terraform_version: 1.1.9
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Test with Mock Database
|
||||
id: test
|
||||
shell: bash
|
||||
env:
|
||||
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 -short -failfast
|
||||
run: |
|
||||
# Code coverage is more computationally expensive and also
|
||||
# prevents test caching, so we disable it on alternate operating
|
||||
# systems.
|
||||
if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then
|
||||
echo ::set-output name=cover::true
|
||||
export COVERAGE_FLAGS='-covermode=atomic -coverprofile="gotests.coverage" -coverpkg=./...'
|
||||
else
|
||||
echo ::set-output name=cover::false
|
||||
fi
|
||||
set -x
|
||||
test_timeout=5m
|
||||
if [[ "${{ matrix.os }}" == windows* ]]; then
|
||||
test_timeout=10m
|
||||
fi
|
||||
gotestsum --junitfile="gotests.xml" --packages="./..." -- -parallel=8 -timeout=$test_timeout -short -failfast $COVERAGE_FLAGS
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: always() && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
if: github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
env:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
DD_DATABASE: fake
|
||||
@@ -221,24 +346,31 @@ jobs:
|
||||
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
|
||||
# This action has a tendency to error out unexpectedly, it has
|
||||
# the `fail_ci_if_error` option that defaults to `false`, but
|
||||
# that is no guarantee, see:
|
||||
# https://github.com/codecov/codecov-action/issues/788
|
||||
continue-on-error: true
|
||||
if: steps.test.outputs.cover && 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
|
||||
# This timeout must be greater than the timeout set by `go test` in
|
||||
# `make test-postgres` to ensure we receive a trace of running
|
||||
# goroutines. Setting this to the timeout +5m should work quite well
|
||||
# even if some of the preceding steps are slow.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
go-version: "~1.19"
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
@@ -250,7 +382,7 @@ jobs:
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-build }}
|
||||
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
|
||||
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum', '**/**.go') }}
|
||||
|
||||
- name: Go Mod Cache
|
||||
uses: actions/cache@v3
|
||||
@@ -258,7 +390,7 @@ jobs:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install goreleaser
|
||||
- name: Install gotestsum
|
||||
uses: jaxxstorm/action-install-gh-release@v1.7.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -268,33 +400,11 @@ jobs:
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
terraform_version: 1.1.2
|
||||
terraform_version: 1.1.9
|
||||
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
|
||||
run: "make test-postgres"
|
||||
run: make test-postgres
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: always() && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
@@ -305,24 +415,32 @@ jobs:
|
||||
run: go run scripts/datadog-cireport/main.go gotests.xml
|
||||
|
||||
- uses: codecov/codecov-action@v3
|
||||
# This action has a tendency to error out unexpectedly, it has
|
||||
# the `fail_ci_if_error` option that defaults to `false`, but
|
||||
# that is no guarantee, see:
|
||||
# https://github.com/codecov/codecov-action/issues/788
|
||||
continue-on-error: true
|
||||
if: github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./gotests.coverage
|
||||
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
|
||||
timeout-minutes: 20
|
||||
if: github.ref == 'refs/heads/main' && !github.event.pull_request.head.repo.fork
|
||||
timeout-minutes: 30
|
||||
needs: changes
|
||||
if: |
|
||||
github.ref == 'refs/heads/main' && !github.event.pull_request.head.repo.fork
|
||||
&& needs.changes.outputs.docs-only == 'false'
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v0
|
||||
@@ -335,7 +453,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
go-version: "~1.19"
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
@@ -366,18 +484,35 @@ jobs:
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
install-only: true
|
||||
- name: Install nfpm
|
||||
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.16.0
|
||||
|
||||
- name: Install zstd
|
||||
run: sudo apt-get install -y zstd
|
||||
|
||||
- name: Build site
|
||||
run: make -B site/out/index.html
|
||||
|
||||
- name: Build Release
|
||||
uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
version: latest
|
||||
args: release --snapshot --rm-dist --skip-sign
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go mod download
|
||||
|
||||
mkdir -p ./dist
|
||||
# build slim binaries
|
||||
./scripts/build_go_slim.sh \
|
||||
--output ./dist/ \
|
||||
--compress 22 \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64 \
|
||||
darwin:amd64,arm64
|
||||
|
||||
# build linux amd64 packages
|
||||
./scripts/build_go_matrix.sh \
|
||||
--output ./dist/ \
|
||||
--package-linux \
|
||||
linux:amd64 \
|
||||
windows:amd64
|
||||
|
||||
- name: Install Release
|
||||
run: |
|
||||
@@ -394,8 +529,12 @@ jobs:
|
||||
with:
|
||||
name: coder
|
||||
path: |
|
||||
./dist/coder_*_linux_amd64.tar.gz
|
||||
./dist/coder_*_windows_amd64.zip
|
||||
./dist/*.zip
|
||||
./dist/*.exe
|
||||
./dist/*.tar.gz
|
||||
./dist/*.apk
|
||||
./dist/*.deb
|
||||
./dist/*.rpm
|
||||
retention-days: 7
|
||||
|
||||
test-js:
|
||||
@@ -419,7 +558,7 @@ jobs:
|
||||
# Go is required for uploading the test results to datadog
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
go-version: "~1.19"
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -432,13 +571,16 @@ jobs:
|
||||
working-directory: site
|
||||
|
||||
- uses: codecov/codecov-action@v3
|
||||
# This action has a tendency to error out unexpectedly, it has
|
||||
# the `fail_ci_if_error` option that defaults to `false`, but
|
||||
# that is no guarantee, see:
|
||||
# https://github.com/codecov/codecov-action/issues/788
|
||||
continue-on-error: true
|
||||
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
|
||||
# this flakes and sometimes fails the build
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: always() && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
@@ -450,6 +592,9 @@ jobs:
|
||||
|
||||
test-e2e:
|
||||
name: "test/e2e/${{ matrix.os }}"
|
||||
needs:
|
||||
- changes
|
||||
if: needs.changes.outputs.docs-only == 'false'
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
@@ -466,28 +611,22 @@ jobs:
|
||||
path: |
|
||||
**/node_modules
|
||||
.eslintcache
|
||||
key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
key: js-${{ runner.os }}-e2e-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
# Go is required for uploading the test results to datadog
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
go-version: "~1.19"
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
terraform_version: 1.1.2
|
||||
terraform_version: 1.1.9
|
||||
terraform_wrapper: false
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "14"
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
install-only: true
|
||||
|
||||
- name: Echo Go Cache Paths
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
@@ -508,6 +647,7 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
sudo npm install -g prettier
|
||||
make -B site/out/index.html
|
||||
|
||||
- run: yarn playwright:install
|
||||
@@ -521,6 +661,14 @@ jobs:
|
||||
DEBUG: pw:api
|
||||
working-directory: site
|
||||
|
||||
- name: Upload Playwright Failed Tests
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: failed-test-videos
|
||||
path: ./site/test-results/**/*.webm
|
||||
retention:days: 7
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
env:
|
||||
@@ -528,3 +676,47 @@ jobs:
|
||||
DD_CATEGORY: e2e
|
||||
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: go run scripts/datadog-cireport/main.go site/test-results/junit.xml
|
||||
chromatic:
|
||||
# REMARK: this is only used to build storybook and deploy it to Chromatic.
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- changes
|
||||
if: needs.changes.outputs.ts == 'true'
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: dogfood
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "*"
|
||||
paths:
|
||||
- "dogfood/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "dogfood/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get branch name
|
||||
id: branch-name
|
||||
uses: tj-actions/branch-names@v5.4
|
||||
|
||||
- name: "Branch name to Docker tag name"
|
||||
id: docker-tag-name
|
||||
run: |
|
||||
tag=${{ steps.branch-name.outputs.current_branch }}
|
||||
# Replace / with --, e.g. user/feature => user--feature.
|
||||
tag=${tag//\//--}
|
||||
echo "::set-output name=tag::${tag}"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: "{{defaultContext}}:dogfood"
|
||||
push: true
|
||||
tags: "codercom/oss-dogfood:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood:latest"
|
||||
cache-from: type=registry,ref=codercom/oss-dogfood:latest
|
||||
cache-to: type=inline
|
||||
+241
-36
@@ -1,26 +1,47 @@
|
||||
# GitHub release workflow.
|
||||
#
|
||||
# This workflow is a bit complicated because we have to build darwin binaries on
|
||||
# a mac runner, but the mac runners are extremely slow. So instead of running
|
||||
# the entire release on a mac (which will take an hour to run), we run only the
|
||||
# mac build on a mac, and the rest on a linux runner. The final release is then
|
||||
# published using a final linux runner.
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
snapshot:
|
||||
description: Force a dev version to be generated, implies dry_run.
|
||||
type: boolean
|
||||
required: true
|
||||
dry_run:
|
||||
description: Perform a dry-run release.
|
||||
type: boolean
|
||||
required: true
|
||||
|
||||
env:
|
||||
CODER_RELEASE: ${{ github.event.inputs.snapshot && 'false' || 'true' }}
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: macos-latest
|
||||
linux-windows:
|
||||
runs-on: ubuntu-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
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
# If the event that triggered the build was an annotated tag (which our
|
||||
# tags are supposed to be), actions/checkout has a bug where the tag in
|
||||
# question is only a lightweight tag and not a full annotated tag. This
|
||||
# command seems to fix it.
|
||||
# https://github.com/actions/checkout/issues/290
|
||||
- name: Fetch git tags
|
||||
run: git fetch --tags --force
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@v2
|
||||
@@ -33,10 +54,118 @@ jobs:
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
|
||||
- name: Install Gon
|
||||
- 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 nfpm
|
||||
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.16.0
|
||||
|
||||
- name: Install zstd
|
||||
run: sudo apt-get install -y zstd
|
||||
|
||||
- name: Build Site
|
||||
run: make site/out/index.html
|
||||
|
||||
- name: Build Linux and Windows Binaries
|
||||
run: |
|
||||
brew tap mitchellh/gon
|
||||
brew install mitchellh/gon/gon
|
||||
set -euo pipefail
|
||||
go mod download
|
||||
|
||||
mkdir -p ./dist
|
||||
# build slim binaries
|
||||
./scripts/build_go_slim.sh \
|
||||
--output ./dist/ \
|
||||
--compress 22 \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64 \
|
||||
darwin:amd64,arm64
|
||||
|
||||
# build linux and windows binaries
|
||||
./scripts/build_go_matrix.sh \
|
||||
--output ./dist/ \
|
||||
--archive \
|
||||
--package-linux \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64
|
||||
|
||||
- name: Build Linux Docker images
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
# build and (maybe) push Docker images for each architecture
|
||||
images=()
|
||||
for arch in amd64 armv7 arm64; do
|
||||
img="$(
|
||||
./scripts/build_docker.sh \
|
||||
${{ (!github.event.inputs.dry_run && !github.event.inputs.snapshot) && '--push' || '' }} \
|
||||
--arch "$arch" \
|
||||
./dist/coder_*_linux_"$arch"
|
||||
)"
|
||||
images+=("$img")
|
||||
done
|
||||
|
||||
# we can't build multi-arch if the images aren't pushed, so quit now
|
||||
# if dry-running
|
||||
if [[ "$CODER_RELEASE" != *t* ]]; then
|
||||
echo Skipping multi-arch docker builds due to dry-run.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# build and push multi-arch manifest
|
||||
./scripts/build_docker_multiarch.sh \
|
||||
--push \
|
||||
"${images[@]}"
|
||||
|
||||
# if the current version is equal to the highest (according to semver)
|
||||
# version in the repo, also create a multi-arch image as ":latest" and
|
||||
# push it
|
||||
if [[ "$(git tag | grep '^v' | grep -vE '(rc|dev|-|\+|\/)' | sort -r --version-sort | head -n1)" == "v$(./scripts/version.sh)" ]]; then
|
||||
./scripts/build_docker_multiarch.sh \
|
||||
--push \
|
||||
--target "$(./scripts/image_tag.sh --version latest)" \
|
||||
"${images[@]}"
|
||||
fi
|
||||
|
||||
- name: Upload binary artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: linux
|
||||
path: |
|
||||
dist/*.zip
|
||||
dist/*.tar.gz
|
||||
dist/*.apk
|
||||
dist/*.deb
|
||||
dist/*.rpm
|
||||
|
||||
# The mac binaries get built on mac runners because they need to be signed,
|
||||
# and the signing tool only runs on mac. This darwin job only builds the Mac
|
||||
# binaries and uploads them as job artifacts used by the publish step.
|
||||
darwin:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# If the event that triggered the build was an annotated tag (which our
|
||||
# tags are supposed to be), actions/checkout has a bug where the tag in
|
||||
# question is only a lightweight tag and not a full annotated tag. This
|
||||
# command seems to fix it.
|
||||
# https://github.com/actions/checkout/issues/290
|
||||
- name: Fetch git tags
|
||||
run: git fetch --tags --force
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
|
||||
- name: Import Signing Certificates
|
||||
uses: Apple-Actions/import-codesign-certs@v1
|
||||
@@ -44,24 +173,6 @@ jobs:
|
||||
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
|
||||
@@ -73,18 +184,112 @@ jobs:
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
|
||||
- name: Install make
|
||||
run: brew install make
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The version of bash that macOS ships with is too old
|
||||
brew install bash
|
||||
|
||||
# The version of make that macOS ships with is too old
|
||||
brew install make
|
||||
echo "$(brew --prefix)/opt/make/libexec/gnubin" >> $GITHUB_PATH
|
||||
|
||||
# BSD getopt is incompatible with the build scripts
|
||||
brew install gnu-getopt
|
||||
echo "$(brew --prefix)/opt/gnu-getopt/bin" >> $GITHUB_PATH
|
||||
|
||||
# Used for notarizing the binaries
|
||||
brew tap mitchellh/gon
|
||||
brew install mitchellh/gon/gon
|
||||
|
||||
# Used for compressing embedded slim binaries
|
||||
brew install zstd
|
||||
|
||||
- name: Build Site
|
||||
run: make site/out/index.html
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
version: latest
|
||||
args: release --rm-dist --timeout 60m
|
||||
- name: Build darwin Binaries (with signatures)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go mod download
|
||||
|
||||
mkdir -p ./dist
|
||||
# build slim binaries
|
||||
./scripts/build_go_slim.sh \
|
||||
--output ./dist/ \
|
||||
--compress 22 \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64 \
|
||||
darwin:amd64,arm64
|
||||
|
||||
# build darwin binaries
|
||||
./scripts/build_go_matrix.sh \
|
||||
--output ./dist/ \
|
||||
--archive \
|
||||
--sign-darwin \
|
||||
darwin:amd64,arm64
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AC_USERNAME: ${{ secrets.AC_USERNAME }}
|
||||
AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
|
||||
AC_APPLICATION_IDENTITY: BDB050EB749EDD6A80C6F119BF1382ECA119CCCC
|
||||
|
||||
- name: Upload Binary Artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: darwin
|
||||
path: ./dist/coder_*.zip
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- linux-windows
|
||||
- darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# If the event that triggered the build was an annotated tag (which our
|
||||
# tags are supposed to be), actions/checkout has a bug where the tag in
|
||||
# question is only a lightweight tag and not a full annotated tag. This
|
||||
# command seems to fix it.
|
||||
# https://github.com/actions/checkout/issues/290
|
||||
- name: Fetch git tags
|
||||
run: git fetch --tags --force
|
||||
|
||||
- name: mkdir artifacts
|
||||
run: mkdir artifacts
|
||||
|
||||
- name: Download darwin Artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: darwin
|
||||
path: artifacts
|
||||
|
||||
- name: Download Linux and Windows Artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: linux
|
||||
path: artifacts
|
||||
|
||||
- name: ls artifacts
|
||||
run: ls artifacts
|
||||
|
||||
- name: Publish Helm
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
./scripts/helm.sh --push
|
||||
mv ./dist/*.tgz ./artifacts/
|
||||
|
||||
- name: Publish Release
|
||||
run: |
|
||||
./scripts/publish_release.sh \
|
||||
${{ (github.event.inputs.dry_run || github.event.inputs.snapshot) && '--dry-run' }} \
|
||||
./artifacts/*.zip \
|
||||
./artifacts/*.tar.gz \
|
||||
./artifacts/*.tgz \
|
||||
./artifacts/*.apk \
|
||||
./artifacts/*.deb \
|
||||
./artifacts/*.rpm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Stale Issue Cron
|
||||
on:
|
||||
schedule:
|
||||
# Every day at midnight
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
# v5.1.0 has a weird bug that makes stalebot add then remove its own label
|
||||
# https://github.com/actions/stale/pull/775
|
||||
- uses: actions/stale@v5.0.0
|
||||
with:
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
# Pull Requests become stale more quickly due to merge conflicts.
|
||||
# Also, we promote minimizing WIP.
|
||||
days-before-pr-stale: 7
|
||||
days-before-pr-close: 3
|
||||
stale-pr-message: >
|
||||
This Pull Request is becoming stale. In order to minimize WIP,
|
||||
prevent merge conflicts and keep the tracker readable, I'm going
|
||||
close to this PR in 3 days if there isn't more activity.
|
||||
stale-issue-message: >
|
||||
This issue is becoming stale. In order to keep the tracker readable
|
||||
and actionable, I'm going close to this issue in 7 days if there
|
||||
isn't more activity.
|
||||
# Upped from 30 since we have a big tracker and was hitting the limit.
|
||||
operations-per-run: 60
|
||||
# Start with the oldest issues, always.
|
||||
ascending: true
|
||||
@@ -0,0 +1,19 @@
|
||||
[default.extend-identifiers]
|
||||
alog = "alog"
|
||||
Jetbrains = "JetBrains"
|
||||
IST = "IST"
|
||||
MacOS = "macOS"
|
||||
|
||||
[default.extend-words]
|
||||
|
||||
[files]
|
||||
extend-exclude = [
|
||||
"**.svg",
|
||||
"**.png",
|
||||
"**.lock",
|
||||
"go.sum",
|
||||
"go.mod",
|
||||
# These files contain base64 strings that confuse the detector
|
||||
"**XService**.ts",
|
||||
"**identity.go",
|
||||
]
|
||||
@@ -13,6 +13,7 @@ node_modules
|
||||
vendor
|
||||
.eslintcache
|
||||
yarn-error.log
|
||||
gotests.coverage
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
@@ -33,9 +34,11 @@ dist/
|
||||
site/out/
|
||||
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfplan
|
||||
*.lock.hcl
|
||||
.terraform/
|
||||
|
||||
.vscode/*.log
|
||||
**/*.swp
|
||||
.coderv2/*
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
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
|
||||
|
||||
# Image templates are empty on snapshots to avoid lengthy builds for development.
|
||||
dockers:
|
||||
- image_templates: ["{{ if not .IsSnapshot }}ghcr.io/coder/coder:{{ .Tag }}-amd64{{ end }}"]
|
||||
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: ["{{ if not .IsSnapshot }}ghcr.io/coder/coder:{{ .Tag }}-arm64{{ end }}"]
|
||||
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: ["{{ if not .IsSnapshot }}ghcr.io/coder/coder:{{ .Tag }}-armv7{{ end }}"]
|
||||
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 }}"
|
||||
Vendored
+2
-1
@@ -8,6 +8,7 @@
|
||||
"zxh404.vscode-proto3",
|
||||
"redhat.vscode-yaml",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"dbaeumer.vscode-eslint"
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+12
-1
@@ -2,6 +2,7 @@
|
||||
"cSpell.words": [
|
||||
"apps",
|
||||
"awsidentity",
|
||||
"bodyclose",
|
||||
"buildinfo",
|
||||
"buildname",
|
||||
"circbuf",
|
||||
@@ -11,6 +12,7 @@
|
||||
"coderdtest",
|
||||
"codersdk",
|
||||
"cronstrue",
|
||||
"databasefake",
|
||||
"devel",
|
||||
"drpc",
|
||||
"drpcconn",
|
||||
@@ -36,12 +38,14 @@
|
||||
"Jobf",
|
||||
"Keygen",
|
||||
"kirsle",
|
||||
"Kubernetes",
|
||||
"ldflags",
|
||||
"manifoldco",
|
||||
"mapstructure",
|
||||
"mattn",
|
||||
"mitchellh",
|
||||
"moby",
|
||||
"namesgenerator",
|
||||
"nfpms",
|
||||
"nhooyr",
|
||||
"nolint",
|
||||
@@ -49,8 +53,10 @@
|
||||
"ntqry",
|
||||
"OIDC",
|
||||
"oneof",
|
||||
"paralleltest",
|
||||
"parameterscopeid",
|
||||
"pqtype",
|
||||
"prometheusmetrics",
|
||||
"promptui",
|
||||
"protobuf",
|
||||
"provisionerd",
|
||||
@@ -63,6 +69,7 @@
|
||||
"sdktrace",
|
||||
"Signup",
|
||||
"sourcemapped",
|
||||
"Srcs",
|
||||
"stretchr",
|
||||
"TCGETS",
|
||||
"tcpip",
|
||||
@@ -70,10 +77,12 @@
|
||||
"templateversions",
|
||||
"testdata",
|
||||
"testid",
|
||||
"testutil",
|
||||
"tfexec",
|
||||
"tfjson",
|
||||
"tfplan",
|
||||
"tfstate",
|
||||
"tparallel",
|
||||
"trimprefix",
|
||||
"turnconn",
|
||||
"typegen",
|
||||
@@ -116,7 +125,9 @@
|
||||
"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"],
|
||||
// Since package coverage pairing can't be defined, all packages cover
|
||||
// all other packages.
|
||||
"go.testFlags": ["-short", "-coverpkg=./..."],
|
||||
"go.coverageDecorator": {
|
||||
"type": "gutter",
|
||||
"coveredHighlightColor": "rgba(64,128,128,0.5)",
|
||||
|
||||
+27
-3
@@ -1,6 +1,30 @@
|
||||
FROM alpine
|
||||
# This is the multi-arch Dockerfile used for Coder. Since it's multi-arch and
|
||||
# cross-compiled, it cannot have ANY "RUN" commands. All binaries are built
|
||||
# using the go toolchain on the host and then copied into the build context by
|
||||
# scripts/build_docker.sh.
|
||||
FROM alpine:latest
|
||||
|
||||
# Generated by goreleaser on `goreleaser release`
|
||||
ADD coder /opt/coder
|
||||
# LABEL doesn't add any real layers so it's fine (and easier) to do it here than
|
||||
# in the build script.
|
||||
ARG CODER_VERSION
|
||||
LABEL \
|
||||
org.opencontainers.image.title="Coder" \
|
||||
org.opencontainers.image.description="A tool for provisioning self-hosted development environments with Terraform." \
|
||||
org.opencontainers.image.url="https://github.com/coder/coder" \
|
||||
org.opencontainers.image.source="https://github.com/coder/coder" \
|
||||
org.opencontainers.image.version="$CODER_VERSION" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0"
|
||||
|
||||
# The coder binary is injected by scripts/build_docker.sh.
|
||||
COPY --chown=coder:coder --chmod=755 coder /opt/coder
|
||||
|
||||
# Create coder group and user. We cannot use `addgroup` and `adduser` because
|
||||
# they won't work if we're building the image for a different architecture.
|
||||
COPY --chown=root:root --chmod=644 group passwd /etc/
|
||||
COPY --chown=coder:coder --chmod=700 empty-dir /home/coder
|
||||
|
||||
USER coder:coder
|
||||
ENV HOME=/home/coder
|
||||
WORKDIR /home/coder
|
||||
|
||||
ENTRYPOINT [ "/opt/coder", "server" ]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
LICENSE (GNU Affero General Public License) applies to
|
||||
all files in this repository, except for those in or under
|
||||
any directory named "enterprise", which are Copyright Coder
|
||||
Technologies, Inc., All Rights Reserved.
|
||||
|
||||
We plan to release an enterprise license covering these files
|
||||
as soon as possible. Watch this space.
|
||||
|
||||
|
||||
@@ -1,39 +1,76 @@
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
# Use a single bash shell for each job, and immediately exit on failure
|
||||
SHELL := bash
|
||||
.SHELLFLAGS = -ceu
|
||||
.ONESHELL:
|
||||
|
||||
# This doesn't work on directories.
|
||||
# See https://stackoverflow.com/questions/25752543/make-delete-on-error-for-directory-targets
|
||||
.DELETE_ON_ERROR:
|
||||
|
||||
INSTALL_DIR=$(shell go env GOPATH)/bin
|
||||
GOOS=$(shell go env GOOS)
|
||||
GOARCH=$(shell go env GOARCH)
|
||||
VERSION=$(shell ./scripts/version.sh)
|
||||
|
||||
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 "== This builds slim binaries for command-line usage."
|
||||
@echo "== Use \"make build\" to embed the site."
|
||||
goreleaser build --snapshot --rm-dist --single-target
|
||||
|
||||
build: dist/artifacts.json
|
||||
mkdir -p ./dist
|
||||
rm -rf ./dist/coder-slim_*
|
||||
rm -f ./site/out/bin/coder*
|
||||
./scripts/build_go_slim.sh \
|
||||
--compress 6 \
|
||||
--version "$(VERSION)" \
|
||||
--output ./dist/ \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64 \
|
||||
darwin:amd64,arm64
|
||||
.PHONY: bin
|
||||
|
||||
build: site/out/index.html $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum $(shell find ./examples/templates)
|
||||
rm -rf ./dist
|
||||
mkdir -p ./dist
|
||||
rm -f ./site/out/bin/coder*
|
||||
|
||||
# build slim artifacts and copy them to the site output directory
|
||||
./scripts/build_go_slim.sh \
|
||||
--version "$(VERSION)" \
|
||||
--compress 6 \
|
||||
--output ./dist/ \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64 \
|
||||
darwin:amd64,arm64
|
||||
|
||||
# build not-so-slim artifacts with the default name format
|
||||
./scripts/build_go_matrix.sh \
|
||||
--version "$(VERSION)" \
|
||||
--output ./dist/ \
|
||||
--archive \
|
||||
--package-linux \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64 \
|
||||
darwin:amd64,arm64
|
||||
.PHONY: build
|
||||
|
||||
# Runs migrations to output a dump of the database.
|
||||
coderd/database/dump.sql: $(wildcard coderd/database/migrations/*.sql)
|
||||
coderd/database/dump.sql: coderd/database/dump/main.go $(wildcard coderd/database/migrations/*.sql)
|
||||
go run coderd/database/dump/main.go
|
||||
|
||||
# Generates Go code for querying the database.
|
||||
coderd/database/querier.go: coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql)
|
||||
coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql)
|
||||
coderd/database/generate.sh
|
||||
|
||||
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"
|
||||
cd site
|
||||
# Avoid writing files in CI to reduce file write activity
|
||||
ifdef CI
|
||||
cd site && yarn run format:check
|
||||
yarn run format:check
|
||||
else
|
||||
cd site && yarn run format:write
|
||||
yarn run format:write
|
||||
endif
|
||||
.PHONY: fmt/prettier
|
||||
|
||||
@@ -49,22 +86,37 @@ ifdef CI
|
||||
else
|
||||
shfmt -w $(shell shfmt -f .)
|
||||
endif
|
||||
.PHONY: fmt/shfmt
|
||||
|
||||
fmt: fmt/prettier fmt/terraform fmt/shfmt
|
||||
.PHONY: fmt
|
||||
|
||||
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
|
||||
.PHONY: gen
|
||||
|
||||
install: build
|
||||
mkdir -p $(INSTALL_DIR)
|
||||
@echo "--- Copying from bin to $(INSTALL_DIR)"
|
||||
cp -r ./dist/coder-$(GOOS)_$(GOOS)_$(GOARCH)*/* $(INSTALL_DIR)
|
||||
@echo "-- CLI available at $(shell ls $(INSTALL_DIR)/coder*)"
|
||||
install: site/out/index.html $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum $(shell find ./examples/templates)
|
||||
@output_file="$(INSTALL_DIR)/coder"
|
||||
|
||||
@if [[ "$(GOOS)" == "windows" ]]; then
|
||||
@output_file="$${output_file}.exe"
|
||||
@fi
|
||||
|
||||
@echo "-- Building CLI for $(GOOS) $(GOARCH) at $$output_file"
|
||||
|
||||
./scripts/build_go.sh \
|
||||
--version "$(VERSION)" \
|
||||
--output "$$output_file" \
|
||||
--os "$(GOOS)" \
|
||||
--arch "$(GOARCH)"
|
||||
|
||||
@echo
|
||||
.PHONY: install
|
||||
|
||||
lint: lint/shellcheck lint/go
|
||||
.PHONY: lint
|
||||
|
||||
lint/go:
|
||||
./scripts/check_enterprise_imports.sh
|
||||
golangci-lint run
|
||||
.PHONY: lint/go
|
||||
|
||||
@@ -72,6 +124,7 @@ lint/go:
|
||||
lint/shellcheck: $(shell shfmt -f .)
|
||||
@echo "--- shellcheck"
|
||||
shellcheck --external-sources $(shell shfmt -f .)
|
||||
.PHONY: lint/shellcheck
|
||||
|
||||
peerbroker/proto/peerbroker.pb.go: peerbroker/proto/peerbroker.proto
|
||||
protoc \
|
||||
@@ -99,42 +152,55 @@ provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
|
||||
|
||||
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
|
||||
cd site
|
||||
yarn typegen
|
||||
yarn build
|
||||
# Restores GITKEEP files!
|
||||
git checkout HEAD site/out
|
||||
git checkout HEAD out
|
||||
|
||||
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
|
||||
cd site
|
||||
yarn run format:types
|
||||
|
||||
.PHONY: test
|
||||
test: test-clean
|
||||
gotestsum -- -v -short ./...
|
||||
.PHONY: test
|
||||
|
||||
# When updating -timeout for this test, keep in sync with
|
||||
# test-go-postgres (.github/workflows/coder.yaml).
|
||||
test-postgres: test-clean test-postgres-docker
|
||||
DB=ci DB_FROM=$(shell go run scripts/migrate-ci/main.go) gotestsum --junitfile="gotests.xml" --packages="./..." -- \
|
||||
-covermode=atomic -coverprofile="gotests.coverage" -timeout=20m \
|
||||
-coverpkg=./... \
|
||||
-count=1 -race -failfast
|
||||
.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 rm -f test-postgres-docker || true
|
||||
docker run \
|
||||
--env POSTGRES_PASSWORD=postgres \
|
||||
--env POSTGRES_USER=postgres \
|
||||
--env POSTGRES_DB=postgres \
|
||||
--env PGDATA=/tmp \
|
||||
--tmpfs /tmp \
|
||||
--publish 5432:5432 \
|
||||
--name test-postgres-docker \
|
||||
--restart unless-stopped \
|
||||
--restart no \
|
||||
--detach \
|
||||
postgres:11 \
|
||||
postgres:13 \
|
||||
-c shared_buffers=1GB \
|
||||
-c max_connections=1000
|
||||
-c max_connections=1000 \
|
||||
-c fsync=off \
|
||||
-c synchronous_commit=off \
|
||||
-c full_page_writes=off
|
||||
while ! pg_isready -h 127.0.0.1
|
||||
do
|
||||
echo "$(date) - waiting for database to start"
|
||||
sleep 0.5
|
||||
done
|
||||
.PHONY: test-postgres-docker
|
||||
|
||||
.PHONY: test-clean
|
||||
test-clean:
|
||||
go clean -testcache
|
||||
.PHONY: test-clean
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# Coder
|
||||
|
||||
[](https://github.com/coder/coder/discussions)
|
||||
[](https://discord.gg/coder)
|
||||
[](https://twitter.com/coderhq)
|
||||
Discord"](https://img.shields.io/badge/join-us%20on%20Discord-gray.svg?longCache=true&logo=discord&colorB=green)](https://discord.gg/coder)
|
||||
[](https://codecov.io/gh/coder/coder)
|
||||
[](https://pkg.go.dev/github.com/coder/coder)
|
||||
[](https://twitter.com/coderhq)
|
||||
|
||||
Coder creates remote development machines so your team can develop from anywhere.
|
||||
|
||||
@@ -31,37 +30,51 @@ Coder creates remote development machines so your team can develop from anywhere
|
||||
## Getting Started
|
||||
|
||||
> **Note**:
|
||||
> Coder is in an alpha state. [Report issues here](https://github.com/coder/coder/issues/new).
|
||||
> Coder is in a beta state. [Report issues here](https://github.com/coder/coder/issues/new).
|
||||
|
||||
There are a few ways to install Coder: [install script](./docs/install.md#installsh) (macOS, Linux), [docker-compose](./docs/install.md#docker-compose), or [manually](./docs/install.md#manual) via the latest release (macOS, Windows, and Linux).
|
||||
|
||||
If you use the install script, you can preview what occurs during the install process:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://coder.com/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
The easiest way to install Coder is to use our [install script](https://github.com/coder/coder/blob/main/install.sh) for Linux and macOS.
|
||||
|
||||
To install, run:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://coder.com/install.sh | sh
|
||||
```bash
|
||||
curl -L https://coder.com/install.sh | sh
|
||||
```
|
||||
|
||||
Once installed, you can run a temporary deployment in dev mode (all data is in-memory and destroyed on exit):
|
||||
You can preview what occurs during the install process:
|
||||
|
||||
```sh
|
||||
coder server --dev
|
||||
```bash
|
||||
curl -L https://coder.com/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
Use `coder --help` to get a complete list of flags and environment variables. Use our [quickstart guide](./docs/quickstart.md) for a full walkthrough.
|
||||
You can modify the installation process by including flags. Run the help command for reference:
|
||||
|
||||
```bash
|
||||
curl -L https://coder.com/install.sh | sh -s -- --help
|
||||
```
|
||||
|
||||
> See [install](docs/install.md) for additional methods.
|
||||
|
||||
Once installed, you can start a production deployment<sup>1</sup> with a single command:
|
||||
|
||||
```sh
|
||||
# Automatically sets up an external access URL on *.try.coder.app
|
||||
coder server --tunnel
|
||||
|
||||
# Requires a PostgreSQL instance and external access URL
|
||||
coder server --postgres-url <url> --access-url <url>
|
||||
```
|
||||
|
||||
> <sup>1</sup> The embedded database is great for trying out Coder with small deployments, but do consider using an external database for increased assurance and control.
|
||||
|
||||
Use `coder --help` to get a complete list of flags and environment variables. Use our [quickstart guide](https://coder.com/docs/coder-oss/latest/quickstart) for a full walkthrough.
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit our docs [here](./docs/index.md).
|
||||
Visit our docs [here](https://coder.com/docs/coder-oss).
|
||||
|
||||
## 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).
|
||||
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](https://coder.com/docs/coder-oss/latest/index#what-coder-is-not).
|
||||
|
||||
| Tool | Type | Delivery Model | Cost | Environments |
|
||||
| :---------------------------------------------------------- | :------- | :----------------- | :---------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -82,6 +95,6 @@ Join our community on [Discord](https://discord.gg/coder) and [Twitter](https://
|
||||
|
||||
## Contributing
|
||||
|
||||
Read the [contributing docs](./docs/CONTRIBUTING.md).
|
||||
Read the [contributing docs](https://coder.com/docs/coder-oss/latest/CONTRIBUTING).
|
||||
|
||||
Find our list of contributors [here](./docs/CONTRIBUTORS.md).
|
||||
Find our list of contributors [here](https://github.com/coder/coder/graphs/contributors).
|
||||
|
||||
+102
-25
@@ -27,10 +27,13 @@ import (
|
||||
"go.uber.org/atomic"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/types/key"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/agent/usershell"
|
||||
"github.com/coder/coder/peer"
|
||||
"github.com/coder/coder/peer/peerwg"
|
||||
"github.com/coder/coder/peerbroker"
|
||||
"github.com/coder/coder/pty"
|
||||
"github.com/coder/retry"
|
||||
@@ -40,23 +43,37 @@ const (
|
||||
ProtocolReconnectingPTY = "reconnecting-pty"
|
||||
ProtocolSSH = "ssh"
|
||||
ProtocolDial = "dial"
|
||||
|
||||
// MagicSessionErrorCode indicates that something went wrong with the session, rather than the
|
||||
// command just returning a nonzero exit code, and is chosen as an arbitrary, high number
|
||||
// unlikely to shadow other exit codes, which are typically 1, 2, 3, etc.
|
||||
MagicSessionErrorCode = 229
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
EnableWireguard bool
|
||||
UploadWireguardKeys UploadWireguardKeys
|
||||
ListenWireguardPeers ListenWireguardPeers
|
||||
ReconnectingPTYTimeout time.Duration
|
||||
EnvironmentVariables map[string]string
|
||||
Logger slog.Logger
|
||||
}
|
||||
|
||||
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"`
|
||||
WireguardAddresses []netaddr.IPPrefix `json:"addresses"`
|
||||
EnvironmentVariables map[string]string `json:"environment_variables"`
|
||||
StartupScript string `json:"startup_script"`
|
||||
Directory string `json:"directory"`
|
||||
}
|
||||
|
||||
type WireguardPublicKeys struct {
|
||||
Public key.NodePublic `json:"public"`
|
||||
Disco key.DiscoPublic `json:"disco"`
|
||||
}
|
||||
|
||||
type Dialer func(ctx context.Context, logger slog.Logger) (Metadata, *peerbroker.Listener, error)
|
||||
type UploadWireguardKeys func(ctx context.Context, keys WireguardPublicKeys) error
|
||||
type ListenWireguardPeers func(ctx context.Context, logger slog.Logger) (<-chan peerwg.Handshake, func(), error)
|
||||
|
||||
func New(dialer Dialer, options *Options) io.Closer {
|
||||
if options == nil {
|
||||
@@ -73,6 +90,9 @@ func New(dialer Dialer, options *Options) io.Closer {
|
||||
closeCancel: cancelFunc,
|
||||
closed: make(chan struct{}),
|
||||
envVars: options.EnvironmentVariables,
|
||||
enableWireguard: options.EnableWireguard,
|
||||
postKeys: options.UploadWireguardKeys,
|
||||
listenWireguardPeers: options.ListenWireguardPeers,
|
||||
}
|
||||
server.init(ctx)
|
||||
return server
|
||||
@@ -95,6 +115,11 @@ type agent struct {
|
||||
metadata atomic.Value
|
||||
startupScript atomic.Bool
|
||||
sshServer *ssh.Server
|
||||
|
||||
enableWireguard bool
|
||||
network *peerwg.Network
|
||||
postKeys UploadWireguardKeys
|
||||
listenWireguardPeers ListenWireguardPeers
|
||||
}
|
||||
|
||||
func (a *agent) run(ctx context.Context) {
|
||||
@@ -104,6 +129,7 @@ func (a *agent) run(ctx context.Context) {
|
||||
// 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); {
|
||||
a.logger.Info(ctx, "connecting")
|
||||
metadata, peerListener, err = a.dialer(ctx, a.logger)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
@@ -138,6 +164,13 @@ func (a *agent) run(ctx context.Context) {
|
||||
}()
|
||||
}
|
||||
|
||||
if a.enableWireguard {
|
||||
err = a.startWireguard(ctx, metadata.WireguardAddresses)
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "start wireguard", slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
conn, err := peerListener.Accept()
|
||||
if err != nil {
|
||||
@@ -223,6 +256,7 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) {
|
||||
}
|
||||
|
||||
func (a *agent) init(ctx context.Context) {
|
||||
a.logger.Info(ctx, "generating host key")
|
||||
// Clients' should ignore the host key when connecting.
|
||||
// The agent needs to authenticate with coderd to SSH,
|
||||
// so SSH authentication doesn't improve security.
|
||||
@@ -246,9 +280,17 @@ func (a *agent) init(ctx context.Context) {
|
||||
},
|
||||
Handler: func(session ssh.Session) {
|
||||
err := a.handleSSHSession(session)
|
||||
var exitError *exec.ExitError
|
||||
if xerrors.As(err, &exitError) {
|
||||
a.logger.Debug(ctx, "ssh session returned", slog.Error(exitError))
|
||||
_ = session.Exit(exitError.ExitCode())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "ssh session failed", slog.Error(err))
|
||||
_ = session.Exit(1)
|
||||
// This exit code is designed to be unlikely to be confused for a legit exit code
|
||||
// from the process.
|
||||
_ = session.Exit(MagicSessionErrorCode)
|
||||
return
|
||||
}
|
||||
},
|
||||
@@ -328,6 +370,11 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri
|
||||
command := rawCommand
|
||||
if len(command) == 0 {
|
||||
command = shell
|
||||
if runtime.GOOS != "windows" {
|
||||
// On Linux and macOS, we should start a login
|
||||
// shell to consume juicy environment variables!
|
||||
command += " -l"
|
||||
}
|
||||
}
|
||||
|
||||
// OpenSSH executes all commands with the users current shell.
|
||||
@@ -347,35 +394,44 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("getting os executable: %w", err)
|
||||
}
|
||||
// Set environment variables reliable detection of being inside a
|
||||
// Coder workspace.
|
||||
cmd.Env = append(cmd.Env, "CODER=true")
|
||||
|
||||
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))
|
||||
|
||||
// Set SSH connection environment variables (these are also set by OpenSSH
|
||||
// and thus expected to be present by SSH clients). Since the agent does
|
||||
// networking in-memory, trying to provide accurate values here would be
|
||||
// nonsensical. For now, we hard code these values so that they're present.
|
||||
srcAddr, srcPort := "0.0.0.0", "0"
|
||||
dstAddr, dstPort := "0.0.0.0", "0"
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_CLIENT=%s %s %s", srcAddr, srcPort, dstPort))
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", srcAddr, srcPort, dstAddr, dstPort))
|
||||
|
||||
// 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))
|
||||
for envKey, value := range metadata.EnvironmentVariables {
|
||||
// Expanding environment variables allows for customization
|
||||
// of the $PATH, among other variables. Customers can prepend
|
||||
// or append to the $PATH, so allowing expand is required!
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", envKey, os.ExpandEnv(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))
|
||||
for envKey, value := range a.envVars {
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", envKey, value))
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
func (a *agent) handleSSHSession(session ssh.Session) (retErr error) {
|
||||
cmd, err := a.createCommand(session.Context(), session.RawCommand(), session.Environ())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -394,19 +450,31 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
sshPty, windowSize, isPty := session.Pty()
|
||||
if isPty {
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", sshPty.Term))
|
||||
|
||||
// The pty package sets `SSH_TTY` on supported platforms.
|
||||
ptty, process, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("start command: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
closeErr := ptty.Close()
|
||||
if closeErr != nil {
|
||||
a.logger.Warn(context.Background(), "failed to close tty",
|
||||
slog.Error(closeErr))
|
||||
if retErr == nil {
|
||||
retErr = closeErr
|
||||
}
|
||||
}
|
||||
}()
|
||||
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.Height), uint16(win.Width))
|
||||
if err != nil {
|
||||
a.logger.Warn(context.Background(), "failed to resize tty", slog.Error(err))
|
||||
resizeErr := ptty.Resize(uint16(win.Height), uint16(win.Width))
|
||||
if resizeErr != nil {
|
||||
a.logger.Warn(context.Background(), "failed to resize tty", slog.Error(resizeErr))
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -416,9 +484,15 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
go func() {
|
||||
_, _ = io.Copy(session, ptty.Output())
|
||||
}()
|
||||
_, _ = process.Wait()
|
||||
_ = ptty.Close()
|
||||
return nil
|
||||
err = process.Wait()
|
||||
var exitErr *exec.ExitError
|
||||
// ExitErrors just mean the command we run returned a non-zero exit code, which is normal
|
||||
// and not something to be concerned about. But, if it's something else, we should log it.
|
||||
if err != nil && !xerrors.As(err, &exitErr) {
|
||||
a.logger.Warn(context.Background(), "wait error",
|
||||
slog.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Stdout = session
|
||||
@@ -431,6 +505,7 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
}
|
||||
go func() {
|
||||
_, _ = io.Copy(stdinPipe, session)
|
||||
_ = stdinPipe.Close()
|
||||
}()
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
@@ -520,7 +595,7 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne
|
||||
go func() {
|
||||
// If the process dies randomly, we should
|
||||
// close the pty.
|
||||
_, _ = process.Wait()
|
||||
_ = process.Wait()
|
||||
rpty.Close()
|
||||
}()
|
||||
go func() {
|
||||
@@ -737,7 +812,9 @@ func (r *reconnectingPTY) Close() {
|
||||
_ = conn.Close()
|
||||
}
|
||||
_ = r.ptty.Close()
|
||||
r.circularBufferMutex.Lock()
|
||||
r.circularBuffer.Reset()
|
||||
r.circularBufferMutex.Unlock()
|
||||
r.timeout.Stop()
|
||||
}
|
||||
|
||||
|
||||
+123
-30
@@ -16,6 +16,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
scp "github.com/bramvdbogaerde/go-scp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pion/udp"
|
||||
"github.com/pion/webrtc/v3"
|
||||
@@ -35,6 +38,7 @@ import (
|
||||
"github.com/coder/coder/peerbroker/proto"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -68,22 +72,7 @@ func TestAgent(t *testing.T) {
|
||||
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.Run("SessionTTYShell", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
// This might be our implementation, or ConPTY itself.
|
||||
@@ -117,6 +106,29 @@ func TestAgent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("SessionTTYExitCode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
session := setupSSHSession(t, agent.Metadata{})
|
||||
command := "areallynotrealcommand"
|
||||
err := session.RequestPty("xterm", 128, 128, ssh.TerminalModes{})
|
||||
require.NoError(t, err)
|
||||
ptty := ptytest.New(t)
|
||||
require.NoError(t, err)
|
||||
session.Stdout = ptty.Output()
|
||||
session.Stderr = ptty.Output()
|
||||
session.Stdin = ptty.Input()
|
||||
err = session.Start(command)
|
||||
require.NoError(t, err)
|
||||
err = session.Wait()
|
||||
exitErr := &ssh.ExitError{}
|
||||
require.True(t, xerrors.As(err, &exitErr))
|
||||
if runtime.GOOS == "windows" {
|
||||
assert.Equal(t, 1, exitErr.ExitStatus())
|
||||
} else {
|
||||
assert.Equal(t, 127, exitErr.ExitStatus())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LocalForwarding", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
random, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
@@ -134,10 +146,12 @@ func TestAgent(t *testing.T) {
|
||||
localPort := tcpAddr.Port
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
conn, err := local.Accept()
|
||||
assert.NoError(t, err)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
err = setupSSHCommand(t, []string{"-L", fmt.Sprintf("%d:127.0.0.1:%d", randomPort, localPort)}, []string{"echo", "test"}).Start()
|
||||
@@ -164,6 +178,20 @@ func TestAgent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("SCP", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sshClient, err := setupAgent(t, agent.Metadata{}, 0).SSHClient()
|
||||
require.NoError(t, err)
|
||||
scpClient, err := scp.NewClientBySSH(sshClient)
|
||||
require.NoError(t, err)
|
||||
tempFile := filepath.Join(t.TempDir(), "scp")
|
||||
content := "hello world"
|
||||
err = scpClient.CopyFile(context.Background(), strings.NewReader(content), tempFile, "0755")
|
||||
require.NoError(t, err)
|
||||
_, err = os.Stat(tempFile)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("EnvironmentVariables", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := "EXAMPLE"
|
||||
@@ -182,9 +210,74 @@ func TestAgent(t *testing.T) {
|
||||
require.Equal(t, value, strings.TrimSpace(string(output)))
|
||||
})
|
||||
|
||||
t.Run("EnvironmentVariableExpansion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := "EXAMPLE"
|
||||
session := setupSSHSession(t, agent.Metadata{
|
||||
EnvironmentVariables: map[string]string{
|
||||
key: "$SOMETHINGNOTSET",
|
||||
},
|
||||
})
|
||||
command := "sh -c 'echo $" + key + "'"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo %" + key + "%"
|
||||
}
|
||||
output, err := session.Output(command)
|
||||
require.NoError(t, err)
|
||||
expect := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
expect = "%EXAMPLE%"
|
||||
}
|
||||
// Output should be empty, because the variable is not set!
|
||||
require.Equal(t, expect, strings.TrimSpace(string(output)))
|
||||
})
|
||||
|
||||
t.Run("Coder env vars", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, key := range []string{"CODER"} {
|
||||
key := key
|
||||
t.Run(key, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := setupSSHSession(t, agent.Metadata{})
|
||||
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.NotEmpty(t, strings.TrimSpace(string(output)))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SSH connection env vars", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Note: the SSH_TTY environment variable should only be set for TTYs.
|
||||
// For some reason this test produces a TTY locally and a non-TTY in CI
|
||||
// so we don't test for the absence of SSH_TTY.
|
||||
for _, key := range []string{"SSH_CONNECTION", "SSH_CLIENT"} {
|
||||
key := key
|
||||
t.Run(key, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := setupSSHSession(t, agent.Metadata{})
|
||||
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.NotEmpty(t, strings.TrimSpace(string(output)))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StartupScript", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempPath := filepath.Join(os.TempDir(), "content.txt")
|
||||
tempPath := filepath.Join(t.TempDir(), "content.txt")
|
||||
content := "somethingnice"
|
||||
setupAgent(t, agent.Metadata{
|
||||
StartupScript: fmt.Sprintf("echo %s > %s", content, tempPath),
|
||||
@@ -202,11 +295,13 @@ func TestAgent(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows uses UTF16! 🪟🪟🪟
|
||||
content, _, err = transform.Bytes(unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder(), content)
|
||||
require.NoError(t, err)
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
gotContent = string(content)
|
||||
return true
|
||||
}, 15*time.Second, 100*time.Millisecond)
|
||||
}, testutil.WaitMedium, testutil.IntervalMedium)
|
||||
require.Equal(t, content, strings.TrimSpace(gotContent))
|
||||
})
|
||||
|
||||
@@ -302,12 +397,7 @@ func TestAgent(t *testing.T) {
|
||||
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)
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
l, err := net.Listen("unix", filepath.Join(tmpDir, "test.sock"))
|
||||
require.NoError(t, err, "create UDP listener")
|
||||
return l
|
||||
@@ -380,15 +470,18 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
go func() {
|
||||
defer listener.Close()
|
||||
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)
|
||||
if !assert.NoError(t, err) {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
go agent.Bicopy(context.Background(), conn, ssh)
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package reaper contains logic for reaping subprocesses. It is
|
||||
// specifically used in the agent to avoid the accumulation of
|
||||
// zombie processes.
|
||||
package reaper
|
||||
@@ -0,0 +1,28 @@
|
||||
package reaper
|
||||
|
||||
import "github.com/hashicorp/go-reap"
|
||||
|
||||
type Option func(o *options)
|
||||
|
||||
// WithExecArgs specifies the exec arguments for the fork exec call.
|
||||
// By default the same arguments as the parent are used as dictated by
|
||||
// os.Args. Since ForkReap calls a fork-exec it is the responsibility of
|
||||
// the caller to avoid fork-bombing oneself.
|
||||
func WithExecArgs(args ...string) Option {
|
||||
return func(o *options) {
|
||||
o.ExecArgs = args
|
||||
}
|
||||
}
|
||||
|
||||
// WithPIDCallback sets the channel that reaped child process PIDs are pushed
|
||||
// onto.
|
||||
func WithPIDCallback(ch reap.PidCh) Option {
|
||||
return func(o *options) {
|
||||
o.PIDs = ch
|
||||
}
|
||||
}
|
||||
|
||||
type options struct {
|
||||
ExecArgs []string
|
||||
PIDs reap.PidCh
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !linux
|
||||
|
||||
package reaper
|
||||
|
||||
// IsInitProcess returns true if the current process's PID is 1.
|
||||
func IsInitProcess() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func ForkReap(_ ...Option) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build linux
|
||||
|
||||
package reaper_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-reap"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/agent/reaper"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestReap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Don't run the reaper test in CI. It does weird
|
||||
// things like forkexecing which may have unintended
|
||||
// consequences in CI.
|
||||
if _, ok := os.LookupEnv("CI"); ok {
|
||||
t.Skip("Detected CI, skipping reaper tests")
|
||||
}
|
||||
|
||||
// OK checks that's the reaper is successfully reaping
|
||||
// exited processes and passing the PIDs through the shared
|
||||
// channel.
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
pids := make(reap.PidCh, 1)
|
||||
err := reaper.ForkReap(
|
||||
reaper.WithPIDCallback(pids),
|
||||
// Provide some argument that immediately exits.
|
||||
reaper.WithExecArgs("/bin/sh", "-c", "exit 0"),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := exec.Command("tail", "-f", "/dev/null")
|
||||
err = cmd.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd2 := exec.Command("tail", "-f", "/dev/null")
|
||||
err = cmd2.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cmd.Process.Kill()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cmd2.Process.Kill()
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedPIDs := []int{cmd.Process.Pid, cmd2.Process.Pid}
|
||||
|
||||
for i := 0; i < len(expectedPIDs); i++ {
|
||||
select {
|
||||
case <-time.After(testutil.WaitShort):
|
||||
t.Fatalf("Timed out waiting for process")
|
||||
case pid := <-pids:
|
||||
require.Contains(t, expectedPIDs, pid)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build linux
|
||||
|
||||
package reaper
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/hashicorp/go-reap"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// IsInitProcess returns true if the current process's PID is 1.
|
||||
func IsInitProcess() bool {
|
||||
return os.Getpid() == 1
|
||||
}
|
||||
|
||||
// ForkReap spawns a goroutine that reaps children. In order to avoid
|
||||
// complications with spawning `exec.Commands` in the same process that
|
||||
// is reaping, we forkexec a child process. This prevents a race between
|
||||
// the reaper and an exec.Command waiting for its process to complete.
|
||||
// The provided 'pids' channel may be nil if the caller does not care about the
|
||||
// reaped children PIDs.
|
||||
func ForkReap(opt ...Option) error {
|
||||
opts := &options{
|
||||
ExecArgs: os.Args,
|
||||
}
|
||||
|
||||
for _, o := range opt {
|
||||
o(opts)
|
||||
}
|
||||
|
||||
go reap.ReapChildren(opts.PIDs, nil, nil, nil)
|
||||
|
||||
pwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get wd: %w", err)
|
||||
}
|
||||
|
||||
pattrs := &syscall.ProcAttr{
|
||||
Dir: pwd,
|
||||
Env: os.Environ(),
|
||||
Sys: &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
},
|
||||
Files: []uintptr{
|
||||
uintptr(syscall.Stdin),
|
||||
uintptr(syscall.Stdout),
|
||||
uintptr(syscall.Stderr),
|
||||
},
|
||||
}
|
||||
|
||||
//#nosec G204
|
||||
pid, _ := syscall.ForkExec(opts.ExecArgs[0], opts.ExecArgs, pattrs)
|
||||
|
||||
var wstatus syscall.WaitStatus
|
||||
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
|
||||
for xerrors.Is(err, syscall.EINTR) {
|
||||
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"inet.af/netaddr"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/peer/peerwg"
|
||||
)
|
||||
|
||||
func (a *agent) startWireguard(ctx context.Context, addrs []netaddr.IPPrefix) error {
|
||||
if a.network != nil {
|
||||
_ = a.network.Close()
|
||||
a.network = nil
|
||||
}
|
||||
|
||||
// We can't create a wireguard network without these.
|
||||
if len(addrs) == 0 || a.listenWireguardPeers == nil || a.postKeys == nil {
|
||||
return xerrors.New("wireguard is enabled, but no addresses were provided or necessary functions were not provided")
|
||||
}
|
||||
|
||||
wg, err := peerwg.New(a.logger.Named("wireguard"), addrs)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create wireguard network: %w", err)
|
||||
}
|
||||
|
||||
// A new keypair is generated on each agent start.
|
||||
// This keypair must be sent to Coder to allow for incoming connections.
|
||||
err = a.postKeys(ctx, WireguardPublicKeys{
|
||||
Public: wg.NodePrivateKey.Public(),
|
||||
Disco: wg.DiscoPublicKey,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "post keys", slog.Error(err))
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
ch, listenClose, err := a.listenWireguardPeers(ctx, a.logger)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "listen wireguard peers", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
peer, ok := <-ch
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
err := wg.AddPeer(peer)
|
||||
a.logger.Info(ctx, "added wireguard peer", slog.F("peer", peer.NodePublicKey.ShortString()), slog.Error(err))
|
||||
}
|
||||
|
||||
listenClose()
|
||||
}
|
||||
}()
|
||||
|
||||
a.startWireguardListeners(ctx, wg, []handlerPort{
|
||||
{port: 12212, handler: a.sshServer.HandleConn},
|
||||
})
|
||||
|
||||
a.network = wg
|
||||
return nil
|
||||
}
|
||||
|
||||
type handlerPort struct {
|
||||
handler func(conn net.Conn)
|
||||
port uint16
|
||||
}
|
||||
|
||||
func (a *agent) startWireguardListeners(ctx context.Context, network *peerwg.Network, handlers []handlerPort) {
|
||||
for _, h := range handlers {
|
||||
go func(h handlerPort) {
|
||||
a.logger.Debug(ctx, "starting wireguard listener", slog.F("port", h.port))
|
||||
|
||||
listener, err := network.Listen("tcp", net.JoinHostPort("", strconv.Itoa(int(h.port))))
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "listen wireguard", slog.F("port", h.port), slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go h.handler(conn)
|
||||
}
|
||||
}(h)
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -3,6 +3,7 @@ package buildinfo
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +25,11 @@ var (
|
||||
tag string
|
||||
)
|
||||
|
||||
const (
|
||||
// develPrefix is prefixed to developer versions of the application.
|
||||
develPrefix = "v0.0.0-devel"
|
||||
)
|
||||
|
||||
// Version returns the semantic version of the build.
|
||||
// Use golang.org/x/mod/semver to compare versions.
|
||||
func Version() string {
|
||||
@@ -35,7 +41,7 @@ func Version() string {
|
||||
if tag == "" {
|
||||
// This occurs when the tag hasn't been injected,
|
||||
// like when using "go run".
|
||||
version = "v0.0.0-devel" + revision
|
||||
version = develPrefix + revision
|
||||
return
|
||||
}
|
||||
version = "v" + tag
|
||||
@@ -48,6 +54,20 @@ func Version() string {
|
||||
return version
|
||||
}
|
||||
|
||||
// VersionsMatch compares the two versions. It assumes the versions match if
|
||||
// the major and the minor versions are equivalent. Patch versions are
|
||||
// disregarded. If it detects that either version is a developer build it
|
||||
// returns true.
|
||||
func VersionsMatch(v1, v2 string) bool {
|
||||
// Developer versions are disregarded...hopefully they know what they are
|
||||
// doing.
|
||||
if strings.HasPrefix(v1, develPrefix) || strings.HasPrefix(v2, develPrefix) {
|
||||
return true
|
||||
}
|
||||
|
||||
return semver.MajorMinor(v1) == semver.MajorMinor(v2)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package buildinfo_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -29,4 +30,70 @@ func TestBuildInfo(t *testing.T) {
|
||||
_, valid := buildinfo.Time()
|
||||
require.False(t, valid)
|
||||
})
|
||||
|
||||
t.Run("VersionsMatch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type testcase struct {
|
||||
name string
|
||||
v1 string
|
||||
v2 string
|
||||
expectMatch bool
|
||||
}
|
||||
|
||||
cases := []testcase{
|
||||
{
|
||||
name: "OK",
|
||||
v1: "v1.2.3",
|
||||
v2: "v1.2.3",
|
||||
expectMatch: true,
|
||||
},
|
||||
// Test that we return true if a developer version is detected.
|
||||
// Developers do not need to be warned of mismatched versions.
|
||||
{
|
||||
name: "DevelIgnored",
|
||||
v1: "v0.0.0-devel+123abac",
|
||||
v2: "v1.2.3",
|
||||
expectMatch: true,
|
||||
},
|
||||
// Our CI instance uses a "-devel" prerelease
|
||||
// flag. This is not the same as a developer WIP build.
|
||||
{
|
||||
name: "DevelPreleaseNotIgnored",
|
||||
v1: "v1.1.1-devel+123abac",
|
||||
v2: "v1.2.3",
|
||||
expectMatch: false,
|
||||
},
|
||||
{
|
||||
name: "MajorMismatch",
|
||||
v1: "v1.2.3",
|
||||
v2: "v0.1.2",
|
||||
expectMatch: false,
|
||||
},
|
||||
{
|
||||
name: "MinorMismatch",
|
||||
v1: "v1.2.3",
|
||||
v2: "v1.3.2",
|
||||
expectMatch: false,
|
||||
},
|
||||
// Different patches are ok, breaking changes are not allowed
|
||||
// in patches.
|
||||
{
|
||||
name: "PatchMismatch",
|
||||
v1: "v1.2.3+hash.whocares",
|
||||
v2: "v1.2.4+somestuff.hm.ok",
|
||||
expectMatch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, c.expectMatch, buildinfo.VersionsMatch(c.v1, c.v2),
|
||||
fmt.Sprintf("expected match=%v for version %s and %s", c.expectMatch, c.v1, c.v2),
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+42
-3
@@ -2,26 +2,27 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
_ "net/http/pprof" //nolint: gosec
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/compute/metadata"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/agent/reaper"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/retry"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
func workspaceAgent() *cobra.Command {
|
||||
@@ -29,6 +30,8 @@ func workspaceAgent() *cobra.Command {
|
||||
auth string
|
||||
pprofEnabled bool
|
||||
pprofAddress string
|
||||
noReap bool
|
||||
wireguard bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
@@ -50,6 +53,27 @@ func workspaceAgent() *cobra.Command {
|
||||
}
|
||||
defer logWriter.Close()
|
||||
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()), sloghuman.Sink(logWriter)).Leveled(slog.LevelDebug)
|
||||
|
||||
isLinux := runtime.GOOS == "linux"
|
||||
|
||||
// Spawn a reaper so that we don't accumulate a ton
|
||||
// of zombie processes.
|
||||
if reaper.IsInitProcess() && !noReap && isLinux {
|
||||
logger.Info(cmd.Context(), "spawning reaper process")
|
||||
// Do not start a reaper on the child process. It's important
|
||||
// to do this else we fork bomb ourselves.
|
||||
args := append(os.Args, "--no-reap")
|
||||
err := reaper.ForkReap(reaper.WithExecArgs(args...))
|
||||
if err != nil {
|
||||
logger.Error(cmd.Context(), "failed to reap", slog.Error(err))
|
||||
return xerrors.Errorf("fork reap: %w", err)
|
||||
}
|
||||
|
||||
logger.Info(cmd.Context(), "reaper process exiting")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info(cmd.Context(), "starting agent", slog.F("url", coderURL), slog.F("auth", auth))
|
||||
client := codersdk.New(coderURL)
|
||||
|
||||
if pprofEnabled {
|
||||
@@ -115,6 +139,7 @@ func workspaceAgent() *cobra.Command {
|
||||
}
|
||||
|
||||
if exchangeToken != nil {
|
||||
logger.Info(cmd.Context(), "exchanging identity token")
|
||||
// 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
|
||||
@@ -138,6 +163,15 @@ func workspaceAgent() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
executablePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("getting os executable: %w", err)
|
||||
}
|
||||
err = os.Setenv("PATH", fmt.Sprintf("%s%c%s", os.Getenv("PATH"), filepath.ListSeparator, filepath.Dir(executablePath)))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("add executable to $PATH: %w", err)
|
||||
}
|
||||
|
||||
closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: logger,
|
||||
EnvironmentVariables: map[string]string{
|
||||
@@ -145,6 +179,9 @@ func workspaceAgent() *cobra.Command {
|
||||
// shells so "gitssh" works!
|
||||
"CODER_AGENT_TOKEN": client.SessionToken,
|
||||
},
|
||||
EnableWireguard: wireguard,
|
||||
UploadWireguardKeys: client.UploadWorkspaceAgentKeys,
|
||||
ListenWireguardPeers: client.WireguardPeerListener,
|
||||
})
|
||||
<-cmd.Context().Done()
|
||||
return closer.Close()
|
||||
@@ -153,6 +190,8 @@ func workspaceAgent() *cobra.Command {
|
||||
|
||||
cliflag.StringVarP(cmd.Flags(), &auth, "auth", "", "CODER_AGENT_AUTH", "token", "Specify the authentication type to use for the agent")
|
||||
cliflag.BoolVarP(cmd.Flags(), &pprofEnabled, "pprof-enable", "", "CODER_AGENT_PPROF_ENABLE", false, "Enable serving pprof metrics on the address defined by --pprof-address.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &noReap, "no-reap", "", "", false, "Do not start a process reaper.")
|
||||
cliflag.StringVarP(cmd.Flags(), &pprofAddress, "pprof-address", "", "CODER_AGENT_PPROF_ADDRESS", "127.0.0.1:6060", "The address to serve pprof.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &wireguard, "wireguard", "", "CODER_AGENT_WIREGUARD", true, "Whether to start the Wireguard interface.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
+3
-3
@@ -46,7 +46,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
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())
|
||||
cmd, _ := clitest.New(t, "agent", "--auth", "azure-instance-identity", "--agent-url", client.URL.String(), "--wireguard=false")
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
errC := make(chan error)
|
||||
@@ -101,7 +101,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
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())
|
||||
cmd, _ := clitest.New(t, "agent", "--auth", "aws-instance-identity", "--agent-url", client.URL.String(), "--wireguard=false")
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
errC := make(chan error)
|
||||
@@ -156,7 +156,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
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())
|
||||
cmd, _ := clitest.New(t, "agent", "--auth", "google-instance-identity", "--agent-url", client.URL.String(), "--wireguard=false")
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
errC := make(chan error)
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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")
|
||||
})
|
||||
|
||||
t.Run("BelowTemplateConstraint", 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)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
|
||||
})
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||
cmdArgs = []string{"autostart", "enable", workspace.Name, "--minute", "*", "--hour", "*"}
|
||||
)
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.ErrorContains(t, err, "schedule: Minimum autostart interval 1m0s below template minimum 1h0m0s")
|
||||
})
|
||||
}
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
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/coderd/database"
|
||||
"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{}
|
||||
)
|
||||
// Unset the workspace TTL
|
||||
err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil})
|
||||
require.NoError(t, err)
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, workspace.TTLMillis)
|
||||
|
||||
// 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)
|
||||
|
||||
// TODO(cian): need to stop and start the workspace as we do not update the deadline yet
|
||||
// see: https://github.com/coder/coder/issues/1783
|
||||
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
|
||||
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart)
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
+50
-6
@@ -6,8 +6,7 @@
|
||||
//
|
||||
// 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")
|
||||
//
|
||||
// -a, --address string The address to serve the API and dashboard (uses $CODER_ADDRESS). (default "127.0.0.1:3000")
|
||||
package cliflag
|
||||
|
||||
import (
|
||||
@@ -17,9 +16,33 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// IsSetBool returns the value of the boolean flag if it is set.
|
||||
// It returns false if the flag isn't set or if any error occurs attempting
|
||||
// to parse the value of the flag.
|
||||
func IsSetBool(cmd *cobra.Command, name string) bool {
|
||||
val, ok := IsSet(cmd, name)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
b, err := strconv.ParseBool(val)
|
||||
return err == nil && b
|
||||
}
|
||||
|
||||
// IsSet returns the string value of the flag and whether it was set.
|
||||
func IsSet(cmd *cobra.Command, name string) (string, bool) {
|
||||
flag := cmd.Flag(name)
|
||||
if flag == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return flag.Value.String(), flag.Changed
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -47,7 +70,7 @@ func StringArrayVarP(flagset *pflag.FlagSet, ptr *[]string, name string, shortha
|
||||
def = strings.Split(val, ",")
|
||||
}
|
||||
}
|
||||
flagset.StringArrayVarP(ptr, name, shorthand, def, usage)
|
||||
flagset.StringArrayVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
// Uint8VarP sets a uint8 flag on the given flag set.
|
||||
@@ -67,6 +90,22 @@ func Uint8VarP(flagset *pflag.FlagSet, ptr *uint8, name string, shorthand string
|
||||
flagset.Uint8VarP(ptr, name, shorthand, uint8(vi64), fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
func Bool(flagset *pflag.FlagSet, name, shorthand, env string, def bool, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if !ok || val == "" {
|
||||
flagset.BoolP(name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
valb, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
flagset.BoolP(name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
flagset.BoolP(name, shorthand, valb, 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)
|
||||
@@ -102,9 +141,14 @@ func DurationVarP(flagset *pflag.FlagSet, ptr *time.Duration, name string, short
|
||||
}
|
||||
|
||||
func fmtUsage(u string, env string) string {
|
||||
if env == "" {
|
||||
return fmt.Sprintf("%s.", u)
|
||||
if env != "" {
|
||||
// Avoid double dotting.
|
||||
dot := "."
|
||||
if strings.HasSuffix(u, ".") {
|
||||
dot = ""
|
||||
}
|
||||
u = fmt.Sprintf("%s%s\nConsumes $%s", u, dot, env)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s - consumes $%s.", u, env)
|
||||
return u
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
)
|
||||
|
||||
// Testcliflag cannot run in parallel because it uses t.Setenv.
|
||||
//
|
||||
//nolint:paralleltest
|
||||
func TestCliflag(t *testing.T) {
|
||||
t.Run("StringDefault", func(t *testing.T) {
|
||||
@@ -24,7 +25,7 @@ func TestCliflag(t *testing.T) {
|
||||
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))
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("StringEnvVar", func(t *testing.T) {
|
||||
@@ -48,7 +49,7 @@ func TestCliflag(t *testing.T) {
|
||||
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))
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("StringVarPEnvVar", func(t *testing.T) {
|
||||
@@ -74,7 +75,7 @@ func TestCliflag(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def, got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.NotContains(t, flagset.FlagUsages(), " - consumes")
|
||||
require.NotContains(t, flagset.FlagUsages(), "Consumes")
|
||||
})
|
||||
|
||||
t.Run("StringArrayDefault", func(t *testing.T) {
|
||||
@@ -117,7 +118,7 @@ func TestCliflag(t *testing.T) {
|
||||
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))
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("IntEnvVar", func(t *testing.T) {
|
||||
@@ -156,7 +157,7 @@ func TestCliflag(t *testing.T) {
|
||||
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))
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("BoolEnvVar", func(t *testing.T) {
|
||||
@@ -195,7 +196,7 @@ func TestCliflag(t *testing.T) {
|
||||
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))
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("DurationEnvVar", func(t *testing.T) {
|
||||
|
||||
+13
-1
@@ -21,10 +21,22 @@ import (
|
||||
// New creates a CLI instance with a configuration pointed to a
|
||||
// temporary testing directory.
|
||||
func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
|
||||
cmd := cli.Root()
|
||||
return NewWithSubcommands(t, cli.AGPL(), args...)
|
||||
}
|
||||
|
||||
func NewWithSubcommands(
|
||||
t *testing.T, subcommands []*cobra.Command, args ...string,
|
||||
) (*cobra.Command, config.Root) {
|
||||
cmd := cli.Root(subcommands)
|
||||
dir := t.TempDir()
|
||||
root := config.Root(dir)
|
||||
cmd.SetArgs(append([]string{"--global-config", dir}, args...))
|
||||
|
||||
// We could consider using writers
|
||||
// that log via t.Log here instead.
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
|
||||
return cmd, root
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||
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)
|
||||
message = "The workspace agent lost connection! Wait for it to reconnect or restart your workspace."
|
||||
}
|
||||
// This saves the cursor position, then defers clearing from the cursor
|
||||
// position to the end of the screen.
|
||||
|
||||
+6
-4
@@ -26,6 +26,7 @@ var Styles = struct {
|
||||
Checkmark,
|
||||
Code,
|
||||
Crossmark,
|
||||
DateTimeStamp,
|
||||
Error,
|
||||
Field,
|
||||
Keyword,
|
||||
@@ -33,7 +34,7 @@ var Styles = struct {
|
||||
Placeholder,
|
||||
Prompt,
|
||||
FocusedPrompt,
|
||||
Fuschia,
|
||||
Fuchsia,
|
||||
Logo,
|
||||
Warn,
|
||||
Wrap lipgloss.Style
|
||||
@@ -42,15 +43,16 @@ var Styles = struct {
|
||||
Checkmark: defaultStyles.Checkmark,
|
||||
Code: defaultStyles.Code,
|
||||
Crossmark: defaultStyles.Error.Copy().SetString("✘"),
|
||||
DateTimeStamp: defaultStyles.LabelDim,
|
||||
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")),
|
||||
Placeholder: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#585858", Dark: "#005fff"}),
|
||||
Prompt: defaultStyles.Prompt.Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}),
|
||||
FocusedPrompt: defaultStyles.FocusedPrompt.Foreground(lipgloss.Color("#651fff")),
|
||||
Fuschia: defaultStyles.SelectedMenuItem.Copy(),
|
||||
Fuchsia: defaultStyles.SelectedMenuItem.Copy(),
|
||||
Logo: defaultStyles.Logo.SetString("Coder"),
|
||||
Warn: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}),
|
||||
Wrap: defaultStyles.Wrap,
|
||||
Wrap: lipgloss.NewStyle().Width(80),
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ func ParameterSchema(cmd *cobra.Command, parameterSchema codersdk.ParameterSchem
|
||||
value, err = Prompt(cmd, PromptOptions{
|
||||
Text: Styles.Bold.Render(text),
|
||||
})
|
||||
value = strings.TrimSpace(value)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
+22
-6
@@ -24,25 +24,41 @@ type PromptOptions struct {
|
||||
Validate func(string) error
|
||||
}
|
||||
|
||||
const skipPromptFlag = "yes"
|
||||
|
||||
func AllowSkipPrompt(cmd *cobra.Command) {
|
||||
cmd.Flags().BoolP("yes", "y", false, "Bypass prompts")
|
||||
cmd.Flags().BoolP(skipPromptFlag, "y", false, "Bypass prompts")
|
||||
}
|
||||
|
||||
const (
|
||||
ConfirmYes = "yes"
|
||||
ConfirmNo = "no"
|
||||
)
|
||||
|
||||
// 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
|
||||
if opts.IsConfirm && cmd.Flags().Lookup(skipPromptFlag) != nil {
|
||||
if skip, _ := cmd.Flags().GetBool(skipPromptFlag); skip {
|
||||
return ConfirmYes, 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) ")))
|
||||
if len(opts.Default) == 0 {
|
||||
opts.Default = ConfirmYes
|
||||
}
|
||||
renderedYes := Styles.Placeholder.Render(ConfirmYes)
|
||||
renderedNo := Styles.Placeholder.Render(ConfirmNo)
|
||||
if opts.Default == ConfirmYes {
|
||||
renderedYes = Styles.Bold.Render(ConfirmYes)
|
||||
} else {
|
||||
renderedNo = Styles.Bold.Render(ConfirmNo)
|
||||
}
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+renderedYes+Styles.Placeholder.Render("/"+renderedNo+Styles.Placeholder.Render(") "))))
|
||||
} else if opts.Default != "" {
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.Placeholder.Render("("+opts.Default+") "))
|
||||
}
|
||||
|
||||
+12
-10
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/pty"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestPrompt(t *testing.T) {
|
||||
@@ -61,7 +61,7 @@ func TestPrompt(t *testing.T) {
|
||||
// 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)
|
||||
dataRead, doneReading := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
go func() {
|
||||
// This will throw an error sometimes. The underlying ptty
|
||||
// has its own cleanup routines in t.Cleanup. Instead of
|
||||
@@ -182,29 +182,31 @@ func TestPasswordTerminalState(t *testing.T) {
|
||||
// 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
|
||||
cmd.Stderr = ptty.Output().Writer
|
||||
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")
|
||||
require.Eventually(t, func() bool {
|
||||
echo, err := ptyWithFlags.EchoEnabled()
|
||||
return err == nil && !echo
|
||||
}, testutil.WaitShort, testutil.IntervalMedium, "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")
|
||||
require.Eventually(t, func() bool {
|
||||
echo, err := ptyWithFlags.EchoEnabled()
|
||||
return err == nil && echo
|
||||
}, testutil.WaitShort, testutil.IntervalMedium, "echo is off after reading password")
|
||||
}
|
||||
|
||||
// nolint:unused
|
||||
func passwordHelper() {
|
||||
cmd := &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
|
||||
+33
-50
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
@@ -23,12 +24,12 @@ type WorkspaceResourcesOptions struct {
|
||||
// ┌────────────────────────────────────────────────────────────────────────────┐
|
||||
// │ RESOURCE STATUS ACCESS │
|
||||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||||
// │ google_compute_disk.root persistent │
|
||||
// │ google_compute_disk.root │
|
||||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||||
// │ google_compute_instance.dev ephemeral │
|
||||
// │ google_compute_instance.dev │
|
||||
// │ └─ dev (linux, amd64) ⦾ connecting [10s] coder ssh dev.dev │
|
||||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||||
// │ kubernetes_pod.dev ephemeral │
|
||||
// │ kubernetes_pod.dev │
|
||||
// │ ├─ go (linux, amd64) ⦿ connected coder ssh dev.go │
|
||||
// │ └─ postgres (linux, amd64) ⦾ disconnected [4s] coder ssh dev.postgres │
|
||||
// └────────────────────────────────────────────────────────────────────────────┘
|
||||
@@ -38,26 +39,16 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
|
||||
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"}
|
||||
row := table.Row{"Resource"}
|
||||
if !options.HideAgentState {
|
||||
row = append(row, "Status")
|
||||
}
|
||||
if !options.HideAccess {
|
||||
row = append(row, "Access")
|
||||
}
|
||||
@@ -76,50 +67,20 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
|
||||
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 = "└"
|
||||
@@ -127,9 +88,31 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
|
||||
row := table.Row{
|
||||
// These tree from a resource!
|
||||
fmt.Sprintf("%s─ %s (%s, %s)", pipe, agent.Name, agent.OperatingSystem, agent.Architecture),
|
||||
agentStatus,
|
||||
}
|
||||
if !options.HideAgentState {
|
||||
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")
|
||||
}
|
||||
}
|
||||
row = append(row, agentStatus)
|
||||
}
|
||||
if !options.HideAccess {
|
||||
sshCommand := "coder ssh " + options.WorkspaceName
|
||||
if totalAgents > 1 {
|
||||
sshCommand += "." + agent.Name
|
||||
}
|
||||
sshCommand = Styles.Code.Render(sshCommand)
|
||||
row = append(row, sshCommand)
|
||||
}
|
||||
tableWriter.AppendRow(row)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/structtag"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// Table creates a new table with standardized styles.
|
||||
@@ -41,3 +46,262 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig
|
||||
}
|
||||
return columnConfigs
|
||||
}
|
||||
|
||||
// DisplayTable renders a table as a string. The input argument must be a slice
|
||||
// of structs. At least one field in the struct must have a `table:""` tag
|
||||
// containing the name of the column in the outputted table.
|
||||
//
|
||||
// Nested structs are processed if the field has the `table:"$NAME,recursive"`
|
||||
// tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is
|
||||
// malformed or a field is marked as recursive but does not contain a struct or
|
||||
// a pointer to a struct, this function will return an error (even with an empty
|
||||
// input slice).
|
||||
//
|
||||
// If sort is empty, the input order will be used. If filterColumns is empty or
|
||||
// nil, all available columns are included.
|
||||
func DisplayTable(out any, sort string, filterColumns []string) (string, error) {
|
||||
v := reflect.Indirect(reflect.ValueOf(out))
|
||||
|
||||
if v.Kind() != reflect.Slice {
|
||||
return "", xerrors.Errorf("DisplayTable called with a non-slice type")
|
||||
}
|
||||
|
||||
// Get the list of table column headers.
|
||||
headersRaw, err := typeToTableHeaders(v.Type().Elem())
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
|
||||
}
|
||||
if len(headersRaw) == 0 {
|
||||
return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`)
|
||||
}
|
||||
headers := make(table.Row, len(headersRaw))
|
||||
for i, header := range headersRaw {
|
||||
headers[i] = header
|
||||
}
|
||||
|
||||
// Verify that the given sort column and filter columns are valid.
|
||||
if sort != "" || len(filterColumns) != 0 {
|
||||
headersMap := make(map[string]string, len(headersRaw))
|
||||
for _, header := range headersRaw {
|
||||
headersMap[strings.ToLower(header)] = header
|
||||
}
|
||||
|
||||
if sort != "" {
|
||||
sort = strings.ToLower(strings.ReplaceAll(sort, "_", " "))
|
||||
h, ok := headersMap[sort]
|
||||
if !ok {
|
||||
return "", xerrors.Errorf(`specified sort column %q not found in table headers, available columns are "%v"`, sort, strings.Join(headersRaw, `", "`))
|
||||
}
|
||||
|
||||
// Autocorrect
|
||||
sort = h
|
||||
}
|
||||
|
||||
for i, column := range filterColumns {
|
||||
column := strings.ToLower(strings.ReplaceAll(column, "_", " "))
|
||||
h, ok := headersMap[column]
|
||||
if !ok {
|
||||
return "", xerrors.Errorf(`specified filter column %q not found in table headers, available columns are "%v"`, sort, strings.Join(headersRaw, `", "`))
|
||||
}
|
||||
|
||||
// Autocorrect
|
||||
filterColumns[i] = h
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the given sort column is valid.
|
||||
if sort != "" {
|
||||
sort = strings.ReplaceAll(sort, "_", " ")
|
||||
found := false
|
||||
for _, header := range headersRaw {
|
||||
if strings.EqualFold(sort, header) {
|
||||
found = true
|
||||
sort = header
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
|
||||
}
|
||||
}
|
||||
|
||||
// Setup the table formatter.
|
||||
tw := Table()
|
||||
tw.AppendHeader(headers)
|
||||
tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns))
|
||||
if sort != "" {
|
||||
tw.SortBy([]table.SortBy{{
|
||||
Name: sort,
|
||||
}})
|
||||
}
|
||||
|
||||
// Write each struct to the table.
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
// Format the row as a slice.
|
||||
rowMap, err := valueToTableMap(v.Index(i))
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get table row map %v: %w", i, err)
|
||||
}
|
||||
|
||||
rowSlice := make([]any, len(headers))
|
||||
for i, h := range headersRaw {
|
||||
v, ok := rowMap[h]
|
||||
if !ok {
|
||||
v = nil
|
||||
}
|
||||
|
||||
// Special type formatting.
|
||||
switch val := v.(type) {
|
||||
case time.Time:
|
||||
v = val.Format(time.Stamp)
|
||||
case *time.Time:
|
||||
if val != nil {
|
||||
v = val.Format(time.Stamp)
|
||||
}
|
||||
case fmt.Stringer:
|
||||
if val != nil {
|
||||
v = val.String()
|
||||
}
|
||||
}
|
||||
|
||||
rowSlice[i] = v
|
||||
}
|
||||
|
||||
tw.AppendRow(table.Row(rowSlice))
|
||||
}
|
||||
|
||||
return tw.Render(), nil
|
||||
}
|
||||
|
||||
// parseTableStructTag returns the name of the field according to the `table`
|
||||
// struct tag. If the table tag does not exist or is "-", an empty string is
|
||||
// returned. If the table tag is malformed, an error is returned.
|
||||
//
|
||||
// The returned name is transformed from "snake_case" to "normal text".
|
||||
func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) {
|
||||
tags, err := structtag.Parse(string(field.Tag))
|
||||
if err != nil {
|
||||
return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
}
|
||||
|
||||
tag, err := tags.Get("table")
|
||||
if err != nil || tag.Name == "-" {
|
||||
// tags.Get only returns an error if the tag is not found.
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
recursive := false
|
||||
for _, opt := range tag.Options {
|
||||
if opt == "recursive" {
|
||||
recursive = true
|
||||
continue
|
||||
}
|
||||
|
||||
return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil
|
||||
}
|
||||
|
||||
func isStructOrStructPointer(t reflect.Type) bool {
|
||||
return t.Kind() == reflect.Struct || (t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Struct)
|
||||
}
|
||||
|
||||
// typeToTableHeaders converts a type to a slice of column names. If the given
|
||||
// type is invalid (not a struct or a pointer to a struct, has invalid table
|
||||
// tags, etc.), an error is returned.
|
||||
func typeToTableHeaders(t reflect.Type) ([]string, error) {
|
||||
if !isStructOrStructPointer(t) {
|
||||
return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type")
|
||||
}
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
headers := []string{}
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
name, recursive, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldType := field.Type
|
||||
if recursive {
|
||||
if !isStructOrStructPointer(fieldType) {
|
||||
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String())
|
||||
}
|
||||
|
||||
childNames, err := typeToTableHeaders(fieldType)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err)
|
||||
}
|
||||
for _, childName := range childNames {
|
||||
headers = append(headers, fmt.Sprintf("%s %s", name, childName))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
headers = append(headers, name)
|
||||
}
|
||||
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// valueToTableMap converts a struct to a map of column name to value. If the
|
||||
// given type is invalid (not a struct or a pointer to a struct, has invalid
|
||||
// table tags, etc.), an error is returned.
|
||||
func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
if !isStructOrStructPointer(val.Type()) {
|
||||
return nil, xerrors.Errorf("valueToTableMap called with a non-struct or a non-pointer-to-a-struct type")
|
||||
}
|
||||
if val.Kind() == reflect.Pointer {
|
||||
if val.IsNil() {
|
||||
// No data for this struct, so return an empty map. All values will
|
||||
// be rendered as nil in the resulting table.
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
row := map[string]any{}
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
fieldVal := val.Field(i)
|
||||
name, recursive, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Recurse if it's a struct.
|
||||
fieldType := field.Type
|
||||
if recursive {
|
||||
if !isStructOrStructPointer(fieldType) {
|
||||
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, fieldType.String())
|
||||
}
|
||||
|
||||
// valueToTableMap does nothing on pointers so we don't need to
|
||||
// filter here.
|
||||
childMap, err := valueToTableMap(fieldVal)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get child field values for field %q in type %q: %w", field.Name, fieldType.String(), err)
|
||||
}
|
||||
for childName, childValue := range childMap {
|
||||
row[fmt.Sprintf("%s %s", name, childName)] = childValue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, we just use the field value.
|
||||
row[name] = val.Field(i).Interface()
|
||||
}
|
||||
|
||||
return row, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
type stringWrapper struct {
|
||||
str string
|
||||
}
|
||||
|
||||
var _ fmt.Stringer = stringWrapper{}
|
||||
|
||||
func (s stringWrapper) String() string {
|
||||
return s.str
|
||||
}
|
||||
|
||||
type tableTest1 struct {
|
||||
Name string `table:"name"`
|
||||
NotIncluded string // no table tag
|
||||
Age int `table:"age"`
|
||||
Roles []string `table:"roles"`
|
||||
Sub1 tableTest2 `table:"sub_1,recursive"`
|
||||
Sub2 *tableTest2 `table:"sub_2,recursive"`
|
||||
Sub3 tableTest3 `table:"sub 3,recursive"`
|
||||
Sub4 tableTest2 `table:"sub 4"` // not recursive
|
||||
|
||||
// Types with special formatting.
|
||||
Time time.Time `table:"time"`
|
||||
TimePtr *time.Time `table:"time_ptr"`
|
||||
}
|
||||
|
||||
type tableTest2 struct {
|
||||
Name stringWrapper `table:"name"`
|
||||
Age int `table:"age"`
|
||||
NotIncluded string `table:"-"`
|
||||
}
|
||||
|
||||
type tableTest3 struct {
|
||||
NotIncluded string // no table tag
|
||||
Sub tableTest2 `table:"inner,recursive"`
|
||||
}
|
||||
|
||||
func Test_DisplayTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.Local)
|
||||
in := []tableTest1{
|
||||
{
|
||||
Name: "foo",
|
||||
Age: 10,
|
||||
Roles: []string{"a", "b", "c"},
|
||||
Sub1: tableTest2{
|
||||
Name: stringWrapper{str: "foo1"},
|
||||
Age: 11,
|
||||
},
|
||||
Sub2: &tableTest2{
|
||||
Name: stringWrapper{str: "foo2"},
|
||||
Age: 12,
|
||||
},
|
||||
Sub3: tableTest3{
|
||||
Sub: tableTest2{
|
||||
Name: stringWrapper{str: "foo3"},
|
||||
Age: 13,
|
||||
},
|
||||
},
|
||||
Sub4: tableTest2{
|
||||
Name: stringWrapper{str: "foo4"},
|
||||
Age: 14,
|
||||
},
|
||||
Time: someTime,
|
||||
TimePtr: &someTime,
|
||||
},
|
||||
{
|
||||
Name: "bar",
|
||||
Age: 20,
|
||||
Roles: []string{"a"},
|
||||
Sub1: tableTest2{
|
||||
Name: stringWrapper{str: "bar1"},
|
||||
Age: 21,
|
||||
},
|
||||
Sub2: nil,
|
||||
Sub3: tableTest3{
|
||||
Sub: tableTest2{
|
||||
Name: stringWrapper{str: "bar3"},
|
||||
Age: 23,
|
||||
},
|
||||
},
|
||||
Sub4: tableTest2{
|
||||
Name: stringWrapper{str: "bar4"},
|
||||
Age: 24,
|
||||
},
|
||||
Time: someTime,
|
||||
TimePtr: nil,
|
||||
},
|
||||
{
|
||||
Name: "baz",
|
||||
Age: 30,
|
||||
Roles: nil,
|
||||
Sub1: tableTest2{
|
||||
Name: stringWrapper{str: "baz1"},
|
||||
Age: 31,
|
||||
},
|
||||
Sub2: nil,
|
||||
Sub3: tableTest3{
|
||||
Sub: tableTest2{
|
||||
Name: stringWrapper{str: "baz3"},
|
||||
Age: 33,
|
||||
},
|
||||
},
|
||||
Sub4: tableTest2{
|
||||
Name: stringWrapper{str: "baz4"},
|
||||
Age: 34,
|
||||
},
|
||||
Time: someTime,
|
||||
TimePtr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
// This test tests skipping fields without table tags, recursion, pointer
|
||||
// dereferencing, and nil pointer skipping.
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
|
||||
`
|
||||
|
||||
// Test with non-pointer values.
|
||||
out, err := cliui.DisplayTable(in, "", nil)
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
|
||||
// Test with pointer values.
|
||||
inPtr := make([]*tableTest1, len(in))
|
||||
for i, v := range in {
|
||||
v := v
|
||||
inPtr[i] = &v
|
||||
}
|
||||
out, err = cliui.DisplayTable(inPtr, "", nil)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
t.Run("Sort", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "name", nil)
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
t.Run("Filter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME SUB 1 NAME SUB 3 INNER NAME TIME
|
||||
foo foo1 foo3 Aug 2 15:49:10
|
||||
bar bar1 bar3 Aug 2 15:49:10
|
||||
baz baz1 baz3 Aug 2 15:49:10
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "", []string{"name", "sub_1_name", "sub_3 inner name", "time"})
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
// This test ensures that safeties against invalid use of `table` tags
|
||||
// causes errors (even without data).
|
||||
t.Run("Errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NotSlice", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in string
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("BadSortColumn", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := cliui.DisplayTable(in, "bad_column_does_not_exist", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("BadFilterColumns", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := cliui.DisplayTable(in, "", []string{"name", "bad_column_does_not_exist"})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("Interfaces", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("WithoutData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in []any
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []any{tableTest1{}}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NotStruct", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("WithoutData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in []string
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []string{"foo", "bar", "baz"}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NoTableTags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type noTableTagsTest struct {
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
t.Run("WithoutData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in []noTableTagsTest
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []noTableTagsTest{{Field: "hi"}}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("InvalidTag/NoName", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type noNameTest struct {
|
||||
Field string `table:""`
|
||||
}
|
||||
|
||||
t.Run("WithoutData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in []noNameTest
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []noNameTest{{Field: "test"}}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("InvalidTag/BadSyntax", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type invalidSyntaxTest struct {
|
||||
Field string `table:"asda,asdjada"`
|
||||
}
|
||||
|
||||
t.Run("WithoutData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in []invalidSyntaxTest
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []invalidSyntaxTest{{Field: "test"}}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// compareTables normalizes the incoming table lines
|
||||
func compareTables(t *testing.T, expected, out string) {
|
||||
t.Helper()
|
||||
|
||||
expectedLines := strings.Split(strings.TrimSpace(expected), "\n")
|
||||
gotLines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
assert.Equal(t, len(expectedLines), len(gotLines), "expected line count does not match generated line count")
|
||||
|
||||
// Map the expected and got lines to normalize them.
|
||||
expectedNormalized := make([]string, len(expectedLines))
|
||||
gotNormalized := make([]string, len(gotLines))
|
||||
normalizeLine := func(s string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
|
||||
}
|
||||
for i, s := range expectedLines {
|
||||
expectedNormalized[i] = normalizeLine(s)
|
||||
}
|
||||
for i, s := range gotLines {
|
||||
gotNormalized[i] = normalizeLine(s)
|
||||
}
|
||||
|
||||
require.Equal(t, expectedNormalized, gotNormalized, "expected lines to match generated lines")
|
||||
}
|
||||
@@ -25,6 +25,18 @@ func (r Root) DotfilesURL() File {
|
||||
return File(filepath.Join(string(r), "dotfilesurl"))
|
||||
}
|
||||
|
||||
func (r Root) PostgresPath() string {
|
||||
return filepath.Join(string(r), "postgres")
|
||||
}
|
||||
|
||||
func (r Root) PostgresPassword() File {
|
||||
return File(filepath.Join(r.PostgresPath(), "password"))
|
||||
}
|
||||
|
||||
func (r Root) PostgresPort() File {
|
||||
return File(filepath.Join(r.PostgresPath(), "port"))
|
||||
}
|
||||
|
||||
// File provides convenience methods for interacting with *os.File.
|
||||
type File string
|
||||
|
||||
|
||||
+179
-259
@@ -10,7 +10,6 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -29,71 +28,36 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
sshDefaultConfigFileName = "~/.ssh/config"
|
||||
sshDefaultCoderConfigFileName = "~/.ssh/coder"
|
||||
sshCoderConfigHeader = "# This file is managed by coder. DO NOT EDIT."
|
||||
sshCoderConfigDocsHeader = `
|
||||
#
|
||||
# You should not hand-edit this file, all changes will be lost when running
|
||||
# "coder config-ssh".`
|
||||
sshCoderConfigOptionsHeader = `
|
||||
sshDefaultConfigFileName = "~/.ssh/config"
|
||||
sshStartToken = "# ------------START-CODER-----------"
|
||||
sshEndToken = "# ------------END-CODER------------"
|
||||
sshConfigSectionHeader = "# This section is managed by coder. DO NOT EDIT."
|
||||
sshConfigDocsHeader = `
|
||||
#
|
||||
# You should not hand-edit this section unless you are removing it, all
|
||||
# changes will be lost when running "coder config-ssh".
|
||||
`
|
||||
sshConfigOptionsHeader = `#
|
||||
# Last config-ssh options:
|
||||
`
|
||||
// Relative paths are assumed to be in ~/.ssh, except when
|
||||
// included in /etc/ssh.
|
||||
sshConfigIncludeStatement = "Include coder"
|
||||
)
|
||||
|
||||
// Regular expressions are used because SSH configs do not have
|
||||
// meaningful indentation and keywords are case-insensitive.
|
||||
var (
|
||||
// Find the first Host and Match statement as these restrict the
|
||||
// following declarations to be used conditionally.
|
||||
sshHostRe = regexp.MustCompile(`(?m)^[\t ]*((?i)Host|Match)\s[^\n\r]*$`)
|
||||
// Find the semantically correct include statement. Since the user can
|
||||
// modify their configuration as they see fit, there could be:
|
||||
// - Leading indentation (space, tab)
|
||||
// - Trailing indentation (space, tab)
|
||||
// - Select newline after Include statement for cleaner removal
|
||||
// In the following cases, we will not recognize the Include statement
|
||||
// and leave as-is (i.e. they're not supported):
|
||||
// - User adds another file to the Include statement
|
||||
// - User adds a comment on the same line as the Include statement
|
||||
sshCoderIncludedRe = regexp.MustCompile(`(?m)^[\t ]*((?i)Include) coder[\t ]*[\r]?[\n]?$`)
|
||||
)
|
||||
|
||||
// sshCoderConfigOptions represents options that can be stored and read
|
||||
// sshConfigOptions represents options that can be stored and read
|
||||
// from the coder config in ~/.ssh/coder.
|
||||
type sshCoderConfigOptions struct {
|
||||
sshConfigDefaultFile string
|
||||
sshConfigFile string
|
||||
sshOptions []string
|
||||
type sshConfigOptions struct {
|
||||
sshOptions []string
|
||||
}
|
||||
|
||||
func (o sshCoderConfigOptions) equal(other sshCoderConfigOptions) bool {
|
||||
func (o sshConfigOptions) equal(other sshConfigOptions) bool {
|
||||
// Compare without side-effects or regard to order.
|
||||
opt1 := slices.Clone(o.sshOptions)
|
||||
sort.Strings(opt1)
|
||||
opt2 := slices.Clone(other.sshOptions)
|
||||
sort.Strings(opt2)
|
||||
return o.sshConfigFile == other.sshConfigFile && slices.Equal(opt1, opt2)
|
||||
return slices.Equal(opt1, opt2)
|
||||
}
|
||||
|
||||
func (o sshCoderConfigOptions) asArgs() (args []string) {
|
||||
if o.sshConfigFile != o.sshConfigDefaultFile {
|
||||
args = append(args, "--ssh-config-file", o.sshConfigFile)
|
||||
}
|
||||
for _, opt := range o.sshOptions {
|
||||
args = append(args, "--ssh-option", fmt.Sprintf("%q", opt))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func (o sshCoderConfigOptions) asList() (list []string) {
|
||||
if o.sshConfigFile != o.sshConfigDefaultFile {
|
||||
list = append(list, fmt.Sprintf("ssh-config-file: %s", o.sshConfigFile))
|
||||
}
|
||||
func (o sshConfigOptions) asList() (list []string) {
|
||||
for _, opt := range o.sshOptions {
|
||||
list = append(list, fmt.Sprintf("ssh-option: %s", opt))
|
||||
}
|
||||
@@ -125,18 +89,23 @@ func sshFetchWorkspaceConfigs(ctx context.Context, client *codersdk.Client) ([]s
|
||||
}
|
||||
|
||||
wc := sshWorkspaceConfig{Name: workspace.Name}
|
||||
var agents []codersdk.WorkspaceAgent
|
||||
for _, resource := range resources {
|
||||
if resource.Transition != codersdk.WorkspaceTransitionStart {
|
||||
continue
|
||||
}
|
||||
for _, agent := range resource.Agents {
|
||||
hostname := workspace.Name
|
||||
if len(resource.Agents) > 1 {
|
||||
hostname += "." + agent.Name
|
||||
}
|
||||
wc.Hosts = append(wc.Hosts, hostname)
|
||||
}
|
||||
agents = append(agents, resource.Agents...)
|
||||
}
|
||||
|
||||
// handle both WORKSPACE and WORKSPACE.AGENT syntax
|
||||
if len(agents) == 1 {
|
||||
wc.Hosts = append(wc.Hosts, workspace.Name)
|
||||
}
|
||||
for _, agent := range agents {
|
||||
hostname := workspace.Name + "." + agent.Name
|
||||
wc.Hosts = append(wc.Hosts, hostname)
|
||||
}
|
||||
|
||||
workspaceConfigs[i] = wc
|
||||
|
||||
return nil
|
||||
@@ -165,35 +134,30 @@ func sshPrepareWorkspaceConfigs(ctx context.Context, client *codersdk.Client) (r
|
||||
|
||||
func configSSH() *cobra.Command {
|
||||
var (
|
||||
coderConfig sshCoderConfigOptions
|
||||
coderConfigFile string
|
||||
showDiff bool
|
||||
sshConfigFile string
|
||||
sshConfigOpts sshConfigOptions
|
||||
usePreviousOpts bool
|
||||
dryRun bool
|
||||
skipProxyCommand bool
|
||||
|
||||
// Diff should exit with status 1 when files differ.
|
||||
filesDiffer bool
|
||||
wireguard 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") + `
|
||||
|
||||
- You can use -D (or --diff) to display the changes that will be made.
|
||||
|
||||
` + cliui.Styles.Code.Render("$ coder config-ssh --diff"),
|
||||
PostRun: func(cmd *cobra.Command, args []string) {
|
||||
// TODO(mafredri): Should we refactor this.. e.g. sentinel error?
|
||||
if showDiff && filesDiffer {
|
||||
os.Exit(1) //nolint: revive
|
||||
}
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
Example: formatExamples(
|
||||
example{
|
||||
Description: "You can use -o (or --ssh-option) so set SSH options to be used for all your workspaces",
|
||||
Command: "coder config-ssh -o ForwardAgent=yes",
|
||||
},
|
||||
example{
|
||||
Description: "You can use --dry-run (or -n) to see the changes that would be made",
|
||||
Command: "coder config-ssh --dry-run",
|
||||
},
|
||||
),
|
||||
Args: cobra.ExactArgs(0),
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -201,7 +165,9 @@ func configSSH() *cobra.Command {
|
||||
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(cmd.Context(), client)
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
if showDiff {
|
||||
if dryRun {
|
||||
// Print everything except diff to stderr so
|
||||
// that it's possible to capture the diff.
|
||||
out = cmd.OutOrStderr()
|
||||
}
|
||||
binaryFile, err := currentBinPath(out)
|
||||
@@ -209,17 +175,13 @@ func configSSH() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
dirname, err := os.UserHomeDir()
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("user home dir failed: %w", err)
|
||||
}
|
||||
|
||||
sshConfigFile := coderConfig.sshConfigFile
|
||||
if strings.HasPrefix(sshConfigFile, "~/") {
|
||||
sshConfigFile = filepath.Join(dirname, sshConfigFile[2:])
|
||||
}
|
||||
if strings.HasPrefix(coderConfigFile, "~/") {
|
||||
coderConfigFile = filepath.Join(dirname, coderConfigFile[2:])
|
||||
sshConfigFile = filepath.Join(homedir, sshConfigFile[2:])
|
||||
}
|
||||
|
||||
// Only allow not-exist errors to avoid trashing
|
||||
@@ -229,32 +191,28 @@ func configSSH() *cobra.Command {
|
||||
return xerrors.Errorf("read ssh config failed: %w", err)
|
||||
}
|
||||
|
||||
coderConfigExists := true
|
||||
coderConfigRaw, err := os.ReadFile(coderConfigFile)
|
||||
if err != nil {
|
||||
//nolint: revive // Inverting this if statement doesn't improve readability.
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
coderConfigExists = false
|
||||
} else {
|
||||
return xerrors.Errorf("read ssh config failed: %w", err)
|
||||
}
|
||||
// Keep track of changes we are making.
|
||||
var changes []string
|
||||
|
||||
// Parse the previous configuration only if config-ssh
|
||||
// has been run previously.
|
||||
var lastConfig *sshConfigOptions
|
||||
if section, ok := sshConfigGetCoderSection(configRaw); ok {
|
||||
c := sshConfigParseLastOptions(bytes.NewReader(section))
|
||||
lastConfig = &c
|
||||
}
|
||||
if len(coderConfigRaw) > 0 {
|
||||
if !bytes.HasPrefix(coderConfigRaw, []byte(sshCoderConfigHeader)) {
|
||||
return xerrors.Errorf("unexpected content in %s: remove the file and rerun the command to continue", coderConfigFile)
|
||||
}
|
||||
}
|
||||
lastCoderConfig := sshCoderConfigParseLastOptions(bytes.NewReader(coderConfigRaw), coderConfig.sshConfigDefaultFile)
|
||||
|
||||
// Avoid prompting in diff mode (unexpected behavior)
|
||||
// or when a previous config does not exist.
|
||||
if !showDiff && !coderConfig.equal(lastCoderConfig) && coderConfigExists {
|
||||
newOpts := coderConfig.asList()
|
||||
if usePreviousOpts && lastConfig != nil {
|
||||
sshConfigOpts = *lastConfig
|
||||
} else if lastConfig != nil && !sshConfigOpts.equal(*lastConfig) {
|
||||
newOpts := sshConfigOpts.asList()
|
||||
newOptsMsg := "\n\n New options: none"
|
||||
if len(newOpts) > 0 {
|
||||
newOptsMsg = fmt.Sprintf("\n\n New options:\n * %s", strings.Join(newOpts, "\n * "))
|
||||
}
|
||||
oldOpts := lastCoderConfig.asList()
|
||||
oldOpts := lastConfig.asList()
|
||||
oldOptsMsg := "\n\n Previous options: none"
|
||||
if len(oldOpts) > 0 {
|
||||
oldOptsMsg = fmt.Sprintf("\n\n Previous options:\n * %s", strings.Join(oldOpts, "\n * "))
|
||||
@@ -265,43 +223,32 @@ func configSSH() *cobra.Command {
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
// TODO(mafredri): Better way to differ between "no" and Ctrl+C?
|
||||
if line == "" && xerrors.Is(err, cliui.Canceled) {
|
||||
return nil
|
||||
}
|
||||
// Selecting "no" will use the last config.
|
||||
coderConfig = lastCoderConfig
|
||||
sshConfigOpts = *lastConfig
|
||||
} else {
|
||||
changes = append(changes, "Use new SSH options")
|
||||
}
|
||||
// Only print when prompts are shown.
|
||||
if yes, _ := cmd.Flags().GetBool("yes"); !yes {
|
||||
_, _ = fmt.Fprint(out, "\n")
|
||||
}
|
||||
_, _ = fmt.Fprint(out, "\n")
|
||||
}
|
||||
|
||||
// Keep track of changes we are making.
|
||||
var changes []string
|
||||
|
||||
// Check for presence of old config format and
|
||||
// remove if present.
|
||||
configModified, ok := stripOldConfigBlock(configRaw)
|
||||
if ok {
|
||||
changes = append(changes, fmt.Sprintf("Remove old config block (START-CODER/END-CODER) from %s", sshConfigFile))
|
||||
}
|
||||
|
||||
// Check for the presence of the coder Include
|
||||
// statement is present and add if missing.
|
||||
configModified, ok = sshConfigAddCoderInclude(configModified)
|
||||
if ok {
|
||||
changes = append(changes, fmt.Sprintf("Add %q to %s", "Include coder", sshConfigFile))
|
||||
}
|
||||
|
||||
configModified := configRaw
|
||||
root := createConfig(cmd)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
before, after := sshConfigSplitOnCoderSection(configModified)
|
||||
// Write the first half of the users config file to buf.
|
||||
_, _ = buf.Write(before)
|
||||
|
||||
// Write header and store the provided options as part
|
||||
// Write comment and store the provided options as part
|
||||
// of the config for future (re)use.
|
||||
err = sshCoderConfigWriteHeader(buf, coderConfig)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write coder config header failed: %w", err)
|
||||
}
|
||||
newline := len(before) > 0
|
||||
sshConfigWriteSectionHeader(buf, newline, sshConfigOpts)
|
||||
|
||||
workspaceConfigs, err := recvWorkspaceConfigs()
|
||||
if err != nil {
|
||||
@@ -318,7 +265,7 @@ func configSSH() *cobra.Command {
|
||||
configOptions := []string{
|
||||
"Host coder." + hostname,
|
||||
}
|
||||
for _, option := range coderConfig.sshOptions {
|
||||
for _, option := range sshConfigOpts.sshOptions {
|
||||
configOptions = append(configOptions, "\t"+option)
|
||||
}
|
||||
configOptions = append(configOptions,
|
||||
@@ -333,7 +280,11 @@ func configSSH() *cobra.Command {
|
||||
"\tLogLevel ERROR",
|
||||
)
|
||||
if !skipProxyCommand {
|
||||
configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname))
|
||||
if !wireguard {
|
||||
configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname))
|
||||
} else {
|
||||
configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --wireguard --stdio %s", binaryFile, root, hostname))
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = buf.WriteString(strings.Join(configOptions, "\n"))
|
||||
@@ -341,146 +292,104 @@ func configSSH() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
modifyCoderConfig := !bytes.Equal(coderConfigRaw, buf.Bytes())
|
||||
if modifyCoderConfig {
|
||||
if len(coderConfigRaw) == 0 {
|
||||
changes = append(changes, fmt.Sprintf("Write auto-generated coder config file to %s", coderConfigFile))
|
||||
} else {
|
||||
changes = append(changes, fmt.Sprintf("Update auto-generated coder config file in %s", coderConfigFile))
|
||||
}
|
||||
sshConfigWriteSectionEnd(buf)
|
||||
|
||||
// Write the remainder of the users config file to buf.
|
||||
_, _ = buf.Write(after)
|
||||
|
||||
if !bytes.Equal(configModified, buf.Bytes()) {
|
||||
changes = append(changes, fmt.Sprintf("Update the coder section in %s", sshConfigFile))
|
||||
configModified = buf.Bytes()
|
||||
}
|
||||
|
||||
if showDiff {
|
||||
if len(changes) > 0 {
|
||||
// Write to stderr to avoid dirtying the diff output.
|
||||
_, _ = fmt.Fprint(out, "The following changes will be made to your SSH configuration:\n\n")
|
||||
for _, change := range changes {
|
||||
_, _ = fmt.Fprintf(out, " * %s\n", change)
|
||||
}
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
_, _ = fmt.Fprintf(out, "No changes to make.\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
_, _ = fmt.Fprintf(out, "Dry run, the following changes would be made to your SSH configuration:\n\n * %s\n\n", strings.Join(changes, "\n * "))
|
||||
|
||||
color := isTTYOut(cmd)
|
||||
for _, diffFn := range []func() ([]byte, error){
|
||||
func() ([]byte, error) { return diffBytes(sshConfigFile, configRaw, configModified, color) },
|
||||
func() ([]byte, error) { return diffBytes(coderConfigFile, coderConfigRaw, buf.Bytes(), color) },
|
||||
} {
|
||||
diff, err := diffFn()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("diff failed: %w", err)
|
||||
}
|
||||
if len(diff) > 0 {
|
||||
filesDiffer = true
|
||||
// Always write to stdout.
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n%s", diff)
|
||||
}
|
||||
diff, err := diffBytes(sshConfigFile, configRaw, configModified, color)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("diff failed: %w", err)
|
||||
}
|
||||
if len(diff) > 0 {
|
||||
// Write diff to stdout.
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s", diff)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(changes) > 0 {
|
||||
// In diff mode we don't prompt re-using the previous
|
||||
// configuration, so we output the entire command.
|
||||
diffCommand := fmt.Sprintf("$ %s %s", cmd.CommandPath(), strings.Join(append(coderConfig.asArgs(), "--diff"), " "))
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("The following changes will be made to your SSH configuration:\n\n * %s\n\n To see changes, run diff:\n\n %s\n\n Continue?", strings.Join(changes, "\n * "), diffCommand),
|
||||
Text: fmt.Sprintf("The following changes will be made to your SSH configuration:\n\n * %s\n\n Continue?", strings.Join(changes, "\n * ")),
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
_, _ = fmt.Fprint(out, "\n")
|
||||
|
||||
if !bytes.Equal(configRaw, configModified) {
|
||||
err = writeWithTempFileAndMove(sshConfigFile, bytes.NewReader(configModified))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write ssh config failed: %w", err)
|
||||
}
|
||||
// Only print when prompts are shown.
|
||||
if yes, _ := cmd.Flags().GetBool("yes"); !yes {
|
||||
_, _ = fmt.Fprint(out, "\n")
|
||||
}
|
||||
if modifyCoderConfig {
|
||||
err := writeWithTempFileAndMove(coderConfigFile, buf)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write coder ssh config failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !bytes.Equal(configRaw, configModified) {
|
||||
err = writeWithTempFileAndMove(sshConfigFile, bytes.NewReader(configModified))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write ssh config failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(workspaceConfigs) > 0 {
|
||||
_, _ = fmt.Fprintln(out, "You should now be able to ssh into your workspace.")
|
||||
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh coder.%s\n\n", workspaceConfigs[0].Name)
|
||||
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh coder.%s\n", workspaceConfigs[0].Name)
|
||||
} else {
|
||||
_, _ = fmt.Fprint(out, "You don't have any workspaces yet, try creating one with:\n\n\t$ coder create <workspace>\n\n")
|
||||
_, _ = fmt.Fprint(out, "You don't have any workspaces yet, try creating one with:\n\n\t$ coder create <workspace>\n")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cliflag.StringVarP(cmd.Flags(), &coderConfig.sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", sshDefaultConfigFileName, "Specifies the path to an SSH config.")
|
||||
cmd.Flags().StringVar(&coderConfig.sshConfigDefaultFile, "test.default-ssh-config-file", sshDefaultConfigFileName, "Specifies the default path to the SSH config file. Useful for testing.")
|
||||
_ = cmd.Flags().MarkHidden("test.default-ssh-config-file")
|
||||
cmd.Flags().StringVar(&coderConfigFile, "test.ssh-coder-config-file", sshDefaultCoderConfigFileName, "Specifies the path to an Coder SSH config file. Useful for testing.")
|
||||
_ = cmd.Flags().MarkHidden("test.ssh-coder-config-file")
|
||||
cmd.Flags().StringArrayVarP(&coderConfig.sshOptions, "ssh-option", "o", []string{}, "Specifies additional SSH options to embed in each host stanza.")
|
||||
cmd.Flags().BoolVarP(&showDiff, "diff", "D", false, "Show diff of changes that will be made.")
|
||||
cliflag.StringVarP(cmd.Flags(), &sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", sshDefaultConfigFileName, "Specifies the path to an SSH config.")
|
||||
cmd.Flags().StringArrayVarP(&sshConfigOpts.sshOptions, "ssh-option", "o", []string{}, "Specifies additional SSH options to embed in each host stanza.")
|
||||
cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Perform a trial run with no changes made, showing a diff at the end.")
|
||||
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")
|
||||
cliflag.BoolVarP(cmd.Flags(), &usePreviousOpts, "use-previous-options", "", "CODER_SSH_USE_PREVIOUS_OPTIONS", false, "Specifies whether or not to keep options from previous run of config-ssh.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &wireguard, "wireguard", "", "CODER_CONFIG_SSH_WIREGUARD", false, "Whether to use Wireguard for SSH tunneling.")
|
||||
_ = cmd.Flags().MarkHidden("wireguard")
|
||||
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// sshConfigAddCoderInclude checks for the coder Include statement and
|
||||
// returns modified = true if it was added.
|
||||
func sshConfigAddCoderInclude(data []byte) (modifiedData []byte, modified bool) {
|
||||
valid := false
|
||||
firstHost := sshHostRe.FindIndex(data)
|
||||
coderInclude := sshCoderIncludedRe.FindIndex(data)
|
||||
if firstHost != nil && coderInclude != nil {
|
||||
// If the Coder Include statement exists
|
||||
// before a Host entry, we're good.
|
||||
valid = coderInclude[1] < firstHost[0]
|
||||
if !valid {
|
||||
// Remove invalid Include statement.
|
||||
d := append([]byte{}, data[:coderInclude[0]]...)
|
||||
d = append(d, data[coderInclude[1]:]...)
|
||||
data = d
|
||||
//nolint:revive
|
||||
func sshConfigWriteSectionHeader(w io.Writer, addNewline bool, o sshConfigOptions) {
|
||||
nl := "\n"
|
||||
if !addNewline {
|
||||
nl = ""
|
||||
}
|
||||
_, _ = fmt.Fprint(w, nl+sshStartToken+"\n")
|
||||
_, _ = fmt.Fprint(w, sshConfigSectionHeader)
|
||||
_, _ = fmt.Fprint(w, sshConfigDocsHeader)
|
||||
if len(o.sshOptions) > 0 {
|
||||
_, _ = fmt.Fprint(w, sshConfigOptionsHeader)
|
||||
for _, opt := range o.sshOptions {
|
||||
_, _ = fmt.Fprintf(w, "# :%s=%s\n", "ssh-option", opt)
|
||||
}
|
||||
} else if coderInclude != nil {
|
||||
valid = true
|
||||
}
|
||||
if valid {
|
||||
return data, false
|
||||
}
|
||||
|
||||
// Add Include statement to the top of SSH config.
|
||||
// The user is allowed to move it as long as it
|
||||
// stays above the first Host (or Match) statement.
|
||||
sep := "\n\n"
|
||||
if len(data) == 0 {
|
||||
// If SSH config is empty, a single newline will suffice.
|
||||
sep = "\n"
|
||||
}
|
||||
data = append([]byte(sshConfigIncludeStatement+sep), data...)
|
||||
|
||||
return data, true
|
||||
}
|
||||
|
||||
func sshCoderConfigWriteHeader(w io.Writer, o sshCoderConfigOptions) error {
|
||||
_, _ = fmt.Fprint(w, sshCoderConfigHeader)
|
||||
_, _ = fmt.Fprint(w, sshCoderConfigDocsHeader)
|
||||
_, _ = fmt.Fprint(w, sshCoderConfigOptionsHeader)
|
||||
if o.sshConfigFile != o.sshConfigDefaultFile {
|
||||
_, _ = fmt.Fprintf(w, "# :%s=%s\n", "ssh-config-file", o.sshConfigFile)
|
||||
}
|
||||
for _, opt := range o.sshOptions {
|
||||
_, _ = fmt.Fprintf(w, "# :%s=%s\n", "ssh-option", opt)
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "#\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func sshCoderConfigParseLastOptions(r io.Reader, sshConfigDefaultFile string) (o sshCoderConfigOptions) {
|
||||
o.sshConfigDefaultFile = sshConfigDefaultFile
|
||||
o.sshConfigFile = sshConfigDefaultFile // Default value is not written.
|
||||
func sshConfigWriteSectionEnd(w io.Writer) {
|
||||
_, _ = fmt.Fprint(w, sshEndToken+"\n")
|
||||
}
|
||||
|
||||
func sshConfigParseLastOptions(r io.Reader) (o sshConfigOptions) {
|
||||
s := bufio.NewScanner(r)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
@@ -488,8 +397,6 @@ func sshCoderConfigParseLastOptions(r io.Reader, sshConfigDefaultFile string) (o
|
||||
line = strings.TrimPrefix(line, "# :")
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
switch parts[0] {
|
||||
case "ssh-config-file":
|
||||
o.sshConfigFile = parts[1]
|
||||
case "ssh-option":
|
||||
o.sshOptions = append(o.sshOptions, parts[1])
|
||||
default:
|
||||
@@ -504,6 +411,38 @@ func sshCoderConfigParseLastOptions(r io.Reader, sshConfigDefaultFile string) (o
|
||||
return o
|
||||
}
|
||||
|
||||
func sshConfigGetCoderSection(data []byte) (section []byte, ok bool) {
|
||||
startIndex := bytes.Index(data, []byte(sshStartToken))
|
||||
endIndex := bytes.Index(data, []byte(sshEndToken))
|
||||
if startIndex != -1 && endIndex != -1 {
|
||||
return data[startIndex : endIndex+len(sshEndToken)], true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// sshConfigSplitOnCoderSection splits the SSH config into two sections,
|
||||
// before contains the lines before sshStartToken and after contains the
|
||||
// lines after sshEndToken.
|
||||
func sshConfigSplitOnCoderSection(data []byte) (before, after []byte) {
|
||||
startIndex := bytes.Index(data, []byte(sshStartToken))
|
||||
endIndex := bytes.Index(data, []byte(sshEndToken))
|
||||
if startIndex != -1 && endIndex != -1 {
|
||||
// We use -1 and +1 here to also include the preceding
|
||||
// and trailing newline, where applicable.
|
||||
start := startIndex
|
||||
if start > 0 {
|
||||
start--
|
||||
}
|
||||
end := endIndex + len(sshEndToken)
|
||||
if end < len(data) {
|
||||
end++
|
||||
}
|
||||
return data[:start], data[end:]
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// writeWithTempFileAndMove writes to a temporary file in the same
|
||||
// directory as path and renames the temp file to the file provided in
|
||||
// path. This ensure we avoid trashing the file we are writing due to
|
||||
@@ -583,19 +522,19 @@ func currentBinPath(w io.Writer) (string, error) {
|
||||
_, _ = fmt.Fprint(w, "\n")
|
||||
}
|
||||
|
||||
return binName, nil
|
||||
return exePath, nil
|
||||
}
|
||||
|
||||
// diffBytes takes two byte slices and diffs them as if they were in a
|
||||
// file named name.
|
||||
//nolint: revive // Color is an option, not a control coupling.
|
||||
// nolint: revive // Color is an option, not a control coupling.
|
||||
func diffBytes(name string, b1, b2 []byte, color bool) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
var opts []write.Option
|
||||
if color {
|
||||
opts = append(opts, write.TerminalColor())
|
||||
}
|
||||
err := diff.Text(name, name+".new", b1, b2, &buf, opts...)
|
||||
err := diff.Text(name, name, b1, b2, &buf, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -604,28 +543,9 @@ func diffBytes(name string, b1, b2 []byte, color bool) ([]byte, error) {
|
||||
//
|
||||
// Example:
|
||||
// --- /home/user/.ssh/config
|
||||
// +++ /home/user/.ssh/config.new
|
||||
// +++ /home/user/.ssh/config
|
||||
if bytes.Count(b, []byte{'\n'}) == 2 {
|
||||
b = nil
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// stripOldConfigBlock is here to migrate users from old config block
|
||||
// format to new include statement.
|
||||
func stripOldConfigBlock(data []byte) ([]byte, bool) {
|
||||
const (
|
||||
sshStartToken = "# ------------START-CODER-----------"
|
||||
sshEndToken = "# ------------END-CODER------------"
|
||||
)
|
||||
|
||||
startIndex := bytes.Index(data, []byte(sshStartToken))
|
||||
endIndex := bytes.Index(data, []byte(sshEndToken))
|
||||
if startIndex != -1 && endIndex != -1 {
|
||||
newdata := append([]byte{}, data[:startIndex-1]...)
|
||||
newdata = append(newdata, data[endIndex+len(sshEndToken):]...)
|
||||
return newdata, true
|
||||
}
|
||||
|
||||
return data, false
|
||||
}
|
||||
|
||||
+398
-204
@@ -1,6 +1,8 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -27,15 +29,14 @@ import (
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func sshConfigFileNames(t *testing.T) (sshConfig string, coderConfig string) {
|
||||
func sshConfigFileName(t *testing.T) (sshConfig string) {
|
||||
t.Helper()
|
||||
tmpdir := t.TempDir()
|
||||
dotssh := filepath.Join(tmpdir, ".ssh")
|
||||
err := os.Mkdir(dotssh, 0o700)
|
||||
require.NoError(t, err)
|
||||
n1 := filepath.Join(dotssh, "config")
|
||||
n2 := filepath.Join(dotssh, "coder")
|
||||
return n1, n2
|
||||
n := filepath.Join(dotssh, "config")
|
||||
return n
|
||||
}
|
||||
|
||||
func sshConfigFileCreate(t *testing.T, name string, data io.Reader) {
|
||||
@@ -106,9 +107,9 @@ func TestConfigSSH(t *testing.T) {
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
defer func() {
|
||||
_ = agentCloser.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)
|
||||
@@ -116,9 +117,9 @@ func TestConfigSSH(t *testing.T) {
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
defer func() {
|
||||
_ = listener.Close()
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
@@ -131,11 +132,8 @@ func TestConfigSSH(t *testing.T) {
|
||||
go io.Copy(ssh, conn)
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
})
|
||||
|
||||
sshConfigFile, coderConfigFile := sshConfigFileNames(t)
|
||||
sshConfigFile := sshConfigFileName(t)
|
||||
|
||||
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
|
||||
require.True(t, valid)
|
||||
@@ -143,7 +141,6 @@ func TestConfigSSH(t *testing.T) {
|
||||
"--ssh-option", "HostName "+tcpAddr.IP.String(),
|
||||
"--ssh-option", "Port "+strconv.Itoa(tcpAddr.Port),
|
||||
"--ssh-config-file", sshConfigFile,
|
||||
"--test.ssh-coder-config-file", coderConfigFile,
|
||||
"--skip-proxy-command")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
@@ -171,9 +168,10 @@ func TestConfigSSH(t *testing.T) {
|
||||
home := filepath.Dir(filepath.Dir(sshConfigFile))
|
||||
// #nosec
|
||||
sshCmd := exec.Command("ssh", "-F", sshConfigFile, "coder."+workspace.Name, "echo", "test")
|
||||
pty = ptytest.New(t)
|
||||
// Set HOME because coder config is included from ~/.ssh/coder.
|
||||
sshCmd.Env = append(sshCmd.Env, fmt.Sprintf("HOME=%s", home))
|
||||
sshCmd.Stderr = os.Stderr
|
||||
sshCmd.Stderr = pty.Output()
|
||||
data, err := sshCmd.Output()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", strings.TrimSpace(string(data)))
|
||||
@@ -182,14 +180,25 @@ func TestConfigSSH(t *testing.T) {
|
||||
func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
headerStart := strings.Join([]string{
|
||||
"# ------------START-CODER-----------",
|
||||
"# This section is managed by coder. DO NOT EDIT.",
|
||||
"#",
|
||||
"# You should not hand-edit this section unless you are removing it, all",
|
||||
"# changes will be lost when running \"coder config-ssh\".",
|
||||
"#",
|
||||
}, "\n")
|
||||
headerEnd := "# ------------END-CODER------------"
|
||||
baseHeader := strings.Join([]string{
|
||||
headerStart,
|
||||
headerEnd,
|
||||
}, "\n")
|
||||
|
||||
type writeConfig struct {
|
||||
ssh string
|
||||
coder string
|
||||
ssh string
|
||||
}
|
||||
type wantConfig struct {
|
||||
ssh string
|
||||
coder string
|
||||
coderPartial bool
|
||||
ssh string
|
||||
}
|
||||
type match struct {
|
||||
match, write string
|
||||
@@ -203,63 +212,30 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Config files are created",
|
||||
name: "Config file is created",
|
||||
matches: []match{
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Include coder",
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
coder: "# This file is managed by coder. DO NOT EDIT.",
|
||||
coderPartial: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Include is written to top of ssh config",
|
||||
name: "Section is written after user content",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"# This is a host",
|
||||
"Host test",
|
||||
" HostName test",
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Include coder",
|
||||
"",
|
||||
"# This is a host",
|
||||
"Host test",
|
||||
" HostName test",
|
||||
}, "\n"),
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Include below Host is invalid, move it to the top",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Host test",
|
||||
" HostName test",
|
||||
"",
|
||||
"Include coder",
|
||||
"",
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Include coder",
|
||||
"",
|
||||
"Host test",
|
||||
" HostName test",
|
||||
"",
|
||||
// Only "Include coder" with accompanying
|
||||
// newline is removed.
|
||||
"",
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
@@ -268,136 +244,64 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Included file must be named exactly coder, otherwise leave as-is",
|
||||
name: "Section is not moved on re-run",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Host test",
|
||||
" HostName test",
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
"",
|
||||
"Include coders",
|
||||
baseHeader,
|
||||
"",
|
||||
"Host otherhost",
|
||||
" HostName otherhost",
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Include coder",
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
"",
|
||||
"Host test",
|
||||
" HostName test",
|
||||
baseHeader,
|
||||
"",
|
||||
"Include coders",
|
||||
"Host otherhost",
|
||||
" HostName otherhost",
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Second file added, Include(s) left as-is, new one on top",
|
||||
name: "Section is not moved on re-run with new options",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Host test",
|
||||
" HostName test",
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
"",
|
||||
"Include coder other",
|
||||
"Include other coder",
|
||||
baseHeader,
|
||||
"",
|
||||
"Host otherhost",
|
||||
" HostName otherhost",
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Include coder",
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
"",
|
||||
"Host test",
|
||||
" HostName test",
|
||||
"",
|
||||
"Include coder other",
|
||||
"Include other coder",
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Comment added, Include left as-is, new one on top",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Host test",
|
||||
" HostName test",
|
||||
"",
|
||||
"Include coder # comment",
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Include coder",
|
||||
"",
|
||||
"Host test",
|
||||
" HostName test",
|
||||
"",
|
||||
"Include coder # comment",
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SSH Config does not need modification",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Include something/other",
|
||||
"Include coder",
|
||||
"",
|
||||
"# This is a host",
|
||||
"Host test",
|
||||
" HostName test",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Include something/other",
|
||||
"Include coder",
|
||||
"",
|
||||
"# This is a host",
|
||||
"Host test",
|
||||
" HostName test",
|
||||
}, "\n"),
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "When options differ, selecting yes overwrites previous options",
|
||||
writeConfig: writeConfig{
|
||||
coder: strings.Join([]string{
|
||||
"# This file is managed by coder. DO NOT EDIT.",
|
||||
"#",
|
||||
"# You should not hand-edit this file, all changes will be lost when running",
|
||||
"# \"coder config-ssh\".",
|
||||
"#",
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
"Host otherhost",
|
||||
" HostName otherhost",
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
coder: strings.Join([]string{
|
||||
"# This file is managed by coder. DO NOT EDIT.",
|
||||
"#",
|
||||
"# You should not hand-edit this file, all changes will be lost when running",
|
||||
"# \"coder config-ssh\".",
|
||||
"#",
|
||||
"# Last config-ssh options:",
|
||||
"#",
|
||||
}, "\n"),
|
||||
coderPartial: true,
|
||||
args: []string{
|
||||
"--ssh-option", "ForwardAgent=yes",
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Use new options?", write: "yes"},
|
||||
@@ -405,52 +309,206 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "When options differ, selecting no preserves previous options",
|
||||
name: "Adds newline at EOF",
|
||||
writeConfig: writeConfig{
|
||||
coder: strings.Join([]string{
|
||||
"# This file is managed by coder. DO NOT EDIT.",
|
||||
"#",
|
||||
"# You should not hand-edit this file, all changes will be lost when running",
|
||||
"# \"coder config-ssh\".",
|
||||
"#",
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
ssh: strings.Join([]string{
|
||||
baseHeader,
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
coder: strings.Join([]string{
|
||||
"# This file is managed by coder. DO NOT EDIT.",
|
||||
"#",
|
||||
"# You should not hand-edit this file, all changes will be lost when running",
|
||||
"# \"coder config-ssh\".",
|
||||
"#",
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
ssh: strings.Join([]string{
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
coderPartial: true,
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Use new options?", write: "no"},
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Do not overwrite unknown coder config",
|
||||
name: "Do not prompt for new options on first run",
|
||||
writeConfig: writeConfig{
|
||||
coder: strings.Join([]string{
|
||||
"We're no strangers to love",
|
||||
"You know the rules and so do I (do I)",
|
||||
ssh: "",
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
args: []string{"--ssh-option", "ForwardAgent=yes"},
|
||||
matches: []match{
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Prompt for new options when there are no previous options",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
baseHeader,
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
coder: strings.Join([]string{
|
||||
"We're no strangers to love",
|
||||
"You know the rules and so do I (do I)",
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantErr: true,
|
||||
args: []string{"--ssh-option", "ForwardAgent=yes"},
|
||||
matches: []match{
|
||||
{match: "Use new options?", write: "yes"},
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Prompt for new options when there are previous options",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Use new options?", write: "yes"},
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "No prompt on no changes",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
args: []string{"--ssh-option", "ForwardAgent=yes"},
|
||||
},
|
||||
{
|
||||
name: "No changes when continue = no",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
args: []string{"--ssh-option", "ForwardAgent=no"},
|
||||
matches: []match{
|
||||
{match: "Use new options?", write: "yes"},
|
||||
{match: "Continue?", write: "no"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Do not prompt when using --yes",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
// Last options overwritten.
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
args: []string{"--yes"},
|
||||
},
|
||||
{
|
||||
name: "Do not prompt for new options when prev opts flag is set",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
args: []string{
|
||||
"--use-previous-options",
|
||||
"--yes",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Do not overwrite config when using --dry-run",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
args: []string{
|
||||
"--ssh-option", "ForwardAgent=yes",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -469,19 +527,14 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
)
|
||||
|
||||
// Prepare ssh config files.
|
||||
sshConfigName, coderConfigName := sshConfigFileNames(t)
|
||||
sshConfigName := sshConfigFileName(t)
|
||||
if tt.writeConfig.ssh != "" {
|
||||
sshConfigFileCreate(t, sshConfigName, strings.NewReader(tt.writeConfig.ssh))
|
||||
}
|
||||
if tt.writeConfig.coder != "" {
|
||||
sshConfigFileCreate(t, coderConfigName, strings.NewReader(tt.writeConfig.coder))
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"config-ssh",
|
||||
"--ssh-config-file", sshConfigName,
|
||||
"--test.default-ssh-config-file", sshConfigName,
|
||||
"--test.ssh-coder-config-file", coderConfigName,
|
||||
}
|
||||
args = append(args, tt.args...)
|
||||
cmd, root := clitest.New(t, args...)
|
||||
@@ -510,14 +563,155 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
got := sshConfigFileRead(t, sshConfigName)
|
||||
assert.Equal(t, tt.wantConfig.ssh, got)
|
||||
}
|
||||
if tt.wantConfig.coder != "" {
|
||||
got := sshConfigFileRead(t, coderConfigName)
|
||||
if tt.wantConfig.coderPartial {
|
||||
assert.Contains(t, got, tt.wantConfig.coder)
|
||||
} else {
|
||||
assert.Equal(t, tt.wantConfig.coder, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSSH_Hostnames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type resourceSpec struct {
|
||||
name string
|
||||
agents []string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
resources []resourceSpec
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "one resource with one agent",
|
||||
resources: []resourceSpec{
|
||||
{name: "foo", agents: []string{"agent1"}},
|
||||
},
|
||||
expected: []string{"coder.@", "coder.@.agent1"},
|
||||
},
|
||||
{
|
||||
name: "one resource with two agents",
|
||||
resources: []resourceSpec{
|
||||
{name: "foo", agents: []string{"agent1", "agent2"}},
|
||||
},
|
||||
expected: []string{"coder.@.agent1", "coder.@.agent2"},
|
||||
},
|
||||
{
|
||||
name: "two resources with one agent",
|
||||
resources: []resourceSpec{
|
||||
{name: "foo", agents: []string{"agent1"}},
|
||||
{name: "bar"},
|
||||
},
|
||||
expected: []string{"coder.@", "coder.@.agent1"},
|
||||
},
|
||||
{
|
||||
name: "two resources with two agents",
|
||||
resources: []resourceSpec{
|
||||
{name: "foo", agents: []string{"agent1"}},
|
||||
{name: "bar", agents: []string{"agent2"}},
|
||||
},
|
||||
expected: []string{"coder.@.agent1", "coder.@.agent2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var resources []*proto.Resource
|
||||
for _, resourceSpec := range tt.resources {
|
||||
resource := &proto.Resource{
|
||||
Name: resourceSpec.name,
|
||||
Type: "aws_instance",
|
||||
}
|
||||
for _, agentName := range resourceSpec.agents {
|
||||
resource.Agents = append(resource.Agents, &proto.Agent{
|
||||
Id: uuid.NewString(),
|
||||
Name: agentName,
|
||||
})
|
||||
}
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
|
||||
provisionResponse := []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: resources,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
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: provisionResponse,
|
||||
Provision: provisionResponse,
|
||||
})
|
||||
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)
|
||||
|
||||
sshConfigFile := sshConfigFileName(t)
|
||||
|
||||
cmd, root := clitest.New(t, "config-ssh", "--ssh-config-file", sshConfigFile)
|
||||
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 := []struct {
|
||||
match, write string
|
||||
}{
|
||||
{match: "Continue?", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
<-doneChan
|
||||
|
||||
var expectedHosts []string
|
||||
for _, hostnamePattern := range tt.expected {
|
||||
hostname := strings.ReplaceAll(hostnamePattern, "@", workspace.Name)
|
||||
expectedHosts = append(expectedHosts, hostname)
|
||||
}
|
||||
|
||||
hosts := sshConfigFileParseHosts(t, sshConfigFile)
|
||||
require.ElementsMatch(t, expectedHosts, hosts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sshConfigFileParseHosts reads a file in the format of .ssh/config and extracts
|
||||
// the hostnames that are listed in "Host" directives.
|
||||
func sshConfigFileParseHosts(t *testing.T, name string) []string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(name)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result []string
|
||||
lineScanner := bufio.NewScanner(bytes.NewBuffer(b))
|
||||
for lineScanner.Scan() {
|
||||
line := lineScanner.Text()
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
tokenScanner := bufio.NewScanner(bytes.NewBufferString(line))
|
||||
tokenScanner.Split(bufio.ScanWords)
|
||||
ok := tokenScanner.Scan()
|
||||
if ok && tokenScanner.Text() == "Host" {
|
||||
for tokenScanner.Scan() {
|
||||
result = append(result, tokenScanner.Text())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package cli
|
||||
|
||||
const (
|
||||
timeFormat = "3:04PM MST"
|
||||
dateFormat = "Jan 2, 2006"
|
||||
)
|
||||
+127
-134
@@ -10,28 +10,24 @@ import (
|
||||
|
||||
"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
|
||||
parameterFile string
|
||||
templateName string
|
||||
startAt string
|
||||
stopAfter time.Duration
|
||||
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)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -49,7 +45,7 @@ func create() *cobra.Command {
|
||||
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, codersdk.WorkspaceByOwnerAndNameParams{})
|
||||
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
||||
}
|
||||
@@ -61,7 +57,7 @@ func create() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceByOwnerAndNameParams{})
|
||||
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
||||
}
|
||||
@@ -115,104 +111,20 @@ func create() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
schedSpec, err := validSchedule(
|
||||
autostartMinute,
|
||||
autostartHour,
|
||||
autostartDow,
|
||||
tzName,
|
||||
time.Duration(template.MinAutostartIntervalMillis)*time.Millisecond,
|
||||
)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("Invalid autostart schedule: %w", err)
|
||||
}
|
||||
if ttl < time.Minute {
|
||||
return xerrors.Errorf("TTL must be at least 1 minute")
|
||||
}
|
||||
if ttlMax := time.Duration(template.MaxTTLMillis) * time.Millisecond; ttl > ttlMax {
|
||||
return xerrors.Errorf("TTL must be below template maximum %s", ttlMax)
|
||||
}
|
||||
|
||||
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)
|
||||
var schedSpec *string
|
||||
if startAt != "" {
|
||||
sched, err := parseCLISchedule(startAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
schedSpec = ptr.Ref(sched.String())
|
||||
}
|
||||
|
||||
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",
|
||||
parameters, err := prepWorkspaceBuild(cmd, client, prepWorkspaceBuildArgs{
|
||||
Template: template,
|
||||
ExistingParams: []codersdk.Parameter{},
|
||||
ParameterFile: parameterFile,
|
||||
NewWorkspaceName: workspaceName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -226,11 +138,12 @@ func create() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
after := time.Now()
|
||||
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: workspaceName,
|
||||
AutostartSchedule: schedSpec,
|
||||
TTLMillis: ptr.Ref(ttl.Milliseconds()),
|
||||
TTLMillis: ptr.Ref(stopAfter.Milliseconds()),
|
||||
ParameterValues: parameters,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -242,19 +155,7 @@ func create() *cobra.Command {
|
||||
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))
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been created at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -262,30 +163,122 @@ func create() *cobra.Command {
|
||||
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", "UTC", "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).")
|
||||
cliflag.StringVarP(cmd.Flags(), &startAt, "start-at", "", "CODER_WORKSPACE_START_AT", "", "Specify the workspace autostart schedule. Check `coder schedule start --help` for the syntax.")
|
||||
cliflag.DurationVarP(cmd.Flags(), &stopAfter, "stop-after", "", "CODER_WORKSPACE_STOP_AFTER", 8*time.Hour, "Specify a duration after which the workspace should shut down (e.g. 8h).")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func validSchedule(minute, hour, dow, tzName string, min time.Duration) (*string, error) {
|
||||
_, err := time.LoadLocation(tzName)
|
||||
type prepWorkspaceBuildArgs struct {
|
||||
Template codersdk.Template
|
||||
ExistingParams []codersdk.Parameter
|
||||
ParameterFile string
|
||||
NewWorkspaceName string
|
||||
}
|
||||
|
||||
// prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version.
|
||||
// Any missing params will be prompted to the user.
|
||||
func prepWorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.CreateParameterRequest, error) {
|
||||
ctx := cmd.Context()
|
||||
templateVersion, err := client.TemplateVersion(ctx, args.Template.ActiveVersionID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("Invalid workspace autostart timezone: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
schedSpec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", tzName, minute, hour, dow)
|
||||
|
||||
sched, err := schedule.Weekly(schedSpec)
|
||||
parameterSchemas, err := client.TemplateVersionSchema(ctx, templateVersion.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if schedMin := sched.Min(); schedMin < min {
|
||||
return nil, xerrors.Errorf("minimum autostart interval %s is above template constraint %s", schedMin, min)
|
||||
// parameterMapFromFile can be nil if parameter file is not specified
|
||||
var parameterMapFromFile map[string]string
|
||||
useParamFile := false
|
||||
if args.ParameterFile != "" {
|
||||
useParamFile = true
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
|
||||
parameterMapFromFile, err = createParameterMapFromFile(args.ParameterFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
disclaimerPrinted := false
|
||||
parameters := make([]codersdk.CreateParameterRequest, 0)
|
||||
PromptParamLoop:
|
||||
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
|
||||
}
|
||||
|
||||
// Param file is all or nothing
|
||||
if !useParamFile {
|
||||
for _, e := range args.ExistingParams {
|
||||
if e.Name == parameterSchema.Name {
|
||||
// If the param already exists, we do not need to prompt it again.
|
||||
// The workspace scope will reuse params for each build.
|
||||
continue PromptParamLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parameterValue, err := getParameterValueFromMapOrInput(cmd, parameterMapFromFile, parameterSchema)
|
||||
if err != nil {
|
||||
return nil, 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: args.NewWorkspaceName,
|
||||
ParameterValues: parameters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 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 nil, xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
|
||||
return &schedSpec, nil
|
||||
resources, err := client.TemplateVersionDryRunResources(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace dry-run resources: %w", err)
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
// Since agents haven't connected yet, hiding this makes more sense.
|
||||
HideAgentState: true,
|
||||
Title: "Workspace Preview",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parameters, nil
|
||||
}
|
||||
|
||||
+56
-132
@@ -2,7 +2,6 @@ package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
@@ -13,12 +12,11 @@ import (
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
@@ -27,18 +25,19 @@ func TestCreate(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)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: provisionCompleteWithAgent,
|
||||
ProvisionDryRun: provisionCompleteWithAgent,
|
||||
})
|
||||
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",
|
||||
"--start-at", "9:30AM Mon-Fri US/Central",
|
||||
"--stop-after", "8h",
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
@@ -51,113 +50,32 @@ func TestCreate(t *testing.T) {
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
matches := []string{
|
||||
"Confirm create", "yes",
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "compute.main"},
|
||||
{match: "smith (linux, i386)"},
|
||||
{match: "Confirm create", write: "yes"},
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
if len(m.write) > 0 {
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("AboveTemplateMaxTTL", 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, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
ctr.MaxTTLMillis = ptr.Ref((12 * time.Hour).Milliseconds())
|
||||
})
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"--template", template.Name,
|
||||
"--ttl", "12h1m",
|
||||
"-y", // don't bother with waiting
|
||||
ws, err := client.WorkspaceByOwnerAndName(context.Background(), "testuser", "my-workspace", codersdk.WorkspaceOptions{})
|
||||
if assert.NoError(t, err, "expected workspace to be created") {
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
if assert.NotNil(t, ws.AutostartSchedule) {
|
||||
assert.Equal(t, *ws.AutostartSchedule, "CRON_TZ=US/Central 30 9 * * Mon-Fri")
|
||||
}
|
||||
if assert.NotNil(t, ws.TTLMillis) {
|
||||
assert.Equal(t, *ws.TTLMillis, 8*time.Hour.Milliseconds())
|
||||
}
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
err := cmd.Execute()
|
||||
assert.ErrorContains(t, err, "TTL must be below template maximum 12h0m0s")
|
||||
})
|
||||
|
||||
t.Run("BelowTemplateMinAutostartInterval", 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, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
|
||||
})
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"--template", template.Name,
|
||||
"--autostart-minute", "*", // Every minute
|
||||
"--autostart-hour", "*", // Every hour
|
||||
"-y", // don't bother with waiting
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
err := cmd.Execute()
|
||||
assert.ErrorContains(t, err, "minimum autostart interval 1m0s is above template constraint 1h0m0s")
|
||||
})
|
||||
|
||||
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",
|
||||
"-y",
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
err := cmd.Execute()
|
||||
assert.ErrorContains(t, err, "Invalid autostart schedule: Invalid workspace autostart timezone: unknown time zone invalid")
|
||||
})
|
||||
|
||||
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",
|
||||
"-y",
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
err := cmd.Execute()
|
||||
assert.EqualError(t, err, "TTL must be at least 1 minute")
|
||||
})
|
||||
|
||||
t.Run("CreateFromListWithSkip", func(t *testing.T) {
|
||||
@@ -171,7 +89,7 @@ func TestCreate(t *testing.T) {
|
||||
|
||||
member := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
cmdCtx, done := context.WithTimeout(context.Background(), time.Second*3)
|
||||
cmdCtx, done := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
go func() {
|
||||
defer done()
|
||||
err := cmd.ExecuteContext(cmdCtx)
|
||||
@@ -188,7 +106,7 @@ func TestCreate(t *testing.T) {
|
||||
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)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "create", "")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
@@ -211,6 +129,12 @@ func TestCreate(t *testing.T) {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
|
||||
ws, err := client.WorkspaceByOwnerAndName(cmd.Context(), "testuser", "my-workspace", codersdk.WorkspaceOptions{})
|
||||
if assert.NoError(t, err, "expected workspace to be created") {
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
assert.Nil(t, ws.AutostartSchedule, "expected workspace autostart schedule to be nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WithParameter", func(t *testing.T) {
|
||||
@@ -269,6 +193,7 @@ func TestCreate(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("region: \"bingo\"\nusername: \"boingo\"")
|
||||
cmd, root := clitest.New(t, "create", "", "--parameter-file", parameterFile.Name())
|
||||
@@ -294,7 +219,6 @@ func TestCreate(t *testing.T) {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
removeTmpDirUntilSuccess(t, tempDir)
|
||||
})
|
||||
|
||||
t.Run("WithParameterFileNotContainingTheValue", func(t *testing.T) {
|
||||
@@ -311,6 +235,7 @@ func TestCreate(t *testing.T) {
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(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())
|
||||
@@ -325,43 +250,42 @@ func TestCreate(t *testing.T) {
|
||||
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})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Parse: []*proto.Parse_Response{{
|
||||
Type: &proto.Parse_Response_Complete{
|
||||
Complete: &proto.Parse_Complete{
|
||||
ParameterSchemas: echo.ParameterSuccess,
|
||||
},
|
||||
},
|
||||
}},
|
||||
ProvisionDryRun: []*proto.Provision_Response{
|
||||
{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Error: "test error",
|
||||
},
|
||||
Complete: &proto.Provision_Complete{},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tempDir := t.TempDir()
|
||||
parameterFile, err := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
require.NoError(t, err)
|
||||
defer parameterFile.Close()
|
||||
_, _ = parameterFile.WriteString(fmt.Sprintf("%s: %q", echo.ParameterExecKey, echo.ParameterError("fail")))
|
||||
|
||||
// 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")
|
||||
require.Equal(t, codersdk.ProvisionerJobSucceeded, version.Job.Status, "job is not failed")
|
||||
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
cmd, root := clitest.New(t, "create", "test")
|
||||
cmd, root := clitest.New(t, "create", "test", "--parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
|
||||
+12
-3
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// nolint
|
||||
func delete() *cobra.Command {
|
||||
func deleteWorkspace() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "delete <workspace>",
|
||||
@@ -21,12 +22,13 @@ func delete() *cobra.Command {
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm delete workspace?",
|
||||
IsConfirm: true,
|
||||
Default: cliui.ConfirmNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -41,7 +43,14 @@ func delete() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been deleted at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("WithParameter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
|
||||
+10
-7
@@ -18,14 +18,17 @@ import (
|
||||
)
|
||||
|
||||
func dotfiles() *cobra.Command {
|
||||
var (
|
||||
symlinkDir string
|
||||
)
|
||||
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",
|
||||
Use: "dotfiles [git_repo_url]",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Check out and install a dotfiles repository.",
|
||||
Example: formatExamples(
|
||||
example{
|
||||
Description: "Check out and install a dotfiles repository without prompts",
|
||||
Command: "coder dotfiles --yes git@github.com:example/dotfiles.git",
|
||||
},
|
||||
),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var (
|
||||
dotfilesRepoDir = "dotfiles"
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
var featureColumns = []string{"Name", "Entitlement", "Enabled", "Limit", "Actual"}
|
||||
|
||||
func features() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Short: "List features",
|
||||
Use: "features",
|
||||
Aliases: []string{"feature"},
|
||||
}
|
||||
cmd.AddCommand(
|
||||
featuresList(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func featuresList() *cobra.Command {
|
||||
var (
|
||||
columns []string
|
||||
outputFormat string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entitlements, err := client.Entitlements(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := ""
|
||||
switch outputFormat {
|
||||
case "table", "":
|
||||
out, err = displayFeatures(columns, entitlements.Features)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("render table: %w", err)
|
||||
}
|
||||
case "json":
|
||||
outBytes, err := json.Marshal(entitlements)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("marshal features to JSON: %w", err)
|
||||
}
|
||||
|
||||
out = string(outBytes)
|
||||
default:
|
||||
return xerrors.Errorf(`unknown output format %q, only "table" and "json" are supported`, outputFormat)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringArrayVarP(&columns, "column", "c", featureColumns,
|
||||
fmt.Sprintf("Specify a column to filter in the table. Available columns are: %s",
|
||||
strings.Join(featureColumns, ", ")))
|
||||
cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. Available formats are: table, json.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type featureRow struct {
|
||||
Name string `table:"name"`
|
||||
Entitlement string `table:"entitlement"`
|
||||
Enabled bool `table:"enabled"`
|
||||
Limit *int64 `table:"limit"`
|
||||
Actual *int64 `table:"actual"`
|
||||
}
|
||||
|
||||
// displayFeatures will return a table displaying all features passed in.
|
||||
// filterColumns must be a subset of the feature fields and will determine which
|
||||
// columns to display
|
||||
func displayFeatures(filterColumns []string, features map[string]codersdk.Feature) (string, error) {
|
||||
rows := make([]featureRow, 0, len(features))
|
||||
for name, feat := range features {
|
||||
rows = append(rows, featureRow{
|
||||
Name: name,
|
||||
Entitlement: string(feat.Entitlement),
|
||||
Enabled: feat.Enabled,
|
||||
Limit: feat.Limit,
|
||||
Actual: feat.Actual,
|
||||
})
|
||||
}
|
||||
|
||||
return cliui.DisplayTable(rows, "name", filterColumns)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"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 TestFeaturesList(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Table", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
cmd, root := clitest.New(t, "features", "list")
|
||||
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.Execute()
|
||||
}()
|
||||
require.NoError(t, <-errC)
|
||||
pty.ExpectMatch("user_limit")
|
||||
pty.ExpectMatch("not_entitled")
|
||||
})
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
cmd, root := clitest.New(t, "features", "list", "-o", "json")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
cmd.SetOut(buf)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
<-doneChan
|
||||
|
||||
var entitlements codersdk.Entitlements
|
||||
err := json.Unmarshal(buf.Bytes(), &entitlements)
|
||||
require.NoError(t, err, "unmarshal JSON output")
|
||||
assert.Len(t, entitlements.Features, 2)
|
||||
assert.Empty(t, entitlements.Warnings)
|
||||
assert.Equal(t, codersdk.EntitlementNotEntitled,
|
||||
entitlements.Features[codersdk.FeatureUserLimit].Entitlement)
|
||||
assert.Equal(t, codersdk.EntitlementNotEntitled,
|
||||
entitlements.Features[codersdk.FeatureAuditLog].Entitlement)
|
||||
assert.False(t, entitlements.HasLicense)
|
||||
})
|
||||
}
|
||||
+2
-1
@@ -22,6 +22,7 @@ import (
|
||||
func TestGitSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Dial", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
@@ -58,7 +59,7 @@ func TestGitSSH(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// start workspace agent
|
||||
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String(), "--wireguard=false")
|
||||
agentClient := client
|
||||
clitest.SetupConfig(t, agentClient, root)
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
|
||||
+76
-119
@@ -2,11 +2,9 @@ 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"
|
||||
@@ -15,29 +13,85 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
type workspaceListRow struct {
|
||||
Workspace string `table:"workspace"`
|
||||
Template string `table:"template"`
|
||||
Status string `table:"status"`
|
||||
LastBuilt string `table:"last built"`
|
||||
Outdated bool `table:"outdated"`
|
||||
StartsAt string `table:"starts at"`
|
||||
StopsAfter string `table:"stops after"`
|
||||
}
|
||||
|
||||
func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]codersdk.User, workspace codersdk.Workspace) workspaceListRow {
|
||||
status := codersdk.WorkspaceDisplayStatus(workspace.LatestBuild.Job.Status, workspace.LatestBuild.Transition)
|
||||
|
||||
lastBuilt := 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 = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location())
|
||||
}
|
||||
}
|
||||
|
||||
autostopDisplay := "-"
|
||||
if !ptr.NilOrZero(workspace.TTLMillis) {
|
||||
dur := time.Duration(*workspace.TTLMillis) * time.Millisecond
|
||||
autostopDisplay = durationDisplay(dur)
|
||||
if !workspace.LatestBuild.Deadline.IsZero() && workspace.LatestBuild.Deadline.Time.After(now) && status == "Running" {
|
||||
remaining := time.Until(workspace.LatestBuild.Deadline.Time)
|
||||
autostopDisplay = fmt.Sprintf("%s (%s)", autostopDisplay, relative(remaining))
|
||||
}
|
||||
}
|
||||
|
||||
user := usersByID[workspace.OwnerID]
|
||||
return workspaceListRow{
|
||||
Workspace: user.Username + "/" + workspace.Name,
|
||||
Template: workspace.TemplateName,
|
||||
Status: status,
|
||||
LastBuilt: durationDisplay(lastBuilt),
|
||||
Outdated: workspace.Outdated,
|
||||
StartsAt: autostartDisplay,
|
||||
StopsAfter: autostopDisplay,
|
||||
}
|
||||
}
|
||||
|
||||
func list() *cobra.Command {
|
||||
var (
|
||||
columns []string
|
||||
columns []string
|
||||
searchQuery string
|
||||
me bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "list",
|
||||
Short: "List all workspaces",
|
||||
Aliases: []string{"ls"},
|
||||
Args: cobra.ExactArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspaces, err := client.Workspaces(cmd.Context(), codersdk.WorkspaceFilter{})
|
||||
filter := codersdk.WorkspaceFilter{
|
||||
FilterQuery: searchQuery,
|
||||
}
|
||||
if me {
|
||||
myUser, err := client.User(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter.Owner = myUser.Username
|
||||
}
|
||||
workspaces, err := client.Workspaces(cmd.Context(), filter)
|
||||
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())
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Prompt.String()+"No workspaces found! Create one:")
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), " "+cliui.Styles.Code.Render("coder create <name>"))
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
return nil
|
||||
}
|
||||
users, err := client.Users(cmd.Context(), codersdk.UsersRequest{})
|
||||
@@ -49,121 +103,24 @@ func list() *cobra.Command {
|
||||
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,
|
||||
})
|
||||
now := time.Now()
|
||||
displayWorkspaces := make([]workspaceListRow, len(workspaces))
|
||||
for i, workspace := range workspaces {
|
||||
displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace)
|
||||
}
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), tableWriter.Render())
|
||||
|
||||
out, err := cliui.DisplayTable(displayWorkspaces, "workspace", columns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArrayVarP(&columns, "column", "c", nil,
|
||||
"Specify a column to filter in the table.")
|
||||
cmd.Flags().StringVar(&searchQuery, "search", "", "Search for a workspace with a query.")
|
||||
cmd.Flags().BoolVar(&me, "me", false, "Only show workspaces owned by the current user.")
|
||||
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
|
||||
}
|
||||
|
||||
+11
-8
@@ -3,21 +3,19 @@ package cli_test
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -30,13 +28,18 @@ func TestList(t *testing.T) {
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
errC := make(chan error)
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
done := make(chan any)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
errC := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, errC)
|
||||
close(done)
|
||||
}()
|
||||
pty.ExpectMatch(workspace.Name)
|
||||
pty.ExpectMatch("Running")
|
||||
pty.ExpectMatch("Started")
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
<-done
|
||||
})
|
||||
}
|
||||
|
||||
+93
-63
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
@@ -37,7 +38,12 @@ func init() {
|
||||
}
|
||||
|
||||
func login() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var (
|
||||
email string
|
||||
username string
|
||||
password string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "login <url>",
|
||||
Short: "Authenticate with a Coder deployment",
|
||||
Args: cobra.ExactArgs(1),
|
||||
@@ -61,76 +67,96 @@ func login() *cobra.Command {
|
||||
}
|
||||
|
||||
client := codersdk.New(serverURL)
|
||||
|
||||
// Try to check the version of the server prior to logging in.
|
||||
// It may be useful to warn the user if they are trying to login
|
||||
// on a very old client.
|
||||
err = checkVersions(cmd, client)
|
||||
if err != nil {
|
||||
// Checking versions isn't a fatal error so we print a warning
|
||||
// and proceed.
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Warn.Render(err.Error()))
|
||||
}
|
||||
|
||||
hasInitialUser, err := client.HasFirstUser(cmd.Context())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("has initial user: %w", err)
|
||||
}
|
||||
if !hasInitialUser {
|
||||
if !isTTY(cmd) {
|
||||
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Your Coder deployment hasn't been set up!\n")
|
||||
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Would you like to create the first user?",
|
||||
Default: "yes",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current user: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return xerrors.New("That's not a valid email address!")
|
||||
}
|
||||
return err
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("specify email prompt: %w", err)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if username == "" {
|
||||
if !isTTY(cmd) {
|
||||
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
|
||||
}
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Would you like to create the first user?",
|
||||
Default: cliui.ConfirmYes,
|
||||
IsConfirm: true,
|
||||
})
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("confirm password prompt: %w", err)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current user: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if 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 {
|
||||
return xerrors.New("That's not a valid email address!")
|
||||
}
|
||||
return err
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("specify email prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
var matching bool
|
||||
|
||||
for !matching {
|
||||
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)
|
||||
}
|
||||
confirm, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("confirm password prompt: %w", err)
|
||||
}
|
||||
|
||||
matching = confirm == password
|
||||
if !matching {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Error.Render("Passwords do not match"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
|
||||
@@ -219,6 +245,10 @@ func login() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cliflag.StringVarP(cmd.Flags(), &email, "email", "e", "CODER_EMAIL", "", "Specifies an email address to authenticate with.")
|
||||
cliflag.StringVarP(cmd.Flags(), &username, "username", "u", "CODER_USERNAME", "", "Specifies a username to authenticate with.")
|
||||
cliflag.StringVarP(cmd.Flags(), &password, "password", "p", "CODER_PASSWORD", "", "Specifies a password to authenticate with.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// isWSL determines if coder-cli is running within Windows Subsystem for Linux
|
||||
|
||||
+30
-3
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
@@ -56,6 +57,26 @@ func TestLogin(t *testing.T) {
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("InitialUserFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
// The --force-tty flag is required on Windows, because the `isatty` library does not
|
||||
// accurately detect Windows ptys when they are not attached to a process:
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--username", "testuser", "--email", "user@coder.com", "--password", "password")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -72,7 +93,7 @@ func TestLogin(t *testing.T) {
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.ExecuteContext(ctx)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
@@ -88,9 +109,15 @@ func TestLogin(t *testing.T) {
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
|
||||
// Validate that we reprompt for matching passwords.
|
||||
pty.ExpectMatch("Passwords do not match")
|
||||
pty.ExpectMatch("password") // Re-prompt password.
|
||||
cancel()
|
||||
pty.ExpectMatch("Enter a " + cliui.Styles.Field.Render("password"))
|
||||
|
||||
pty.WriteLine("pass")
|
||||
pty.ExpectMatch("Confirm")
|
||||
pty.WriteLine("pass")
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ func logout() *cobra.Command {
|
||||
Use: "logout",
|
||||
Short: "Remove the local authenticated session",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -26,9 +26,9 @@ func logout() *cobra.Command {
|
||||
config := createConfig(cmd)
|
||||
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Are you sure you want to logout?",
|
||||
Text: "Are you sure you want to log out?",
|
||||
IsConfirm: true,
|
||||
Default: "yes",
|
||||
Default: cliui.ConfirmYes,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+8
-8
@@ -41,7 +41,7 @@ func TestLogout(t *testing.T) {
|
||||
assert.NoFileExists(t, string(config.Session()))
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Are you sure you want to logout?")
|
||||
pty.ExpectMatch("Are you sure you want to log out?")
|
||||
pty.WriteLine("yes")
|
||||
pty.ExpectMatch("You are no longer logged in. You can log in using 'coder login <url>'.")
|
||||
<-logoutChan
|
||||
@@ -152,19 +152,19 @@ func TestLogout(t *testing.T) {
|
||||
err = os.Chmod(string(config), 0500)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
defer func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Closing the opened files for cleanup.
|
||||
err = urlFile.Close()
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
err = sessionFile.Close()
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
// Setting the permissions back for cleanup.
|
||||
err = os.Chmod(string(config), 0700)
|
||||
require.NoError(t, err)
|
||||
err = os.Chmod(string(config), 0o700)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}()
|
||||
|
||||
logoutChan := make(chan struct{})
|
||||
logout, _ := clitest.New(t, "logout", "--global-config", string(config))
|
||||
@@ -186,7 +186,7 @@ func TestLogout(t *testing.T) {
|
||||
assert.Regexp(t, errRegex, err.Error())
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Are you sure you want to logout?")
|
||||
pty.ExpectMatch("Are you sure you want to log out?")
|
||||
pty.WriteLine("yes")
|
||||
<-logoutChan
|
||||
})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func parameters() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Short: "List parameters for a given scope",
|
||||
Example: formatExamples(
|
||||
example{
|
||||
Command: "coder parameters list workspace my-workspace",
|
||||
},
|
||||
),
|
||||
Use: "parameters",
|
||||
// Currently hidden as this shows parameter values, not parameter
|
||||
// schemes. Until we have a good way to distinguish the two, it's better
|
||||
// not to add confusion or lock ourselves into a certain api.
|
||||
// This cmd is still valuable debugging tool for devs to avoid
|
||||
// constructing curl requests.
|
||||
Hidden: true,
|
||||
Aliases: []string{"params"},
|
||||
}
|
||||
cmd.AddCommand(
|
||||
parameterList(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func parameterList() *cobra.Command {
|
||||
var (
|
||||
columns []string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
scope, name := args[0], args[1]
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current organization: %w", err)
|
||||
}
|
||||
|
||||
var scopeID uuid.UUID
|
||||
switch codersdk.ParameterScope(scope) {
|
||||
case codersdk.ParameterWorkspace:
|
||||
workspace, err := namedWorkspace(cmd, client, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scopeID = workspace.ID
|
||||
case codersdk.ParameterTemplate:
|
||||
template, err := client.TemplateByName(cmd.Context(), organization.ID, name)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace template: %w", err)
|
||||
}
|
||||
scopeID = template.ID
|
||||
case codersdk.ParameterImportJob, "template_version":
|
||||
scope = string(codersdk.ParameterImportJob)
|
||||
scopeID, err = uuid.Parse(name)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("%q must be a uuid for this scope type", name)
|
||||
}
|
||||
|
||||
// Could be a template_version id or a job id. Check for the
|
||||
// version id.
|
||||
tv, err := client.TemplateVersion(cmd.Context(), scopeID)
|
||||
if err == nil {
|
||||
scopeID = tv.Job.ID
|
||||
}
|
||||
|
||||
default:
|
||||
return xerrors.Errorf("%q is an unsupported scope, use %v", scope, []codersdk.ParameterScope{
|
||||
codersdk.ParameterWorkspace, codersdk.ParameterTemplate, codersdk.ParameterImportJob,
|
||||
})
|
||||
}
|
||||
|
||||
params, err := client.Parameters(cmd.Context(), codersdk.ParameterScope(scope), scopeID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch params: %w", err)
|
||||
}
|
||||
|
||||
out, err := cliui.DisplayTable(params, "name", columns)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("render table: %w", err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "scope", "destination scheme"},
|
||||
"Specify a column to filter in the table.")
|
||||
return cmd
|
||||
}
|
||||
+36
-30
@@ -29,31 +29,35 @@ func portForward() *cobra.Command {
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "port-forward <workspace>",
|
||||
Short: "Forward one or more ports from the local machine to the remote 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"),
|
||||
Example: formatExamples(
|
||||
example{
|
||||
Description: "Port forward a single TCP port from 1234 in the workspace to port 5678 on your local machine",
|
||||
Command: "coder port-forward <workspace> --tcp 5678:1234",
|
||||
},
|
||||
example{
|
||||
Description: "Port forward a single UDP port from port 9000 to port 9000 on your local machine",
|
||||
Command: "coder port-forward <workspace> --udp 9000",
|
||||
},
|
||||
example{
|
||||
Description: "Forward a Unix socket in the workspace to a local Unix socket",
|
||||
Command: "coder port-forward <workspace> --unix ./local.sock:~/remote.sock",
|
||||
},
|
||||
example{
|
||||
Description: "Forward a Unix socket in the workspace to a local TCP port",
|
||||
Command: "coder port-forward <workspace> --unix 8080:~/remote.sock",
|
||||
},
|
||||
example{
|
||||
Description: "Port forward multiple TCP ports and a UDP port",
|
||||
Command: "coder port-forward <workspace> --tcp 8080:8080 --tcp 9000:3000 --udp 5353:53",
|
||||
},
|
||||
),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
specs, err := parsePortForwards(tcpForwards, udpForwards, unixForwards)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse port-forward specs: %w", err)
|
||||
@@ -66,12 +70,12 @@ func portForward() *cobra.Command {
|
||||
return xerrors.New("no port-forwards requested")
|
||||
}
|
||||
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, agent, err := getWorkspaceAndAgent(cmd, client, codersdk.Me, args[0], false)
|
||||
workspace, agent, err := getWorkspaceAndAgent(ctx, cmd, client, codersdk.Me, args[0], false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -79,13 +83,13 @@ func portForward() *cobra.Command {
|
||||
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)
|
||||
err = cliui.WorkspaceBuild(ctx, cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = cliui.Agent(cmd.Context(), cmd.ErrOrStderr(), cliui.AgentOptions{
|
||||
err = cliui.Agent(ctx, cmd.ErrOrStderr(), cliui.AgentOptions{
|
||||
WorkspaceName: workspace.Name,
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
return client.WorkspaceAgent(ctx, agent.ID)
|
||||
@@ -95,7 +99,7 @@ func portForward() *cobra.Command {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
|
||||
conn, err := client.DialWorkspaceAgent(cmd.Context(), agent.ID, nil)
|
||||
conn, err := client.DialWorkspaceAgent(ctx, agent.ID, nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dial workspace agent: %w", err)
|
||||
}
|
||||
@@ -103,7 +107,6 @@ func portForward() *cobra.Command {
|
||||
|
||||
// Start all listeners.
|
||||
var (
|
||||
ctx, cancel = context.WithCancel(cmd.Context())
|
||||
wg = new(sync.WaitGroup)
|
||||
listeners = make([]net.Listener, len(specs))
|
||||
closeAllListeners = func() {
|
||||
@@ -115,11 +118,11 @@ func portForward() *cobra.Command {
|
||||
}
|
||||
}
|
||||
)
|
||||
defer cancel()
|
||||
defer closeAllListeners()
|
||||
|
||||
for i, spec := range specs {
|
||||
l, err := listenAndPortForward(ctx, cmd, conn, wg, spec)
|
||||
if err != nil {
|
||||
closeAllListeners()
|
||||
return err
|
||||
}
|
||||
listeners[i] = l
|
||||
@@ -128,7 +131,10 @@ func portForward() *cobra.Command {
|
||||
// Wait for the context to be canceled or for a signal and close
|
||||
// all listeners.
|
||||
var closeErr error
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
|
||||
+40
-45
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -24,6 +23,7 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestPortForward(t *testing.T) {
|
||||
@@ -119,44 +119,35 @@ func TestPortForward(t *testing.T) {
|
||||
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)
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
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)
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "test.sock")
|
||||
return path, path
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Setup agent once to be shared between test-cases (avoid expensive
|
||||
// non-parallel setup).
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
_, workspace = runAgent(t, client, user.UserID)
|
||||
)
|
||||
|
||||
for _, c := range cases { //nolint:paralleltest // the `c := c` confuses the linter
|
||||
c := c
|
||||
// Avoid parallel test here because setupLocal reserves
|
||||
// Delay parallel tests here because setupLocal reserves
|
||||
// a free open port which is not guaranteed to be free
|
||||
// after the listener closes.
|
||||
//nolint:paralleltest
|
||||
// between the listener closing and port-forward ready.
|
||||
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))
|
||||
)
|
||||
p1 := setupTestListener(t, c.setupRemote(t))
|
||||
|
||||
// Create a flag that forwards from local to listener 1.
|
||||
localAddress, localFlag := c.setupLocal(t)
|
||||
@@ -167,7 +158,7 @@ func TestPortForward(t *testing.T) {
|
||||
cmd, root := clitest.New(t, "port-forward", workspace.Name, flag)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(io.MultiWriter(buf, os.Stderr))
|
||||
cmd.SetOut(buf)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
@@ -176,9 +167,11 @@ func TestPortForward(t *testing.T) {
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
|
||||
t.Parallel() // Port is reserved, enable parallel execution.
|
||||
|
||||
// Open two connections simultaneously and test them out of
|
||||
// sync.
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
d := net.Dialer{Timeout: testutil.WaitShort}
|
||||
c1, err := d.DialContext(ctx, c.network, localAddress)
|
||||
require.NoError(t, err, "open connection 1 to 'local' listener")
|
||||
defer c1.Close()
|
||||
@@ -196,11 +189,8 @@ func TestPortForward(t *testing.T) {
|
||||
//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))
|
||||
p1 = setupTestListener(t, c.setupRemote(t))
|
||||
p2 = setupTestListener(t, c.setupRemote(t))
|
||||
)
|
||||
|
||||
// Create a flags for listener 1 and listener 2.
|
||||
@@ -214,7 +204,7 @@ func TestPortForward(t *testing.T) {
|
||||
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))
|
||||
cmd.SetOut(buf)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
@@ -223,9 +213,11 @@ func TestPortForward(t *testing.T) {
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
|
||||
t.Parallel() // Port is reserved, enable parallel execution.
|
||||
|
||||
// Open a connection to both listener 1 and 2 simultaneously and
|
||||
// then test them out of order.
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
d := net.Dialer{Timeout: testutil.WaitShort}
|
||||
c1, err := d.DialContext(ctx, c.network, localAddress1)
|
||||
require.NoError(t, err, "open connection 1 to 'local' listener 1")
|
||||
defer c1.Close()
|
||||
@@ -246,10 +238,6 @@ func TestPortForward(t *testing.T) {
|
||||
//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]
|
||||
@@ -269,7 +257,7 @@ func TestPortForward(t *testing.T) {
|
||||
cmd, root := clitest.New(t, "port-forward", workspace.Name, flag)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(io.MultiWriter(buf, os.Stderr))
|
||||
cmd.SetOut(buf)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
@@ -278,9 +266,11 @@ func TestPortForward(t *testing.T) {
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
|
||||
t.Parallel() // Port is reserved, enable parallel execution.
|
||||
|
||||
// Open two connections simultaneously and test them out of
|
||||
// sync.
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
d := net.Dialer{Timeout: testutil.WaitShort}
|
||||
c1, err := d.DialContext(ctx, tcpCase.network, localAddress)
|
||||
require.NoError(t, err, "open connection 1 to 'local' listener")
|
||||
defer c1.Close()
|
||||
@@ -299,9 +289,6 @@ func TestPortForward(t *testing.T) {
|
||||
//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{}
|
||||
@@ -330,7 +317,7 @@ func TestPortForward(t *testing.T) {
|
||||
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))
|
||||
cmd.SetOut(buf)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
@@ -339,9 +326,11 @@ func TestPortForward(t *testing.T) {
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
|
||||
t.Parallel() // Port is reserved, enable parallel execution.
|
||||
|
||||
// Open connections to all items in the "dial" array.
|
||||
var (
|
||||
d = net.Dialer{Timeout: 3 * time.Second}
|
||||
d = net.Dialer{Timeout: testutil.WaitShort}
|
||||
conns = make([]net.Conn, len(dials))
|
||||
)
|
||||
for i, a := range dials {
|
||||
@@ -402,7 +391,7 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]coders
|
||||
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())
|
||||
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String(), "--wireguard=false")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
errC := make(chan error)
|
||||
agentCtx, agentCancel := context.WithCancel(ctx)
|
||||
@@ -425,6 +414,8 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]coders
|
||||
// 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 {
|
||||
t.Helper()
|
||||
|
||||
// Wait for listener to completely exit before releasing.
|
||||
done := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
@@ -440,6 +431,7 @@ func setupTestListener(t *testing.T, l net.Listener) string {
|
||||
for {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
_ = l.Close()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -479,6 +471,7 @@ func testAccept(t *testing.T, c net.Conn) {
|
||||
}
|
||||
|
||||
func assertReadPayload(t *testing.T, r io.Reader, payload []byte) {
|
||||
t.Helper()
|
||||
b := make([]byte, len(payload)+16)
|
||||
n, err := r.Read(b)
|
||||
assert.NoError(t, err, "read payload")
|
||||
@@ -487,14 +480,16 @@ func assertReadPayload(t *testing.T, r io.Reader, payload []byte) {
|
||||
}
|
||||
|
||||
func assertWritePayload(t *testing.T, w io.Writer, payload []byte) {
|
||||
t.Helper()
|
||||
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) {
|
||||
t.Helper()
|
||||
for i := 0; i < 100; i++ {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
time.Sleep(testutil.IntervalMedium)
|
||||
|
||||
data := output.String()
|
||||
if strings.Contains(data, "Ready!") {
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ func publickey() *cobra.Command {
|
||||
Aliases: []string{"pubkey"},
|
||||
Short: "Output your public key for Git operations",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create codersdk client: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
func TestPublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
cmd, root := clitest.New(t, "publickey")
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func rename() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "rename <workspace> <new name>",
|
||||
Short: "Rename a workspace",
|
||||
Args: cobra.ExactArgs(2),
|
||||
// Keep hidden until renaming is safe, see:
|
||||
// * https://github.com/coder/coder/issues/3000
|
||||
// * https://github.com/coder/coder/issues/3386
|
||||
Hidden: true,
|
||||
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)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n\n",
|
||||
cliui.Styles.Wrap.Render("WARNING: A rename can result in data loss if a resource references the workspace name in the template (e.g volumes)."),
|
||||
)
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Type %q to confirm rename:", workspace.Name),
|
||||
Validate: func(s string) error {
|
||||
if s == workspace.Name {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("Input %q does not match %q", s, workspace.Name)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.UpdateWorkspace(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceRequest{
|
||||
Name: args[1],
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("rename workspace: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestRename(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)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
want := workspace.Name + "-test"
|
||||
cmd, root := clitest.New(t, "rename", workspace.Name, want, "--yes")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("confirm rename:")
|
||||
pty.WriteLine(workspace.Name)
|
||||
|
||||
require.NoError(t, <-errC)
|
||||
|
||||
ws, err := client.Workspace(ctx, workspace.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
got := ws.Name
|
||||
assert.Equal(t, want, got, "workspace name did not change")
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -80,6 +81,7 @@ func resetPassword() *cobra.Command {
|
||||
return xerrors.Errorf("updating password: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nPassword has been reset for user %s!\n", cliui.Styles.Keyword.Render(user.Username))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
+15
-12
@@ -5,7 +5,6 @@ import (
|
||||
"net/url"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/coder/coder/coderd/database/postgres"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
// nolint:paralleltest
|
||||
@@ -38,23 +38,26 @@ func TestResetPassword(t *testing.T) {
|
||||
defer closeFunc()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
serverDone := make(chan struct{})
|
||||
serverCmd, cfg := clitest.New(t, "server", "--address", ":0", "--postgres-url", connectionURL)
|
||||
serverCmd, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--address", ":0",
|
||||
"--postgres-url", connectionURL,
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
err = serverCmd.ExecuteContext(ctx)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
}()
|
||||
var client *codersdk.Client
|
||||
var rawURL string
|
||||
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)
|
||||
rawURL, err = cfg.URL().Read()
|
||||
return err == nil && rawURL != ""
|
||||
}, testutil.WaitLong, testutil.IntervalFast)
|
||||
accessURL, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
client := codersdk.New(accessURL)
|
||||
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
|
||||
+219
-55
@@ -1,14 +1,17 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kirsle/configdir"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -17,6 +20,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -26,7 +30,7 @@ var (
|
||||
// Applied as annotations to workspace commands
|
||||
// so they display in a separated "help" section.
|
||||
workspaceCommand = map[string]string{
|
||||
"workspaces": " ",
|
||||
"workspaces": "",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -37,65 +41,118 @@ const (
|
||||
varAgentURL = "agent-url"
|
||||
varGlobalConfig = "global-config"
|
||||
varNoOpen = "no-open"
|
||||
varNoVersionCheck = "no-version-warning"
|
||||
varForceTty = "force-tty"
|
||||
varVerbose = "verbose"
|
||||
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
|
||||
|
||||
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
|
||||
)
|
||||
|
||||
var (
|
||||
errUnauthenticated = xerrors.New(notLoggedInMessage)
|
||||
envSessionToken = "CODER_SESSION_TOKEN"
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
// Set cobra template functions in init to avoid conflicts in tests.
|
||||
cobra.AddTemplateFuncs(templateFunctions)
|
||||
}
|
||||
|
||||
func Root() *cobra.Command {
|
||||
func Core() []*cobra.Command {
|
||||
return []*cobra.Command{
|
||||
configSSH(),
|
||||
create(),
|
||||
deleteWorkspace(),
|
||||
dotfiles(),
|
||||
gitssh(),
|
||||
list(),
|
||||
login(),
|
||||
logout(),
|
||||
parameters(),
|
||||
portForward(),
|
||||
publickey(),
|
||||
resetPassword(),
|
||||
schedules(),
|
||||
show(),
|
||||
ssh(),
|
||||
start(),
|
||||
state(),
|
||||
stop(),
|
||||
rename(),
|
||||
templates(),
|
||||
update(),
|
||||
users(),
|
||||
versionCmd(),
|
||||
wireguardPortForward(),
|
||||
workspaceAgent(),
|
||||
features(),
|
||||
}
|
||||
}
|
||||
|
||||
func AGPL() []*cobra.Command {
|
||||
all := append(Core(), Server(coderd.New))
|
||||
return all
|
||||
}
|
||||
|
||||
func Root(subcommands []*cobra.Command) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "coder",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
Long: `Coder — A tool for provisioning self-hosted development environments.
|
||||
`,
|
||||
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") + `
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
err := func() error {
|
||||
if cliflag.IsSetBool(cmd, varNoVersionCheck) {
|
||||
return nil
|
||||
}
|
||||
|
||||
Get started by creating a template from an example.
|
||||
` + cliui.Styles.Code.Render("$ coder templates init"),
|
||||
// Login handles checking the versions itself since it
|
||||
// has a handle to an unauthenticated client.
|
||||
// Server is skipped for obvious reasons.
|
||||
if cmd.Name() == "login" || cmd.Name() == "server" || cmd.Name() == "gitssh" {
|
||||
return nil
|
||||
}
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
// If the client is unauthenticated we can ignore the check.
|
||||
// The child commands should handle an unauthenticated client.
|
||||
if xerrors.Is(err, errUnauthenticated) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create client: %w", err)
|
||||
}
|
||||
return checkVersions(cmd, client)
|
||||
}()
|
||||
if err != nil {
|
||||
// Just log the error here. We never want to fail a command
|
||||
// due to a pre-run.
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
|
||||
cliui.Styles.Warn.Render("check versions error: %s"), err)
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
}
|
||||
},
|
||||
Example: formatExamples(
|
||||
example{
|
||||
Description: "Start a Coder server",
|
||||
Command: "coder server",
|
||||
},
|
||||
example{
|
||||
Description: "Get started by creating a template from an example",
|
||||
Command: "coder templates init",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
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(),
|
||||
versionCmd(),
|
||||
)
|
||||
cmd.AddCommand(subcommands...)
|
||||
|
||||
cmd.SetUsageTemplate(usageTemplate())
|
||||
|
||||
cmd.PersistentFlags().String(varURL, "", "Specify the URL to your deployment.")
|
||||
cmd.PersistentFlags().String(varToken, "", "Specify an authentication token.")
|
||||
cliflag.Bool(cmd.PersistentFlags(), varNoVersionCheck, "", envNoVersionCheck, false, "Suppress warning when client and server versions do not match.")
|
||||
cliflag.String(cmd.PersistentFlags(), varToken, "", envSessionToken, "", fmt.Sprintf("Specify an authentication token. For security reasons setting %s is preferred.", envSessionToken))
|
||||
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.")
|
||||
@@ -105,6 +162,7 @@ func Root() *cobra.Command {
|
||||
_ = cmd.PersistentFlags().MarkHidden(varForceTty)
|
||||
cmd.PersistentFlags().Bool(varNoOpen, false, "Block automatically opening URLs in the browser.")
|
||||
_ = cmd.PersistentFlags().MarkHidden(varNoOpen)
|
||||
cliflag.Bool(cmd.PersistentFlags(), varVerbose, "v", "CODER_VERBOSE", false, "Enable verbose output")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -112,9 +170,8 @@ func Root() *cobra.Command {
|
||||
// versionCmd prints the coder version
|
||||
func versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Show coder version",
|
||||
Example: "coder version",
|
||||
Use: "version",
|
||||
Short: "Show coder version",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var str strings.Builder
|
||||
_, _ = str.WriteString(fmt.Sprintf("Coder %s", buildinfo.Version()))
|
||||
@@ -130,9 +187,13 @@ func versionCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// createClient returns a new client from the command context.
|
||||
func isTest() bool {
|
||||
return flag.Lookup("test.v") != nil
|
||||
}
|
||||
|
||||
// CreateClient returns a new client from the command context.
|
||||
// It reads from global configuration files if flags are not set.
|
||||
func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
func CreateClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
root := createConfig(cmd)
|
||||
rawURL, err := cmd.Flags().GetString(varURL)
|
||||
if err != nil || rawURL == "" {
|
||||
@@ -140,7 +201,7 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
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, errUnauthenticated
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -155,7 +216,7 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
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, errUnauthenticated
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -166,7 +227,7 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
}
|
||||
|
||||
// createAgentClient returns a new client from the command context.
|
||||
// It works just like createClient, but uses the agent token and URL instead.
|
||||
// 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 {
|
||||
@@ -214,7 +275,7 @@ func namedWorkspace(cmd *cobra.Command, client *codersdk.Client, identifier stri
|
||||
return codersdk.Workspace{}, xerrors.Errorf("invalid workspace name: %q", identifier)
|
||||
}
|
||||
|
||||
return client.WorkspaceByOwnerAndName(cmd.Context(), owner, name, codersdk.WorkspaceByOwnerAndNameParams{})
|
||||
return client.WorkspaceByOwnerAndName(cmd.Context(), owner, name, codersdk.WorkspaceOptions{})
|
||||
}
|
||||
|
||||
// createConfig consumes the global configuration flag to produce a config root.
|
||||
@@ -262,6 +323,30 @@ func isTTYOut(cmd *cobra.Command) bool {
|
||||
return isatty.IsTerminal(file.Fd())
|
||||
}
|
||||
|
||||
var templateFunctions = template.FuncMap{
|
||||
"usageHeader": usageHeader,
|
||||
"isWorkspaceCommand": isWorkspaceCommand,
|
||||
}
|
||||
|
||||
func usageHeader(s string) string {
|
||||
// Customizes the color of headings to make subcommands more visually
|
||||
// appealing.
|
||||
return cliui.Styles.Placeholder.Render(s)
|
||||
}
|
||||
|
||||
func isWorkspaceCommand(cmd *cobra.Command) bool {
|
||||
if _, ok := cmd.Annotations["workspaces"]; ok {
|
||||
return true
|
||||
}
|
||||
var ws bool
|
||||
cmd.VisitParents(func(cmd *cobra.Command) {
|
||||
if _, ok := cmd.Annotations["workspaces"]; ok {
|
||||
ws = true
|
||||
}
|
||||
})
|
||||
return ws
|
||||
}
|
||||
|
||||
func usageTemplate() string {
|
||||
// usageHeader is defined in init().
|
||||
return `{{usageHeader "Usage:"}}
|
||||
@@ -282,19 +367,21 @@ func usageTemplate() string {
|
||||
{{.Example}}
|
||||
{{end}}
|
||||
|
||||
{{- $isRootHelp := (not .HasParent)}}
|
||||
{{- if .HasAvailableSubCommands}}
|
||||
{{usageHeader "Commands:"}}
|
||||
{{- range .Commands}}
|
||||
{{- if (or (and .IsAvailableCommand (eq (len .Annotations) 0)) (eq .Name "help"))}}
|
||||
{{- $isRootWorkspaceCommand := (and $isRootHelp (isWorkspaceCommand .))}}
|
||||
{{- if (or (and .IsAvailableCommand (not $isRootWorkspaceCommand)) (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
{{- if and (not .HasParent) .HasAvailableSubCommands}}
|
||||
{{- if (and $isRootHelp .HasAvailableSubCommands)}}
|
||||
{{usageHeader "Workspace Commands:"}}
|
||||
{{- range .Commands}}
|
||||
{{- if (and .IsAvailableCommand (ne (index .Annotations "workspaces") ""))}}
|
||||
{{- if (and .IsAvailableCommand (isWorkspaceCommand .))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
@@ -302,12 +389,12 @@ func usageTemplate() string {
|
||||
|
||||
{{- if .HasAvailableLocalFlags}}
|
||||
{{usageHeader "Flags:"}}
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}
|
||||
{{.LocalFlags.FlagUsagesWrapped 100 | trimTrailingWhitespaces}}
|
||||
{{end}}
|
||||
|
||||
{{- if .HasAvailableInheritedFlags}}
|
||||
{{usageHeader "Global Flags:"}}
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}
|
||||
{{.InheritedFlags.FlagUsagesWrapped 100 | trimTrailingWhitespaces}}
|
||||
{{end}}
|
||||
|
||||
{{- if .HasHelpSubCommands}}
|
||||
@@ -324,8 +411,85 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.
|
||||
{{end}}`
|
||||
}
|
||||
|
||||
// example represents a standard example for command usage, to be used
|
||||
// with formatExamples.
|
||||
type example struct {
|
||||
Description string
|
||||
Command string
|
||||
}
|
||||
|
||||
// formatExamples formats the examples as width wrapped bulletpoint
|
||||
// descriptions with the command underneath.
|
||||
func formatExamples(examples ...example) string {
|
||||
wrap := cliui.Styles.Wrap.Copy()
|
||||
wrap.PaddingLeft(4)
|
||||
var sb strings.Builder
|
||||
for i, e := range examples {
|
||||
if len(e.Description) > 0 {
|
||||
_, _ = sb.WriteString(" - " + wrap.Render(e.Description + ":")[4:] + "\n\n ")
|
||||
}
|
||||
// We add 1 space here because `cliui.Styles.Code` adds an extra
|
||||
// space. This makes the code block align at an even 2 or 6
|
||||
// spaces for symmetry.
|
||||
_, _ = sb.WriteString(" " + cliui.Styles.Code.Render(fmt.Sprintf("$ %s", e.Command)))
|
||||
if i < len(examples)-1 {
|
||||
_, _ = sb.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
var (
|
||||
httpErr *codersdk.Error
|
||||
output strings.Builder
|
||||
)
|
||||
|
||||
if xerrors.As(err, &httpErr) {
|
||||
_, _ = fmt.Fprintln(&output, httpErr.Friendly())
|
||||
}
|
||||
|
||||
// If the httpErr is nil then we just have a regular error in which
|
||||
// case we want to print out what's happening.
|
||||
if httpErr == nil || cliflag.IsSetBool(cmd, varVerbose) {
|
||||
_, _ = fmt.Fprintln(&output, err.Error())
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(&output, helpErrMsg)
|
||||
|
||||
return cliui.Styles.Error.Render(output.String())
|
||||
}
|
||||
|
||||
func checkVersions(cmd *cobra.Command, client *codersdk.Client) error {
|
||||
if cliflag.IsSetBool(cmd, varNoVersionCheck) {
|
||||
return nil
|
||||
}
|
||||
|
||||
clientVersion := buildinfo.Version()
|
||||
|
||||
info, err := client.BuildInfo(cmd.Context())
|
||||
// Avoid printing errors that are connection-related.
|
||||
if codersdk.IsConnectionErr(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return xerrors.Errorf("build info: %w", err)
|
||||
}
|
||||
|
||||
fmtWarningText := `version mismatch: client %s, server %s
|
||||
download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'
|
||||
`
|
||||
|
||||
if !buildinfo.VersionsMatch(clientVersion, info.Version) {
|
||||
warn := cliui.Styles.Warn.Copy().Align(lipgloss.Left)
|
||||
// Trim the leading 'v', our install.sh script does not handle this case well.
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), warn.Render(fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v"))
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func Test_formatExamples(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
examples []example
|
||||
wantMatches []string
|
||||
}{
|
||||
{
|
||||
name: "No examples",
|
||||
examples: nil,
|
||||
wantMatches: nil,
|
||||
},
|
||||
{
|
||||
name: "Output examples",
|
||||
examples: []example{
|
||||
{
|
||||
Description: "Hello world",
|
||||
Command: "echo hello",
|
||||
},
|
||||
{
|
||||
Description: "Bye bye",
|
||||
Command: "echo bye",
|
||||
},
|
||||
},
|
||||
wantMatches: []string{
|
||||
"Hello world", "echo hello",
|
||||
"Bye bye", "echo bye",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "No description outputs commands",
|
||||
examples: []example{
|
||||
{
|
||||
Command: "echo hello",
|
||||
},
|
||||
},
|
||||
wantMatches: []string{
|
||||
"echo hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := formatExamples(tt.examples...)
|
||||
if len(tt.wantMatches) == 0 {
|
||||
require.Empty(t, got)
|
||||
} else {
|
||||
for _, want := range tt.wantMatches {
|
||||
require.Contains(t, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m,
|
||||
// The lumberjack library is used by by agent and seems to leave
|
||||
// goroutines after Close(), fails TestGitSSH tests.
|
||||
// https://github.com/natefinch/lumberjack/pull/100
|
||||
goleak.IgnoreTopFunction("gopkg.in/natefinch/lumberjack%2ev2.(*Logger).millRun"),
|
||||
goleak.IgnoreTopFunction("gopkg.in/natefinch/lumberjack%2ev2.(*Logger).mill.func1"),
|
||||
)
|
||||
}
|
||||
+99
-6
@@ -4,23 +4,116 @@ import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/coder/buildinfo"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/cli"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func TestRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("FormatCobraError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, _ := clitest.New(t, "delete")
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, err := cmd.ExecuteC()
|
||||
errStr := cli.FormatCobraError(err, cmd)
|
||||
require.Contains(t, errStr, "Run 'coder delete --help' for usage.")
|
||||
cmd, _ := clitest.New(t, "delete")
|
||||
|
||||
cmd, err := cmd.ExecuteC()
|
||||
errStr := cli.FormatCobraError(err, cmd)
|
||||
require.Contains(t, errStr, "Run 'coder delete --help' for usage.")
|
||||
})
|
||||
|
||||
t.Run("Verbose", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test that the verbose error is masked without verbose flag.
|
||||
t.Run("NoVerboseAPIError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, _ := clitest.New(t)
|
||||
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
var err error = &codersdk.Error{
|
||||
Response: codersdk.Response{
|
||||
Message: "This is a message.",
|
||||
},
|
||||
Helper: "Try this instead.",
|
||||
}
|
||||
|
||||
err = xerrors.Errorf("wrap me: %w", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
cmd, err := cmd.ExecuteC()
|
||||
errStr := cli.FormatCobraError(err, cmd)
|
||||
require.Contains(t, errStr, "This is a message. Try this instead.")
|
||||
require.NotContains(t, errStr, err.Error())
|
||||
})
|
||||
|
||||
// Assert that a regular error is not masked when verbose is not
|
||||
// specified.
|
||||
t.Run("NoVerboseRegularError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, _ := clitest.New(t)
|
||||
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
return xerrors.Errorf("this is a non-codersdk error: %w", xerrors.Errorf("a wrapped error"))
|
||||
}
|
||||
|
||||
cmd, err := cmd.ExecuteC()
|
||||
errStr := cli.FormatCobraError(err, cmd)
|
||||
require.Contains(t, errStr, err.Error())
|
||||
})
|
||||
|
||||
// Test that both the friendly error and the verbose error are
|
||||
// displayed when verbose is passed.
|
||||
t.Run("APIError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, _ := clitest.New(t, "--verbose")
|
||||
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
var err error = &codersdk.Error{
|
||||
Response: codersdk.Response{
|
||||
Message: "This is a message.",
|
||||
},
|
||||
Helper: "Try this instead.",
|
||||
}
|
||||
|
||||
err = xerrors.Errorf("wrap me: %w", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
cmd, err := cmd.ExecuteC()
|
||||
errStr := cli.FormatCobraError(err, cmd)
|
||||
require.Contains(t, errStr, "This is a message. Try this instead.")
|
||||
require.Contains(t, errStr, err.Error())
|
||||
})
|
||||
|
||||
// Assert that a regular error is not masked when verbose specified.
|
||||
t.Run("RegularError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, _ := clitest.New(t, "--verbose")
|
||||
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
return xerrors.Errorf("this is a non-codersdk error: %w", xerrors.Errorf("a wrapped error"))
|
||||
}
|
||||
|
||||
cmd, err := cmd.ExecuteC()
|
||||
errStr := cli.FormatCobraError(err, cmd)
|
||||
require.Contains(t, errStr, err.Error())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Version", func(t *testing.T) {
|
||||
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/coderd/util/tz"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
scheduleShowDescriptionLong = `Shows the following information for the given workspace:
|
||||
* The automatic start schedule
|
||||
* The next scheduled start time
|
||||
* The duration after which it will stop
|
||||
* The next scheduled stop time
|
||||
`
|
||||
scheduleStartDescriptionLong = `Schedules a workspace to regularly start at a specific time.
|
||||
Schedule format: <start-time> [day-of-week] [location].
|
||||
* Start-time (required) is accepted either in 12-hour (hh:mm{am|pm}) format, or 24-hour format hh:mm.
|
||||
* Day-of-week (optional) allows specifying in the cron format, e.g. 1,3,5 or Mon-Fri.
|
||||
Aliases such as @daily are not supported.
|
||||
Default: * (every day)
|
||||
* Location (optional) must be a valid location in the IANA timezone database.
|
||||
If omitted, we will fall back to either the TZ environment variable or /etc/localtime.
|
||||
You can check your corresponding location by visiting https://ipinfo.io - it shows in the demo widget on the right.
|
||||
`
|
||||
scheduleStopDescriptionLong = `Schedules a workspace to stop after a given duration has elapsed.
|
||||
* Workspace runtime is measured from the time that the workspace build completed.
|
||||
* The minimum scheduled stop time is 1 minute.
|
||||
* The workspace template may place restrictions on the maximum shutdown time.
|
||||
* Changes to workspace schedules only take effect upon the next build of the workspace,
|
||||
and do not affect a running instance of a workspace.
|
||||
|
||||
When enabling scheduled stop, enter a duration in one of the following formats:
|
||||
* 3h2m (3 hours and two minutes)
|
||||
* 3h (3 hours)
|
||||
* 2m (2 minutes)
|
||||
* 2 (2 minutes)
|
||||
`
|
||||
scheduleOverrideDescriptionLong = `Override the stop time of a currently running workspace instance.
|
||||
* The new stop time is calculated from *now*.
|
||||
* The new stop time must be at least 30 minutes in the future.
|
||||
* The workspace template may restrict the maximum workspace runtime.
|
||||
`
|
||||
)
|
||||
|
||||
func schedules() *cobra.Command {
|
||||
scheduleCmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "schedule { show | start | stop | override } <workspace>",
|
||||
Short: "Modify scheduled stop and start times for your workspace",
|
||||
}
|
||||
|
||||
scheduleCmd.AddCommand(
|
||||
scheduleShow(),
|
||||
scheduleStart(),
|
||||
scheduleStop(),
|
||||
scheduleOverride(),
|
||||
)
|
||||
|
||||
return scheduleCmd
|
||||
}
|
||||
|
||||
func scheduleShow() *cobra.Command {
|
||||
showCmd := &cobra.Command{
|
||||
Use: "show <workspace-name>",
|
||||
Short: "Show workspace schedule",
|
||||
Long: scheduleShowDescriptionLong,
|
||||
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
|
||||
}
|
||||
|
||||
return displaySchedule(workspace, cmd.OutOrStdout())
|
||||
},
|
||||
}
|
||||
return showCmd
|
||||
}
|
||||
|
||||
func scheduleStart() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "start <workspace-name> { <start-time> [day-of-week] [location] | manual }",
|
||||
Example: formatExamples(
|
||||
example{
|
||||
Description: "Set the workspace to start at 9:30am (in Dublin) from Monday to Friday",
|
||||
Command: "coder schedule start my-workspace 9:30AM Mon-Fri Europe/Dublin",
|
||||
},
|
||||
),
|
||||
Short: "Edit workspace start schedule",
|
||||
Long: scheduleStartDescriptionLong,
|
||||
Args: cobra.RangeArgs(2, 4),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var schedStr *string
|
||||
if args[1] != "manual" {
|
||||
sched, err := parseCLISchedule(args[1:]...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
schedStr = ptr.Ref(sched.String())
|
||||
}
|
||||
|
||||
err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
Schedule: schedStr,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return displaySchedule(updated, cmd.OutOrStdout())
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func scheduleStop() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Args: cobra.ExactArgs(2),
|
||||
Use: "stop <workspace-name> { <duration> | manual }",
|
||||
Example: formatExamples(
|
||||
example{
|
||||
Command: "coder schedule stop my-workspace 2h30m",
|
||||
},
|
||||
),
|
||||
Short: "Edit workspace stop schedule",
|
||||
Long: scheduleStopDescriptionLong,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var durMillis *int64
|
||||
if args[1] != "manual" {
|
||||
dur, err := parseDuration(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
durMillis = ptr.Ref(dur.Milliseconds())
|
||||
}
|
||||
|
||||
if err := client.UpdateWorkspaceTTL(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTLMillis: durMillis,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return displaySchedule(updated, cmd.OutOrStdout())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleOverride() *cobra.Command {
|
||||
overrideCmd := &cobra.Command{
|
||||
Args: cobra.ExactArgs(2),
|
||||
Use: "override-stop <workspace-name> <duration from now>",
|
||||
Example: formatExamples(
|
||||
example{
|
||||
Command: "coder schedule override-stop my-workspace 90m",
|
||||
},
|
||||
),
|
||||
Short: "Edit stop time of active workspace",
|
||||
Long: scheduleOverrideDescriptionLong,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
overrideDuration, err := parseDuration(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
loc, err := tz.TimezoneIANA()
|
||||
if err != nil {
|
||||
loc = time.UTC // best effort
|
||||
}
|
||||
|
||||
if overrideDuration < 29*time.Minute {
|
||||
_, _ = fmt.Fprintf(
|
||||
cmd.OutOrStdout(),
|
||||
"Please specify a duration of at least 30 minutes.\n",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
newDeadline := time.Now().In(loc).Add(overrideDuration)
|
||||
if err := client.PutExtendWorkspace(cmd.Context(), workspace.ID, codersdk.PutExtendWorkspaceRequest{
|
||||
Deadline: newDeadline,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return displaySchedule(updated, cmd.OutOrStdout())
|
||||
},
|
||||
}
|
||||
return overrideCmd
|
||||
}
|
||||
|
||||
func displaySchedule(workspace codersdk.Workspace, out io.Writer) error {
|
||||
loc, err := tz.TimezoneIANA()
|
||||
if err != nil {
|
||||
loc = time.UTC // best effort
|
||||
}
|
||||
|
||||
var (
|
||||
schedStart = "manual"
|
||||
schedStop = "manual"
|
||||
schedNextStart = "-"
|
||||
schedNextStop = "-"
|
||||
)
|
||||
if !ptr.NilOrEmpty(workspace.AutostartSchedule) {
|
||||
sched, err := schedule.Weekly(ptr.NilToEmpty(workspace.AutostartSchedule))
|
||||
if err != nil {
|
||||
// This should never happen.
|
||||
_, _ = fmt.Fprintf(out, "Invalid autostart schedule %q for workspace %s: %s\n", *workspace.AutostartSchedule, workspace.Name, err.Error())
|
||||
return nil
|
||||
}
|
||||
schedNext := sched.Next(time.Now()).In(sched.Location())
|
||||
schedStart = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location())
|
||||
schedNextStart = schedNext.Format(timeFormat + " on " + dateFormat)
|
||||
}
|
||||
|
||||
if !ptr.NilOrZero(workspace.TTLMillis) {
|
||||
d := time.Duration(*workspace.TTLMillis) * time.Millisecond
|
||||
schedStop = durationDisplay(d) + " after start"
|
||||
}
|
||||
|
||||
if !workspace.LatestBuild.Deadline.IsZero() {
|
||||
if workspace.LatestBuild.Transition != "start" {
|
||||
schedNextStop = "-"
|
||||
} else {
|
||||
schedNextStop = workspace.LatestBuild.Deadline.Time.In(loc).Format(timeFormat + " on " + dateFormat)
|
||||
schedNextStop = fmt.Sprintf("%s (in %s)", schedNextStop, durationDisplay(time.Until(workspace.LatestBuild.Deadline.Time)))
|
||||
}
|
||||
}
|
||||
|
||||
tw := cliui.Table()
|
||||
tw.AppendRow(table.Row{"Starts at", schedStart})
|
||||
tw.AppendRow(table.Row{"Starts next", schedNextStart})
|
||||
tw.AppendRow(table.Row{"Stops at", schedStop})
|
||||
tw.AppendRow(table.Row{"Stops next", schedNextStop})
|
||||
|
||||
_, _ = fmt.Fprintln(out, tw.Render())
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
//nolint:paralleltest // t.Setenv
|
||||
func TestParseCLISchedule(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
input []string
|
||||
expectedSchedule string
|
||||
expectedError string
|
||||
tzEnv string
|
||||
}{
|
||||
{
|
||||
name: "TimeAndDayOfWeekAndLocation",
|
||||
input: []string{"09:00AM", "Sun-Sat", "America/Chicago"},
|
||||
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
|
||||
tzEnv: "UTC",
|
||||
},
|
||||
{
|
||||
name: "TimeOfDay24HourAndDayOfWeekAndLocation",
|
||||
input: []string{"09:00", "Sun-Sat", "America/Chicago"},
|
||||
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
|
||||
tzEnv: "UTC",
|
||||
},
|
||||
{
|
||||
name: "TimeOfDay24HourAndDayOfWeekAndLocationButItsAllQuoted",
|
||||
input: []string{"09:00 Sun-Sat America/Chicago"},
|
||||
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
|
||||
tzEnv: "UTC",
|
||||
},
|
||||
{
|
||||
name: "TimeOfDayOnly",
|
||||
input: []string{"09:00AM"},
|
||||
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
|
||||
tzEnv: "America/Chicago",
|
||||
},
|
||||
{
|
||||
name: "Time24Military",
|
||||
input: []string{"0900"},
|
||||
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
|
||||
tzEnv: "America/Chicago",
|
||||
},
|
||||
{
|
||||
name: "DayOfWeekAndTime",
|
||||
input: []string{"09:00AM", "Sun-Sat"},
|
||||
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
|
||||
tzEnv: "America/Chicago",
|
||||
},
|
||||
{
|
||||
name: "TimeAndLocation",
|
||||
input: []string{"09:00AM", "America/Chicago"},
|
||||
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
|
||||
tzEnv: "UTC",
|
||||
},
|
||||
{
|
||||
name: "LazyTime",
|
||||
input: []string{"9am", "America/Chicago"},
|
||||
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
|
||||
tzEnv: "UTC",
|
||||
},
|
||||
{
|
||||
name: "ZeroPrefixedLazyTime",
|
||||
input: []string{"09am", "America/Chicago"},
|
||||
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
|
||||
tzEnv: "UTC",
|
||||
},
|
||||
{
|
||||
name: "InvalidTime",
|
||||
input: []string{"nine"},
|
||||
expectedError: errInvalidTimeFormat.Error(),
|
||||
},
|
||||
{
|
||||
name: "DayOfWeekAndInvalidTime",
|
||||
input: []string{"nine", "Sun-Sat"},
|
||||
expectedError: errInvalidTimeFormat.Error(),
|
||||
},
|
||||
{
|
||||
name: "InvalidTimeAndLocation",
|
||||
input: []string{"nine", "America/Chicago"},
|
||||
expectedError: errInvalidTimeFormat.Error(),
|
||||
},
|
||||
{
|
||||
name: "DayOfWeekAndInvalidTimeAndLocation",
|
||||
input: []string{"nine", "Sun-Sat", "America/Chicago"},
|
||||
expectedError: errInvalidTimeFormat.Error(),
|
||||
},
|
||||
{
|
||||
name: "TimezoneProvidedInsteadOfLocation",
|
||||
input: []string{"09:00AM", "Sun-Sat", "CST"},
|
||||
expectedError: errUnsupportedTimezone.Error(),
|
||||
},
|
||||
{
|
||||
name: "WhoKnows",
|
||||
input: []string{"Time", "is", "a", "human", "construct"},
|
||||
expectedError: errInvalidTimeFormat.Error(),
|
||||
},
|
||||
} {
|
||||
testCase := testCase
|
||||
//nolint:paralleltest // t.Setenv
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Setenv("TZ", testCase.tzEnv)
|
||||
actualSchedule, actualError := parseCLISchedule(testCase.input...)
|
||||
if testCase.expectedError != "" {
|
||||
assert.Nil(t, actualSchedule)
|
||||
assert.ErrorContains(t, actualError, testCase.expectedError)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, actualError)
|
||||
if assert.NotEmpty(t, actualSchedule) {
|
||||
assert.Equal(t, testCase.expectedSchedule, actualSchedule.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"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/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func TestScheduleShow(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Enabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
tz = "Europe/Dublin"
|
||||
sched = "30 7 * * 1-5"
|
||||
schedCron = fmt.Sprintf("CRON_TZ=%s %s", tz, sched)
|
||||
ttl = 8 * time.Hour
|
||||
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.AutostartSchedule = ptr.Ref(schedCron)
|
||||
cwr.TTLMillis = ptr.Ref(ttl.Milliseconds())
|
||||
})
|
||||
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
cmdArgs = []string{"schedule", "show", workspace.Name}
|
||||
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")
|
||||
lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n")
|
||||
if assert.Len(t, lines, 4) {
|
||||
assert.Contains(t, lines[0], "Starts at 7:30AM Mon-Fri (Europe/Dublin)")
|
||||
assert.Contains(t, lines[1], "Starts next 7:30AM IST on ")
|
||||
assert.Contains(t, lines[2], "Stops at 8h after start")
|
||||
assert.NotContains(t, lines[3], "Stops next -")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Manual", 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, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
cwr.AutostartSchedule = nil
|
||||
})
|
||||
cmdArgs = []string{"schedule", "show", workspace.Name}
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
// unset workspace TTL
|
||||
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}))
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err, "unexpected error")
|
||||
lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n")
|
||||
if assert.Len(t, lines, 4) {
|
||||
assert.Contains(t, lines[0], "Starts at manual")
|
||||
assert.Contains(t, lines[1], "Starts next -")
|
||||
assert.Contains(t, lines[2], "Stops at manual")
|
||||
assert.Contains(t, lines[3], "Stops next -")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("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, "schedule", "show", "doesnotexist")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.ErrorContains(t, err, "status code 404", "unexpected error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestScheduleStart(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, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
cwr.AutostartSchedule = nil
|
||||
})
|
||||
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
tz = "Europe/Dublin"
|
||||
sched = "CRON_TZ=Europe/Dublin 30 9 * * Mon-Fri"
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
// Set a well-specified autostart schedule
|
||||
cmd, root := clitest.New(t, "schedule", "start", workspace.Name, "9:30AM", "Mon-Fri", tz)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err, "unexpected error")
|
||||
lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n")
|
||||
if assert.Len(t, lines, 4) {
|
||||
assert.Contains(t, lines[0], "Starts at 9:30AM Mon-Fri (Europe/Dublin)")
|
||||
assert.Contains(t, lines[1], "Starts next 9:30AM IST on")
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// Reset stdout
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
|
||||
// unset schedule
|
||||
cmd, root = clitest.New(t, "schedule", "start", workspace.Name, "manual")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
err = cmd.Execute()
|
||||
assert.NoError(t, err, "unexpected error")
|
||||
lines = strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n")
|
||||
if assert.Len(t, lines, 4) {
|
||||
assert.Contains(t, lines[0], "Starts at manual")
|
||||
assert.Contains(t, lines[1], "Starts next -")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleStop(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)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
ttl = 8*time.Hour + 30*time.Minute
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
// Set the workspace TTL
|
||||
cmd, root := clitest.New(t, "schedule", "stop", workspace.Name, ttl.String())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err, "unexpected error")
|
||||
lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n")
|
||||
if assert.Len(t, lines, 4) {
|
||||
assert.Contains(t, lines[2], "Stops at 8h30m after start")
|
||||
// Should not be manual
|
||||
assert.NotContains(t, lines[3], "Stops next -")
|
||||
}
|
||||
|
||||
// Reset stdout
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
|
||||
// Unset the workspace TTL
|
||||
cmd, root = clitest.New(t, "schedule", "stop", workspace.Name, "manual")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
err = cmd.Execute()
|
||||
assert.NoError(t, err, "unexpected error")
|
||||
lines = strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n")
|
||||
if assert.Len(t, lines, 4) {
|
||||
assert.Contains(t, lines[2], "Stops at manual")
|
||||
// Deadline of a running workspace is not updated.
|
||||
assert.NotContains(t, lines[3], "Stops next -")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", 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{"schedule", "override-stop", workspace.Name, "10h"}
|
||||
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 := time.Now().Add(10 * time.Hour)
|
||||
|
||||
// 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, time.Minute)
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
// When: we execute `coder schedule override 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, time.Minute)
|
||||
})
|
||||
|
||||
t.Run("InvalidDuration", 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{"schedule", "override-stop", 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, 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("NoDeadline", 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{"schedule", "override-stop", workspace.Name, "1h"}
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
// Unset the workspace TTL
|
||||
err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil})
|
||||
require.NoError(t, err)
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, workspace.TTLMillis)
|
||||
|
||||
// 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)
|
||||
|
||||
// NOTE(cian): need to stop and start the workspace as we do not update the deadline
|
||||
// see: https://github.com/coder/coder/issues/2224
|
||||
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
|
||||
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart)
|
||||
|
||||
// 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.Error(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)
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:paralleltest // t.Setenv
|
||||
func TestScheduleStartDefaults(t *testing.T) {
|
||||
t.Setenv("TZ", "Pacific/Tongatapu")
|
||||
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)
|
||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
cwr.AutostartSchedule = nil
|
||||
})
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
// Set an underspecified schedule
|
||||
cmd, root := clitest.New(t, "schedule", "start", workspace.Name, "9:30AM")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err, "unexpected error")
|
||||
lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n")
|
||||
if assert.Len(t, lines, 4) {
|
||||
assert.Contains(t, lines[0], "Starts at 9:30AM daily (Pacific/Tongatapu)")
|
||||
assert.Contains(t, lines[1], "Starts next 9:30AM +13 on")
|
||||
assert.Contains(t, lines[2], "Stops at 8h after start")
|
||||
}
|
||||
}
|
||||
+719
-314
File diff suppressed because it is too large
Load Diff
+334
-138
@@ -1,6 +1,7 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
@@ -8,34 +9,39 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"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/cli/config"
|
||||
"github.com/coder/coder/coderd/database/postgres"
|
||||
"github.com/coder/coder/coderd/telemetry"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
// This cannot be ran in parallel because it uses a signal.
|
||||
// nolint:paralleltest
|
||||
// nolint:tparallel,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()
|
||||
@@ -45,22 +51,20 @@ func TestServer(t *testing.T) {
|
||||
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)
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--address", ":0",
|
||||
"--postgres-url", connectionURL,
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
errC := make(chan error, 1)
|
||||
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)
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
client := codersdk.New(accessURL)
|
||||
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: "some@one.com",
|
||||
Username: "example",
|
||||
@@ -71,104 +75,147 @@ func TestServer(t *testing.T) {
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
})
|
||||
t.Run("BuiltinPostgres", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
t.Run("Development", func(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--address", ":0",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
//nolint:gocritic // Embedded postgres take a while to fire up.
|
||||
require.Eventually(t, func() bool {
|
||||
rawURL, err := cfg.URL().Read()
|
||||
return err == nil && rawURL != ""
|
||||
}, 3*time.Minute, testutil.IntervalFast, "failed to get access URL")
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
})
|
||||
t.Run("BuiltinPostgresURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
root, _ := clitest.New(t, "server", "postgres-builtin-url")
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
pty.ExpectMatch("psql")
|
||||
})
|
||||
|
||||
// Validate that an http scheme is prepended to a loopback
|
||||
// access URL and that a warning is printed that it may not be externally
|
||||
// reachable.
|
||||
t.Run("NoSchemeLocalAccessURL", 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)
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "localhost:3000/",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
buf := newThreadSafeBuffer()
|
||||
root.SetOutput(buf)
|
||||
errC := make(chan error, 1)
|
||||
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)
|
||||
// Just wait for startup
|
||||
_ = waitAccessURL(t, cfg)
|
||||
|
||||
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")
|
||||
}
|
||||
require.Contains(t, buf.String(), "this may cause unexpected problems when creating workspaces")
|
||||
require.Contains(t, buf.String(), "View the Web UI: http://localhost:3000/\n")
|
||||
})
|
||||
|
||||
// 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) {
|
||||
// Validate that an https scheme is prepended to a remote access URL
|
||||
// and that a warning is printed for a host that cannot be resolved.
|
||||
t.Run("NoSchemeRemoteAccessURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
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)
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "foobarbaz.mydomain",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
buf := newThreadSafeBuffer()
|
||||
root.SetOutput(buf)
|
||||
errC := make(chan error, 1)
|
||||
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)
|
||||
// Just wait for startup
|
||||
_ = waitAccessURL(t, cfg)
|
||||
|
||||
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)
|
||||
require.Contains(t, buf.String(), "this may cause unexpected problems when creating workspaces")
|
||||
require.Contains(t, buf.String(), "View the Web UI: https://foobarbaz.mydomain\n")
|
||||
})
|
||||
|
||||
t.Run("NoWarningWithRemoteAccessURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "https://google.com",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
buf := newThreadSafeBuffer()
|
||||
root.SetOutput(buf)
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
// Just wait for startup
|
||||
_ = waitAccessURL(t, cfg)
|
||||
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
require.NotContains(t, buf.String(), "this may cause unexpected problems when creating workspaces")
|
||||
require.Contains(t, buf.String(), "View the Web UI: https://google.com\n")
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--tls-enable",
|
||||
"--tls-min-version", "tls9",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
})
|
||||
@@ -176,8 +223,15 @@ func TestServer(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")
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--tls-enable",
|
||||
"--tls-client-auth", "something",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
})
|
||||
@@ -185,8 +239,14 @@ func TestServer(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")
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--tls-enable",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
})
|
||||
@@ -196,22 +256,22 @@ func TestServer(t *testing.T) {
|
||||
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)
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--tls-enable",
|
||||
"--tls-cert-file", certPath,
|
||||
"--tls-key-file", keyPath,
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
errC := make(chan error, 1)
|
||||
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)
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
require.Equal(t, "https", accessURL.Scheme)
|
||||
client := codersdk.New(accessURL)
|
||||
client.HTTPClient = &http.Client{
|
||||
@@ -222,7 +282,7 @@ func TestServer(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
_, err := client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
@@ -237,55 +297,41 @@ func TestServer(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--provisioner-daemons", "1",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
err := root.ExecuteContext(ctx)
|
||||
serverErr <- err
|
||||
serverErr <- 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
|
||||
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)
|
||||
_ = waitAccessURL(t, cfg)
|
||||
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)
|
||||
// We cannot send more signals here, because it's possible Coder
|
||||
// has already exited, which could cause the test to fail due to interrupt.
|
||||
err = <-serverErr
|
||||
require.NoError(t, err)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
})
|
||||
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)
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--trace=true",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
@@ -293,6 +339,140 @@ func TestServer(t *testing.T) {
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
require.Error(t, goleak.Find())
|
||||
})
|
||||
t.Run("Telemetry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
deployment := make(chan struct{}, 64)
|
||||
snapshot := make(chan *telemetry.Snapshot, 64)
|
||||
r := chi.NewRouter()
|
||||
r.Post("/deployment", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
deployment <- struct{}{}
|
||||
})
|
||||
r.Post("/snapshot", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
ss := &telemetry.Snapshot{}
|
||||
err := json.NewDecoder(r.Body).Decode(ss)
|
||||
require.NoError(t, err)
|
||||
snapshot <- ss
|
||||
})
|
||||
server := httptest.NewServer(r)
|
||||
defer server.Close()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--telemetry",
|
||||
"--telemetry-url", server.URL,
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
<-deployment
|
||||
<-snapshot
|
||||
cancelFunc()
|
||||
<-errC
|
||||
})
|
||||
t.Run("Prometheus", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
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
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--provisioner-daemons", "1",
|
||||
"--prometheus-enable",
|
||||
"--prometheus-address", ":"+strconv.Itoa(randomPort),
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
_ = waitAccessURL(t, cfg)
|
||||
|
||||
var res *http.Response
|
||||
require.Eventually(t, func() bool {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("http://127.0.0.1:%d", randomPort), nil)
|
||||
assert.NoError(t, err)
|
||||
// nolint:bodyclose
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
return err == nil
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
scanner := bufio.NewScanner(res.Body)
|
||||
hasActiveUsers := false
|
||||
hasWorkspaces := false
|
||||
for scanner.Scan() {
|
||||
// This metric is manually registered to be tracked in the server. That's
|
||||
// why we test it's tracked here.
|
||||
if strings.HasPrefix(scanner.Text(), "coderd_api_active_users_duration_hour") {
|
||||
hasActiveUsers = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(scanner.Text(), "coderd_api_workspace_latest_build_total") {
|
||||
hasWorkspaces = true
|
||||
continue
|
||||
}
|
||||
t.Logf("scanned %s", scanner.Text())
|
||||
}
|
||||
require.NoError(t, scanner.Err())
|
||||
require.True(t, hasActiveUsers)
|
||||
require.True(t, hasWorkspaces)
|
||||
cancelFunc()
|
||||
<-serverErr
|
||||
})
|
||||
t.Run("GitHubOAuth", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
fakeRedirect := "https://fake-url.com"
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--oauth2-github-client-id", "fake",
|
||||
"--oauth2-github-client-secret", "fake",
|
||||
"--oauth2-github-enterprise-base-url", fakeRedirect,
|
||||
)
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
client := codersdk.New(accessURL)
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
githubURL, err := accessURL.Parse("/api/v2/users/oauth2/github")
|
||||
require.NoError(t, err)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubURL.String(), nil)
|
||||
require.NoError(t, err)
|
||||
res, err := client.HTTPClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer res.Body.Close()
|
||||
fakeURL, err := res.Location()
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(fakeURL.String(), fakeRedirect), fakeURL.String())
|
||||
cancelFunc()
|
||||
<-serverErr
|
||||
})
|
||||
}
|
||||
|
||||
func generateTLSCertificate(t testing.TB) (certPath, keyPath string) {
|
||||
@@ -329,3 +509,19 @@ func generateTLSCertificate(t testing.TB) (certPath, keyPath string) {
|
||||
require.NoError(t, err)
|
||||
return certFile.Name(), keyFile.Name()
|
||||
}
|
||||
|
||||
func waitAccessURL(t *testing.T, cfg config.Root) *url.URL {
|
||||
t.Helper()
|
||||
|
||||
var err error
|
||||
var rawURL string
|
||||
require.Eventually(t, func() bool {
|
||||
rawURL, err = cfg.URL().Read()
|
||||
return err == nil && rawURL != ""
|
||||
}, testutil.WaitLong, testutil.IntervalFast, "failed to get access URL")
|
||||
|
||||
accessURL, err := url.Parse(rawURL)
|
||||
require.NoError(t, err, "failed to parse access URL")
|
||||
|
||||
return accessURL
|
||||
}
|
||||
|
||||
+2
-2
@@ -10,11 +10,11 @@ import (
|
||||
func show() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "show",
|
||||
Use: "show <workspace>",
|
||||
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)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestShow(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Exists", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: provisionCompleteWithAgent,
|
||||
ProvisionDryRun: provisionCompleteWithAgent,
|
||||
})
|
||||
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)
|
||||
|
||||
args := []string{
|
||||
"show",
|
||||
workspace.Name,
|
||||
}
|
||||
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 := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{match: "compute.main"},
|
||||
{match: "smith (linux, i386)"},
|
||||
{match: "coder ssh " + workspace.Name},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
if len(m.write) > 0 {
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var interruptSignals = []os.Signal{
|
||||
os.Interrupt,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGHUP,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var interruptSignals = []os.Signal{os.Interrupt}
|
||||
+129
-35
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -18,17 +19,24 @@ import (
|
||||
gosshagent "golang.org/x/crypto/ssh/agent"
|
||||
"golang.org/x/term"
|
||||
"golang.org/x/xerrors"
|
||||
"inet.af/netaddr"
|
||||
tslogger "tailscale.com/types/logger"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"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"
|
||||
"github.com/coder/coder/peer/peerwg"
|
||||
)
|
||||
|
||||
var workspacePollInterval = time.Minute
|
||||
var autostopNotifyCountdown = []time.Duration{30 * time.Minute}
|
||||
var (
|
||||
workspacePollInterval = time.Minute
|
||||
autostopNotifyCountdown = []time.Duration{30 * time.Minute}
|
||||
)
|
||||
|
||||
func ssh() *cobra.Command {
|
||||
var (
|
||||
@@ -37,6 +45,7 @@ func ssh() *cobra.Command {
|
||||
forwardAgent bool
|
||||
identityAgent string
|
||||
wsPollInterval time.Duration
|
||||
wireguard bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
@@ -44,7 +53,10 @@ func ssh() *cobra.Command {
|
||||
Short: "SSH into a workspace",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -61,52 +73,121 @@ func ssh() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
workspace, agent, err := getWorkspaceAndAgent(cmd, client, codersdk.Me, args[0], shuffle)
|
||||
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, 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{
|
||||
err = cliui.Agent(ctx, cmd.ErrOrStderr(), cliui.AgentOptions{
|
||||
WorkspaceName: workspace.Name,
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
return client.WorkspaceAgent(ctx, agent.ID)
|
||||
return client.WorkspaceAgent(ctx, workspaceAgent.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()
|
||||
var newSSHClient func() (*gossh.Client, error)
|
||||
|
||||
stopPolling := tryPollWorkspaceAutostop(cmd.Context(), client, workspace)
|
||||
defer stopPolling()
|
||||
|
||||
if stdio {
|
||||
rawSSH, err := conn.SSH()
|
||||
if !wireguard {
|
||||
conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
_, _ = io.Copy(cmd.OutOrStdout(), rawSSH)
|
||||
}()
|
||||
_, _ = io.Copy(rawSSH, cmd.InOrStdin())
|
||||
return nil
|
||||
defer conn.Close()
|
||||
|
||||
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
|
||||
defer stopPolling()
|
||||
|
||||
if stdio {
|
||||
rawSSH, err := conn.SSH()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rawSSH.Close()
|
||||
|
||||
go func() {
|
||||
_, _ = io.Copy(cmd.OutOrStdout(), rawSSH)
|
||||
}()
|
||||
_, _ = io.Copy(rawSSH, cmd.InOrStdin())
|
||||
return nil
|
||||
}
|
||||
|
||||
newSSHClient = conn.SSHClient
|
||||
} else {
|
||||
// TODO: more granual control of Tailscale logging.
|
||||
peerwg.Logf = tslogger.Discard
|
||||
|
||||
ipv6 := peerwg.UUIDToNetaddr(uuid.New())
|
||||
wgn, err := peerwg.New(
|
||||
slog.Make(sloghuman.Sink(cmd.ErrOrStderr())),
|
||||
[]netaddr.IPPrefix{netaddr.IPPrefixFrom(ipv6, 128)},
|
||||
)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create wireguard network: %w", err)
|
||||
}
|
||||
defer wgn.Close()
|
||||
|
||||
err = client.PostWireguardPeer(ctx, workspace.ID, peerwg.Handshake{
|
||||
Recipient: workspaceAgent.ID,
|
||||
NodePublicKey: wgn.NodePrivateKey.Public(),
|
||||
DiscoPublicKey: wgn.DiscoPublicKey,
|
||||
IPv6: ipv6,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("post wireguard peer: %w", err)
|
||||
}
|
||||
|
||||
err = wgn.AddPeer(peerwg.Handshake{
|
||||
Recipient: workspaceAgent.ID,
|
||||
DiscoPublicKey: workspaceAgent.DiscoPublicKey,
|
||||
NodePublicKey: workspaceAgent.WireguardPublicKey,
|
||||
IPv6: workspaceAgent.IPv6.IP(),
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("add workspace agent as peer: %w", err)
|
||||
}
|
||||
|
||||
if stdio {
|
||||
rawSSH, err := wgn.SSH(ctx, workspaceAgent.IPv6.IP())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rawSSH.Close()
|
||||
|
||||
go func() {
|
||||
_, _ = io.Copy(cmd.OutOrStdout(), rawSSH)
|
||||
}()
|
||||
_, _ = io.Copy(rawSSH, cmd.InOrStdin())
|
||||
return nil
|
||||
}
|
||||
|
||||
newSSHClient = func() (*gossh.Client, error) {
|
||||
return wgn.SSHClient(ctx, workspaceAgent.IPv6.IP())
|
||||
}
|
||||
}
|
||||
sshClient, err := conn.SSHClient()
|
||||
|
||||
sshClient, err := newSSHClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sshClient.Close()
|
||||
|
||||
sshSession, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sshSession.Close()
|
||||
|
||||
// Ensure context cancellation is propagated to the
|
||||
// SSH session, e.g. to cancel `Wait()` at the end.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = sshSession.Close()
|
||||
}()
|
||||
|
||||
if identityAgent == "" {
|
||||
identityAgent = os.Getenv("SSH_AUTH_SOCK")
|
||||
@@ -122,25 +203,29 @@ func ssh() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
stdoutFile, valid := cmd.OutOrStdout().(*os.File)
|
||||
if valid && isatty.IsTerminal(stdoutFile.Fd()) {
|
||||
state, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||
stdoutFile, validOut := cmd.OutOrStdout().(*os.File)
|
||||
stdinFile, validIn := cmd.InOrStdin().(*os.File)
|
||||
if validOut && validIn && isatty.IsTerminal(stdoutFile.Fd()) {
|
||||
state, err := term.MakeRaw(int(stdinFile.Fd()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = term.Restore(int(os.Stdin.Fd()), state)
|
||||
_ = term.Restore(int(stdinFile.Fd()), state)
|
||||
}()
|
||||
|
||||
windowChange := listenWindowSize(cmd.Context())
|
||||
windowChange := listenWindowSize(ctx)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-windowChange:
|
||||
}
|
||||
width, height, _ := term.GetSize(int(stdoutFile.Fd()))
|
||||
width, height, err := term.GetSize(int(stdoutFile.Fd()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = sshSession.WindowChange(height, width)
|
||||
}
|
||||
}()
|
||||
@@ -153,15 +238,24 @@ func ssh() *cobra.Command {
|
||||
|
||||
sshSession.Stdin = cmd.InOrStdin()
|
||||
sshSession.Stdout = cmd.OutOrStdout()
|
||||
sshSession.Stderr = cmd.OutOrStdout()
|
||||
sshSession.Stderr = cmd.ErrOrStderr()
|
||||
|
||||
err = sshSession.Shell()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Put cancel at the top of the defer stack to initiate
|
||||
// shutdown of services.
|
||||
defer cancel()
|
||||
|
||||
err = sshSession.Wait()
|
||||
if err != nil {
|
||||
// If the connection drops unexpectedly, we get an ExitMissingError but no other
|
||||
// error details, so try to at least give the user a better message
|
||||
if errors.Is(err, &gossh.ExitMissingError{}) {
|
||||
return xerrors.New("SSH connection ended unexpectedly")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -174,6 +268,8 @@ func ssh() *cobra.Command {
|
||||
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.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &wireguard, "wireguard", "", "CODER_SSH_WIREGUARD", false, "Whether to use Wireguard for SSH tunneling.")
|
||||
_ = cmd.Flags().MarkHidden("wireguard")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -181,16 +277,14 @@ func ssh() *cobra.Command {
|
||||
// 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()
|
||||
|
||||
func getWorkspaceAndAgent(ctx context.Context, cmd *cobra.Command, client *codersdk.Client, userID string, in string, shuffle bool) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { //nolint:revive
|
||||
var (
|
||||
workspace codersdk.Workspace
|
||||
workspaceParts = strings.Split(in, ".")
|
||||
err error
|
||||
)
|
||||
if shuffle {
|
||||
workspaces, err := client.Workspaces(cmd.Context(), codersdk.WorkspaceFilter{
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -293,7 +387,7 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
deadline = ws.LatestBuild.Deadline
|
||||
deadline = ws.LatestBuild.Deadline.Time
|
||||
callback = func() {
|
||||
ttl := deadline.Sub(now)
|
||||
var title, body string
|
||||
|
||||
+39
-24
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func setupWorkspaceForSSH(t *testing.T) (*codersdk.Client, codersdk.Workspace, string) {
|
||||
@@ -59,6 +60,7 @@ func setupWorkspaceForSSH(t *testing.T) (*codersdk.Client, codersdk.Workspace, s
|
||||
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)
|
||||
|
||||
return client, workspace, agentToken
|
||||
}
|
||||
@@ -67,6 +69,7 @@ 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)
|
||||
@@ -74,20 +77,24 @@ func TestSSH(t *testing.T) {
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetErr(pty.Output())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.Execute()
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
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() {
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
}()
|
||||
|
||||
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
|
||||
pty.WriteLine("exit")
|
||||
@@ -96,11 +103,9 @@ func TestSSH(t *testing.T) {
|
||||
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{
|
||||
@@ -112,6 +117,14 @@ func TestSSH(t *testing.T) {
|
||||
|
||||
clientOutput, clientInput := io.Pipe()
|
||||
serverOutput, serverInput := io.Pipe()
|
||||
defer func() {
|
||||
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
cmd, root := clitest.New(t, "ssh", "--stdio", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
@@ -119,7 +132,7 @@ func TestSSH(t *testing.T) {
|
||||
cmd.SetOut(serverInput)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.Execute()
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
@@ -131,9 +144,13 @@ func TestSSH(t *testing.T) {
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
sshClient := ssh.NewClient(conn, channels, requests)
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
command := "sh -c exit"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c exit"
|
||||
@@ -155,18 +172,12 @@ func TestSSH(t *testing.T) {
|
||||
|
||||
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()
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
})
|
||||
defer agentCloser.Close()
|
||||
|
||||
// Generate private key.
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
@@ -187,18 +198,22 @@ func TestSSH(t *testing.T) {
|
||||
fd, err := l.Accept()
|
||||
if err != nil {
|
||||
if !errors.Is(err, net.ErrClosed) {
|
||||
t.Logf("accept error: %v", err)
|
||||
assert.NoError(t, err, "listener accept failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = gosshagent.ServeAgent(kr, fd)
|
||||
if !errors.Is(err, io.EOF) {
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, err, "serve agent failed")
|
||||
}
|
||||
_ = fd.Close()
|
||||
}
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
cmd, root := clitest.New(t,
|
||||
"ssh",
|
||||
workspace.Name,
|
||||
@@ -209,10 +224,10 @@ func TestSSH(t *testing.T) {
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetErr(pty.Output())
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err, "ssh command failed")
|
||||
})
|
||||
|
||||
// Ensure that SSH_AUTH_SOCK is set.
|
||||
@@ -223,7 +238,7 @@ func TestSSH(t *testing.T) {
|
||||
// Ensure that ssh-add lists our key.
|
||||
pty.WriteLine("ssh-add -L")
|
||||
keys, err := kr.List()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, err, "list keys failed")
|
||||
pty.ExpectMatch(keys[0].String())
|
||||
|
||||
// And we're done.
|
||||
|
||||
+10
-10
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -16,15 +17,7 @@ func start() *cobra.Command {
|
||||
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)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -39,7 +32,14 @@ func start() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been started at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
@@ -26,7 +27,7 @@ func statePull() *cobra.Command {
|
||||
Use: "pull <workspace> [file]",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -50,7 +51,7 @@ func statePull() *cobra.Command {
|
||||
}
|
||||
|
||||
if len(args) < 2 {
|
||||
cmd.Println(string(state))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(state))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -67,7 +68,7 @@ func statePush() *cobra.Command {
|
||||
Use: "push <workspace> <file>",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -97,8 +96,6 @@ func TestStatePush(t *testing.T) {
|
||||
err = stateFile.Close()
|
||||
require.NoError(t, err)
|
||||
cmd, root := clitest.New(t, "state", "push", workspace.Name, stateFile.Name())
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetOut(io.Discard)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
err = cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
+10
-2
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -24,7 +25,7 @@ func stop() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -39,7 +40,14 @@ func stop() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been stopped at %s!\n", cliui.Styles.Keyword.Render(workspace.Name), cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
+99
-28
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,7 +32,7 @@ func templateCreate() *cobra.Command {
|
||||
Short: "Create a template from the current directory or as specified by flag",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -60,7 +59,7 @@ func templateCreate() *cobra.Command {
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Create and upload %q?", prettyDir),
|
||||
IsConfirm: true,
|
||||
Default: "yes",
|
||||
Default: cliui.ConfirmYes,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -82,7 +81,13 @@ func templateCreate() *cobra.Command {
|
||||
}
|
||||
spin.Stop()
|
||||
|
||||
job, parameters, err := createValidTemplateVersion(cmd, client, organization, database.ProvisionerType(provisioner), resp.Hash, parameterFile)
|
||||
job, _, err := createValidTemplateVersion(cmd, createValidTemplateVersionArgs{
|
||||
Client: client,
|
||||
Organization: organization,
|
||||
Provisioner: database.ProvisionerType(provisioner),
|
||||
FileHash: resp.Hash,
|
||||
ParameterFile: parameterFile,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -98,7 +103,6 @@ func templateCreate() *cobra.Command {
|
||||
createReq := codersdk.CreateTemplateRequest{
|
||||
Name: templateName,
|
||||
VersionID: job.ID,
|
||||
ParameterValues: parameters,
|
||||
MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()),
|
||||
MinAutostartIntervalMillis: ptr.Ref(minAutostartInterval.Milliseconds()),
|
||||
}
|
||||
@@ -109,7 +113,7 @@ func templateCreate() *cobra.Command {
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n"+cliui.Styles.Wrap.Render(
|
||||
"The "+cliui.Styles.Keyword.Render(templateName)+" template has been created! "+
|
||||
"The "+cliui.Styles.Keyword.Render(templateName)+" template has been created at "+cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp))+"! "+
|
||||
"Developers can provision a workspace with this template using:")+"\n")
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render(fmt.Sprintf("coder create --template=%q [workspace name]", templateName)))
|
||||
@@ -122,7 +126,7 @@ func templateCreate() *cobra.Command {
|
||||
cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from")
|
||||
cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend")
|
||||
cmd.Flags().StringVarP(¶meterFile, "parameter-file", "", "", "Specify a file path with parameter values.")
|
||||
cmd.Flags().DurationVarP(&maxTTL, "max-ttl", "", 168*time.Hour, "Specify a maximum TTL for worksapces created from this template.")
|
||||
cmd.Flags().DurationVarP(&maxTTL, "max-ttl", "", 24*time.Hour, "Specify a maximum TTL for workspaces created from this template.")
|
||||
cmd.Flags().DurationVarP(&minAutostartInterval, "min-autostart-interval", "", time.Hour, "Specify a minimum autostart interval for workspaces created from this template.")
|
||||
// This is for testing!
|
||||
err := cmd.Flags().MarkHidden("test.provisioner")
|
||||
@@ -133,14 +137,34 @@ func templateCreate() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, organization codersdk.Organization, provisioner database.ProvisionerType, hash string, parameterFile string, parameters ...codersdk.CreateParameterRequest) (*codersdk.TemplateVersion, []codersdk.CreateParameterRequest, error) {
|
||||
type createValidTemplateVersionArgs struct {
|
||||
Client *codersdk.Client
|
||||
Organization codersdk.Organization
|
||||
Provisioner database.ProvisionerType
|
||||
FileHash string
|
||||
ParameterFile string
|
||||
// Template is only required if updating a template's active version.
|
||||
Template *codersdk.Template
|
||||
// ReuseParameters will attempt to reuse params from the Template field
|
||||
// before prompting the user. Set to false to always prompt for param
|
||||
// values.
|
||||
ReuseParameters bool
|
||||
}
|
||||
|
||||
func createValidTemplateVersion(cmd *cobra.Command, args createValidTemplateVersionArgs, parameters ...codersdk.CreateParameterRequest) (*codersdk.TemplateVersion, []codersdk.CreateParameterRequest, error) {
|
||||
before := time.Now()
|
||||
version, err := client.CreateTemplateVersion(cmd.Context(), organization.ID, codersdk.CreateTemplateVersionRequest{
|
||||
client := args.Client
|
||||
|
||||
req := codersdk.CreateTemplateVersionRequest{
|
||||
StorageMethod: codersdk.ProvisionerStorageMethodFile,
|
||||
StorageSource: hash,
|
||||
Provisioner: codersdk.ProvisionerType(provisioner),
|
||||
StorageSource: args.FileHash,
|
||||
Provisioner: codersdk.ProvisionerType(args.Provisioner),
|
||||
ParameterValues: parameters,
|
||||
})
|
||||
}
|
||||
if args.Template != nil {
|
||||
req.TemplateID = args.Template.ID
|
||||
}
|
||||
version, err := client.CreateTemplateVersion(cmd.Context(), args.Organization.ID, req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -175,33 +199,73 @@ func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, org
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// lastParameterValues are pulled from the current active template version if
|
||||
// templateID is provided. This allows pulling params from the last
|
||||
// version instead of prompting if we are updating template versions.
|
||||
lastParameterValues := make(map[string]codersdk.Parameter)
|
||||
if args.ReuseParameters && args.Template != nil {
|
||||
activeVersion, err := client.TemplateVersion(cmd.Context(), args.Template.ActiveVersionID)
|
||||
if err != nil {
|
||||
return nil, nil, xerrors.Errorf("Fetch current active template version: %w", err)
|
||||
}
|
||||
|
||||
// We don't want to compute the params, we only want to copy from this scope
|
||||
values, err := client.Parameters(cmd.Context(), codersdk.ParameterImportJob, activeVersion.Job.ID)
|
||||
if err != nil {
|
||||
return nil, nil, xerrors.Errorf("Fetch previous version parameters: %w", err)
|
||||
}
|
||||
for _, value := range values {
|
||||
lastParameterValues[value.Name] = value
|
||||
}
|
||||
}
|
||||
|
||||
if provisionerd.IsMissingParameterError(version.Job.Error) {
|
||||
valuesBySchemaID := map[string]codersdk.TemplateVersionParameter{}
|
||||
valuesBySchemaID := map[string]codersdk.ComputedParameter{}
|
||||
for _, parameterValue := range parameterValues {
|
||||
valuesBySchemaID[parameterValue.SchemaID.String()] = parameterValue
|
||||
}
|
||||
sort.Slice(parameterSchemas, func(i, j int) bool {
|
||||
return parameterSchemas[i].Name < parameterSchemas[j].Name
|
||||
})
|
||||
|
||||
// parameterMapFromFile can be nil if parameter file is not specified
|
||||
var parameterMapFromFile map[string]string
|
||||
if args.ParameterFile != "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
|
||||
parameterMapFromFile, err = createParameterMapFromFile(args.ParameterFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// pulled params come from the last template version
|
||||
pulled := make([]string, 0)
|
||||
missingSchemas := make([]codersdk.ParameterSchema, 0)
|
||||
for _, parameterSchema := range parameterSchemas {
|
||||
_, ok := valuesBySchemaID[parameterSchema.ID.String()]
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// The file values are handled below. So don't handle them here,
|
||||
// just check if a value is present in the file.
|
||||
_, fileOk := parameterMapFromFile[parameterSchema.Name]
|
||||
if inherit, ok := lastParameterValues[parameterSchema.Name]; ok && !fileOk {
|
||||
// If the value is not in the param file, and can be pulled from the last template version,
|
||||
// then don't mark it as missing.
|
||||
parameters = append(parameters, codersdk.CreateParameterRequest{
|
||||
CloneID: inherit.ID,
|
||||
})
|
||||
pulled = append(pulled, fmt.Sprintf("%q", parameterSchema.Name))
|
||||
continue
|
||||
}
|
||||
|
||||
missingSchemas = append(missingSchemas, parameterSchema)
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has required variables! They are scoped to the template, and not viewable after being set.")+"\r\n")
|
||||
|
||||
// parameterMapFromFile can be nil if parameter file is not specified
|
||||
var parameterMapFromFile map[string]string
|
||||
if parameterFile != "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
|
||||
parameterMapFromFile, err = createParameterMapFromFile(parameterFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has required variables! They are scoped to the template, and not viewable after being set."))
|
||||
if len(pulled) > 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(fmt.Sprintf("The following parameter values are being pulled from the latest template version: %s.", strings.Join(pulled, ", "))))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Use \"--always-prompt\" flag to change the values."))
|
||||
}
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "\r\n")
|
||||
|
||||
for _, parameterSchema := range missingSchemas {
|
||||
parameterValue, err := getParameterValueFromMapOrInput(cmd, parameterMapFromFile, parameterSchema)
|
||||
if err != nil {
|
||||
@@ -218,7 +282,7 @@ func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, org
|
||||
|
||||
// This recursion is only 1 level deep in practice.
|
||||
// The first pass populates the missing parameters, so it does not enter this `if` block again.
|
||||
return createValidTemplateVersion(cmd, client, organization, provisioner, hash, parameterFile, parameters...)
|
||||
return createValidTemplateVersion(cmd, args, parameters...)
|
||||
}
|
||||
|
||||
if version.Job.Status != codersdk.ProvisionerJobSucceeded {
|
||||
@@ -230,7 +294,14 @@ func createValidTemplateVersion(cmd *cobra.Command, client *codersdk.Client, org
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
||||
// Only display the resources on the start transition, to avoid listing them more than once.
|
||||
var startResources []codersdk.WorkspaceResource
|
||||
for _, r := range resources {
|
||||
if r.Transition == codersdk.WorkspaceTransitionStart {
|
||||
startResources = append(startResources, r)
|
||||
}
|
||||
}
|
||||
err = cliui.WorkspaceResources(cmd.OutOrStdout(), startResources, cliui.WorkspaceResourcesOptions{
|
||||
HideAgentState: true,
|
||||
HideAccess: true,
|
||||
Title: "Template Preview",
|
||||
|
||||
@@ -14,6 +14,28 @@ import (
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
var provisionCompleteWithAgent = []*proto.Provision_Response{
|
||||
{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{
|
||||
{
|
||||
Type: "compute",
|
||||
Name: "main",
|
||||
Agents: []*proto.Agent{
|
||||
{
|
||||
Name: "smith",
|
||||
OperatingSystem: "linux",
|
||||
Architecture: "i386",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestTemplateCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
@@ -22,7 +44,7 @@ func TestTemplateCreate(t *testing.T) {
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: echo.ProvisionComplete,
|
||||
Provision: provisionCompleteWithAgent,
|
||||
})
|
||||
args := []string{
|
||||
"templates",
|
||||
@@ -49,11 +71,15 @@ func TestTemplateCreate(t *testing.T) {
|
||||
write string
|
||||
}{
|
||||
{match: "Create and upload", write: "yes"},
|
||||
{match: "compute.main"},
|
||||
{match: "smith (linux, i386)"},
|
||||
{match: "Confirm create?", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
if len(m.write) > 0 {
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
@@ -105,6 +131,7 @@ func TestTemplateCreate(t *testing.T) {
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
})
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("region: \"bananas\"")
|
||||
cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--parameter-file", parameterFile.Name())
|
||||
@@ -131,7 +158,6 @@ func TestTemplateCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
removeTmpDirUntilSuccess(t, tempDir)
|
||||
})
|
||||
|
||||
t.Run("WithParameterFileNotContainingTheValue", func(t *testing.T) {
|
||||
@@ -144,6 +170,7 @@ func TestTemplateCreate(t *testing.T) {
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
})
|
||||
tempDir := t.TempDir()
|
||||
removeTmpDirUntilSuccessAfterTest(t, tempDir)
|
||||
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
|
||||
_, _ = parameterFile.WriteString("zone: \"bananas\"")
|
||||
cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--parameter-file", parameterFile.Name())
|
||||
@@ -169,7 +196,50 @@ func TestTemplateCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
require.EqualError(t, <-execDone, "Parameter value absent in parameter file for \"region\"!")
|
||||
removeTmpDirUntilSuccess(t, tempDir)
|
||||
})
|
||||
|
||||
t.Run("Recreate template with same name (create, delete, create)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
create := func() error {
|
||||
source := clitest.CreateTemplateVersionSource(t, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: provisionCompleteWithAgent,
|
||||
})
|
||||
args := []string{
|
||||
"templates",
|
||||
"create",
|
||||
"my-template",
|
||||
"--yes",
|
||||
"--directory", source,
|
||||
"--test.provisioner", string(database.ProvisionerTypeEcho),
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
return cmd.Execute()
|
||||
}
|
||||
del := func() error {
|
||||
args := []string{
|
||||
"templates",
|
||||
"delete",
|
||||
"my-template",
|
||||
"--yes",
|
||||
}
|
||||
cmd, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
err := create()
|
||||
require.NoError(t, err, "Template must be created without error")
|
||||
err = del()
|
||||
require.NoError(t, err, "Template must be deleted without error")
|
||||
err = create()
|
||||
require.NoError(t, err, "Template must be recreated without error")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,7 +262,7 @@ func createTestParseResponse() []*proto.Parse_Response {
|
||||
|
||||
// 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) {
|
||||
func removeTmpDirUntilSuccessAfterTest(t *testing.T, tempDir string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
err := os.RemoveAll(tempDir)
|
||||
|
||||
+25
-10
@@ -2,6 +2,8 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -11,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func templateDelete() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete [name...]",
|
||||
Short: "Delete templates",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -21,7 +23,7 @@ func templateDelete() *cobra.Command {
|
||||
templates = []codersdk.Template{}
|
||||
)
|
||||
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -32,6 +34,14 @@ func templateDelete() *cobra.Command {
|
||||
|
||||
if len(args) > 0 {
|
||||
templateNames = args
|
||||
|
||||
for _, templateName := range templateNames {
|
||||
template, err := client.TemplateByName(ctx, organization.ID, templateName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template by name: %w", err)
|
||||
}
|
||||
templates = append(templates, template)
|
||||
}
|
||||
} else {
|
||||
allTemplates, err := client.TemplatesByOrganization(ctx, organization.ID)
|
||||
if err != nil {
|
||||
@@ -57,17 +67,19 @@ func templateDelete() *cobra.Command {
|
||||
for _, template := range allTemplates {
|
||||
if template.Name == selection {
|
||||
templates = append(templates, template)
|
||||
templateNames = append(templateNames, template.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, templateName := range templateNames {
|
||||
template, err := client.TemplateByName(ctx, organization.ID, templateName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template by name: %w", err)
|
||||
}
|
||||
|
||||
templates = append(templates, template)
|
||||
// Confirm deletion of the template.
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Delete these templates: %s?", cliui.Styles.Code.Render(strings.Join(templateNames, ", "))),
|
||||
IsConfirm: true,
|
||||
Default: cliui.ConfirmNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, template := range templates {
|
||||
@@ -76,10 +88,13 @@ func templateDelete() *cobra.Command {
|
||||
return xerrors.Errorf("delete template %q: %w", template.Name, err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Deleted template "+cliui.Styles.Code.Render(template.Name)+"!")
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Deleted template "+cliui.Styles.Code.Render(template.Name)+" at "+cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp))+"!")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
@@ -25,14 +28,54 @@ func TestTemplateDelete(t *testing.T) {
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "templates", "delete", template.Name)
|
||||
|
||||
clitest.SetupConfig(t, client, root)
|
||||
require.NoError(t, cmd.Execute())
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
|
||||
pty.ExpectMatch(fmt.Sprintf("Delete these templates: %s?", cliui.Styles.Code.Render(template.Name)))
|
||||
pty.WriteLine("yes")
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
|
||||
_, err := client.Template(context.Background(), template.ID)
|
||||
require.Error(t, err, "template should not exist")
|
||||
})
|
||||
|
||||
t.Run("Multiple", func(t *testing.T) {
|
||||
t.Run("Multiple --yes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
templates := []codersdk.Template{
|
||||
coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID),
|
||||
coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID),
|
||||
coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID),
|
||||
}
|
||||
templateNames := []string{}
|
||||
for _, template := range templates {
|
||||
templateNames = append(templateNames, template.Name)
|
||||
}
|
||||
|
||||
cmd, root := clitest.New(t, append([]string{"templates", "delete", "--yes"}, templateNames...)...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
require.NoError(t, cmd.Execute())
|
||||
|
||||
for _, template := range templates {
|
||||
_, err := client.Template(context.Background(), template.ID)
|
||||
require.Error(t, err, "template should not exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Multiple prompted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
@@ -51,7 +94,19 @@ func TestTemplateDelete(t *testing.T) {
|
||||
|
||||
cmd, root := clitest.New(t, append([]string{"templates", "delete"}, templateNames...)...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
require.NoError(t, cmd.Execute())
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
execDone := make(chan error)
|
||||
go func() {
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
|
||||
pty.ExpectMatch(fmt.Sprintf("Delete these templates: %s?", cliui.Styles.Code.Render(strings.Join(templateNames, ", "))))
|
||||
pty.WriteLine("yes")
|
||||
|
||||
require.NoError(t, <-execDone)
|
||||
|
||||
for _, template := range templates {
|
||||
_, err := client.Template(context.Background(), template.ID)
|
||||
@@ -80,7 +135,7 @@ func TestTemplateDelete(t *testing.T) {
|
||||
execDone <- cmd.Execute()
|
||||
}()
|
||||
|
||||
pty.WriteLine("docker-local")
|
||||
pty.WriteLine("yes")
|
||||
require.NoError(t, <-execDone)
|
||||
|
||||
_, err := client.Template(context.Background(), template.ID)
|
||||
|
||||
+10
-4
@@ -13,7 +13,9 @@ import (
|
||||
|
||||
func templateEdit() *cobra.Command {
|
||||
var (
|
||||
name string
|
||||
description string
|
||||
icon string
|
||||
maxTTL time.Duration
|
||||
minAutostartInterval time.Duration
|
||||
)
|
||||
@@ -23,7 +25,7 @@ func templateEdit() *cobra.Command {
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Edit the metadata of a template by name.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create client: %w", err)
|
||||
}
|
||||
@@ -38,7 +40,9 @@ func templateEdit() *cobra.Command {
|
||||
|
||||
// NOTE: coderd will ignore empty fields.
|
||||
req := codersdk.UpdateTemplateMeta{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Icon: icon,
|
||||
MaxTTLMillis: maxTTL.Milliseconds(),
|
||||
MinAutostartIntervalMillis: minAutostartInterval.Milliseconds(),
|
||||
}
|
||||
@@ -47,14 +51,16 @@ func templateEdit() *cobra.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update template metadata: %w", err)
|
||||
}
|
||||
_, _ = fmt.Printf("Updated template metadata!\n")
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Updated template metadata at %s!\n", cliui.Styles.DateTimeStamp.Render(time.Now().Format(time.Stamp)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&name, "name", "", "", "Edit the template name")
|
||||
cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description")
|
||||
cmd.Flags().DurationVarP(&maxTTL, "max_ttl", "", 0, "Edit the template maximum time before shutdown")
|
||||
cmd.Flags().DurationVarP(&minAutostartInterval, "min_autostart_interval", "", 0, "Edit the template minimum autostart interval")
|
||||
cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path")
|
||||
cmd.Flags().DurationVarP(&maxTTL, "max-ttl", "", 0, "Edit the template maximum time before shutdown - workspaces created from this template cannot stay running longer than this.")
|
||||
cmd.Flags().DurationVarP(&minAutostartInterval, "min-autostart-interval", "", 0, "Edit the template minimum autostart interval - workspaces created from this template must wait at least this long between autostarts.")
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -25,21 +25,26 @@ func TestTemplateEdit(t *testing.T) {
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
ctr.Description = "original description"
|
||||
ctr.Icon = "/icons/default-icon.png"
|
||||
ctr.MaxTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
|
||||
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
|
||||
})
|
||||
|
||||
// Test the cli command.
|
||||
name := "new-template-name"
|
||||
desc := "lorem ipsum dolor sit amet et cetera"
|
||||
icon := "/icons/new-icon.png"
|
||||
maxTTL := 12 * time.Hour
|
||||
minAutostartInterval := time.Minute
|
||||
cmdArgs := []string{
|
||||
"templates",
|
||||
"edit",
|
||||
template.Name,
|
||||
"--name", name,
|
||||
"--description", desc,
|
||||
"--max_ttl", maxTTL.String(),
|
||||
"--min_autostart_interval", minAutostartInterval.String(),
|
||||
"--icon", icon,
|
||||
"--max-ttl", maxTTL.String(),
|
||||
"--min-autostart-interval", minAutostartInterval.String(),
|
||||
}
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
@@ -51,7 +56,9 @@ func TestTemplateEdit(t *testing.T) {
|
||||
// Assert that the template metadata changed.
|
||||
updated, err := client.Template(context.Background(), template.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, name, updated.Name)
|
||||
assert.Equal(t, desc, updated.Description)
|
||||
assert.Equal(t, icon, updated.Icon)
|
||||
assert.Equal(t, maxTTL.Milliseconds(), updated.MaxTTLMillis)
|
||||
assert.Equal(t, minAutostartInterval.Milliseconds(), updated.MinAutostartIntervalMillis)
|
||||
})
|
||||
@@ -64,6 +71,7 @@ func TestTemplateEdit(t *testing.T) {
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
ctr.Description = "original description"
|
||||
ctr.Icon = "/icons/default-icon.png"
|
||||
ctr.MaxTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
|
||||
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
|
||||
})
|
||||
@@ -73,9 +81,11 @@ func TestTemplateEdit(t *testing.T) {
|
||||
"templates",
|
||||
"edit",
|
||||
template.Name,
|
||||
"--name", template.Name,
|
||||
"--description", template.Description,
|
||||
"--max_ttl", (time.Duration(template.MaxTTLMillis) * time.Millisecond).String(),
|
||||
"--min_autostart_interval", (time.Duration(template.MinAutostartIntervalMillis) * time.Millisecond).String(),
|
||||
"--icon", template.Icon,
|
||||
"--max-ttl", (time.Duration(template.MaxTTLMillis) * time.Millisecond).String(),
|
||||
"--min-autostart-interval", (time.Duration(template.MinAutostartIntervalMillis) * time.Millisecond).String(),
|
||||
}
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
@@ -87,7 +97,9 @@ func TestTemplateEdit(t *testing.T) {
|
||||
// Assert that the template metadata did not change.
|
||||
updated, err := client.Template(context.Background(), template.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, template.Name, updated.Name)
|
||||
assert.Equal(t, template.Description, updated.Description)
|
||||
assert.Equal(t, template.Icon, updated.Icon)
|
||||
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
|
||||
assert.Equal(t, template.MinAutostartIntervalMillis, updated.MinAutostartIntervalMillis)
|
||||
})
|
||||
|
||||
+9
-3
@@ -24,13 +24,19 @@ func templateInit() *cobra.Command {
|
||||
exampleNames := []string{}
|
||||
exampleByName := map[string]examples.Example{}
|
||||
for _, example := range exampleList {
|
||||
exampleNames = append(exampleNames, example.Name)
|
||||
exampleByName[example.Name] = example
|
||||
name := fmt.Sprintf(
|
||||
"%s\n%s\n%s\n",
|
||||
cliui.Styles.Bold.Render(example.Name),
|
||||
cliui.Styles.Wrap.Copy().PaddingLeft(6).Render(example.Description),
|
||||
cliui.Styles.Keyword.Copy().PaddingLeft(6).Render(example.URL),
|
||||
)
|
||||
exampleNames = append(exampleNames, name)
|
||||
exampleByName[name] = example
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Wrap.Render(
|
||||
"A template defines infrastructure as code to be provisioned "+
|
||||
"for individual developer workspaces. Select an example to get started:\n"))
|
||||
"for individual developer workspaces. Select an example to be copied to the active directory:\n"))
|
||||
option, err := cliui.Select(cmd, cliui.SelectOptions{
|
||||
Options: exampleNames,
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user