Compare commits
1062 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abf14d976a | |||
| 0f3221f9d0 | |||
| c13e68248b | |||
| 9dcbe753f4 | |||
| cc1602ad78 | |||
| c619138ece | |||
| 62357084ba | |||
| aefb477e21 | |||
| 443173c071 | |||
| 3cb2d52a08 | |||
| a70278e0e1 | |||
| b1a095e486 | |||
| a64731eea5 | |||
| 934777d9ca | |||
| 5411abb9c1 | |||
| b402c6aba8 | |||
| 8047a3ea61 | |||
| cf999f3e28 | |||
| 704840c04e | |||
| 5ca17c3f63 | |||
| 3120c94c22 | |||
| 2687e3db49 | |||
| d22996ea20 | |||
| 6bc03907bd | |||
| b1faaef482 | |||
| b50bb99fe7 | |||
| daa34cf7b8 | |||
| 85c679597c | |||
| cb54986d3f | |||
| 5e594adfba | |||
| eefc26c108 | |||
| dd5173b45c | |||
| c01910fb75 | |||
| 0ad8e775a5 | |||
| 3ad27b547f | |||
| 50966c4cf7 | |||
| 34f799257c | |||
| 257df81667 | |||
| 2b6586d542 | |||
| adcf8838d2 | |||
| e8e095e2f8 | |||
| 3cc77d96eb | |||
| 3049a56355 | |||
| 915bb41ea2 | |||
| 05670d133e | |||
| 32bb1e7ce9 | |||
| a89d6909b2 | |||
| f5df54831a | |||
| fe7c9f8ec1 | |||
| 9cf3e102ba | |||
| 3b15f13ae4 | |||
| 9b1ff43e9f | |||
| ea42212a2a | |||
| 0ebcb7de55 | |||
| d275331c13 | |||
| 29a2fe46e8 | |||
| 93b8121c9b | |||
| 1386465631 | |||
| bbe2baf3f6 | |||
| 3ad5e11d22 | |||
| 9a670b90df | |||
| 2a66395fb7 | |||
| 4f3958c831 | |||
| b65c555dfc | |||
| 8d14076a23 | |||
| 3759bb2a9a | |||
| 504cd462a7 | |||
| 8940ea179e | |||
| 587017665a | |||
| 06d7e368ab | |||
| f2952000d9 | |||
| a6bb3b29d0 | |||
| db7030716d | |||
| 45c05a0896 | |||
| ffbaa93722 | |||
| 18b282cabb | |||
| 78283cf236 | |||
| d165d76338 | |||
| ce953441fb | |||
| cd4ab97efa | |||
| 6325a9ea91 | |||
| a1056bfa2a | |||
| bf63cc929a | |||
| 1d88b9c65c | |||
| 738a38d71f | |||
| 9bc0d06aa0 | |||
| aa3812ff4e | |||
| 15d7b78527 | |||
| cb62e16b41 | |||
| 46f194e7f1 | |||
| 0a95ba62b1 | |||
| 4f6355506c | |||
| df2649ed2a | |||
| d11d83cc98 | |||
| bbebc1a86a | |||
| 74cd31bdb1 | |||
| 88d49dbcab | |||
| c7aea2fc42 | |||
| 087a7defde | |||
| 951343aa06 | |||
| 2c74d974ca | |||
| 132a788c54 | |||
| f2051218ee | |||
| fc1536daab | |||
| bf0d530e78 | |||
| 093e3bb3d7 | |||
| f077e14b38 | |||
| 6c0552a5d4 | |||
| 9104a067d6 | |||
| 00d0620679 | |||
| 78a39a809d | |||
| 092a22f242 | |||
| 4919975f13 | |||
| 3ab8d57630 | |||
| d931b2c10d | |||
| 139bc6f58b | |||
| d8008de77a | |||
| 69c73b2d28 | |||
| f9b7588963 | |||
| c9bedc5e58 | |||
| 8c4de49359 | |||
| 4b540b7c42 | |||
| e49ef68ebc | |||
| 1755e97748 | |||
| d9a61dd4c8 | |||
| 776f287685 | |||
| 70d7dd9b2f | |||
| e6f568fcac | |||
| 028a4edbd4 | |||
| 6d2b7ea3ba | |||
| 9339d597b9 | |||
| 574635f43d | |||
| 0c75ea6286 | |||
| df7c7393ad | |||
| 2a7fe13397 | |||
| af502a6a66 | |||
| 6c83012082 | |||
| 518f6960d0 | |||
| d38cc75f31 | |||
| 31aaa1ed59 | |||
| 59cc4a2586 | |||
| 9775228b00 | |||
| 65ff604969 | |||
| fedb180735 | |||
| 21e6bea792 | |||
| 27c8345ef2 | |||
| 5a449bf86f | |||
| a7e08db16d | |||
| b6426083b9 | |||
| 4f453544d4 | |||
| 47a53ce6c5 | |||
| 20bcb04e8a | |||
| c86fc6e976 | |||
| 2f0d30d7b5 | |||
| 48c0b59447 | |||
| 39cf329404 | |||
| ee4b934601 | |||
| b8ec5c786d | |||
| c37ecdb9ff | |||
| 413bfb8d58 | |||
| 112eaf80d1 | |||
| 4054a9c7cb | |||
| 6571e52f17 | |||
| 28428d1294 | |||
| 3c215a83b6 | |||
| 266a3b24e7 | |||
| f9075cab0e | |||
| b64f624d17 | |||
| ea115c981d | |||
| 1c85799be5 | |||
| 15b9a59786 | |||
| 95aea104c7 | |||
| 4c8be34d81 | |||
| f160830226 | |||
| 38e2a28ada | |||
| 189c562826 | |||
| ee00a1d886 | |||
| 99013b3aed | |||
| 8cd5aeaf25 | |||
| 1214022c5a | |||
| 8738755ffc | |||
| 1e1967e0db | |||
| 7898581e50 | |||
| 6b365f46f5 | |||
| 2e30d0512e | |||
| 4183c5e1d0 | |||
| 6deef06ad2 | |||
| 80b45f1aa1 | |||
| a7ee8b31e0 | |||
| 9e099b543f | |||
| 5fd90471fc | |||
| 57c84d6446 | |||
| b77d6bdd91 | |||
| 764600003b | |||
| 7ad4276224 | |||
| 656dcc0050 | |||
| 5de6f86959 | |||
| 1bf2dc0cc3 | |||
| 5698b9d706 | |||
| 3db9ea9dd2 | |||
| 93475453d8 | |||
| ceef283bfd | |||
| d30945c5c5 | |||
| 0899548208 | |||
| eb71053e56 | |||
| 5e2efb68f1 | |||
| 50321ba2aa | |||
| bc47d7ce69 | |||
| 3618b098cb | |||
| 2ca7214259 | |||
| 8d7954b015 | |||
| 67230babc0 | |||
| 3993f66997 | |||
| db0ba8588e | |||
| 714c366d16 | |||
| 1186e643ec | |||
| 7fe7ffea6d | |||
| 72d6731924 | |||
| 153e96f574 | |||
| 794b88fab4 | |||
| 29d804e692 | |||
| adad347902 | |||
| 6f82ad09c8 | |||
| 353fb8724a | |||
| 3e4b67893e | |||
| 9196b3978d | |||
| 732bc5910c | |||
| 64e4ea73c0 | |||
| bf8d823ae3 | |||
| f314f30ebc | |||
| 36a599ea9a | |||
| 68ee82437e | |||
| d499416024 | |||
| b3d07ffd87 | |||
| 63fd4945a2 | |||
| b340634aaa | |||
| 1bca269b90 | |||
| 77acf0c340 | |||
| fc841898cd | |||
| 6e9c05f859 | |||
| 21664c5c58 | |||
| 9e12850f38 | |||
| 86fdafda23 | |||
| b2bc74e3af | |||
| 87ab6ae8a0 | |||
| b8bd3208ca | |||
| 9e9a9e0cd2 | |||
| 40c0fc285c | |||
| b78ab9e028 | |||
| 938bd7341b | |||
| 45f39ba488 | |||
| e847e7386a | |||
| ec453f01e4 | |||
| 22e49c4316 | |||
| 62d97b18f4 | |||
| a01ab27751 | |||
| b20ecfdf37 | |||
| b6712ffbee | |||
| 4f0417c6ad | |||
| 0f8c2f592e | |||
| 478d49c19c | |||
| 0552c36e29 | |||
| 9b5ee8f267 | |||
| 9ab437d6e2 | |||
| 99a7a8dd22 | |||
| f16dd5acb4 | |||
| d57c181aad | |||
| 3ded910cca | |||
| 214e59452f | |||
| 83c35bb916 | |||
| 21e8fb243b | |||
| 57c7fcf27f | |||
| 1ee1db9664 | |||
| a4980446c5 | |||
| 708bdbc134 | |||
| 850a83097c | |||
| a2098254cd | |||
| 846dd999b7 | |||
| 7e54413d3b | |||
| e9efb7e253 | |||
| 34a2d40f27 | |||
| 184e7dbce0 | |||
| 0e59cb21ce | |||
| 5c0d63d31f | |||
| d4f0a6fecf | |||
| 4db98b2b9f | |||
| cab6fe9482 | |||
| edec39baef | |||
| a7a56f9a26 | |||
| 0551a6cba2 | |||
| 42d1b5e4ba | |||
| 4f78368403 | |||
| 31f25002a6 | |||
| 2b8223bdd5 | |||
| 07e2565a4f | |||
| 761f1e7c1a | |||
| 09da3858ce | |||
| b4c29f34c3 | |||
| d0b02e581d | |||
| 66ad86a755 | |||
| 43f368dfc4 | |||
| e5e1ed2f9c | |||
| 067069d2e2 | |||
| 5b5bc1da56 | |||
| 6e20f9c729 | |||
| 9e148a5cac | |||
| f5bbbdf638 | |||
| 522fde47dc | |||
| 29bac36816 | |||
| 442df9e132 | |||
| 849e389388 | |||
| 20d950d1b3 | |||
| ba6a868a80 | |||
| ce211fd8f5 | |||
| 8a94b72c7d | |||
| f6aa025a01 | |||
| 346583f13e | |||
| abb804f2de | |||
| d380c9494d | |||
| 4e26e325a6 | |||
| c026464375 | |||
| 3610f09c77 | |||
| d38e645492 | |||
| 9c5b879b16 | |||
| 2c41343ce5 | |||
| 7dc73ed6c6 | |||
| 5e04a2f800 | |||
| e1afec6db4 | |||
| bb4a681833 | |||
| 6a3876d6df | |||
| 8596023e31 | |||
| 7718fa53c9 | |||
| 519d724ca4 | |||
| 332056af29 | |||
| 2b0fcf3ece | |||
| c8d9c44aba | |||
| f510f01768 | |||
| 2a085d1936 | |||
| 47ee44e5ca | |||
| 9a07d5de6e | |||
| 11abb85df5 | |||
| a00fdd699f | |||
| 1359850715 | |||
| 720c9dadcf | |||
| 762063ed8f | |||
| 87379f413f | |||
| c880263926 | |||
| a79e34c0c7 | |||
| ac279b3483 | |||
| d46b04cb1e | |||
| 819622182b | |||
| 3d6d51fbd0 | |||
| ad24404018 | |||
| 69f430257c | |||
| cd85be52de | |||
| 3db927bc09 | |||
| 00104096c2 | |||
| 73ec618aff | |||
| f9ef4b148b | |||
| 80352656e9 | |||
| 0f0e3d1068 | |||
| b077f71015 | |||
| d2e6f305b1 | |||
| 502a7370c8 | |||
| d970d2d3da | |||
| bb17fe5398 | |||
| 65d63f9167 | |||
| b1bdf10e38 | |||
| dca24bd15d | |||
| 18af9426c0 | |||
| bb0e79eb88 | |||
| 5301d36027 | |||
| f5ba90b963 | |||
| 30ce62b5b4 | |||
| a7cdec5d39 | |||
| 1b6f9e54a3 | |||
| 3264960fb3 | |||
| 3c94ca9cbe | |||
| 94eb503aac | |||
| 419d701927 | |||
| 4f0105ef7e | |||
| 209e011404 | |||
| 1f55135765 | |||
| 8e1dfc2763 | |||
| 1b56a8cccb | |||
| e3bbc77c35 | |||
| 1254e7a902 | |||
| 38825b9ab4 | |||
| d6812e0be8 | |||
| 2fa77a9bbd | |||
| 3ca6f1fcd4 | |||
| 1a5d3eace4 | |||
| 00f05e798b | |||
| d8f9537880 | |||
| 05e2806ff3 | |||
| 67c4605370 | |||
| 271d075667 | |||
| 0a7fad674a | |||
| 1b3e75c3ab | |||
| aae57476f1 | |||
| 0372586382 | |||
| a24f26c137 | |||
| 4f4d470c7c | |||
| a09ffd6c0d | |||
| ac50070713 | |||
| 2e1db6cc63 | |||
| e490bdd531 | |||
| d350d9033c | |||
| ff0aa8d742 | |||
| de219d966d | |||
| 3be7bb58b4 | |||
| 6fe63ed358 | |||
| 5618640227 | |||
| 55c13c8ff9 | |||
| fefdff4946 | |||
| e6699d25ca | |||
| 8c70b6c360 | |||
| 21ae411237 | |||
| b9e5cc97a1 | |||
| f1976a086f | |||
| e20ff62c9f | |||
| afd6834ff7 | |||
| e1a4f3a16b | |||
| 46bf265e9b | |||
| 4c18034260 | |||
| 3f73243b37 | |||
| 2d347657dc | |||
| 3c91b92930 | |||
| 04b03792cb | |||
| 80e9f24ac7 | |||
| be273a20a7 | |||
| 081259314b | |||
| ff026d4890 | |||
| cde036c1ab | |||
| 30f8fd9b95 | |||
| e0cb52ceea | |||
| 5f0b13795a | |||
| 1efcd33d63 | |||
| 6d95145d3b | |||
| 6826b976d7 | |||
| f4c8bfdc18 | |||
| 5b9573d7c1 | |||
| b57b8b887d | |||
| f4a78c976f | |||
| 567e750659 | |||
| 9bd83e5ec7 | |||
| 00da01fdf7 | |||
| 9583e16a05 | |||
| 5362f4636e | |||
| aa9a1c3f56 | |||
| e6802f0a56 | |||
| 774d7588dd | |||
| 126d71f41d | |||
| 6644e951d8 | |||
| 02c0100d4d | |||
| 01a06e1213 | |||
| a410ac42f5 | |||
| f037aad456 | |||
| 1dc0485027 | |||
| 0708e37a38 | |||
| 190310464d | |||
| 8a60ee0391 | |||
| 20086c1e77 | |||
| c4a9be9c41 | |||
| cc346afce6 | |||
| 05f932b37e | |||
| 053fe6ff61 | |||
| 3cf17d34e7 | |||
| 779c446a6e | |||
| 62f686c003 | |||
| 6285d65b6a | |||
| 611ca55458 | |||
| 34d902ebf1 | |||
| dc9b4155e0 | |||
| f4c5020f63 | |||
| b9b9c2fb9f | |||
| 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 |
@@ -0,0 +1,83 @@
|
||||
FROM ubuntu
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
ENV EDITOR=vim
|
||||
|
||||
RUN apt-get update && apt-get upgrade
|
||||
|
||||
RUN apt-get install --yes \
|
||||
ca-certificates \
|
||||
bash-completion \
|
||||
build-essential \
|
||||
curl \
|
||||
cmake \
|
||||
direnv \
|
||||
emacs-nox \
|
||||
gnupg \
|
||||
htop \
|
||||
jq \
|
||||
less \
|
||||
lsb-release \
|
||||
lsof \
|
||||
man-db \
|
||||
nano \
|
||||
neovim \
|
||||
ssl-cert \
|
||||
sudo \
|
||||
unzip \
|
||||
xz-utils \
|
||||
zip
|
||||
|
||||
# configure locales to UTF8
|
||||
RUN apt-get install locales && locale-gen en_US.UTF-8
|
||||
ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8'
|
||||
|
||||
# configure direnv
|
||||
RUN direnv hook bash >> $HOME/.bashrc
|
||||
|
||||
# install nix
|
||||
RUN sh <(curl -L https://nixos.org/nix/install) --daemon
|
||||
|
||||
RUN mkdir -p $HOME/.config/nix $HOME/.config/nixpkgs \
|
||||
&& echo 'sandbox = false' >> $HOME/.config/nix/nix.conf \
|
||||
&& echo '{ allowUnfree = true; }' >> $HOME/.config/nixpkgs/config.nix \
|
||||
&& echo '. $HOME/.nix-profile/etc/profile.d/nix.sh' >> $HOME/.bashrc
|
||||
|
||||
|
||||
# install docker and configure daemon to use vfs as GitHub codespaces requires vfs
|
||||
# https://github.com/moby/moby/issues/13742#issuecomment-725197223
|
||||
RUN mkdir -p /etc/apt/keyrings \
|
||||
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||
&& echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null \
|
||||
&& apt-get update \
|
||||
&& apt-get install --yes docker-ce docker-ce-cli containerd.io docker-compose-plugin \
|
||||
&& mkdir -p /etc/docker \
|
||||
&& echo '{"cgroup-parent":"/actions_job","storage-driver":"vfs"}' >> /etc/docker/daemon.json
|
||||
|
||||
# install golang and language tooling
|
||||
ENV GO_VERSION=1.19
|
||||
ENV GOPATH=$HOME/go-packages
|
||||
ENV GOROOT=$HOME/go
|
||||
ENV PATH=$GOROOT/bin:$GOPATH/bin:$PATH
|
||||
RUN curl -fsSL https://dl.google.com/go/go$GO_VERSION.linux-amd64.tar.gz | tar xzs
|
||||
RUN echo 'export PATH=$GOPATH/bin:$PATH' >> $HOME/.bashrc
|
||||
|
||||
RUN bash -c ". $HOME/.bashrc \
|
||||
go install -v golang.org/x/tools/gopls@latest \
|
||||
&& go install -v mvdan.cc/sh/v3/cmd/shfmt@latest \
|
||||
"
|
||||
|
||||
# install nodejs
|
||||
RUN bash -c "$(curl -fsSL https://deb.nodesource.com/setup_14.x)" \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# install zstd
|
||||
RUN bash -c "$(curl -fsSL https://raw.githubusercontent.com/horta/zstd.install/main/install)"
|
||||
|
||||
# install nfpm
|
||||
RUN echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list \
|
||||
&& apt update \
|
||||
&& apt install nfpm
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json
|
||||
{
|
||||
"name": "Development environments on your infrastructure",
|
||||
|
||||
// Sets the run context to one level up instead of the .devcontainer folder.
|
||||
"context": ".",
|
||||
|
||||
// Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
|
||||
"dockerFile": "Dockerfile",
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
"postStartCommand": "dockerd",
|
||||
|
||||
// privileged is required by GitHub codespaces - https://github.com/microsoft/vscode-dev-containers/issues/727
|
||||
"runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined", "--privileged", "--init" ]
|
||||
}
|
||||
+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,4 @@
|
||||
site/ @coder/frontend
|
||||
docs/ @coder/docs
|
||||
README.md @coder/docs
|
||||
ADOPTERS.md @coder/docs
|
||||
|
||||
+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,13 +1,3 @@
|
||||
<!-- Help reviewers by listing the subtasks in this PR
|
||||
|
||||
Here's an example:
|
||||
|
||||
This PR adds a new feature to the CLI.
|
||||
|
||||
## Subtasks
|
||||
|
||||
- [x] added a test for feature
|
||||
|
||||
Fixes #345
|
||||
|
||||
<!--
|
||||
Check if your change requires documentation edits before merging: https://coder.com/docs/coder. Make edits in `docs/`.
|
||||
-->
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,26 @@
|
||||
name: "CLA Assistant"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened,closed,synchronize]
|
||||
|
||||
jobs:
|
||||
CLAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "CLA Assistant"
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.2.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||
PERSONAL_ACCESS_TOKEN : ${{ secrets.CDRCOMMUNITY_GITHUB_TOKEN }}
|
||||
with:
|
||||
remote-organization-name: 'coder'
|
||||
remote-repository-name: 'cla'
|
||||
path-to-signatures: 'v2022-09-04/signatures.json'
|
||||
path-to-document: 'https://github.com/coder/cla/blob/main/README.md'
|
||||
# branch should not be protected
|
||||
branch: 'main'
|
||||
allowlist: dependabot*
|
||||
+261
-127
@@ -4,8 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
pull_request:
|
||||
|
||||
@@ -30,6 +28,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@v1.12.8
|
||||
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 +94,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 +148,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
|
||||
|
||||
@@ -104,21 +191,55 @@ jobs:
|
||||
- name: Install node_modules
|
||||
run: ./scripts/yarn_install.sh
|
||||
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v1
|
||||
with:
|
||||
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: Install Protoc
|
||||
run: |
|
||||
# protoc must be in lockstep with our dogfood Dockerfile
|
||||
# or the version in the comments will differ.
|
||||
set -x
|
||||
cd dogfood
|
||||
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
|
||||
protoc_path=/usr/local/bin/protoc
|
||||
docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_path
|
||||
chmod +x $protoc_path
|
||||
protoc --version
|
||||
|
||||
- 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"
|
||||
@@ -148,7 +269,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
|
||||
|
||||
@@ -167,7 +289,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
|
||||
@@ -179,7 +301,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
|
||||
@@ -197,47 +319,55 @@ 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=5m -count=$GOCOUNT -short -failfast
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: always() && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
env:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
DD_DATABASE: fake
|
||||
DD_CATEGORY: unit
|
||||
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: go run scripts/datadog-cireport/main.go gotests.xml
|
||||
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
|
||||
|
||||
- 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
|
||||
@@ -249,7 +379,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
|
||||
@@ -267,56 +397,32 @@ 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"
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
if: always() && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork
|
||||
env:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
DD_DATABASE: postgresql
|
||||
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: go run scripts/datadog-cireport/main.go gotests.xml
|
||||
run: make test-postgres
|
||||
|
||||
- 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: 30
|
||||
if: github.ref == 'refs/heads/main' && !github.event.pull_request.head.repo.fork
|
||||
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
|
||||
@@ -336,7 +442,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
|
||||
@@ -367,36 +473,30 @@ jobs:
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
- name: Install nfpm
|
||||
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.16.0
|
||||
|
||||
- name: Build site
|
||||
run: make -B site/out/index.html
|
||||
- name: Install zstd
|
||||
run: sudo apt-get install -y zstd
|
||||
|
||||
- name: Build Release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go mod download
|
||||
|
||||
mkdir -p ./dist
|
||||
# build slim binaries
|
||||
./scripts/build_go_slim.sh \
|
||||
--output ./dist/ \
|
||||
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
|
||||
version="$(./scripts/version.sh)"
|
||||
make gen/mark-fresh
|
||||
make -j \
|
||||
build/coder_"$version"_windows_amd64.zip \
|
||||
build/coder_"$version"_linux_amd64.{tar.gz,deb}
|
||||
|
||||
- name: Install Release
|
||||
run: |
|
||||
gcloud config set project coder-dogfood
|
||||
gcloud config set compute/zone us-central1-a
|
||||
gcloud compute scp ./dist/coder_*_linux_amd64.deb coder:/tmp/coder.deb
|
||||
gcloud compute scp ./build/coder_*_linux_amd64.deb coder:/tmp/coder.deb
|
||||
gcloud compute ssh coder -- sudo dpkg -i --force-confdef /tmp/coder.deb
|
||||
gcloud compute ssh coder -- sudo systemctl daemon-reload
|
||||
|
||||
@@ -407,11 +507,9 @@ jobs:
|
||||
with:
|
||||
name: coder
|
||||
path: |
|
||||
./dist/*.zip
|
||||
./dist/*.tar.gz
|
||||
./dist/*.apk
|
||||
./dist/*.deb
|
||||
./dist/*.rpm
|
||||
./build/*.zip
|
||||
./build/*.tar.gz
|
||||
./build/*.deb
|
||||
retention-days: 7
|
||||
|
||||
test-js:
|
||||
@@ -432,11 +530,6 @@ jobs:
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
|
||||
# Go is required for uploading the test results to datadog
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "14"
|
||||
@@ -448,24 +541,22 @@ 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
|
||||
env:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
DD_CATEGORY: unit
|
||||
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: go run scripts/datadog-cireport/main.go site/test-results/junit.xml
|
||||
|
||||
test-e2e:
|
||||
name: "test/e2e/${{ matrix.os }}"
|
||||
needs:
|
||||
- changes
|
||||
if: needs.changes.outputs.docs-only == 'false'
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
@@ -482,18 +573,15 @@ 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
|
||||
@@ -520,6 +608,7 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
sudo npm install -g prettier
|
||||
make -B site/out/index.html
|
||||
|
||||
- run: yarn playwright:install
|
||||
@@ -533,10 +622,55 @@ jobs:
|
||||
DEBUG: pw:api
|
||||
working-directory: site
|
||||
|
||||
- name: Upload DataDog Trace
|
||||
- name: Upload Playwright Failed Tests
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
env:
|
||||
DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
DD_CATEGORY: e2e
|
||||
GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
run: go run scripts/datadog-cireport/main.go site/test-results/junit.xml
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: failed-test-videos
|
||||
path: ./site/test-results/**/*.webm
|
||||
retention-days: 7
|
||||
|
||||
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,13 @@
|
||||
# Dependabot is annoying, but this makes it a bit less so.
|
||||
name: Auto Approve Dependabot
|
||||
|
||||
on: pull_request_target
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: hmarr/auto-approve-action@v2
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
@@ -0,0 +1,49 @@
|
||||
name: dogfood
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
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@v6.1
|
||||
|
||||
- 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
|
||||
+84
-185
@@ -1,10 +1,4 @@
|
||||
# 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:
|
||||
@@ -21,11 +15,17 @@ on:
|
||||
type: boolean
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
# Required to publish a release
|
||||
contents: write
|
||||
# Necessary to push docker images to ghcr.io.
|
||||
packages: write
|
||||
|
||||
env:
|
||||
CODER_RELEASE: ${{ github.event.inputs.snapshot && 'false' || 'true' }}
|
||||
|
||||
jobs:
|
||||
linux-windows:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Necessary for Docker manifest
|
||||
@@ -47,12 +47,12 @@ jobs:
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.18"
|
||||
go-version: "~1.19"
|
||||
|
||||
- name: Cache Node
|
||||
id: cache-node
|
||||
@@ -66,47 +66,66 @@ jobs:
|
||||
js-${{ runner.os }}-
|
||||
|
||||
- name: Install nfpm
|
||||
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.16.0
|
||||
run: |
|
||||
set -euo pipefail
|
||||
wget -O /tmp/nfpm.deb https://github.com/goreleaser/nfpm/releases/download/v2.18.1/nfpm_amd64.deb
|
||||
sudo dpkg -i /tmp/nfpm.deb
|
||||
- name: Install zstd
|
||||
run: sudo apt-get install -y zstd
|
||||
|
||||
- name: Build Site
|
||||
run: make site/out/index.html
|
||||
- name: Install rcodesign
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
- name: Build Linux and Windows Binaries
|
||||
# Install a prebuilt binary of rcodesign for linux amd64. Once the
|
||||
# following PR is merged and released upstream, we can download
|
||||
# directly from GitHub releases instead:
|
||||
# https://github.com/indygreg/PyOxidizer/pull/635
|
||||
wget -O /tmp/rcodesign https://cdn.discordapp.com/attachments/283356472258199552/1016767245717872700/rcodesign
|
||||
sudo install --mode 755 /tmp/rcodesign /usr/local/bin/rcodesign
|
||||
|
||||
- name: Setup Apple Developer certificate and API key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
touch /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
|
||||
chmod 600 /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
|
||||
echo "$AC_CERTIFICATE_P12_BASE64" | base64 -d > /tmp/apple_cert.p12
|
||||
echo "$AC_CERTIFICATE_PASSWORD" > /tmp/apple_cert_password.txt
|
||||
echo "$AC_APIKEY_P8_BASE64" | base64 -d > /tmp/apple_apikey.p8
|
||||
env:
|
||||
AC_CERTIFICATE_P12_BASE64: ${{ secrets.AC_CERTIFICATE_P12_BASE64 }}
|
||||
AC_CERTIFICATE_PASSWORD: ${{ secrets.AC_CERTIFICATE_PASSWORD }}
|
||||
AC_APIKEY_P8_BASE64: ${{ secrets.AC_APIKEY_P8_BASE64 }}
|
||||
|
||||
- name: Build binaries
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go mod download
|
||||
|
||||
mkdir -p ./dist
|
||||
# build slim binaries
|
||||
./scripts/build_go_slim.sh \
|
||||
--output ./dist/ \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64 \
|
||||
darwin:amd64,arm64
|
||||
version="$(./scripts/version.sh)"
|
||||
make gen/mark-fresh
|
||||
make -j \
|
||||
build/coder_"$version"_linux_{amd64,armv7,arm64}.{tar.gz,apk,deb,rpm} \
|
||||
build/coder_"$version"_{darwin,windows}_{amd64,arm64}.zip \
|
||||
build/coder_helm_"$version".tgz
|
||||
env:
|
||||
CODER_SIGN_DARWIN: "1"
|
||||
AC_CERTIFICATE_FILE: /tmp/apple_cert.p12
|
||||
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
|
||||
AC_APIKEY_ISSUER_ID: ${{ secrets.AC_APIKEY_ISSUER_ID }}
|
||||
AC_APIKEY_ID: ${{ secrets.AC_APIKEY_ID }}
|
||||
AC_APIKEY_FILE: /tmp/apple_apikey.p8
|
||||
|
||||
# build linux and windows binaries
|
||||
./scripts/build_go_matrix.sh \
|
||||
--output ./dist/ \
|
||||
--archive \
|
||||
--package-linux \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64
|
||||
- name: Delete Apple Developer certificate and API key
|
||||
run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
|
||||
|
||||
- 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
|
||||
# build Docker images for each architecture
|
||||
version="$(./scripts/version.sh)"
|
||||
make -j build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
|
||||
|
||||
# we can't build multi-arch if the images aren't pushed, so quit now
|
||||
# if dry-running
|
||||
@@ -115,10 +134,9 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# build and push multi-arch manifest
|
||||
./scripts/build_docker_multiarch.sh \
|
||||
--push \
|
||||
"${images[@]}"
|
||||
# build and push multi-arch manifest, this depends on the other images
|
||||
# being pushed so will automatically push them.
|
||||
make -j push/build/coder_"$version"_linux.tag
|
||||
|
||||
# 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
|
||||
@@ -127,154 +145,35 @@ jobs:
|
||||
./scripts/build_docker_multiarch.sh \
|
||||
--push \
|
||||
--target "$(./scripts/image_tag.sh --version latest)" \
|
||||
"${images[@]}"
|
||||
$(cat build/coder_"$version"_linux_{amd64,arm64,armv7}.tag)
|
||||
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
|
||||
- name: ls build
|
||||
run: ls -lh build
|
||||
|
||||
# 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
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.AC_CERTIFICATE_P12_BASE64 }}
|
||||
p12-password: ${{ secrets.AC_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- 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 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
|
||||
|
||||
- name: Build Site
|
||||
run: make site/out/index.html
|
||||
|
||||
- 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/ \
|
||||
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:
|
||||
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 Release
|
||||
- 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/*.apk \
|
||||
./artifacts/*.deb \
|
||||
./artifacts/*.rpm
|
||||
./build/*.zip \
|
||||
./build/*.tar.gz \
|
||||
./build/*.tgz \
|
||||
./build/*.apk \
|
||||
./build/*.deb \
|
||||
./build/*.rpm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload artifacts to actions (if dry-run or snapshot)
|
||||
if: ${{ github.event.inputs.dry_run || github.event.inputs.snapshot }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: release-artifacts
|
||||
path: |
|
||||
./build/*.zip
|
||||
./build/*.tar.gz
|
||||
./build/*.tgz
|
||||
./build/*.apk
|
||||
./build/*.deb
|
||||
./build/*.rpm
|
||||
retention-days: 7
|
||||
|
||||
@@ -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@v6.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",
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Welcome
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: wow-actions/welcome@v1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FIRST_PR_REACTIONS: '+1, hooray, rocket, heart'
|
||||
FIRST_PR_COMMENT: |
|
||||
👋 Welcome @{{ author }} to Coder! Yo @coder/docs this is @{{ author }}'s first pull-request here!
|
||||
FIRST_PR_MERGED: |
|
||||
🎉 Thanks for the contribution @{{ author }}! Yo @coder/docs @{{ author }}'s first contribution has been merged! 👀👀👀
|
||||
+8
-1
@@ -13,7 +13,9 @@ node_modules
|
||||
vendor
|
||||
.eslintcache
|
||||
yarn-error.log
|
||||
gotests.coverage
|
||||
.idea
|
||||
.gitpod.yml
|
||||
.DS_Store
|
||||
|
||||
# Front-end ignore
|
||||
@@ -29,13 +31,18 @@ site/**/*.typegen.ts
|
||||
site/build-storybook.log
|
||||
|
||||
# Build
|
||||
dist/
|
||||
/build/
|
||||
/dist/
|
||||
site/out/
|
||||
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfplan
|
||||
*.lock.hcl
|
||||
.terraform/
|
||||
|
||||
.vscode/*.log
|
||||
.vscode/launch.json
|
||||
**/*.swp
|
||||
.coderv2/*
|
||||
**/__debug_bin
|
||||
|
||||
Vendored
+65
-7
@@ -2,29 +2,39 @@
|
||||
"cSpell.words": [
|
||||
"apps",
|
||||
"awsidentity",
|
||||
"bodyclose",
|
||||
"buildinfo",
|
||||
"buildname",
|
||||
"circbuf",
|
||||
"cliflag",
|
||||
"cliui",
|
||||
"codecov",
|
||||
"coderd",
|
||||
"coderdenttest",
|
||||
"coderdtest",
|
||||
"codersdk",
|
||||
"cronstrue",
|
||||
"databasefake",
|
||||
"DERP",
|
||||
"derphttp",
|
||||
"derpmap",
|
||||
"devel",
|
||||
"drpc",
|
||||
"drpcconn",
|
||||
"drpcmux",
|
||||
"drpcserver",
|
||||
"Dsts",
|
||||
"enablements",
|
||||
"fatih",
|
||||
"Formik",
|
||||
"gitsshkey",
|
||||
"goarch",
|
||||
"gographviz",
|
||||
"goleak",
|
||||
"gonet",
|
||||
"gossh",
|
||||
"gsyslog",
|
||||
"GTTY",
|
||||
"hashicorp",
|
||||
"hclsyntax",
|
||||
"httpapi",
|
||||
@@ -32,67 +42,114 @@
|
||||
"idtoken",
|
||||
"Iflag",
|
||||
"incpatch",
|
||||
"ipnstate",
|
||||
"isatty",
|
||||
"Jobf",
|
||||
"Keygen",
|
||||
"kirsle",
|
||||
"Kubernetes",
|
||||
"ldflags",
|
||||
"magicsock",
|
||||
"manifoldco",
|
||||
"mapstructure",
|
||||
"mattn",
|
||||
"mitchellh",
|
||||
"moby",
|
||||
"namesgenerator",
|
||||
"namespacing",
|
||||
"netaddr",
|
||||
"netip",
|
||||
"netmap",
|
||||
"netns",
|
||||
"netstack",
|
||||
"nettype",
|
||||
"nfpms",
|
||||
"nhooyr",
|
||||
"nmcfg",
|
||||
"nolint",
|
||||
"nosec",
|
||||
"ntqry",
|
||||
"OIDC",
|
||||
"oneof",
|
||||
"opty",
|
||||
"paralleltest",
|
||||
"parameterscopeid",
|
||||
"pqtype",
|
||||
"prometheusmetrics",
|
||||
"promptui",
|
||||
"protobuf",
|
||||
"provisionerd",
|
||||
"provisionersdk",
|
||||
"ptty",
|
||||
"ptys",
|
||||
"ptytest",
|
||||
"quickstart",
|
||||
"reconfig",
|
||||
"retrier",
|
||||
"rpty",
|
||||
"sdkproto",
|
||||
"sdktrace",
|
||||
"Signup",
|
||||
"slogtest",
|
||||
"sourcemapped",
|
||||
"Srcs",
|
||||
"stretchr",
|
||||
"STTY",
|
||||
"stuntest",
|
||||
"tailbroker",
|
||||
"tailcfg",
|
||||
"tailexchange",
|
||||
"tailnet",
|
||||
"tailnettest",
|
||||
"Tailscale",
|
||||
"TCGETS",
|
||||
"tcpip",
|
||||
"TCSETS",
|
||||
"templateversions",
|
||||
"testdata",
|
||||
"testid",
|
||||
"testutil",
|
||||
"tfexec",
|
||||
"tfjson",
|
||||
"tfplan",
|
||||
"tfstate",
|
||||
"tios",
|
||||
"tparallel",
|
||||
"trimprefix",
|
||||
"tsdial",
|
||||
"tslogger",
|
||||
"tstun",
|
||||
"turnconn",
|
||||
"typegen",
|
||||
"typesafe",
|
||||
"unconvert",
|
||||
"Untar",
|
||||
"Userspace",
|
||||
"VMID",
|
||||
"walkthrough",
|
||||
"weblinks",
|
||||
"webrtc",
|
||||
"wgcfg",
|
||||
"wgconfig",
|
||||
"wgengine",
|
||||
"wgmonitor",
|
||||
"wgnet",
|
||||
"workspaceagent",
|
||||
"workspaceagents",
|
||||
"workspaceapp",
|
||||
"workspaceapps",
|
||||
"workspacebuilds",
|
||||
"workspacename",
|
||||
"wsconncache",
|
||||
"wsjson",
|
||||
"xerrors",
|
||||
"xstate",
|
||||
"yamux"
|
||||
],
|
||||
"cSpell.ignorePaths": [
|
||||
"site/package.json",
|
||||
".vscode/settings.json"
|
||||
],
|
||||
"emeraldwalk.runonsave": {
|
||||
"commands": [
|
||||
{
|
||||
@@ -115,18 +172,19 @@
|
||||
"go.lintFlags": ["--fast"],
|
||||
"go.lintOnSave": "package",
|
||||
"go.coverOnSave": true,
|
||||
// The codersdk is used by coderd another other packages extensively.
|
||||
// To reduce redundancy in tests, it's covered by other packages.
|
||||
"go.testFlags": ["-short", "-coverpkg=./.,github.com/coder/coder/codersdk"],
|
||||
"go.coverageDecorator": {
|
||||
"type": "gutter",
|
||||
"coveredHighlightColor": "rgba(64,128,128,0.5)",
|
||||
"uncoveredHighlightColor": "rgba(128,64,64,0.25)",
|
||||
"coveredBorderColor": "rgba(64,128,128,0.5)",
|
||||
"uncoveredBorderColor": "rgba(128,64,64,0.25)",
|
||||
"coveredGutterStyle": "blockgreen",
|
||||
"uncoveredGutterStyle": "blockred"
|
||||
},
|
||||
// The codersdk is used by coderd another other packages extensively.
|
||||
// To reduce redundancy in tests, it's covered by other packages.
|
||||
// Since package coverage pairing can't be defined, all packages cover
|
||||
// all other packages.
|
||||
"go.testFlags": [
|
||||
"-short",
|
||||
"-coverpkg=./..."
|
||||
],
|
||||
// We often use a version of TypeScript that's ahead of the version shipped
|
||||
// with VS Code.
|
||||
"typescript.tsdk": "./site/node_modules/typescript/lib"
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Adopters
|
||||
[](https://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=adopters.md) [](https://twitter.com/coderhq)
|
||||
|
||||
🦩 _If you're using Coder in your organization, please try to add your company name to this list. It really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact. You can do this by by editing this file and contributing your changes via a pull-request on GitHub._
|
||||
|
||||
> 👋 _If you are considering using Coder in your organization please introduce yourself via https://coder.com/demo_ 🙇🏻♂️
|
||||
|
||||
| Organization | Contact | Description of Use |
|
||||
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Coder](https://www.coder.com) | [@coderhq](https://twitter.com/coderhq) | Coder builds coder with Coder. |
|
||||
+16
-2
@@ -1,4 +1,8 @@
|
||||
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
|
||||
|
||||
# LABEL doesn't add any real layers so it's fine (and easier) to do it here than
|
||||
# in the build script.
|
||||
@@ -12,6 +16,16 @@ LABEL \
|
||||
org.opencontainers.image.licenses="AGPL-3.0"
|
||||
|
||||
# The coder binary is injected by scripts/build_docker.sh.
|
||||
ADD coder /opt/coder
|
||||
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
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt
|
||||
WORKDIR /home/coder
|
||||
|
||||
ENTRYPOINT [ "/opt/coder", "server" ]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
## Acceptance
|
||||
|
||||
By using any software and associated documentation files under Coder
|
||||
Technologies Inc.’s ("Coder") directory named "enterprise" ("Enterprise
|
||||
Software"), you agree to all of the terms and conditions below.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
||||
non-sublicensable, non-transferable license to use, copy, distribute, make
|
||||
available, modify and prepare derivative works of the Enterprise Software, in
|
||||
each case subject to the limitations and conditions below.
|
||||
|
||||
## Limitations
|
||||
|
||||
You may not move, change, disable, or circumvent the license key functionality
|
||||
in the software, and you may not remove or obscure any functionality in the
|
||||
software that is protected by the license key.
|
||||
|
||||
You may not alter, remove, or obscure any licensing, copyright, or other notices
|
||||
of the licensor in the software.
|
||||
|
||||
You agree that Coder and/or its licensors (as applicable) retain all right,
|
||||
title and interest in and to all such modifications and/or patches.
|
||||
|
||||
## Additional Terms
|
||||
|
||||
This Enterprise Software may only be used in production, if you (and any entity
|
||||
that you represent) have agreed to, and are in compliance with, the Coder’s
|
||||
Terms of Service, available at https://coder.com/legal/terms-of-service, or
|
||||
other agreement governing the use of the Software, as agreed by you and Coder.
|
||||
@@ -1,70 +1,349 @@
|
||||
.DEFAULT_GOAL := build
|
||||
# This is the Coder Makefile. The build directory for most tasks is `build/`.
|
||||
#
|
||||
# These are the targets you're probably looking for:
|
||||
# - clean
|
||||
# - build-fat: builds all "fat" binaries for all architectures
|
||||
# - build-slim: builds all "slim" binaries (no frontend or slim binaries
|
||||
# embedded) for all architectures
|
||||
# - release: simulate a release (mostly, does not push images)
|
||||
# - build/coder(-slim)?_${os}_${arch}(.exe)?: build a single fat binary
|
||||
# - build/coder_${os}_${arch}.(zip|tar.gz): build a release archive
|
||||
# - build/coder_linux_${arch}.(apk|deb|rpm): build a release Linux package
|
||||
# - build/coder_${version}_linux_${arch}.tag: build a release Linux Docker image
|
||||
# - build/coder_helm.tgz: build a release Helm chart
|
||||
|
||||
.DEFAULT_GOAL := build-fat
|
||||
|
||||
# Use a single bash shell for each job, and immediately exit on failure
|
||||
SHELL := bash
|
||||
.SHELLFLAGS = -ceu
|
||||
.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)
|
||||
# Don't print the commands in the file unless you specify VERBOSE. This is
|
||||
# essentially the same as putting "@" at the start of each line.
|
||||
ifndef VERBOSE
|
||||
.SILENT:
|
||||
endif
|
||||
|
||||
bin: $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum $(shell find ./examples/templates)
|
||||
@echo "== This builds slim binaries for command-line usage."
|
||||
@echo "== Use \"make build\" to embed the site."
|
||||
# Create the output directories if they do not exist.
|
||||
$(shell mkdir -p build site/out/bin)
|
||||
|
||||
mkdir -p ./dist
|
||||
rm -rf ./dist/coder-slim_*
|
||||
./scripts/build_go_slim.sh \
|
||||
GOOS := $(shell go env GOOS)
|
||||
GOARCH := $(shell go env GOARCH)
|
||||
GOOS_BIN_EXT := $(if $(filter windows, $(GOOS)),.exe,)
|
||||
VERSION := $(shell ./scripts/version.sh)
|
||||
|
||||
# Use the highest ZSTD compression level in CI.
|
||||
ifdef CI
|
||||
ZSTDFLAGS := -22 --ultra
|
||||
else
|
||||
ZSTDFLAGS := -6
|
||||
endif
|
||||
|
||||
# All ${OS}_${ARCH} combos we build for. Windows binaries have the .exe suffix.
|
||||
OS_ARCHES := \
|
||||
linux_amd64 linux_arm64 linux_armv7 \
|
||||
darwin_amd64 darwin_arm64 \
|
||||
windows_amd64.exe windows_arm64.exe
|
||||
|
||||
# Archive formats and their corresponding ${OS}_${ARCH} combos.
|
||||
ARCHIVE_TAR_GZ := linux_amd64 linux_arm64 linux_armv7
|
||||
ARCHIVE_ZIP := \
|
||||
darwin_amd64 darwin_arm64 \
|
||||
windows_amd64 windows_arm64
|
||||
|
||||
# All package formats we build and the ${OS}_${ARCH} combos we build them for.
|
||||
PACKAGE_FORMATS := apk deb rpm
|
||||
PACKAGE_OS_ARCHES := linux_amd64 linux_armv7 linux_arm64
|
||||
|
||||
# All architectures we build Docker images for (Linux only).
|
||||
DOCKER_ARCHES := amd64 arm64 armv7
|
||||
|
||||
# Computed variables based on the above.
|
||||
CODER_SLIM_BINARIES := $(addprefix build/coder-slim_$(VERSION)_,$(OS_ARCHES))
|
||||
CODER_FAT_BINARIES := $(addprefix build/coder_$(VERSION)_,$(OS_ARCHES))
|
||||
CODER_ALL_BINARIES := $(CODER_SLIM_BINARIES) $(CODER_FAT_BINARIES)
|
||||
CODER_TAR_GZ_ARCHIVES := $(foreach os_arch, $(ARCHIVE_TAR_GZ), build/coder_$(VERSION)_$(os_arch).tar.gz)
|
||||
CODER_ZIP_ARCHIVES := $(foreach os_arch, $(ARCHIVE_ZIP), build/coder_$(VERSION)_$(os_arch).zip)
|
||||
CODER_ALL_ARCHIVES := $(CODER_TAR_GZ_ARCHIVES) $(CODER_ZIP_ARCHIVES)
|
||||
CODER_ALL_PACKAGES := $(foreach os_arch, $(PACKAGE_OS_ARCHES), $(addprefix build/coder_$(VERSION)_$(os_arch).,$(PACKAGE_FORMATS)))
|
||||
CODER_ARCH_IMAGES := $(foreach arch, $(DOCKER_ARCHES), build/coder_$(VERSION)_linux_$(arch).tag)
|
||||
CODER_ARCH_IMAGES_PUSHED := $(addprefix push/, $(CODER_ARCH_IMAGES))
|
||||
CODER_MAIN_IMAGE := build/coder_$(VERSION)_linux.tag
|
||||
|
||||
CODER_SLIM_NOVERSION_BINARIES := $(addprefix build/coder-slim_,$(OS_ARCHES))
|
||||
CODER_FAT_NOVERSION_BINARIES := $(addprefix build/coder_,$(OS_ARCHES))
|
||||
CODER_ALL_NOVERSION_IMAGES := $(foreach arch, $(DOCKER_ARCHES), build/coder_linux_$(arch).tag) build/coder_linux.tag
|
||||
CODER_ALL_NOVERSION_IMAGES_PUSHED := $(addprefix push/, $(CODER_ALL_NOVERSION_IMAGES))
|
||||
|
||||
|
||||
clean:
|
||||
rm -rf build site/out
|
||||
mkdir -p build site/out/bin
|
||||
git restore site/out
|
||||
.PHONY: clean
|
||||
|
||||
build-slim: $(CODER_SLIM_BINARIES)
|
||||
.PHONY: build-slim
|
||||
|
||||
build-fat build-full build: $(CODER_FAT_BINARIES)
|
||||
.PHONY: build-fat build-full build
|
||||
|
||||
release: $(CODER_FAT_BINARIES) $(CODER_ALL_ARCHIVES) $(CODER_ALL_PACKAGES) $(CODER_ARCH_IMAGES) build/coder_helm_$(VERSION).tgz
|
||||
.PHONY: release
|
||||
|
||||
build/coder-slim_$(VERSION)_checksums.sha1 site/out/bin/coder.sha1: $(CODER_SLIM_BINARIES)
|
||||
pushd ./site/out/bin
|
||||
openssl dgst -r -sha1 coder-* | tee coder.sha1
|
||||
popd
|
||||
|
||||
cp "site/out/bin/coder.sha1" "build/coder-slim_$(VERSION)_checksums.sha1"
|
||||
|
||||
build/coder-slim_$(VERSION).tar: build/coder-slim_$(VERSION)_checksums.sha1 $(CODER_SLIM_BINARIES)
|
||||
pushd ./site/out/bin
|
||||
tar cf "../../../build/$(@F)" coder-*
|
||||
popd
|
||||
|
||||
build/coder-slim_$(VERSION).tar.zst site/out/bin/coder.tar.zst: build/coder-slim_$(VERSION).tar
|
||||
zstd $(ZSTDFLAGS) \
|
||||
--force \
|
||||
--long \
|
||||
--no-progress \
|
||||
-o "build/coder-slim_$(VERSION).tar.zst" \
|
||||
"build/coder-slim_$(VERSION).tar"
|
||||
|
||||
cp "build/coder-slim_$(VERSION).tar.zst" "site/out/bin/coder.tar.zst"
|
||||
# delete the uncompressed binaries from the embedded dir
|
||||
rm site/out/bin/coder-*
|
||||
|
||||
# Redirect from version-less targets to the versioned ones. There is a similar
|
||||
# target for slim binaries below.
|
||||
#
|
||||
# Called like this:
|
||||
# make build/coder_linux_amd64
|
||||
# make build/coder_windows_amd64.exe
|
||||
$(CODER_FAT_NOVERSION_BINARIES): build/coder_%: build/coder_$(VERSION)_%
|
||||
rm -f "$@"
|
||||
ln "$<" "$@"
|
||||
|
||||
# Same as above, but for slim binaries.
|
||||
#
|
||||
# Called like this:
|
||||
# make build/coder-slim_linux_amd64
|
||||
# make build/coder-slim_windows_amd64.exe
|
||||
$(CODER_SLIM_NOVERSION_BINARIES): build/coder-slim_%: build/coder-slim_$(VERSION)_%
|
||||
rm -f "$@"
|
||||
ln "$<" "$@"
|
||||
|
||||
# "fat" binaries always depend on the site and the compressed slim binaries.
|
||||
$(CODER_FAT_BINARIES): \
|
||||
site/out/index.html \
|
||||
site/out/bin/coder.sha1 \
|
||||
site/out/bin/coder.tar.zst
|
||||
|
||||
# This is a handy block that parses the target to determine whether it's "slim"
|
||||
# or "fat", which OS was specified and which architecture was specified.
|
||||
#
|
||||
# It populates the following variables: mode, os, arch_ext, arch, ext (without
|
||||
# dot).
|
||||
define get-mode-os-arch-ext =
|
||||
mode="$$([[ "$@" = build/coder-slim* ]] && echo "slim" || echo "fat")"
|
||||
os="$$(echo $@ | cut -d_ -f3)"
|
||||
arch_ext="$$(echo $@ | cut -d_ -f4)"
|
||||
if [[ "$$arch_ext" == *.* ]]; then
|
||||
arch="$$(echo $$arch_ext | cut -d. -f1)"
|
||||
ext="$${arch_ext#*.}"
|
||||
else
|
||||
arch="$$arch_ext"
|
||||
ext=""
|
||||
fi
|
||||
endef
|
||||
|
||||
# This task handles all builds, for both "fat" and "slim" binaries. It parses
|
||||
# the target name to get the metadata for the build, so it must be specified in
|
||||
# this format:
|
||||
# build/coder(-slim)?_${version}_${os}_${arch}(.exe)?
|
||||
#
|
||||
# You should probably use the non-version targets above instead if you're
|
||||
# calling this manually.
|
||||
$(CODER_ALL_BINARIES): go.mod go.sum \
|
||||
$(shell find . -not -path './vendor/*' -type f -name '*.go') \
|
||||
$(shell find ./examples/templates)
|
||||
|
||||
$(get-mode-os-arch-ext)
|
||||
if [[ "$$os" != "windows" ]] && [[ "$$ext" != "" ]]; then
|
||||
echo "ERROR: Invalid build binary extension" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$$os" == "windows" ]] && [[ "$$ext" != exe ]]; then
|
||||
echo "ERROR: Windows binaries must have an .exe extension." 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_args=( \
|
||||
--os "$$os" \
|
||||
--arch "$$arch" \
|
||||
--version "$(VERSION)" \
|
||||
--output ./dist/ \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64 \
|
||||
darwin:amd64,arm64
|
||||
.PHONY: bin
|
||||
--output "$@" \
|
||||
)
|
||||
if [ "$$mode" == "slim" ]; then
|
||||
build_args+=(--slim)
|
||||
fi
|
||||
|
||||
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
|
||||
./scripts/build_go.sh "$${build_args[@]}"
|
||||
|
||||
# build slim artifacts and copy them to the site output directory
|
||||
./scripts/build_go_slim.sh \
|
||||
if [[ "$$mode" == "slim" ]]; then
|
||||
dot_ext=""
|
||||
if [[ "$$ext" != "" ]]; then
|
||||
dot_ext=".$$ext"
|
||||
fi
|
||||
|
||||
cp "$@" "./site/out/bin/coder-$$os-$$arch$$dot_ext"
|
||||
fi
|
||||
|
||||
# This task builds all archives. It parses the target name to get the metadata
|
||||
# for the build, so it must be specified in this format:
|
||||
# build/coder_${version}_${os}_${arch}.${format}
|
||||
#
|
||||
# The following OS/arch/format combinations are supported:
|
||||
# .tar.gz: linux_amd64, linux_arm64, linux_armv7
|
||||
# .zip: darwin_amd64, darwin_arm64, windows_amd64, windows_arm64
|
||||
#
|
||||
# This depends on all fat binaries because it's difficult to do dynamic
|
||||
# dependencies due to the .exe requirement on Windows. These targets are
|
||||
# typically only used during release anyways.
|
||||
$(CODER_ALL_ARCHIVES): $(CODER_FAT_BINARIES)
|
||||
$(get-mode-os-arch-ext)
|
||||
bin_ext=""
|
||||
if [[ "$$os" == "windows" ]]; then
|
||||
bin_ext=".exe"
|
||||
fi
|
||||
|
||||
./scripts/archive.sh \
|
||||
--format "$$ext" \
|
||||
--os "$$os" \
|
||||
--output "$@" \
|
||||
"build/coder_$(VERSION)_$${os}_$${arch}$${bin_ext}"
|
||||
|
||||
# This task builds all packages. It parses the target name to get the metadata
|
||||
# for the build, so it must be specified in this format:
|
||||
# build/coder_${version}_linux_${arch}.${format}
|
||||
#
|
||||
# Supports apk, deb, rpm for all linux targets.
|
||||
#
|
||||
# This depends on all Linux fat binaries and archives because it's difficult to
|
||||
# do dynamic dependencies due to the extensions in the filenames. These targets
|
||||
# are typically only used during release anyways.
|
||||
#
|
||||
# Packages need to run after the archives are built, otherwise they cause tar
|
||||
# errors like "file changed as we read it".
|
||||
CODER_PACKAGE_DEPS := $(foreach os_arch, $(PACKAGE_OS_ARCHES), build/coder_$(VERSION)_$(os_arch) build/coder_$(VERSION)_$(os_arch).tar.gz)
|
||||
$(CODER_ALL_PACKAGES): $(CODER_PACKAGE_DEPS)
|
||||
$(get-mode-os-arch-ext)
|
||||
|
||||
./scripts/package.sh \
|
||||
--arch "$$arch" \
|
||||
--format "$$ext" \
|
||||
--version "$(VERSION)" \
|
||||
--output ./dist/ \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64 \
|
||||
darwin:amd64,arm64
|
||||
--output "$@" \
|
||||
"build/coder_$(VERSION)_$${os}_$${arch}"
|
||||
|
||||
# build not-so-slim artifacts with the default name format
|
||||
./scripts/build_go_matrix.sh \
|
||||
# Redirect from version-less Docker image targets to the versioned ones.
|
||||
#
|
||||
# Called like this:
|
||||
# make build/coder_linux_amd64.tag
|
||||
$(CODER_ALL_NOVERSION_IMAGES): build/coder_%: build/coder_$(VERSION)_%
|
||||
.PHONY: $(CODER_ALL_NOVERSION_IMAGES)
|
||||
|
||||
# Redirect from version-less push Docker image targets to the versioned ones.
|
||||
#
|
||||
# Called like this:
|
||||
# make push/build/coder_linux_amd64.tag
|
||||
$(CODER_ALL_NOVERSION_IMAGES_PUSHED): push/build/coder_%: push/build/coder_$(VERSION)_%
|
||||
.PHONY: $(CODER_ALL_NOVERSION_IMAGES_PUSHED)
|
||||
|
||||
# This task builds all Docker images. It parses the target name to get the
|
||||
# metadata for the build, so it must be specified in this format:
|
||||
# build/coder_${version}_${os}_${arch}.tag
|
||||
#
|
||||
# Supports linux_amd64, linux_arm64, linux_armv7.
|
||||
#
|
||||
# Images need to run after the archives and packages are built, otherwise they
|
||||
# cause errors like "file changed as we read it".
|
||||
$(CODER_ARCH_IMAGES): build/coder_$(VERSION)_%.tag: \
|
||||
build/coder_$(VERSION)_% \
|
||||
build/coder_$(VERSION)_%.apk \
|
||||
build/coder_$(VERSION)_%.deb \
|
||||
build/coder_$(VERSION)_%.rpm \
|
||||
build/coder_$(VERSION)_%.tar.gz
|
||||
|
||||
$(get-mode-os-arch-ext)
|
||||
|
||||
image_tag="$$(./scripts/image_tag.sh --arch "$$arch" --version "$(VERSION)")"
|
||||
./scripts/build_docker.sh \
|
||||
--arch "$$arch" \
|
||||
--target "$$image_tag" \
|
||||
--version "$(VERSION)" \
|
||||
--output ./dist/ \
|
||||
--archive \
|
||||
--package-linux \
|
||||
linux:amd64,armv7,arm64 \
|
||||
windows:amd64,arm64 \
|
||||
darwin:amd64,arm64
|
||||
.PHONY: build
|
||||
"build/coder_$(VERSION)_$${os}_$${arch}"
|
||||
|
||||
# Runs migrations to output a dump of the database.
|
||||
coderd/database/dump.sql: $(wildcard coderd/database/migrations/*.sql)
|
||||
go run coderd/database/dump/main.go
|
||||
echo "$$image_tag" > "$@"
|
||||
|
||||
# Generates Go code for querying the database.
|
||||
coderd/database/querier.go: coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql)
|
||||
coderd/database/generate.sh
|
||||
# Multi-arch Docker image. This requires all architecture-specific images to be
|
||||
# built AND pushed.
|
||||
$(CODER_MAIN_IMAGE): $(CODER_ARCH_IMAGES_PUSHED)
|
||||
image_tag="$$(./scripts/image_tag.sh --version "$(VERSION)")"
|
||||
./scripts/build_docker_multiarch.sh \
|
||||
--target "$$image_tag" \
|
||||
--version "$(VERSION)" \
|
||||
$(foreach img, $^, "$$(cat "$(img:push/%=%)")")
|
||||
|
||||
dev:
|
||||
./scripts/develop.sh
|
||||
.PHONY: dev
|
||||
echo "$$image_tag" > "$@"
|
||||
|
||||
# Push a Docker image.
|
||||
$(CODER_ARCH_IMAGES_PUSHED): push/%: %
|
||||
image_tag="$$(cat "$<")"
|
||||
docker push "$$image_tag"
|
||||
.PHONY: $(CODER_ARCH_IMAGES_PUSHED)
|
||||
|
||||
# Push the multi-arch Docker manifest.
|
||||
push/$(CODER_MAIN_IMAGE): $(CODER_MAIN_IMAGE)
|
||||
image_tag="$$(cat "$<")"
|
||||
docker manifest push "$$image_tag"
|
||||
.PHONY: push/$(CODER_MAIN_IMAGE)
|
||||
|
||||
# Shortcut for Helm chart package.
|
||||
build/coder_helm.tgz: build/coder_helm_$(VERSION).tgz
|
||||
rm -f "$@"
|
||||
ln "$<" "$@"
|
||||
|
||||
# Helm chart package.
|
||||
build/coder_helm_$(VERSION).tgz:
|
||||
./scripts/helm.sh \
|
||||
--version "$(VERSION)" \
|
||||
--output "$@"
|
||||
|
||||
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 build
|
||||
|
||||
install: build/coder_$(VERSION)_$(GOOS)_$(GOARCH)$(GOOS_BIN_EXT)
|
||||
install_dir="$$(go env GOPATH)/bin"
|
||||
output_file="$${install_dir}/coder$(GOOS_BIN_EXT)"
|
||||
|
||||
mkdir -p "$$install_dir"
|
||||
cp "$<" "$$output_file"
|
||||
.PHONY: install
|
||||
|
||||
fmt: fmt/prettier fmt/terraform fmt/shfmt
|
||||
.PHONY: fmt
|
||||
|
||||
fmt/prettier:
|
||||
@echo "--- prettier"
|
||||
echo "--- prettier"
|
||||
cd site
|
||||
# Avoid writing files in CI to reduce file write activity
|
||||
ifdef CI
|
||||
@@ -79,7 +358,7 @@ fmt/terraform: $(wildcard *.tf)
|
||||
.PHONY: fmt/terraform
|
||||
|
||||
fmt/shfmt: $(shell shfmt -f .)
|
||||
@echo "--- shfmt"
|
||||
echo "--- shfmt"
|
||||
# Only do diff check in CI, errors on diff.
|
||||
ifdef CI
|
||||
shfmt -d $(shell shfmt -f .)
|
||||
@@ -88,58 +367,53 @@ else
|
||||
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: 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
|
||||
|
||||
# Use shfmt to determine the shell files, takes editorconfig into consideration.
|
||||
lint/shellcheck: $(shell shfmt -f .)
|
||||
@echo "--- shellcheck"
|
||||
echo "--- shellcheck"
|
||||
shellcheck --external-sources $(shell shfmt -f .)
|
||||
.PHONY: lint/shellcheck
|
||||
|
||||
peerbroker/proto/peerbroker.pb.go: peerbroker/proto/peerbroker.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./peerbroker/proto/peerbroker.proto
|
||||
# all gen targets should be added here and to gen/mark-fresh
|
||||
gen: \
|
||||
coderd/database/dump.sql \
|
||||
coderd/database/querier.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
site/src/api/typesGenerated.ts
|
||||
.PHONY: gen
|
||||
|
||||
provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./provisionerd/proto/provisionerd.proto
|
||||
# Mark all generated files as fresh so make thinks they're up-to-date. This is
|
||||
# used during releases so we don't run generation scripts.
|
||||
gen/mark-fresh:
|
||||
files="coderd/database/dump.sql coderd/database/querier.go provisionersdk/proto/provisioner.pb.go provisionerd/proto/provisionerd.pb.go site/src/api/typesGenerated.ts"
|
||||
for file in $$files; do
|
||||
echo "$$file"
|
||||
if [ ! -f "$$file" ]; then
|
||||
echo "File '$$file' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# touch sets the mtime of the file to the current time
|
||||
touch $$file
|
||||
done
|
||||
.PHONY: gen/mark-fresh
|
||||
|
||||
# Runs migrations to output a dump of the database schema after migrations are
|
||||
# applied.
|
||||
coderd/database/dump.sql: coderd/database/gen/dump/main.go $(wildcard coderd/database/migrations/*.sql)
|
||||
go run ./coderd/database/gen/dump/main.go
|
||||
|
||||
# Generates Go code for querying the database.
|
||||
coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql) coderd/database/gen/enum/main.go
|
||||
./coderd/database/generate.sh
|
||||
|
||||
provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
|
||||
protoc \
|
||||
@@ -149,13 +423,13 @@ provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./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
|
||||
yarn build
|
||||
# Restores GITKEEP files!
|
||||
git checkout HEAD out
|
||||
provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./provisionerd/proto/provisionerd.proto
|
||||
|
||||
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
|
||||
@@ -166,14 +440,17 @@ test: test-clean
|
||||
gotestsum -- -v -short ./...
|
||||
.PHONY: test
|
||||
|
||||
test-postgres: test-clean
|
||||
DB=ci gotestsum --junitfile="gotests.xml" --packages="./..." -- \
|
||||
-covermode=atomic -coverprofile="gotests.coverage" -timeout=30m \
|
||||
-coverpkg=./...,github.com/coder/coder/codersdk \
|
||||
-count=1 -race -failfast
|
||||
# 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-docker:
|
||||
docker rm -f test-postgres-docker || true
|
||||
docker run \
|
||||
--env POSTGRES_PASSWORD=postgres \
|
||||
--env POSTGRES_USER=postgres \
|
||||
@@ -184,12 +461,17 @@ test-postgres-docker:
|
||||
--name test-postgres-docker \
|
||||
--restart no \
|
||||
--detach \
|
||||
postgres:11 \
|
||||
postgres:13 \
|
||||
-c shared_buffers=1GB \
|
||||
-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
|
||||
|
||||
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://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md)
|
||||
[](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,32 +30,42 @@ 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](https://coder.com/docs/coder-oss/latest/install#installsh) (macOS, Linux), [docker-compose](https://coder.com/docs/coder-oss/latest/install#docker-compose), or [manually](https://coder.com/docs/coder-oss/latest/install#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 start a production deployment with a single command:
|
||||
You can preview what occurs during the install process:
|
||||
|
||||
```bash
|
||||
curl -L https://coder.com/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
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) 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
|
||||
coder server
|
||||
|
||||
# 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
|
||||
@@ -80,12 +89,14 @@ _Last updated: 5/27/22_
|
||||
|
||||
## Community and Support
|
||||
|
||||
Join our community on [Discord](https://discord.gg/coder) and [Twitter](https://twitter.com/coderhq)!
|
||||
Join our community on [Discord](https://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) and [Twitter](https://twitter.com/coderhq)!
|
||||
|
||||
[Suggest improvements and report problems](https://github.com/coder/coder/issues/new/choose)
|
||||
|
||||
## Contributing
|
||||
|
||||
If you're using Coder in your organization, please try to add your company name to the [ADOPTERS.md](./ADOPTERS.md). It really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact.
|
||||
|
||||
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).
|
||||
|
||||
+355
-229
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
@@ -27,12 +29,14 @@ import (
|
||||
"go.uber.org/atomic"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/net/speedtest"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/agent/usershell"
|
||||
"github.com/coder/coder/peer"
|
||||
"github.com/coder/coder/peerbroker"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty"
|
||||
"github.com/coder/coder/tailnet"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
@@ -40,46 +44,54 @@ 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 {
|
||||
ReconnectingPTYTimeout time.Duration
|
||||
EnvironmentVariables map[string]string
|
||||
Logger slog.Logger
|
||||
CoordinatorDialer CoordinatorDialer
|
||||
FetchMetadata FetchMetadata
|
||||
StatsReporter StatsReporter
|
||||
WorkspaceAgentApps WorkspaceAgentApps
|
||||
PostWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth
|
||||
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"`
|
||||
}
|
||||
// CoordinatorDialer is a function that constructs a new broker.
|
||||
// A dialer must be passed in to allow for reconnects.
|
||||
type CoordinatorDialer func(context.Context) (net.Conn, error)
|
||||
|
||||
type Dialer func(ctx context.Context, logger slog.Logger) (Metadata, *peerbroker.Listener, error)
|
||||
// FetchMetadata is a function to obtain metadata for the agent.
|
||||
type FetchMetadata func(context.Context) (codersdk.WorkspaceAgentMetadata, error)
|
||||
|
||||
func New(dialer Dialer, options *Options) io.Closer {
|
||||
if options == nil {
|
||||
options = &Options{}
|
||||
}
|
||||
func New(options Options) io.Closer {
|
||||
if options.ReconnectingPTYTimeout == 0 {
|
||||
options.ReconnectingPTYTimeout = 5 * time.Minute
|
||||
}
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
server := &agent{
|
||||
dialer: dialer,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
closeCancel: cancelFunc,
|
||||
closed: make(chan struct{}),
|
||||
envVars: options.EnvironmentVariables,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
closeCancel: cancelFunc,
|
||||
closed: make(chan struct{}),
|
||||
envVars: options.EnvironmentVariables,
|
||||
coordinatorDialer: options.CoordinatorDialer,
|
||||
fetchMetadata: options.FetchMetadata,
|
||||
stats: &Stats{},
|
||||
statsReporter: options.StatsReporter,
|
||||
workspaceAgentApps: options.WorkspaceAgentApps,
|
||||
postWorkspaceAgentAppHealth: options.PostWorkspaceAgentAppHealth,
|
||||
}
|
||||
server.init(ctx)
|
||||
return server
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
dialer Dialer
|
||||
logger slog.Logger
|
||||
|
||||
reconnectingPTYs sync.Map
|
||||
@@ -93,18 +105,25 @@ type agent struct {
|
||||
envVars map[string]string
|
||||
// metadata is atomic because values can change after reconnection.
|
||||
metadata atomic.Value
|
||||
startupScript atomic.Bool
|
||||
fetchMetadata FetchMetadata
|
||||
sshServer *ssh.Server
|
||||
|
||||
network *tailnet.Conn
|
||||
coordinatorDialer CoordinatorDialer
|
||||
stats *Stats
|
||||
statsReporter StatsReporter
|
||||
workspaceAgentApps WorkspaceAgentApps
|
||||
postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth
|
||||
}
|
||||
|
||||
func (a *agent) run(ctx context.Context) {
|
||||
var metadata Metadata
|
||||
var peerListener *peerbroker.Listener
|
||||
var metadata codersdk.WorkspaceAgentMetadata
|
||||
var err error
|
||||
// An exponential back-off occurs when the connection is failing to dial.
|
||||
// This is to prevent server spam in case of a coderd outage.
|
||||
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
|
||||
metadata, peerListener, err = a.dialer(ctx, a.logger)
|
||||
a.logger.Info(ctx, "connecting")
|
||||
metadata, err = a.fetchMetadata(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
@@ -115,7 +134,7 @@ func (a *agent) run(ctx context.Context) {
|
||||
a.logger.Warn(context.Background(), "failed to dial", slog.Error(err))
|
||||
continue
|
||||
}
|
||||
a.logger.Info(context.Background(), "connected")
|
||||
a.logger.Info(context.Background(), "fetched metadata")
|
||||
break
|
||||
}
|
||||
select {
|
||||
@@ -125,33 +144,204 @@ func (a *agent) run(ctx context.Context) {
|
||||
}
|
||||
a.metadata.Store(metadata)
|
||||
|
||||
if a.startupScript.CAS(false, true) {
|
||||
// The startup script has not ran yet!
|
||||
go func() {
|
||||
err := a.runStartupScript(ctx, metadata.StartupScript)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "agent script failed", slog.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for {
|
||||
conn, err := peerListener.Accept()
|
||||
if err != nil {
|
||||
if a.isClosed() {
|
||||
return
|
||||
}
|
||||
a.logger.Debug(ctx, "peer listener accept exited; restarting connection", slog.Error(err))
|
||||
a.run(ctx)
|
||||
// The startup script has not ran yet!
|
||||
go func() {
|
||||
err := a.runStartupScript(ctx, metadata.StartupScript)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
a.closeMutex.Lock()
|
||||
a.connCloseWait.Add(1)
|
||||
a.closeMutex.Unlock()
|
||||
go a.handlePeerConn(ctx, conn)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "agent script failed", slog.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
if metadata.DERPMap != nil {
|
||||
go a.runTailnet(ctx, metadata.DERPMap)
|
||||
}
|
||||
|
||||
if a.workspaceAgentApps != nil && a.postWorkspaceAgentAppHealth != nil {
|
||||
go NewWorkspaceAppHealthReporter(a.logger, a.workspaceAgentApps, a.postWorkspaceAgentAppHealth)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) {
|
||||
a.closeMutex.Lock()
|
||||
defer a.closeMutex.Unlock()
|
||||
if a.isClosed() {
|
||||
return
|
||||
}
|
||||
if a.network != nil {
|
||||
a.network.SetDERPMap(derpMap)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
a.network, err = tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(codersdk.TailnetIP, 128)},
|
||||
DERPMap: derpMap,
|
||||
Logger: a.logger.Named("tailnet"),
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Critical(ctx, "create tailnet", slog.Error(err))
|
||||
return
|
||||
}
|
||||
a.network.SetForwardTCPCallback(func(conn net.Conn, listenerExists bool) net.Conn {
|
||||
if listenerExists {
|
||||
// If a listener already exists, we would double-wrap the conn.
|
||||
return conn
|
||||
}
|
||||
return a.stats.wrapConn(conn)
|
||||
})
|
||||
go a.runCoordinator(ctx)
|
||||
|
||||
sshListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetSSHPort))
|
||||
if err != nil {
|
||||
a.logger.Critical(ctx, "listen for ssh", slog.Error(err))
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := sshListener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go a.sshServer.HandleConn(a.stats.wrapConn(conn))
|
||||
}
|
||||
}()
|
||||
|
||||
reconnectingPTYListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetReconnectingPTYPort))
|
||||
if err != nil {
|
||||
a.logger.Critical(ctx, "listen for reconnecting pty", slog.Error(err))
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := reconnectingPTYListener.Accept()
|
||||
if err != nil {
|
||||
a.logger.Debug(ctx, "accept pty failed", slog.Error(err))
|
||||
return
|
||||
}
|
||||
conn = a.stats.wrapConn(conn)
|
||||
// This cannot use a JSON decoder, since that can
|
||||
// buffer additional data that is required for the PTY.
|
||||
rawLen := make([]byte, 2)
|
||||
_, err = conn.Read(rawLen)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
length := binary.LittleEndian.Uint16(rawLen)
|
||||
data := make([]byte, length)
|
||||
_, err = conn.Read(data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var msg codersdk.ReconnectingPTYInit
|
||||
err = json.Unmarshal(data, &msg)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
go a.handleReconnectingPTY(ctx, msg, conn)
|
||||
}
|
||||
}()
|
||||
|
||||
speedtestListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetSpeedtestPort))
|
||||
if err != nil {
|
||||
a.logger.Critical(ctx, "listen for speedtest", slog.Error(err))
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := speedtestListener.Accept()
|
||||
if err != nil {
|
||||
a.logger.Debug(ctx, "speedtest listener failed", slog.Error(err))
|
||||
return
|
||||
}
|
||||
a.closeMutex.Lock()
|
||||
a.connCloseWait.Add(1)
|
||||
a.closeMutex.Unlock()
|
||||
go func() {
|
||||
defer a.connCloseWait.Done()
|
||||
_ = speedtest.ServeConn(conn)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
statisticsListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetStatisticsPort))
|
||||
if err != nil {
|
||||
a.logger.Critical(ctx, "listen for statistics", slog.Error(err))
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer statisticsListener.Close()
|
||||
server := &http.Server{
|
||||
Handler: a.statisticsHandler(),
|
||||
ReadTimeout: 20 * time.Second,
|
||||
ReadHeaderTimeout: 20 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
ErrorLog: slog.Stdlib(ctx, a.logger.Named("statistics_http_server"), slog.LevelInfo),
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = server.Close()
|
||||
}()
|
||||
|
||||
err = server.Serve(statisticsListener)
|
||||
if err != nil && !xerrors.Is(err, http.ErrServerClosed) && !strings.Contains(err.Error(), "use of closed network connection") {
|
||||
a.logger.Critical(ctx, "serve statistics HTTP server", slog.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// runCoordinator listens for nodes and updates the self-node as it changes.
|
||||
func (a *agent) runCoordinator(ctx context.Context) {
|
||||
for {
|
||||
reconnect := a.runCoordinatorWithRetry(ctx)
|
||||
if !reconnect {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) runCoordinatorWithRetry(ctx context.Context) (reconnect bool) {
|
||||
var coordinator net.Conn
|
||||
var err error
|
||||
// An exponential back-off occurs when the connection is failing to dial.
|
||||
// This is to prevent server spam in case of a coderd outage.
|
||||
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
|
||||
coordinator, err = a.coordinatorDialer(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
if a.isClosed() {
|
||||
return false
|
||||
}
|
||||
a.logger.Warn(context.Background(), "failed to dial", slog.Error(err))
|
||||
continue
|
||||
}
|
||||
//nolint:revive // Defer is ok because we're exiting this loop.
|
||||
defer coordinator.Close()
|
||||
a.logger.Info(context.Background(), "connected to coordination server")
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
default:
|
||||
}
|
||||
sendNodes, errChan := tailnet.ServeCoordinator(coordinator, a.network.UpdateNodes)
|
||||
a.network.SetNodeCallback(sendNodes)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case err := <-errChan:
|
||||
if a.isClosed() {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
a.logger.Debug(ctx, "node broker accept exited; restarting connection", slog.Error(err))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +350,7 @@ func (a *agent) runStartupScript(ctx context.Context, script string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
writer, err := os.OpenFile(filepath.Join(os.TempDir(), "coder-startup-script.log"), os.O_CREATE|os.O_RDWR, 0600)
|
||||
writer, err := os.OpenFile(filepath.Join(os.TempDir(), "coder-startup-script.log"), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("open startup script log file: %w", err)
|
||||
}
|
||||
@@ -187,42 +377,8 @@ func (a *agent) runStartupScript(ctx context.Context, script string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) {
|
||||
go func() {
|
||||
select {
|
||||
case <-a.closed:
|
||||
case <-conn.Closed():
|
||||
}
|
||||
_ = conn.Close()
|
||||
a.connCloseWait.Done()
|
||||
}()
|
||||
for {
|
||||
channel, err := conn.Accept(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, peer.ErrClosed) || a.isClosed() {
|
||||
return
|
||||
}
|
||||
a.logger.Debug(ctx, "accept channel from peer connection", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
switch channel.Protocol() {
|
||||
case ProtocolSSH:
|
||||
go a.sshServer.HandleConn(channel.NetConn())
|
||||
case ProtocolReconnectingPTY:
|
||||
go a.handleReconnectingPTY(ctx, channel.Label(), channel.NetConn())
|
||||
case ProtocolDial:
|
||||
go a.handleDial(ctx, channel.Label(), channel.NetConn())
|
||||
default:
|
||||
a.logger.Warn(ctx, "unhandled protocol from channel",
|
||||
slog.F("protocol", channel.Protocol()),
|
||||
slog.F("label", channel.Label()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) init(ctx context.Context) {
|
||||
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 +402,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
|
||||
}
|
||||
},
|
||||
@@ -281,6 +445,8 @@ func (a *agent) init(ctx context.Context) {
|
||||
},
|
||||
SubsystemHandlers: map[string]ssh.SubsystemHandler{
|
||||
"sftp": func(session ssh.Session) {
|
||||
session.DisablePTYEmulation()
|
||||
|
||||
server, err := sftp.NewServer(session)
|
||||
if err != nil {
|
||||
a.logger.Debug(session.Context(), "initialize sftp server", slog.Error(err))
|
||||
@@ -297,6 +463,21 @@ func (a *agent) init(ctx context.Context) {
|
||||
}
|
||||
|
||||
go a.run(ctx)
|
||||
if a.statsReporter != nil {
|
||||
cl, err := a.statsReporter(ctx, a.logger, func() *codersdk.AgentStats {
|
||||
return a.stats.Copy()
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "report stats", slog.Error(err))
|
||||
return
|
||||
}
|
||||
a.connCloseWait.Add(1)
|
||||
go func() {
|
||||
defer a.connCloseWait.Done()
|
||||
<-a.closed
|
||||
cl.Close()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// createCommand processes raw command input with OpenSSH-like behavior.
|
||||
@@ -318,7 +499,7 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri
|
||||
if rawMetadata == nil {
|
||||
return nil, xerrors.Errorf("no metadata was provided: %w", err)
|
||||
}
|
||||
metadata, valid := rawMetadata.(Metadata)
|
||||
metadata, valid := rawMetadata.(codersdk.WorkspaceAgentMetadata)
|
||||
if !valid {
|
||||
return nil, xerrors.Errorf("metadata is the wrong type: %T", metadata)
|
||||
}
|
||||
@@ -352,38 +533,46 @@ 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))
|
||||
// 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 {
|
||||
for envKey, value := range metadata.EnvironmentVariables {
|
||||
// Expanding environment variables allows for customization
|
||||
// of the $PATH, among other variables. Customers can prepand
|
||||
// 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", key, os.ExpandEnv(value)))
|
||||
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 {
|
||||
cmd, err := a.createCommand(session.Context(), session.RawCommand(), session.Environ())
|
||||
func (a *agent) handleSSHSession(session ssh.Session) (retErr error) {
|
||||
ctx := session.Context()
|
||||
cmd, err := a.createCommand(ctx, session.RawCommand(), session.Environ())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -400,20 +589,34 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
|
||||
sshPty, windowSize, isPty := session.Pty()
|
||||
if isPty {
|
||||
// Disable minimal PTY emulation set by gliderlabs/ssh (NL-to-CRNL).
|
||||
// See https://github.com/coder/coder/issues/3371.
|
||||
session.DisablePTYEmulation()
|
||||
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", sshPty.Term))
|
||||
ptty, process, err := pty.Start(cmd)
|
||||
|
||||
// The pty package sets `SSH_TTY` on supported platforms.
|
||||
ptty, process, err := pty.Start(cmd, pty.WithPTYOption(
|
||||
pty.WithSSHRequest(sshPty),
|
||||
pty.WithLogger(slog.Stdlib(ctx, a.logger, slog.LevelInfo)),
|
||||
))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("start command: %w", err)
|
||||
}
|
||||
err = ptty.Resize(uint16(sshPty.Window.Height), uint16(sshPty.Window.Width))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resize ptty: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
closeErr := ptty.Close()
|
||||
if closeErr != nil {
|
||||
a.logger.Warn(ctx, "failed to close tty", slog.Error(closeErr))
|
||||
if retErr == nil {
|
||||
retErr = closeErr
|
||||
}
|
||||
}
|
||||
}()
|
||||
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(ctx, "failed to resize tty", slog.Error(resizeErr))
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -423,9 +626,14 @@ 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(ctx, "wait error", slog.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Stdout = session
|
||||
@@ -438,6 +646,7 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
}
|
||||
go func() {
|
||||
_, _ = io.Copy(stdinPipe, session)
|
||||
_ = stdinPipe.Close()
|
||||
}()
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
@@ -446,60 +655,36 @@ func (a *agent) handleSSHSession(session ssh.Session) error {
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn net.Conn) {
|
||||
func (a *agent) handleReconnectingPTY(ctx context.Context, msg codersdk.ReconnectingPTYInit, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
// The ID format is referenced in conn.go.
|
||||
// <uuid>:<height>:<width>
|
||||
idParts := strings.SplitN(rawID, ":", 4)
|
||||
if len(idParts) != 4 {
|
||||
a.logger.Warn(ctx, "client sent invalid id format", slog.F("raw-id", rawID))
|
||||
return
|
||||
}
|
||||
id := idParts[0]
|
||||
// Enforce a consistent format for IDs.
|
||||
_, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "client sent reconnection token that isn't a uuid", slog.F("id", id), slog.Error(err))
|
||||
return
|
||||
}
|
||||
// Parse the initial terminal dimensions.
|
||||
height, err := strconv.Atoi(idParts[1])
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "client sent invalid height", slog.F("id", id), slog.F("height", idParts[1]))
|
||||
return
|
||||
}
|
||||
width, err := strconv.Atoi(idParts[2])
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "client sent invalid width", slog.F("id", id), slog.F("width", idParts[2]))
|
||||
return
|
||||
}
|
||||
|
||||
var rpty *reconnectingPTY
|
||||
rawRPTY, ok := a.reconnectingPTYs.Load(id)
|
||||
rawRPTY, ok := a.reconnectingPTYs.Load(msg.ID)
|
||||
if ok {
|
||||
rpty, ok = rawRPTY.(*reconnectingPTY)
|
||||
if !ok {
|
||||
a.logger.Warn(ctx, "found invalid type in reconnecting pty map", slog.F("id", id))
|
||||
a.logger.Error(ctx, "found invalid type in reconnecting pty map", slog.F("id", msg.ID))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Empty command will default to the users shell!
|
||||
cmd, err := a.createCommand(ctx, idParts[3], nil)
|
||||
cmd, err := a.createCommand(ctx, msg.Command, nil)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "create reconnecting pty command", slog.Error(err))
|
||||
a.logger.Error(ctx, "create reconnecting pty command", slog.Error(err))
|
||||
return
|
||||
}
|
||||
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
|
||||
|
||||
ptty, process, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "start reconnecting pty command", slog.F("id", id))
|
||||
}
|
||||
|
||||
// Default to buffer 64KiB.
|
||||
circularBuffer, err := circbuf.NewBuffer(64 << 10)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "create circular buffer", slog.Error(err))
|
||||
a.logger.Error(ctx, "create circular buffer", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
ptty, process, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "start reconnecting pty command", slog.F("id", msg.ID))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -514,7 +699,7 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne
|
||||
timeout: time.AfterFunc(a.reconnectingPTYTimeout, cancelFunc),
|
||||
circularBuffer: circularBuffer,
|
||||
}
|
||||
a.reconnectingPTYs.Store(id, rpty)
|
||||
a.reconnectingPTYs.Store(msg.ID, rpty)
|
||||
go func() {
|
||||
// CommandContext isn't respected for Windows PTYs right now,
|
||||
// so we need to manually track the lifecycle.
|
||||
@@ -527,7 +712,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() {
|
||||
@@ -543,7 +728,7 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne
|
||||
_, err = rpty.circularBuffer.Write(part)
|
||||
rpty.circularBufferMutex.Unlock()
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "reconnecting pty write buffer", slog.Error(err), slog.F("id", id))
|
||||
a.logger.Error(ctx, "reconnecting pty write buffer", slog.Error(err), slog.F("id", msg.ID))
|
||||
break
|
||||
}
|
||||
rpty.activeConnsMutex.Lock()
|
||||
@@ -557,22 +742,22 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne
|
||||
// ID from memory.
|
||||
_ = process.Kill()
|
||||
rpty.Close()
|
||||
a.reconnectingPTYs.Delete(id)
|
||||
a.reconnectingPTYs.Delete(msg.ID)
|
||||
a.connCloseWait.Done()
|
||||
}()
|
||||
}
|
||||
// Resize the PTY to initial height + width.
|
||||
err = rpty.ptty.Resize(uint16(height), uint16(width))
|
||||
err := rpty.ptty.Resize(msg.Height, msg.Width)
|
||||
if err != nil {
|
||||
// We can continue after this, it's not fatal!
|
||||
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err))
|
||||
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", msg.ID), slog.Error(err))
|
||||
}
|
||||
// Write any previously stored data for the TTY.
|
||||
rpty.circularBufferMutex.RLock()
|
||||
_, err = conn.Write(rpty.circularBuffer.Bytes())
|
||||
rpty.circularBufferMutex.RUnlock()
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "write reconnecting pty buffer", slog.F("id", id), slog.Error(err))
|
||||
a.logger.Warn(ctx, "write reconnecting pty buffer", slog.F("id", msg.ID), slog.Error(err))
|
||||
return
|
||||
}
|
||||
connectionID := uuid.NewString()
|
||||
@@ -611,19 +796,19 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne
|
||||
rpty.activeConnsMutex.Unlock()
|
||||
}()
|
||||
decoder := json.NewDecoder(conn)
|
||||
var req ReconnectingPTYRequest
|
||||
var req codersdk.ReconnectingPTYRequest
|
||||
for {
|
||||
err = decoder.Decode(&req)
|
||||
if xerrors.Is(err, io.EOF) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "reconnecting pty buffer read error", slog.F("id", id), slog.Error(err))
|
||||
a.logger.Warn(ctx, "reconnecting pty buffer read error", slog.F("id", msg.ID), slog.Error(err))
|
||||
return
|
||||
}
|
||||
_, err = rpty.ptty.Input().Write([]byte(req.Data))
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "write to reconnecting pty", slog.F("id", id), slog.Error(err))
|
||||
a.logger.Warn(ctx, "write to reconnecting pty", slog.F("id", msg.ID), slog.Error(err))
|
||||
return
|
||||
}
|
||||
// Check if a resize needs to happen!
|
||||
@@ -633,75 +818,11 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn ne
|
||||
err = rpty.ptty.Resize(req.Height, req.Width)
|
||||
if err != nil {
|
||||
// We can continue after this, it's not fatal!
|
||||
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err))
|
||||
a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", msg.ID), slog.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dialResponse is written to datachannels with protocol "dial" by the agent as
|
||||
// the first packet to signify whether the dial succeeded or failed.
|
||||
type dialResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (a *agent) handleDial(ctx context.Context, label string, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
writeError := func(responseError error) error {
|
||||
msg := ""
|
||||
if responseError != nil {
|
||||
msg = responseError.Error()
|
||||
if !xerrors.Is(responseError, io.EOF) {
|
||||
a.logger.Warn(ctx, "handle dial", slog.F("label", label), slog.Error(responseError))
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(dialResponse{
|
||||
Error: msg,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "write dial response", slog.F("label", label), slog.Error(err))
|
||||
return xerrors.Errorf("marshal agent webrtc dial response: %w", err)
|
||||
}
|
||||
|
||||
_, err = conn.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
u, err := url.Parse(label)
|
||||
if err != nil {
|
||||
_ = writeError(xerrors.Errorf("parse URL %q: %w", label, err))
|
||||
return
|
||||
}
|
||||
|
||||
network := u.Scheme
|
||||
addr := u.Host + u.Path
|
||||
if strings.HasPrefix(network, "unix") {
|
||||
if runtime.GOOS == "windows" {
|
||||
_ = writeError(xerrors.New("Unix forwarding is not supported from Windows workspaces"))
|
||||
return
|
||||
}
|
||||
addr, err = ExpandRelativeHomePath(addr)
|
||||
if err != nil {
|
||||
_ = writeError(xerrors.Errorf("expand path %q: %w", addr, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
nconn, err := d.DialContext(ctx, network, addr)
|
||||
if err != nil {
|
||||
_ = writeError(xerrors.Errorf("dial '%v://%v': %w", network, addr, err))
|
||||
return
|
||||
}
|
||||
|
||||
err = writeError(nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
Bicopy(ctx, conn, nconn)
|
||||
}
|
||||
|
||||
// isClosed returns whether the API is closed or not.
|
||||
func (a *agent) isClosed() bool {
|
||||
select {
|
||||
@@ -720,6 +841,9 @@ func (a *agent) Close() error {
|
||||
}
|
||||
close(a.closed)
|
||||
a.closeCancel()
|
||||
if a.network != nil {
|
||||
_ = a.network.Close()
|
||||
}
|
||||
_ = a.sshServer.Close()
|
||||
a.connCloseWait.Wait()
|
||||
return nil
|
||||
@@ -744,7 +868,9 @@ func (r *reconnectingPTY) Close() {
|
||||
_ = conn.Close()
|
||||
}
|
||||
_ = r.ptty.Close()
|
||||
r.circularBufferMutex.Lock()
|
||||
r.circularBuffer.Reset()
|
||||
r.circularBufferMutex.Unlock()
|
||||
r.timeout.Stop()
|
||||
}
|
||||
|
||||
|
||||
+277
-86
@@ -7,18 +7,23 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/net/speedtest"
|
||||
|
||||
scp "github.com/bramvdbogaerde/go-scp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pion/udp"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -30,11 +35,11 @@ import (
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/peer"
|
||||
"github.com/coder/coder/peerbroker"
|
||||
"github.com/coder/coder/peerbroker/proto"
|
||||
"github.com/coder/coder/provisionersdk"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/tailnet"
|
||||
"github.com/coder/coder/tailnet/tailnettest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -43,9 +48,55 @@ func TestMain(m *testing.M) {
|
||||
|
||||
func TestAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Stats", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SSH", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
conn, stats := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
|
||||
sshClient, err := conn.SSHClient()
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
assert.EqualValues(t, 1, (<-stats).NumConns)
|
||||
assert.Greater(t, (<-stats).RxBytes, int64(0))
|
||||
assert.Greater(t, (<-stats).TxBytes, int64(0))
|
||||
})
|
||||
|
||||
t.Run("ReconnectingPTY", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn, stats := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
|
||||
ptyConn, err := conn.ReconnectingPTY(uuid.NewString(), 128, 128, "/bin/bash")
|
||||
require.NoError(t, err)
|
||||
defer ptyConn.Close()
|
||||
|
||||
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
|
||||
Data: "echo test\r\n",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ptyConn.Write(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
var s *codersdk.AgentStats
|
||||
require.Eventuallyf(t, func() bool {
|
||||
var ok bool
|
||||
s, ok = (<-stats)
|
||||
return ok && s.NumConns > 0 && s.RxBytes > 0 && s.TxBytes > 0
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats: %+v", s,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("SessionExec", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
session := setupSSHSession(t, agent.Metadata{})
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
|
||||
|
||||
command := "echo test"
|
||||
if runtime.GOOS == "windows" {
|
||||
@@ -58,7 +109,7 @@ func TestAgent(t *testing.T) {
|
||||
|
||||
t.Run("GitSSH", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
session := setupSSHSession(t, agent.Metadata{})
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
|
||||
command := "sh -c 'echo $GIT_SSH_COMMAND'"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo %GIT_SSH_COMMAND%"
|
||||
@@ -68,7 +119,7 @@ func TestAgent(t *testing.T) {
|
||||
require.True(t, strings.HasSuffix(strings.TrimSpace(string(output)), "gitssh --"))
|
||||
})
|
||||
|
||||
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.
|
||||
@@ -76,7 +127,7 @@ func TestAgent(t *testing.T) {
|
||||
// it seems like it could be either.
|
||||
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
||||
}
|
||||
session := setupSSHSession(t, agent.Metadata{})
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
|
||||
command := "bash"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe"
|
||||
@@ -102,6 +153,29 @@ func TestAgent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("SessionTTYExitCode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{})
|
||||
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")
|
||||
@@ -119,10 +193,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()
|
||||
@@ -136,8 +212,10 @@ func TestAgent(t *testing.T) {
|
||||
|
||||
t.Run("SFTP", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sshClient, err := setupAgent(t, agent.Metadata{}, 0).SSHClient()
|
||||
conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
sshClient, err := conn.SSHClient()
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
client, err := sftp.NewClient(sshClient)
|
||||
require.NoError(t, err)
|
||||
tempFile := filepath.Join(t.TempDir(), "sftp")
|
||||
@@ -149,11 +227,28 @@ func TestAgent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("SCP", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
sshClient, err := conn.SSHClient()
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
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"
|
||||
value := "value"
|
||||
session := setupSSHSession(t, agent.Metadata{
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{
|
||||
EnvironmentVariables: map[string]string{
|
||||
key: value,
|
||||
},
|
||||
@@ -170,7 +265,7 @@ func TestAgent(t *testing.T) {
|
||||
t.Run("EnvironmentVariableExpansion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := "EXAMPLE"
|
||||
session := setupSSHSession(t, agent.Metadata{
|
||||
session := setupSSHSession(t, codersdk.WorkspaceAgentMetadata{
|
||||
EnvironmentVariables: map[string]string{
|
||||
key: "$SOMETHINGNOTSET",
|
||||
},
|
||||
@@ -189,11 +284,54 @@ func TestAgent(t *testing.T) {
|
||||
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, codersdk.WorkspaceAgentMetadata{})
|
||||
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, codersdk.WorkspaceAgentMetadata{})
|
||||
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{
|
||||
setupAgent(t, codersdk.WorkspaceAgentMetadata{
|
||||
StartupScript: fmt.Sprintf("echo %s > %s", content, tempPath),
|
||||
}, 0)
|
||||
|
||||
@@ -209,11 +347,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))
|
||||
})
|
||||
|
||||
@@ -226,7 +366,7 @@ func TestAgent(t *testing.T) {
|
||||
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
||||
}
|
||||
|
||||
conn := setupAgent(t, agent.Metadata{}, 0)
|
||||
conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
id := uuid.NewString()
|
||||
netConn, err := conn.ReconnectingPTY(id, 100, 100, "/bin/bash")
|
||||
require.NoError(t, err)
|
||||
@@ -236,7 +376,7 @@ func TestAgent(t *testing.T) {
|
||||
// the shell is simultaneously sending a prompt.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
data, err := json.Marshal(agent.ReconnectingPTYRequest{
|
||||
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
|
||||
Data: "echo test\r\n",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -302,24 +442,6 @@ func TestAgent(t *testing.T) {
|
||||
return l
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unix",
|
||||
setup: func(t *testing.T) net.Listener {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix socket forwarding isn't supported on Windows")
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "coderd_agent_test_")
|
||||
require.NoError(t, err, "create temp dir for unix listener")
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
l, err := net.Listen("unix", filepath.Join(tmpDir, "test.sock"))
|
||||
require.NoError(t, err, "create UDP listener")
|
||||
return l
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -341,8 +463,11 @@ func TestAgent(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Dial the listener over WebRTC twice and test out of order
|
||||
conn := setupAgent(t, agent.Metadata{}, 0)
|
||||
conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := conn.Ping()
|
||||
return err == nil
|
||||
}, testutil.WaitMedium, testutil.IntervalFast)
|
||||
conn1, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String())
|
||||
require.NoError(t, err)
|
||||
defer conn1.Close()
|
||||
@@ -351,55 +476,54 @@ func TestAgent(t *testing.T) {
|
||||
defer conn2.Close()
|
||||
testDial(t, conn2)
|
||||
testDial(t, conn1)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DialError", func(t *testing.T) {
|
||||
t.Run("Speedtest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
// This test uses Unix listeners so we can very easily ensure that
|
||||
// no other tests decide to listen on the same random port we
|
||||
// picked.
|
||||
t.Skip("this test is unsupported on Windows")
|
||||
return
|
||||
if testing.Short() {
|
||||
t.Skip("The minimum duration for a speedtest is hardcoded in Tailscale to 5s!")
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "coderd_agent_test_")
|
||||
require.NoError(t, err, "create temp dir")
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
// Try to dial the non-existent Unix socket over WebRTC
|
||||
conn := setupAgent(t, agent.Metadata{}, 0)
|
||||
netConn, err := conn.DialContext(context.Background(), "unix", filepath.Join(tmpDir, "test.sock"))
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "remote dial error")
|
||||
require.ErrorContains(t, err, "no such file")
|
||||
require.Nil(t, netConn)
|
||||
derpMap := tailnettest.RunDERPAndSTUN(t)
|
||||
conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{
|
||||
DERPMap: derpMap,
|
||||
}, 0)
|
||||
defer conn.Close()
|
||||
res, err := conn.Speedtest(speedtest.Upload, 250*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
t.Logf("%.2f MBits/s", res[len(res)-1].MBitsPerSecond())
|
||||
})
|
||||
}
|
||||
|
||||
func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd {
|
||||
agentConn := setupAgent(t, agent.Metadata{}, 0)
|
||||
agentConn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
waitGroup := sync.WaitGroup{}
|
||||
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 err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
agent.Bicopy(context.Background(), conn, ssh)
|
||||
waitGroup.Done()
|
||||
}()
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
waitGroup.Wait()
|
||||
})
|
||||
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
|
||||
require.True(t, valid)
|
||||
@@ -411,43 +535,110 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe
|
||||
return exec.Command("ssh", args...)
|
||||
}
|
||||
|
||||
func setupSSHSession(t *testing.T, options agent.Metadata) *ssh.Session {
|
||||
sshClient, err := setupAgent(t, options, 0).SSHClient()
|
||||
func setupSSHSession(t *testing.T, options codersdk.WorkspaceAgentMetadata) *ssh.Session {
|
||||
conn, _ := setupAgent(t, options, 0)
|
||||
sshClient, err := conn.SSHClient()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = sshClient.Close()
|
||||
})
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
return session
|
||||
}
|
||||
|
||||
func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) *agent.Conn {
|
||||
client, server := provisionersdk.TransportPipe()
|
||||
closer := agent.New(func(ctx context.Context, logger slog.Logger) (agent.Metadata, *peerbroker.Listener, error) {
|
||||
listener, err := peerbroker.Listen(server, nil)
|
||||
return metadata, listener, err
|
||||
}, &agent.Options{
|
||||
type closeFunc func() error
|
||||
|
||||
func (c closeFunc) Close() error {
|
||||
return c()
|
||||
}
|
||||
|
||||
func setupAgent(t *testing.T, metadata codersdk.WorkspaceAgentMetadata, ptyTimeout time.Duration) (
|
||||
*codersdk.AgentConn,
|
||||
<-chan *codersdk.AgentStats,
|
||||
) {
|
||||
if metadata.DERPMap == nil {
|
||||
metadata.DERPMap = tailnettest.RunDERPAndSTUN(t)
|
||||
}
|
||||
coordinator := tailnet.NewCoordinator()
|
||||
agentID := uuid.New()
|
||||
statsCh := make(chan *codersdk.AgentStats)
|
||||
closer := agent.New(agent.Options{
|
||||
FetchMetadata: func(ctx context.Context) (codersdk.WorkspaceAgentMetadata, error) {
|
||||
return metadata, nil
|
||||
},
|
||||
CoordinatorDialer: func(ctx context.Context) (net.Conn, error) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
closed := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
_ = serverConn.Close()
|
||||
_ = clientConn.Close()
|
||||
<-closed
|
||||
})
|
||||
go func() {
|
||||
_ = coordinator.ServeAgent(serverConn, agentID)
|
||||
close(closed)
|
||||
}()
|
||||
return clientConn, nil
|
||||
},
|
||||
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
ReconnectingPTYTimeout: ptyTimeout,
|
||||
StatsReporter: func(ctx context.Context, log slog.Logger, statsFn func() *codersdk.AgentStats) (io.Closer, error) {
|
||||
doneCh := make(chan struct{})
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
go func() {
|
||||
defer close(doneCh)
|
||||
|
||||
t := time.NewTicker(time.Millisecond * 100)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
select {
|
||||
case statsCh <- statsFn():
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
// We don't want to send old stats.
|
||||
continue
|
||||
}
|
||||
}
|
||||
}()
|
||||
return closeFunc(func() error {
|
||||
cancel()
|
||||
<-doneCh
|
||||
close(statsCh)
|
||||
return nil
|
||||
}), nil
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
_ = server.Close()
|
||||
_ = closer.Close()
|
||||
})
|
||||
api := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client))
|
||||
stream, err := api.NegotiateConnection(context.Background())
|
||||
assert.NoError(t, err)
|
||||
conn, err := peerbroker.Dial(stream, []webrtc.ICEServer{}, &peer.ConnOptions{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
conn, err := tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)},
|
||||
DERPMap: metadata.DERPMap,
|
||||
Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
_ = clientConn.Close()
|
||||
_ = serverConn.Close()
|
||||
_ = conn.Close()
|
||||
})
|
||||
|
||||
return &agent.Conn{
|
||||
Negotiator: api,
|
||||
Conn: conn,
|
||||
}
|
||||
go coordinator.ServeClient(serverConn, uuid.New(), agentID)
|
||||
sendNode, _ := tailnet.ServeCoordinator(clientConn, func(node []*tailnet.Node) error {
|
||||
return conn.UpdateNodes(node)
|
||||
})
|
||||
conn.SetNodeCallback(sendNode)
|
||||
return &codersdk.AgentConn{
|
||||
Conn: conn,
|
||||
}, statsCh
|
||||
}
|
||||
|
||||
var dialTestPayload = []byte("dean-was-here123")
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
// WorkspaceAgentApps fetches the workspace apps.
|
||||
type WorkspaceAgentApps func(context.Context) ([]codersdk.WorkspaceApp, error)
|
||||
|
||||
// PostWorkspaceAgentAppHealth updates the workspace app health.
|
||||
type PostWorkspaceAgentAppHealth func(context.Context, codersdk.PostWorkspaceAppHealthsRequest) error
|
||||
|
||||
// WorkspaceAppHealthReporter is a function that checks and reports the health of the workspace apps until the passed context is canceled.
|
||||
type WorkspaceAppHealthReporter func(ctx context.Context)
|
||||
|
||||
// NewWorkspaceAppHealthReporter creates a WorkspaceAppHealthReporter that reports app health to coderd.
|
||||
func NewWorkspaceAppHealthReporter(logger slog.Logger, workspaceAgentApps WorkspaceAgentApps, postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth) WorkspaceAppHealthReporter {
|
||||
runHealthcheckLoop := func(ctx context.Context) error {
|
||||
apps, err := workspaceAgentApps(ctx)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("getting workspace apps: %w", err)
|
||||
}
|
||||
|
||||
// no need to run this loop if no apps for this workspace.
|
||||
if len(apps) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hasHealthchecksEnabled := false
|
||||
health := make(map[string]codersdk.WorkspaceAppHealth, 0)
|
||||
for _, app := range apps {
|
||||
health[app.Name] = app.Health
|
||||
if !hasHealthchecksEnabled && app.Health != codersdk.WorkspaceAppHealthDisabled {
|
||||
hasHealthchecksEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// no need to run this loop if no health checks are configured.
|
||||
if !hasHealthchecksEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// run a ticker for each app health check.
|
||||
var mu sync.RWMutex
|
||||
failures := make(map[string]int, 0)
|
||||
for _, nextApp := range apps {
|
||||
if !shouldStartTicker(nextApp) {
|
||||
continue
|
||||
}
|
||||
app := nextApp
|
||||
t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
// we set the http timeout to the healthcheck interval to prevent getting too backed up.
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(app.Healthcheck.Interval) * time.Second,
|
||||
}
|
||||
err := func() error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, app.Healthcheck.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// successful healthcheck is a non-5XX status code
|
||||
res.Body.Close()
|
||||
if res.StatusCode >= http.StatusInternalServerError {
|
||||
return xerrors.Errorf("error status code: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if failures[app.Name] < int(app.Healthcheck.Threshold) {
|
||||
// increment the failure count and keep status the same.
|
||||
// we will change it when we hit the threshold.
|
||||
failures[app.Name]++
|
||||
} else {
|
||||
// set to unhealthy if we hit the failure threshold.
|
||||
// we stop incrementing at the threshold to prevent the failure value from increasing forever.
|
||||
health[app.Name] = codersdk.WorkspaceAppHealthUnhealthy
|
||||
}
|
||||
mu.Unlock()
|
||||
} else {
|
||||
mu.Lock()
|
||||
// we only need one successful health check to be considered healthy.
|
||||
health[app.Name] = codersdk.WorkspaceAppHealthHealthy
|
||||
failures[app.Name] = 0
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
t.Reset(time.Duration(app.Healthcheck.Interval) * time.Second)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
lastHealth := copyHealth(health)
|
||||
mu.Unlock()
|
||||
reportTicker := time.NewTicker(time.Second)
|
||||
// every second we check if the health values of the apps have changed
|
||||
// and if there is a change we will report the new values.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-reportTicker.C:
|
||||
mu.RLock()
|
||||
changed := healthChanged(lastHealth, health)
|
||||
mu.RUnlock()
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
lastHealth = copyHealth(health)
|
||||
mu.Unlock()
|
||||
err := postWorkspaceAgentAppHealth(ctx, codersdk.PostWorkspaceAppHealthsRequest{
|
||||
Healths: lastHealth,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "failed to report workspace app stat", slog.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return func(ctx context.Context) {
|
||||
for r := retry.New(time.Second, 30*time.Second); r.Wait(ctx); {
|
||||
err := runHealthcheckLoop(ctx)
|
||||
if err == nil || xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
logger.Error(ctx, "failed running workspace app reporter", slog.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldStartTicker(app codersdk.WorkspaceApp) bool {
|
||||
return app.Healthcheck.URL != "" && app.Healthcheck.Interval > 0 && app.Healthcheck.Threshold > 0
|
||||
}
|
||||
|
||||
func healthChanged(old map[string]codersdk.WorkspaceAppHealth, new map[string]codersdk.WorkspaceAppHealth) bool {
|
||||
for name, newValue := range new {
|
||||
oldValue, found := old[name]
|
||||
if !found {
|
||||
return true
|
||||
}
|
||||
if newValue != oldValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func copyHealth(h1 map[string]codersdk.WorkspaceAppHealth) map[string]codersdk.WorkspaceAppHealth {
|
||||
h2 := make(map[string]codersdk.WorkspaceAppHealth, 0)
|
||||
for k, v := range h1 {
|
||||
h2[k] = v
|
||||
}
|
||||
|
||||
return h2
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package agent_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestAppHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Healthy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
Name: "app1",
|
||||
Healthcheck: codersdk.Healthcheck{},
|
||||
Health: codersdk.WorkspaceAppHealthDisabled,
|
||||
},
|
||||
{
|
||||
Name: "app2",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
// URL: We don't set the URL for this test because the setup will
|
||||
// create a httptest server for us and set it for us.
|
||||
Interval: 1,
|
||||
Threshold: 1,
|
||||
},
|
||||
Health: codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
}
|
||||
handlers := []http.Handler{
|
||||
nil,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, nil)
|
||||
}),
|
||||
}
|
||||
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
apps, err := getApps(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, apps[0].Health)
|
||||
require.Eventually(t, func() bool {
|
||||
apps, err := getApps(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return apps[1].Health == codersdk.WorkspaceAppHealthHealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
})
|
||||
|
||||
t.Run("500", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
Name: "app2",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
// URL: We don't set the URL for this test because the setup will
|
||||
// create a httptest server for us and set it for us.
|
||||
Interval: 1,
|
||||
Threshold: 1,
|
||||
},
|
||||
Health: codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
}
|
||||
handlers := []http.Handler{
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), w, http.StatusInternalServerError, nil)
|
||||
}),
|
||||
}
|
||||
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
require.Eventually(t, func() bool {
|
||||
apps, err := getApps(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
})
|
||||
|
||||
t.Run("Timeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
Name: "app2",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
// URL: We don't set the URL for this test because the setup will
|
||||
// create a httptest server for us and set it for us.
|
||||
Interval: 1,
|
||||
Threshold: 1,
|
||||
},
|
||||
Health: codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
}
|
||||
handlers := []http.Handler{
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// sleep longer than the interval to cause the health check to time out
|
||||
time.Sleep(2 * time.Second)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, nil)
|
||||
}),
|
||||
}
|
||||
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
require.Eventually(t, func() bool {
|
||||
apps, err := getApps(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
})
|
||||
|
||||
t.Run("NotSpamming", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
Name: "app2",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
// URL: We don't set the URL for this test because the setup will
|
||||
// create a httptest server for us and set it for us.
|
||||
Interval: 1,
|
||||
Threshold: 1,
|
||||
},
|
||||
Health: codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
}
|
||||
|
||||
var counter = new(int32)
|
||||
handlers := []http.Handler{
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(counter, 1)
|
||||
}),
|
||||
}
|
||||
_, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
// Ensure we haven't made more than 2 (expected 1 + 1 for buffer) requests in the last second.
|
||||
// if there is a bug where we are spamming the healthcheck route this will catch it.
|
||||
time.Sleep(time.Second)
|
||||
require.LessOrEqual(t, *counter, int32(2))
|
||||
})
|
||||
}
|
||||
|
||||
func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.WorkspaceApp, handlers []http.Handler) (agent.WorkspaceAgentApps, func()) {
|
||||
closers := []func(){}
|
||||
for i, handler := range handlers {
|
||||
if handler == nil {
|
||||
continue
|
||||
}
|
||||
ts := httptest.NewServer(handler)
|
||||
app := apps[i]
|
||||
app.Healthcheck.URL = ts.URL
|
||||
apps[i] = app
|
||||
closers = append(closers, ts.Close)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
workspaceAgentApps := func(context.Context) ([]codersdk.WorkspaceApp, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var newApps []codersdk.WorkspaceApp
|
||||
return append(newApps, apps...), nil
|
||||
}
|
||||
postWorkspaceAgentAppHealth := func(_ context.Context, req codersdk.PostWorkspaceAppHealthsRequest) error {
|
||||
mu.Lock()
|
||||
for name, health := range req.Healths {
|
||||
for i, app := range apps {
|
||||
if app.Name != name {
|
||||
continue
|
||||
}
|
||||
app.Health = health
|
||||
apps[i] = app
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
go agent.NewWorkspaceAppHealthReporter(slogtest.Make(t, nil).Leveled(slog.LevelDebug), workspaceAgentApps, postWorkspaceAgentAppHealth)(ctx)
|
||||
|
||||
return workspaceAgentApps, func() {
|
||||
for _, closeFn := range closers {
|
||||
closeFn()
|
||||
}
|
||||
}
|
||||
}
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/peer"
|
||||
"github.com/coder/coder/peerbroker/proto"
|
||||
)
|
||||
|
||||
// ReconnectingPTYRequest is sent from the client to the server
|
||||
// to pipe data to a PTY.
|
||||
type ReconnectingPTYRequest struct {
|
||||
Data string `json:"data"`
|
||||
Height uint16 `json:"height"`
|
||||
Width uint16 `json:"width"`
|
||||
}
|
||||
|
||||
// Conn wraps a peer connection with helper functions to
|
||||
// communicate with the agent.
|
||||
type Conn struct {
|
||||
// Negotiator is responsible for exchanging messages.
|
||||
Negotiator proto.DRPCPeerBrokerClient
|
||||
|
||||
*peer.Conn
|
||||
}
|
||||
|
||||
// ReconnectingPTY returns a connection serving a TTY that can
|
||||
// be reconnected to via ID.
|
||||
//
|
||||
// The command is optional and defaults to start a shell.
|
||||
func (c *Conn) ReconnectingPTY(id string, height, width uint16, command string) (net.Conn, error) {
|
||||
channel, err := c.CreateChannel(context.Background(), fmt.Sprintf("%s:%d:%d:%s", id, height, width, command), &peer.ChannelOptions{
|
||||
Protocol: ProtocolReconnectingPTY,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("pty: %w", err)
|
||||
}
|
||||
return channel.NetConn(), nil
|
||||
}
|
||||
|
||||
// SSH dials the built-in SSH server.
|
||||
func (c *Conn) SSH() (net.Conn, error) {
|
||||
channel, err := c.CreateChannel(context.Background(), "ssh", &peer.ChannelOptions{
|
||||
Protocol: ProtocolSSH,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dial: %w", err)
|
||||
}
|
||||
return channel.NetConn(), nil
|
||||
}
|
||||
|
||||
// SSHClient calls SSH to create a client that uses a weak cipher
|
||||
// for high throughput.
|
||||
func (c *Conn) SSHClient() (*ssh.Client, error) {
|
||||
netConn, err := c.SSH()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("ssh: %w", err)
|
||||
}
|
||||
sshConn, channels, requests, err := ssh.NewClientConn(netConn, "localhost:22", &ssh.ClientConfig{
|
||||
// SSH host validation isn't helpful, because obtaining a peer
|
||||
// connection already signifies user-intent to dial a workspace.
|
||||
// #nosec
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("ssh conn: %w", err)
|
||||
}
|
||||
return ssh.NewClient(sshConn, channels, requests), nil
|
||||
}
|
||||
|
||||
// DialContext dials an arbitrary protocol+address from inside the workspace and
|
||||
// proxies it through the provided net.Conn.
|
||||
func (c *Conn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) {
|
||||
u := &url.URL{
|
||||
Scheme: network,
|
||||
}
|
||||
if strings.HasPrefix(network, "unix") {
|
||||
u.Path = addr
|
||||
} else {
|
||||
u.Host = addr
|
||||
}
|
||||
|
||||
channel, err := c.CreateChannel(ctx, u.String(), &peer.ChannelOptions{
|
||||
Protocol: ProtocolDial,
|
||||
Unordered: strings.HasPrefix(network, "udp"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create datachannel: %w", err)
|
||||
}
|
||||
|
||||
// The first message written from the other side is a JSON payload
|
||||
// containing the dial error.
|
||||
dec := json.NewDecoder(channel)
|
||||
var res dialResponse
|
||||
err = dec.Decode(&res)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("decode agent dial response: %w", err)
|
||||
}
|
||||
if res.Error != "" {
|
||||
_ = channel.Close()
|
||||
return nil, xerrors.Errorf("remote dial error: %v", res.Error)
|
||||
}
|
||||
|
||||
return channel.NetConn(), nil
|
||||
}
|
||||
|
||||
func (c *Conn) Close() error {
|
||||
_ = c.Negotiator.DRPCConn().Close()
|
||||
return c.Conn.Close()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build linux || (windows && amd64)
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/cakturk/go-netstat/netstat"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) {
|
||||
lp.mut.Lock()
|
||||
defer lp.mut.Unlock()
|
||||
|
||||
if time.Since(lp.mtime) < time.Second {
|
||||
// copy
|
||||
ports := make([]codersdk.ListeningPort, len(lp.ports))
|
||||
copy(ports, lp.ports)
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
tabs, err := netstat.TCPSocks(func(s *netstat.SockTabEntry) bool {
|
||||
return s.State == netstat.Listen
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("scan listening ports: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[uint16]struct{}, len(tabs))
|
||||
ports := []codersdk.ListeningPort{}
|
||||
for _, tab := range tabs {
|
||||
if tab.LocalAddr == nil || tab.LocalAddr.Port < uint16(codersdk.MinimumListeningPort) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Don't include ports that we've already seen. This can happen on
|
||||
// Windows, and maybe on Linux if you're using a shared listener socket.
|
||||
if _, ok := seen[tab.LocalAddr.Port]; ok {
|
||||
continue
|
||||
}
|
||||
seen[tab.LocalAddr.Port] = struct{}{}
|
||||
|
||||
procName := ""
|
||||
if tab.Process != nil {
|
||||
procName = tab.Process.Name
|
||||
}
|
||||
ports = append(ports, codersdk.ListeningPort{
|
||||
ProcessName: procName,
|
||||
Network: codersdk.ListeningPortNetworkTCP,
|
||||
Port: tab.LocalAddr.Port,
|
||||
})
|
||||
}
|
||||
|
||||
lp.ports = ports
|
||||
lp.mtime = time.Now()
|
||||
|
||||
// copy
|
||||
ports = make([]codersdk.ListeningPort, len(lp.ports))
|
||||
copy(ports, lp.ports)
|
||||
return ports, nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !linux && !(windows && amd64)
|
||||
|
||||
package agent
|
||||
|
||||
import "github.com/coder/coder/codersdk"
|
||||
|
||||
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) {
|
||||
// Can't scan for ports on non-linux or non-windows_amd64 systems at the
|
||||
// moment. The UI will not show any "no ports found" message to the user, so
|
||||
// the user won't suspect a thing.
|
||||
return []codersdk.ListeningPort{}, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,18 +2,11 @@
|
||||
|
||||
package reaper
|
||||
|
||||
import "github.com/hashicorp/go-reap"
|
||||
|
||||
// IsChild returns true if we're the forked process.
|
||||
func IsChild() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsInitProcess returns true if the current process's PID is 1.
|
||||
func IsInitProcess() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func ForkReap(_ reap.PidCh) error {
|
||||
func ForkReap(_ ...Option) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/agent/reaper"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestReap(t *testing.T) {
|
||||
@@ -24,17 +25,17 @@ func TestReap(t *testing.T) {
|
||||
t.Skip("Detected CI, skipping reaper tests")
|
||||
}
|
||||
|
||||
// Because we're forkexecing these tests will try to run twice...
|
||||
if reaper.IsChild() {
|
||||
t.Skip("I'm a child!")
|
||||
}
|
||||
|
||||
// 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(pids)
|
||||
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")
|
||||
@@ -53,10 +54,9 @@ func TestReap(t *testing.T) {
|
||||
|
||||
expectedPIDs := []int{cmd.Process.Pid, cmd2.Process.Pid}
|
||||
|
||||
deadline := time.NewTimer(time.Second * 5)
|
||||
for i := 0; i < len(expectedPIDs); i++ {
|
||||
select {
|
||||
case <-deadline.C:
|
||||
case <-time.After(testutil.WaitShort):
|
||||
t.Fatalf("Timed out waiting for process")
|
||||
case pid := <-pids:
|
||||
require.Contains(t, expectedPIDs, pid)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
package reaper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
@@ -11,17 +10,6 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// agentEnvMark is a simple environment variable that we use as a marker
|
||||
// to indicated that the process is a child as opposed to the reaper.
|
||||
// Since we are forkexec'ing we need to be able to differentiate between
|
||||
// the two to avoid fork bombing ourselves.
|
||||
const agentEnvMark = "CODER_DO_NOT_REAP"
|
||||
|
||||
// IsChild returns true if we're the forked process.
|
||||
func IsChild() bool {
|
||||
return os.Getenv(agentEnvMark) != ""
|
||||
}
|
||||
|
||||
// IsInitProcess returns true if the current process's PID is 1.
|
||||
func IsInitProcess() bool {
|
||||
return os.Getpid() == 1
|
||||
@@ -33,19 +21,16 @@ func IsInitProcess() bool {
|
||||
// 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(pids reap.PidCh) error {
|
||||
// Check if the process is the parent or the child.
|
||||
// If it's the child we want to skip attempting to reap.
|
||||
if IsChild() {
|
||||
return nil
|
||||
func ForkReap(opt ...Option) error {
|
||||
opts := &options{
|
||||
ExecArgs: os.Args,
|
||||
}
|
||||
|
||||
go reap.ReapChildren(pids, nil, nil, nil)
|
||||
for _, o := range opt {
|
||||
o(opts)
|
||||
}
|
||||
|
||||
args := os.Args
|
||||
// This is simply done to help identify the real agent process
|
||||
// when viewing in something like 'ps'.
|
||||
args = append(args, "#Agent")
|
||||
go reap.ReapChildren(opts.PIDs, nil, nil, nil)
|
||||
|
||||
pwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
@@ -54,8 +39,7 @@ func ForkReap(pids reap.PidCh) error {
|
||||
|
||||
pattrs := &syscall.ProcAttr{
|
||||
Dir: pwd,
|
||||
// Add our marker for identifying the child process.
|
||||
Env: append(os.Environ(), fmt.Sprintf("%s=true", agentEnvMark)),
|
||||
Env: os.Environ(),
|
||||
Sys: &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
},
|
||||
@@ -67,7 +51,7 @@ func ForkReap(pids reap.PidCh) error {
|
||||
}
|
||||
|
||||
//#nosec G204
|
||||
pid, _ := syscall.ForkExec(args[0], args, pattrs)
|
||||
pid, _ := syscall.ForkExec(opts.ExecArgs[0], opts.ExecArgs, pattrs)
|
||||
|
||||
var wstatus syscall.WaitStatus
|
||||
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// statsConn wraps a net.Conn with statistics.
|
||||
type statsConn struct {
|
||||
*Stats
|
||||
net.Conn `json:"-"`
|
||||
}
|
||||
|
||||
var _ net.Conn = new(statsConn)
|
||||
|
||||
func (c *statsConn) Read(b []byte) (n int, err error) {
|
||||
n, err = c.Conn.Read(b)
|
||||
atomic.AddInt64(&c.RxBytes, int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *statsConn) Write(b []byte) (n int, err error) {
|
||||
n, err = c.Conn.Write(b)
|
||||
atomic.AddInt64(&c.TxBytes, int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
var _ net.Conn = new(statsConn)
|
||||
|
||||
// Stats records the Agent's network connection statistics for use in
|
||||
// user-facing metrics and debugging.
|
||||
// Each member value must be written and read with atomic.
|
||||
type Stats struct {
|
||||
NumConns int64 `json:"num_comms"`
|
||||
RxBytes int64 `json:"rx_bytes"`
|
||||
TxBytes int64 `json:"tx_bytes"`
|
||||
}
|
||||
|
||||
func (s *Stats) Copy() *codersdk.AgentStats {
|
||||
return &codersdk.AgentStats{
|
||||
NumConns: atomic.LoadInt64(&s.NumConns),
|
||||
RxBytes: atomic.LoadInt64(&s.RxBytes),
|
||||
TxBytes: atomic.LoadInt64(&s.TxBytes),
|
||||
}
|
||||
}
|
||||
|
||||
// wrapConn returns a new connection that records statistics.
|
||||
func (s *Stats) wrapConn(conn net.Conn) net.Conn {
|
||||
atomic.AddInt64(&s.NumConns, 1)
|
||||
cs := &statsConn{
|
||||
Stats: s,
|
||||
Conn: conn,
|
||||
}
|
||||
|
||||
return cs
|
||||
}
|
||||
|
||||
// StatsReporter periodically accept and records agent stats.
|
||||
type StatsReporter func(
|
||||
ctx context.Context,
|
||||
log slog.Logger,
|
||||
stats func() *codersdk.AgentStats,
|
||||
) (io.Closer, error)
|
||||
@@ -0,0 +1,49 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func (*agent) statisticsHandler() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
||||
Message: "Hello from the agent!",
|
||||
})
|
||||
})
|
||||
|
||||
lp := &listeningPortsHandler{}
|
||||
r.Get("/api/v0/listening-ports", lp.handler)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type listeningPortsHandler struct {
|
||||
mut sync.Mutex
|
||||
ports []codersdk.ListeningPort
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
// handler returns a list of listening ports. This is tested by coderd's
|
||||
// TestWorkspaceAgentListeningPorts test.
|
||||
func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request) {
|
||||
ports, err := lp.getListeningPorts()
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Could not scan for listening ports.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.ListeningPortsResponse{
|
||||
Ports: ports,
|
||||
})
|
||||
}
|
||||
+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),
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+35
-7
@@ -14,17 +14,16 @@ import (
|
||||
"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/buildinfo"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/retry"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
func workspaceAgent() *cobra.Command {
|
||||
@@ -32,6 +31,7 @@ func workspaceAgent() *cobra.Command {
|
||||
auth string
|
||||
pprofEnabled bool
|
||||
pprofAddress string
|
||||
noReap bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
@@ -58,9 +58,12 @@ func workspaceAgent() *cobra.Command {
|
||||
|
||||
// Spawn a reaper so that we don't accumulate a ton
|
||||
// of zombie processes.
|
||||
if reaper.IsInitProcess() && !reaper.IsChild() && isLinux {
|
||||
if reaper.IsInitProcess() && !noReap && isLinux {
|
||||
logger.Info(cmd.Context(), "spawning reaper process")
|
||||
err := reaper.ForkReap(nil)
|
||||
// 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)
|
||||
@@ -70,6 +73,12 @@ func workspaceAgent() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
version := buildinfo.Version()
|
||||
logger.Info(cmd.Context(), "starting agent",
|
||||
slog.F("url", coderURL),
|
||||
slog.F("auth", auth),
|
||||
slog.F("version", version),
|
||||
)
|
||||
client := codersdk.New(coderURL)
|
||||
|
||||
if pprofEnabled {
|
||||
@@ -135,6 +144,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
|
||||
@@ -158,6 +168,18 @@ func workspaceAgent() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
retryCtx, cancelRetry := context.WithTimeout(cmd.Context(), time.Hour)
|
||||
defer cancelRetry()
|
||||
for retrier := retry.New(100*time.Millisecond, 5*time.Second); retrier.Wait(retryCtx); {
|
||||
err := client.PostWorkspaceAgentVersion(retryCtx, version)
|
||||
if err != nil {
|
||||
logger.Warn(retryCtx, "post agent version: %w", slog.Error(err), slog.F("version", version))
|
||||
continue
|
||||
}
|
||||
logger.Info(retryCtx, "updated agent version", slog.F("version", version))
|
||||
break
|
||||
}
|
||||
|
||||
executablePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("getting os executable: %w", err)
|
||||
@@ -167,13 +189,18 @@ func workspaceAgent() *cobra.Command {
|
||||
return xerrors.Errorf("add executable to $PATH: %w", err)
|
||||
}
|
||||
|
||||
closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: logger,
|
||||
closer := agent.New(agent.Options{
|
||||
FetchMetadata: client.WorkspaceAgentMetadata,
|
||||
Logger: logger,
|
||||
EnvironmentVariables: map[string]string{
|
||||
// Override the "CODER_AGENT_TOKEN" variable in all
|
||||
// shells so "gitssh" works!
|
||||
"CODER_AGENT_TOKEN": client.SessionToken,
|
||||
},
|
||||
CoordinatorDialer: client.ListenWorkspaceAgentTailnet,
|
||||
StatsReporter: client.AgentReportStats,
|
||||
WorkspaceAgentApps: client.WorkspaceAgentApps,
|
||||
PostWorkspaceAgentAppHealth: client.PostWorkspaceAgentAppHealth,
|
||||
})
|
||||
<-cmd.Context().Done()
|
||||
return closer.Close()
|
||||
@@ -182,6 +209,7 @@ 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.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
+43
-21
@@ -4,12 +4,16 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestWorkspaceAgent(t *testing.T) {
|
||||
@@ -19,8 +23,8 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
instanceID := "instanceidentifier"
|
||||
certificates, metadataClient := coderdtest.NewAzureInstanceIdentity(t, instanceID)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
AzureCertificates: certificates,
|
||||
IncludeProvisionerD: true,
|
||||
AzureCertificates: certificates,
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
@@ -56,14 +60,20 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
ctx := context.WithValue(ctx, "azure-client", metadataClient)
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
workspace, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
resources := workspace.LatestBuild.Resources
|
||||
if assert.NotEmpty(t, workspace.LatestBuild.Resources) && assert.NotEmpty(t, resources[0].Agents) {
|
||||
assert.NotEmpty(t, resources[0].Agents[0].Version)
|
||||
}
|
||||
dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
_, err = dialer.Ping()
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := dialer.Ping()
|
||||
return err == nil
|
||||
}, testutil.WaitMedium, testutil.IntervalFast)
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
@@ -74,8 +84,8 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
instanceID := "instanceidentifier"
|
||||
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
AWSCertificates: certificates,
|
||||
IncludeProvisionerD: true,
|
||||
AWSCertificates: certificates,
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
@@ -111,14 +121,20 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
ctx := context.WithValue(ctx, "aws-client", metadataClient)
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
workspace, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
resources := workspace.LatestBuild.Resources
|
||||
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
|
||||
assert.NotEmpty(t, resources[0].Agents[0].Version)
|
||||
}
|
||||
dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
_, err = dialer.Ping()
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := dialer.Ping()
|
||||
return err == nil
|
||||
}, testutil.WaitMedium, testutil.IntervalFast)
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
@@ -129,8 +145,8 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
instanceID := "instanceidentifier"
|
||||
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
GoogleTokenValidator: validator,
|
||||
IncludeProvisionerD: true,
|
||||
GoogleTokenValidator: validator,
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
@@ -166,14 +182,20 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
ctx := context.WithValue(ctx, "gcp-client", metadata)
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
workspace, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err)
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
resources := workspace.LatestBuild.Resources
|
||||
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
|
||||
assert.NotEmpty(t, resources[0].Agents[0].Version)
|
||||
}
|
||||
dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
_, err = dialer.Ping()
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := dialer.Ping()
|
||||
return err == nil
|
||||
}, testutil.WaitMedium, testutil.IntervalFast)
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
|
||||
+81
-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,35 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
)
|
||||
|
||||
// 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)
|
||||
@@ -38,6 +63,18 @@ func StringVarP(flagset *pflag.FlagSet, p *string, name string, shorthand string
|
||||
flagset.StringVarP(p, name, shorthand, v, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
func StringArray(flagset *pflag.FlagSet, name, shorthand, env string, def []string, usage string) {
|
||||
v, ok := os.LookupEnv(env)
|
||||
if !ok || v == "" {
|
||||
if v == "" {
|
||||
def = []string{}
|
||||
} else {
|
||||
def = strings.Split(v, ",")
|
||||
}
|
||||
}
|
||||
flagset.StringArrayP(name, shorthand, def, fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
func StringArrayVarP(flagset *pflag.FlagSet, ptr *[]string, name string, shorthand string, env string, def []string, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if ok {
|
||||
@@ -47,7 +84,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 +104,39 @@ func Uint8VarP(flagset *pflag.FlagSet, ptr *uint8, name string, shorthand string
|
||||
flagset.Uint8VarP(ptr, name, shorthand, uint8(vi64), fmtUsage(usage, env))
|
||||
}
|
||||
|
||||
// IntVarP sets a uint8 flag on the given flag set.
|
||||
func IntVarP(flagset *pflag.FlagSet, ptr *int, name string, shorthand string, env string, def int, usage string) {
|
||||
val, ok := os.LookupEnv(env)
|
||||
if !ok || val == "" {
|
||||
flagset.IntVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
vi64, err := strconv.ParseUint(val, 10, 8)
|
||||
if err != nil {
|
||||
flagset.IntVarP(ptr, name, shorthand, def, fmtUsage(usage, env))
|
||||
return
|
||||
}
|
||||
|
||||
flagset.IntVarP(ptr, name, shorthand, int(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 +172,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\n"+cliui.Styles.Placeholder.Render("Consumes $%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) {
|
||||
@@ -107,7 +108,7 @@ func TestCliflag(t *testing.T) {
|
||||
require.Equal(t, []string{}, got)
|
||||
})
|
||||
|
||||
t.Run("IntDefault", func(t *testing.T) {
|
||||
t.Run("UInt8Default", func(t *testing.T) {
|
||||
var ptr uint8
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.Int63n(10)
|
||||
@@ -117,10 +118,10 @@ 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) {
|
||||
t.Run("UInt8EnvVar", func(t *testing.T) {
|
||||
var ptr uint8
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.Int63n(10)
|
||||
@@ -133,7 +134,7 @@ func TestCliflag(t *testing.T) {
|
||||
require.Equal(t, uint8(envValue), got)
|
||||
})
|
||||
|
||||
t.Run("IntFailParse", func(t *testing.T) {
|
||||
t.Run("UInt8FailParse", func(t *testing.T) {
|
||||
var ptr uint8
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
@@ -146,6 +147,45 @@ func TestCliflag(t *testing.T) {
|
||||
require.Equal(t, uint8(def), got)
|
||||
})
|
||||
|
||||
t.Run("IntDefault", func(t *testing.T) {
|
||||
var ptr int
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
def, _ := cryptorand.Int63n(10)
|
||||
|
||||
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, int(def), usage)
|
||||
got, err := flagset.GetInt(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int(def), got)
|
||||
require.Contains(t, flagset.FlagUsages(), usage)
|
||||
require.Contains(t, flagset.FlagUsages(), fmt.Sprintf("Consumes $%s", env))
|
||||
})
|
||||
|
||||
t.Run("IntEnvVar", func(t *testing.T) {
|
||||
var ptr int
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.Int63n(10)
|
||||
t.Setenv(env, strconv.FormatUint(uint64(envValue), 10))
|
||||
def, _ := cryptorand.Int()
|
||||
|
||||
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, def, usage)
|
||||
got, err := flagset.GetInt(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int(envValue), got)
|
||||
})
|
||||
|
||||
t.Run("IntFailParse", func(t *testing.T) {
|
||||
var ptr int
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
envValue, _ := cryptorand.String(10)
|
||||
t.Setenv(env, envValue)
|
||||
def, _ := cryptorand.Int63n(10)
|
||||
|
||||
cliflag.IntVarP(flagset, &ptr, name, shorthand, env, int(def), usage)
|
||||
got, err := flagset.GetInt(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int(def), got)
|
||||
})
|
||||
|
||||
t.Run("BoolDefault", func(t *testing.T) {
|
||||
var ptr bool
|
||||
flagset, name, shorthand, env, usage := randomFlag()
|
||||
@@ -156,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("BoolEnvVar", func(t *testing.T) {
|
||||
@@ -195,7 +235,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) {
|
||||
|
||||
+17
-1
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -21,10 +22,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
|
||||
}
|
||||
|
||||
@@ -40,6 +53,9 @@ func SetupConfig(t *testing.T, client *codersdk.Client, root config.Root) {
|
||||
// new temporary testing directory.
|
||||
func CreateTemplateVersionSource(t *testing.T, responses *echo.Responses) string {
|
||||
directory := t.TempDir()
|
||||
f, err := ioutil.TempFile(directory, "*.tf")
|
||||
require.NoError(t, err)
|
||||
f.Close()
|
||||
data, err := echo.Tar(responses)
|
||||
require.NoError(t, err)
|
||||
extractTar(t, data, directory)
|
||||
|
||||
+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: "#4d46b3"}),
|
||||
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+") "))
|
||||
}
|
||||
|
||||
+11
-13
@@ -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
|
||||
@@ -165,9 +165,6 @@ func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions, cmdOpt func(cmd *cob
|
||||
}
|
||||
|
||||
func TestPasswordTerminalState(t *testing.T) {
|
||||
// TODO: fix this test so that it runs reliably
|
||||
t.Skip()
|
||||
|
||||
if os.Getenv("TEST_SUBPROCESS") == "1" {
|
||||
passwordHelper()
|
||||
return
|
||||
@@ -185,27 +182,28 @@ 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
|
||||
|
||||
@@ -22,7 +22,7 @@ func WorkspaceBuild(ctx context.Context, writer io.Writer, client *codersdk.Clie
|
||||
build, err := client.WorkspaceBuild(ctx, build)
|
||||
return build.Job, err
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return client.WorkspaceBuildLogsAfter(ctx, build, before)
|
||||
},
|
||||
})
|
||||
@@ -31,7 +31,7 @@ func WorkspaceBuild(ctx context.Context, writer io.Writer, client *codersdk.Clie
|
||||
type ProvisionerJobOptions struct {
|
||||
Fetch func() (codersdk.ProvisionerJob, error)
|
||||
Cancel func() error
|
||||
Logs func() (<-chan codersdk.ProvisionerJobLog, error)
|
||||
Logs func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error)
|
||||
|
||||
FetchInterval time.Duration
|
||||
// Verbose determines whether debug and trace logs will be shown.
|
||||
@@ -132,10 +132,11 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
|
||||
// The initial stage needs to print after the signal handler has been registered.
|
||||
printStage()
|
||||
|
||||
logs, err := opts.Logs()
|
||||
logs, closer, err := opts.Logs()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("logs: %w", err)
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
var (
|
||||
// logOutput is where log output is written
|
||||
|
||||
@@ -2,6 +2,7 @@ package cliui_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
@@ -136,8 +137,10 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
|
||||
Cancel: func() error {
|
||||
return nil
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
return logs, nil
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return logs, closeFunc(func() error {
|
||||
return nil
|
||||
}), nil
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -164,3 +167,9 @@ func newProvisionerJob(t *testing.T) provisionerJobTest {
|
||||
PTY: ptty,
|
||||
}
|
||||
}
|
||||
|
||||
type closeFunc func() error
|
||||
|
||||
func (c closeFunc) Close() error {
|
||||
return c()
|
||||
}
|
||||
|
||||
+38
-13
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
|
||||
@@ -18,6 +19,7 @@ type WorkspaceResourcesOptions struct {
|
||||
HideAgentState bool
|
||||
HideAccess bool
|
||||
Title string
|
||||
ServerVersion string
|
||||
}
|
||||
|
||||
// WorkspaceResources displays the connection status and tree-view of provided resources.
|
||||
@@ -48,6 +50,7 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
|
||||
row := table.Row{"Resource"}
|
||||
if !options.HideAgentState {
|
||||
row = append(row, "Status")
|
||||
row = append(row, "Version")
|
||||
}
|
||||
if !options.HideAccess {
|
||||
row = append(row, "Access")
|
||||
@@ -91,21 +94,12 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
|
||||
}
|
||||
if !options.HideAgentState {
|
||||
var agentStatus string
|
||||
var agentVersion 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")
|
||||
}
|
||||
agentStatus = renderAgentStatus(agent)
|
||||
agentVersion = renderAgentVersion(agent.Version, options.ServerVersion)
|
||||
}
|
||||
row = append(row, agentStatus)
|
||||
row = append(row, agentStatus, agentVersion)
|
||||
}
|
||||
if !options.HideAccess {
|
||||
sshCommand := "coder ssh " + options.WorkspaceName
|
||||
@@ -122,3 +116,34 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
|
||||
_, err := fmt.Fprintln(writer, tableWriter.Render())
|
||||
return err
|
||||
}
|
||||
|
||||
func renderAgentStatus(agent codersdk.WorkspaceAgent) string {
|
||||
switch agent.Status {
|
||||
case codersdk.WorkspaceAgentConnecting:
|
||||
since := database.Now().Sub(agent.CreatedAt)
|
||||
return Styles.Warn.Render("⦾ connecting") + " " +
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentDisconnected:
|
||||
since := database.Now().Sub(*agent.DisconnectedAt)
|
||||
return Styles.Error.Render("⦾ disconnected") + " " +
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentConnected:
|
||||
return Styles.Keyword.Render("⦿ connected")
|
||||
default:
|
||||
return Styles.Warn.Render("○ unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func renderAgentVersion(agentVersion, serverVersion string) string {
|
||||
if agentVersion == "" {
|
||||
agentVersion = "(unknown)"
|
||||
}
|
||||
if !semver.IsValid(serverVersion) || !semver.IsValid(agentVersion) {
|
||||
return Styles.Placeholder.Render(agentVersion)
|
||||
}
|
||||
outdated := semver.Compare(agentVersion, serverVersion) < 0
|
||||
if outdated {
|
||||
return Styles.Warn.Render(agentVersion + " (outdated)")
|
||||
}
|
||||
return Styles.Keyword.Render(agentVersion)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRenderAgentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
agentVersion string
|
||||
serverVersion string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
agentVersion: "v1.2.3",
|
||||
serverVersion: "v1.2.3",
|
||||
expected: "v1.2.3",
|
||||
},
|
||||
{
|
||||
name: "Outdated",
|
||||
agentVersion: "v1.2.3",
|
||||
serverVersion: "v1.2.4",
|
||||
expected: "v1.2.3 (outdated)",
|
||||
},
|
||||
{
|
||||
name: "AgentUnknown",
|
||||
agentVersion: "",
|
||||
serverVersion: "v1.2.4",
|
||||
expected: "(unknown)",
|
||||
},
|
||||
{
|
||||
name: "ServerUnknown",
|
||||
agentVersion: "v1.2.3",
|
||||
serverVersion: "",
|
||||
expected: "v1.2.3",
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
actual := renderAgentVersion(testCase.agentVersion, testCase.serverVersion)
|
||||
assert.Equal(t, testCase.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,281 @@ 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.RFC3339)
|
||||
case *time.Time:
|
||||
if val != nil {
|
||||
v = val.Format(time.RFC3339)
|
||||
}
|
||||
case *int64:
|
||||
if val != nil {
|
||||
v = *val
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// TableHeaders returns the table header names of all
|
||||
// fields in tSlice. tSlice must be a slice of some type.
|
||||
func TableHeaders(tSlice any) ([]string, error) {
|
||||
v := reflect.Indirect(reflect.ValueOf(tSlice))
|
||||
rawHeaders, err := typeToTableHeaders(v.Type().Elem())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("type to table headers: %w", err)
|
||||
}
|
||||
out := make([]string, 0, len(rawHeaders))
|
||||
for _, hdr := range rawHeaders {
|
||||
out = append(out, strings.Replace(hdr, " ", "_", -1))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
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 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||
`
|
||||
|
||||
// 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 } 2022-08-02T15:49:10Z <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
`
|
||||
|
||||
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 2022-08-02T15:49:10Z
|
||||
bar bar1 bar3 2022-08-02T15:49:10Z
|
||||
baz baz1 baz3 2022-08-02T15:49:10Z
|
||||
`
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func Test_TableHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := []tableTest1{}
|
||||
expectedFields := []string{
|
||||
"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",
|
||||
}
|
||||
headers, err := cliui.TableHeaders(s)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, expectedFields, headers)
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
+138
-121
@@ -57,13 +57,6 @@ func (o sshConfigOptions) equal(other sshConfigOptions) bool {
|
||||
return slices.Equal(opt1, opt2)
|
||||
}
|
||||
|
||||
func (o sshConfigOptions) asArgs() (args []string) {
|
||||
for _, opt := range o.sshOptions {
|
||||
args = append(args, "--ssh-option", fmt.Sprintf("%q", opt))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func (o sshConfigOptions) asList() (list []string) {
|
||||
for _, opt := range o.sshOptions {
|
||||
list = append(list, fmt.Sprintf("ssh-option: %s", opt))
|
||||
@@ -96,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
|
||||
@@ -139,33 +137,26 @@ func configSSH() *cobra.Command {
|
||||
sshConfigFile string
|
||||
sshConfigOpts sshConfigOptions
|
||||
usePreviousOpts bool
|
||||
coderConfigFile string
|
||||
showDiff bool
|
||||
dryRun bool
|
||||
skipProxyCommand bool
|
||||
|
||||
// Diff should exit with status 1 when files differ.
|
||||
filesDiffer 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) {
|
||||
if showDiff && filesDiffer {
|
||||
os.Exit(1) //nolint: revive
|
||||
}
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
Short: "Add an SSH Host entry for your workspaces \"ssh coder.workspace\"",
|
||||
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
|
||||
}
|
||||
@@ -173,20 +164,31 @@ 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)
|
||||
coderBinary, err := currentBinPath(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
escapedCoderBinary, err := sshConfigExecEscape(coderBinary)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("escape coder binary for ssh failed: %w", err)
|
||||
}
|
||||
|
||||
root := createConfig(cmd)
|
||||
escapedGlobalConfig, err := sshConfigExecEscape(string(root))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("escape global config for ssh failed: %w", err)
|
||||
}
|
||||
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("user home dir failed: %w", err)
|
||||
}
|
||||
|
||||
sshConfigFileOrig := sshConfigFile
|
||||
if strings.HasPrefix(sshConfigFile, "~/") {
|
||||
sshConfigFile = filepath.Join(homedir, sshConfigFile[2:])
|
||||
}
|
||||
@@ -204,15 +206,7 @@ func configSSH() *cobra.Command {
|
||||
// Parse the previous configuration only if config-ssh
|
||||
// has been run previously.
|
||||
var lastConfig *sshConfigOptions
|
||||
var ok bool
|
||||
var coderConfigRaw []byte
|
||||
if coderConfigFile, coderConfigRaw, ok = readDeprecatedCoderConfigFile(homedir, coderConfigFile); ok {
|
||||
// Deprecated: Remove after migration period.
|
||||
changes = append(changes, fmt.Sprintf("Remove old auto-generated coder config file at %s", coderConfigFile))
|
||||
// Backwards compate, restore old options.
|
||||
c := sshConfigParseLastOptions(bytes.NewReader(coderConfigRaw))
|
||||
lastConfig = &c
|
||||
} else if section, ok := sshConfigGetCoderSection(configRaw); ok {
|
||||
if section, ok := sshConfigGetCoderSection(configRaw); ok {
|
||||
c := sshConfigParseLastOptions(bytes.NewReader(section))
|
||||
lastConfig = &c
|
||||
}
|
||||
@@ -221,7 +215,7 @@ func configSSH() *cobra.Command {
|
||||
// or when a previous config does not exist.
|
||||
if usePreviousOpts && lastConfig != nil {
|
||||
sshConfigOpts = *lastConfig
|
||||
} else if !showDiff && lastConfig != nil && !sshConfigOpts.equal(*lastConfig) {
|
||||
} else if lastConfig != nil && !sshConfigOpts.equal(*lastConfig) {
|
||||
newOpts := sshConfigOpts.asList()
|
||||
newOptsMsg := "\n\n New options: none"
|
||||
if len(newOpts) > 0 {
|
||||
@@ -243,21 +237,17 @@ func configSSH() *cobra.Command {
|
||||
}
|
||||
// Selecting "no" will use the last config.
|
||||
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")
|
||||
}
|
||||
|
||||
configModified := configRaw
|
||||
|
||||
// Check for the presence of the coder Include
|
||||
// statement is present and add if missing.
|
||||
// Deprecated: Remove after migration period.
|
||||
if configModified, ok = removeDeprecatedSSHIncludeStatement(configModified); ok {
|
||||
changes = append(changes, fmt.Sprintf("Remove %q from %s", "Include coder", sshConfigFile))
|
||||
}
|
||||
|
||||
root := createConfig(cmd)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
before, after := sshConfigSplitOnCoderSection(configModified)
|
||||
// Write the first half of the users config file to buf.
|
||||
@@ -298,7 +288,13 @@ func configSSH() *cobra.Command {
|
||||
"\tLogLevel ERROR",
|
||||
)
|
||||
if !skipProxyCommand {
|
||||
configOptions = append(configOptions, fmt.Sprintf("\tProxyCommand %q --global-config %q ssh --stdio %s", binaryFile, root, hostname))
|
||||
configOptions = append(
|
||||
configOptions,
|
||||
fmt.Sprintf(
|
||||
"\tProxyCommand %s --global-config %s ssh --stdio %s",
|
||||
escapedCoderBinary, escapedGlobalConfig, hostname,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
_, _ = buf.WriteString(strings.Join(configOptions, "\n"))
|
||||
@@ -312,97 +308,67 @@ func configSSH() *cobra.Command {
|
||||
_, _ = buf.Write(after)
|
||||
|
||||
if !bytes.Equal(configModified, buf.Bytes()) {
|
||||
changes = append(changes, fmt.Sprintf("Update coder config section in %s", sshConfigFile))
|
||||
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)
|
||||
diffFns := []func() ([]byte, error){
|
||||
func() ([]byte, error) { return diffBytes(sshConfigFile, configRaw, configModified, color) },
|
||||
diff, err := diffBytes(sshConfigFile, configRaw, configModified, color)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("diff failed: %w", err)
|
||||
}
|
||||
if len(coderConfigRaw) > 0 {
|
||||
// Deprecated: Remove after migration period.
|
||||
diffFns = append(diffFns, func() ([]byte, error) { return diffBytes(coderConfigFile, coderConfigRaw, nil, color) })
|
||||
}
|
||||
|
||||
for _, diffFn := range diffFns {
|
||||
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)
|
||||
}
|
||||
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.
|
||||
var args []string
|
||||
if sshConfigFileOrig != sshDefaultConfigFileName {
|
||||
args = append(args, "--ssh-config-file", sshConfigFileOrig)
|
||||
}
|
||||
args = append(args, sshConfigOpts.asArgs()...)
|
||||
args = append(args, "--diff")
|
||||
diffCommand := fmt.Sprintf("$ %s %s", cmd.CommandPath(), strings.Join(args, " "))
|
||||
_, 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")
|
||||
}
|
||||
// Deprecated: Remove after migration period.
|
||||
if len(coderConfigRaw) > 0 {
|
||||
err = os.Remove(coderConfigFile)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("remove coder 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(), &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(&showDiff, "diff", "D", false, "Show diff of changes that will be made.")
|
||||
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.")
|
||||
|
||||
// Deprecated: Remove after migration period.
|
||||
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")
|
||||
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
return cmd
|
||||
@@ -492,6 +458,11 @@ func writeWithTempFileAndMove(path string, r io.Reader) (err error) {
|
||||
dir := filepath.Dir(path)
|
||||
name := filepath.Base(path)
|
||||
|
||||
// Ensure that e.g. the ~/.ssh directory exists.
|
||||
if err = os.MkdirAll(dir, 0o700); err != nil {
|
||||
return xerrors.Errorf("create directory: %w", err)
|
||||
}
|
||||
|
||||
// Create a tempfile in the same directory for ensuring write
|
||||
// operation does not fail.
|
||||
f, err := os.CreateTemp(dir, fmt.Sprintf(".%s.", name))
|
||||
@@ -523,6 +494,52 @@ func writeWithTempFileAndMove(path string, r io.Reader) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sshConfigExecEscape quotes the string if it contains spaces, as per
|
||||
// `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to
|
||||
// run the command, and as such the formatting/escape requirements
|
||||
// cannot simply be covered by `fmt.Sprintf("%q", path)`.
|
||||
//
|
||||
// Always escaping the path with `fmt.Sprintf("%q", path)` usually works
|
||||
// on most platforms, but double quotes sometimes break on Windows 10
|
||||
// (see #2853). This function takes a best-effort approach to improving
|
||||
// compatibility and covering edge cases.
|
||||
//
|
||||
// Given the following ProxyCommand:
|
||||
//
|
||||
// ProxyCommand "/path/with space/coder" ssh --stdio work
|
||||
//
|
||||
// This is ~what OpenSSH would execute:
|
||||
//
|
||||
// /bin/bash -c '"/path/with space/to/coder" ssh --stdio workspace'
|
||||
//
|
||||
// However, since it's actually an arg in C, the contents inside the
|
||||
// single quotes are interpreted as is, e.g. if there was a '\t', it
|
||||
// would be the literal string '\t', not a tab.
|
||||
//
|
||||
// See:
|
||||
// - https://github.com/coder/coder/issues/2853
|
||||
// - https://github.com/openssh/openssh-portable/blob/V_9_0_P1/sshconnect.c#L158-L167
|
||||
// - https://github.com/PowerShell/openssh-portable/blob/v8.1.0.0/sshconnect.c#L231-L293
|
||||
// - https://github.com/PowerShell/openssh-portable/blob/v8.1.0.0/contrib/win32/win32compat/w32fd.c#L1075-L1100
|
||||
func sshConfigExecEscape(path string) (string, error) {
|
||||
// This is unlikely to ever happen, but newlines are allowed on
|
||||
// certain filesystems, but cannot be used inside ssh config.
|
||||
if strings.ContainsAny(path, "\n") {
|
||||
return "", xerrors.Errorf("invalid path: %s", path)
|
||||
}
|
||||
// In the unlikely even that a path contains quotes, they must be
|
||||
// escaped so that they are not interpreted as shell quotes.
|
||||
if strings.Contains(path, "\"") {
|
||||
path = strings.ReplaceAll(path, "\"", "\\\"")
|
||||
}
|
||||
// A space or a tab requires quoting, but tabs must not be escaped
|
||||
// (\t) since OpenSSH interprets it as a literal \t, not a tab.
|
||||
if strings.ContainsAny(path, " \t") {
|
||||
path = fmt.Sprintf("\"%s\"", path) //nolint:gocritic // We don't want %q here.
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// currentBinPath returns the path to the coder binary suitable for use in ssh
|
||||
// ProxyCommand.
|
||||
func currentBinPath(w io.Writer) (string, error) {
|
||||
@@ -563,19 +580,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
|
||||
}
|
||||
@@ -584,7 +601,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// This test tries to mimic the behavior of OpenSSH
|
||||
// when executing e.g. a ProxyCommand.
|
||||
func Test_sshConfigExecEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantErr bool
|
||||
windows bool
|
||||
}{
|
||||
{"no spaces", "simple", false, true},
|
||||
{"spaces", "path with spaces", false, true},
|
||||
{"quotes", "path with \"quotes\"", false, false},
|
||||
{"backslashes", "path with \\backslashes", false, false},
|
||||
{"tabs", "path with \ttabs", false, false},
|
||||
{"newline fails", "path with \nnewline", true, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows doesn't typically execute via /bin/sh or cmd.exe, so this test is not applicable.")
|
||||
}
|
||||
|
||||
dir := filepath.Join(t.TempDir(), tt.path)
|
||||
err := os.MkdirAll(dir, 0o755)
|
||||
require.NoError(t, err)
|
||||
bin := filepath.Join(dir, "coder")
|
||||
contents := []byte("#!/bin/sh\necho yay\n")
|
||||
err = os.WriteFile(bin, contents, 0o755) //nolint:gosec
|
||||
require.NoError(t, err)
|
||||
|
||||
escaped, err := sshConfigExecEscape(bin)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := exec.Command("/bin/sh", "-c", escaped).CombinedOutput() //nolint:gosec
|
||||
require.NoError(t, err)
|
||||
got := strings.TrimSpace(string(b))
|
||||
require.Equal(t, "yay", got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file contains config-ssh definitions that are deprecated, they
|
||||
// will be removed after a migratory period.
|
||||
|
||||
const (
|
||||
sshDefaultCoderConfigFileName = "~/.ssh/coder"
|
||||
sshCoderConfigHeader = "# This file is managed by coder. DO NOT EDIT."
|
||||
)
|
||||
|
||||
// Regular expressions are used because SSH configs do not have
|
||||
// meaningful indentation and keywords are case-insensitive.
|
||||
var (
|
||||
// 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]?$`)
|
||||
)
|
||||
|
||||
// removeDeprecatedSSHIncludeStatement checks for the Include coder statement
|
||||
// and returns modified = true if it was removed.
|
||||
func removeDeprecatedSSHIncludeStatement(data []byte) (modifiedData []byte, modified bool) {
|
||||
coderInclude := sshCoderIncludedRe.FindIndex(data)
|
||||
if coderInclude == nil {
|
||||
return data, false
|
||||
}
|
||||
|
||||
// Remove Include statement.
|
||||
d := append([]byte{}, data[:coderInclude[0]]...)
|
||||
d = append(d, data[coderInclude[1]:]...)
|
||||
data = d
|
||||
|
||||
return data, true
|
||||
}
|
||||
|
||||
// readDeprecatedCoderConfigFile reads the deprecated split config file.
|
||||
func readDeprecatedCoderConfigFile(homedir, coderConfigFile string) (name string, data []byte, ok bool) {
|
||||
if strings.HasPrefix(coderConfigFile, "~/") {
|
||||
coderConfigFile = filepath.Join(homedir, coderConfigFile[2:])
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(coderConfigFile)
|
||||
if err != nil {
|
||||
return coderConfigFile, nil, false
|
||||
}
|
||||
if len(b) > 0 {
|
||||
if !bytes.HasPrefix(b, []byte(sshCoderConfigHeader)) {
|
||||
return coderConfigFile, nil, false
|
||||
}
|
||||
}
|
||||
return coderConfigFile, b, true
|
||||
}
|
||||
+196
-135
@@ -1,22 +1,25 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
@@ -28,15 +31,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) {
|
||||
@@ -61,7 +63,7 @@ func sshConfigFileRead(t *testing.T, name string) string {
|
||||
func TestConfigSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
@@ -104,39 +106,49 @@ func TestConfigSSH(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
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)
|
||||
}()
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
agentConn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, resources[0].Agents[0].ID)
|
||||
require.NoError(t, err)
|
||||
defer agentConn.Close()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
defer func() {
|
||||
_ = listener.Close()
|
||||
})
|
||||
}()
|
||||
copyDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(copyDone)
|
||||
var wg sync.WaitGroup
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
break
|
||||
}
|
||||
ssh, err := agentConn.SSH()
|
||||
assert.NoError(t, err)
|
||||
go io.Copy(conn, ssh)
|
||||
go io.Copy(ssh, conn)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(conn, ssh)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(ssh, conn)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
})
|
||||
|
||||
sshConfigFile, _ := sshConfigFileNames(t)
|
||||
sshConfigFile := sshConfigFileName(t)
|
||||
|
||||
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
|
||||
require.True(t, valid)
|
||||
@@ -171,12 +183,16 @@ 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)))
|
||||
|
||||
_ = listener.Close()
|
||||
<-copyDone
|
||||
}
|
||||
|
||||
func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
@@ -197,12 +213,10 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
}, "\n")
|
||||
|
||||
type writeConfig struct {
|
||||
ssh string
|
||||
coder string
|
||||
ssh string
|
||||
}
|
||||
type wantConfig struct {
|
||||
ssh string
|
||||
coderKept bool
|
||||
ssh string
|
||||
}
|
||||
type match struct {
|
||||
match, write string
|
||||
@@ -494,74 +508,13 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
"--yes",
|
||||
},
|
||||
},
|
||||
|
||||
// Tests for deprecated split coder config.
|
||||
{
|
||||
name: "Do not overwrite unknown coder config",
|
||||
name: "Do not overwrite config when using --dry-run",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
coder: strings.Join([]string{
|
||||
"We're no strangers to love",
|
||||
"You know the rules and so do I (do I)",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
coderKept: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Transfer options from coder to ssh config",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Include coder",
|
||||
"",
|
||||
}, "\n"),
|
||||
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",
|
||||
"#",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Use new options?", write: "no"},
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Allow overwriting previous options from coder config",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Include coder",
|
||||
"",
|
||||
}, "\n"),
|
||||
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",
|
||||
"#",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
@@ -569,43 +522,10 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Use new options?", write: "yes"},
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Allow overwriting previous options from coder config when they differ",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Include coder",
|
||||
"",
|
||||
}, "\n"),
|
||||
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",
|
||||
"#",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=no",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
args: []string{"--ssh-option", "ForwardAgent=no"},
|
||||
matches: []match{
|
||||
{match: "Use new options?", write: "yes"},
|
||||
{match: "Continue?", write: "yes"},
|
||||
args: []string{
|
||||
"--ssh-option", "ForwardAgent=yes",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -615,7 +535,7 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -625,18 +545,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.ssh-coder-config-file", coderConfigName,
|
||||
}
|
||||
args = append(args, tt.args...)
|
||||
cmd, root := clitest.New(t, args...)
|
||||
@@ -665,10 +581,155 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
got := sshConfigFileRead(t, sshConfigName)
|
||||
assert.Equal(t, tt.wantConfig.ssh, got)
|
||||
}
|
||||
if !tt.wantConfig.coderKept {
|
||||
_, err := os.ReadFile(coderConfigName)
|
||||
assert.ErrorIs(t, err, fs.ErrNotExist)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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{IncludeProvisionerDaemon: 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
|
||||
}
|
||||
|
||||
+134
-93
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -25,9 +26,9 @@ func create() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "create [name]",
|
||||
Short: "Create a workspace from a template",
|
||||
Short: "Create a workspace",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -72,7 +73,7 @@ func create() *cobra.Command {
|
||||
}
|
||||
|
||||
slices.SortFunc(templates, func(a, b codersdk.Template) bool {
|
||||
return a.WorkspaceOwnerCount > b.WorkspaceOwnerCount
|
||||
return a.ActiveUserCount > b.ActiveUserCount
|
||||
})
|
||||
|
||||
templateNames := make([]string, 0, len(templates))
|
||||
@@ -81,13 +82,13 @@ func create() *cobra.Command {
|
||||
for _, template := range templates {
|
||||
templateName := template.Name
|
||||
|
||||
if template.WorkspaceOwnerCount > 0 {
|
||||
developerText := "developer"
|
||||
if template.WorkspaceOwnerCount != 1 {
|
||||
developerText = "developers"
|
||||
}
|
||||
|
||||
templateName += cliui.Styles.Placeholder.Render(fmt.Sprintf(" (used by %d %s)", template.WorkspaceOwnerCount, developerText))
|
||||
if template.ActiveUserCount > 0 {
|
||||
templateName += cliui.Styles.Placeholder.Render(
|
||||
fmt.Sprintf(
|
||||
" (used by %s)",
|
||||
formatActiveDevelopers(template.ActiveUserCount),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
templateNames = append(templateNames, templateName)
|
||||
@@ -120,87 +121,11 @@ func create() *cobra.Command {
|
||||
schedSpec = ptr.Ref(sched.String())
|
||||
}
|
||||
|
||||
templateVersion, err := client.TemplateVersion(cmd.Context(), template.ActiveVersionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parameterSchemas, err := client.TemplateVersionSchema(cmd.Context(), templateVersion.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// parameterMapFromFile can be nil if parameter file is not specified
|
||||
var parameterMapFromFile map[string]string
|
||||
if parameterFile != "" {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("Attempting to read the variables from the parameter file.")+"\r\n")
|
||||
parameterMapFromFile, err = createParameterMapFromFile(parameterFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
disclaimerPrinted := false
|
||||
parameters := make([]codersdk.CreateParameterRequest, 0)
|
||||
for _, parameterSchema := range parameterSchemas {
|
||||
if !parameterSchema.AllowOverrideSource {
|
||||
continue
|
||||
}
|
||||
if !disclaimerPrinted {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n")
|
||||
disclaimerPrinted = true
|
||||
}
|
||||
parameterValue, err := getParameterValueFromMapOrInput(cmd, parameterMapFromFile, parameterSchema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parameters = append(parameters, codersdk.CreateParameterRequest{
|
||||
Name: parameterSchema.Name,
|
||||
SourceValue: parameterValue,
|
||||
SourceScheme: codersdk.ParameterSourceSchemeData,
|
||||
DestinationScheme: parameterSchema.DefaultDestinationScheme,
|
||||
})
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout())
|
||||
|
||||
// Run a dry-run with the given parameters to check correctness
|
||||
after := time.Now()
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(cmd.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: workspaceName,
|
||||
ParameterValues: parameters,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("begin workspace dry-run: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
return client.TemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelTemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
|
||||
return client.TemplateVersionDryRunLogsAfter(cmd.Context(), templateVersion.ID, dryRun.ID, after)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
})
|
||||
if err != nil {
|
||||
// TODO (Dean): reprompt for parameter values if we deem it to
|
||||
// be a validation error
|
||||
return xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
|
||||
resources, err := client.TemplateVersionDryRunResources(cmd.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace dry-run resources: %w", err)
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: workspaceName,
|
||||
// Since agent's haven't connected yet, hiding this makes more sense.
|
||||
HideAgentState: true,
|
||||
Title: "Workspace Preview",
|
||||
parameters, err := prepWorkspaceBuild(cmd, client, prepWorkspaceBuildArgs{
|
||||
Template: template,
|
||||
ExistingParams: []codersdk.Parameter{},
|
||||
ParameterFile: parameterFile,
|
||||
NewWorkspaceName: workspaceName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -214,7 +139,8 @@ func create() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.CreateWorkspaceRequest{
|
||||
after := time.Now()
|
||||
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.Me, codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: workspaceName,
|
||||
AutostartSchedule: schedSpec,
|
||||
@@ -230,7 +156,7 @@ func create() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %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
|
||||
},
|
||||
}
|
||||
@@ -242,3 +168,118 @@ func create() *cobra.Command {
|
||||
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
|
||||
}
|
||||
|
||||
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, err
|
||||
}
|
||||
parameterSchemas, err := client.TemplateVersionSchema(ctx, templateVersion.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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, io.Closer, 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+27
-28
@@ -2,7 +2,6 @@ package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
@@ -13,18 +12,18 @@ import (
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
@@ -81,7 +80,7 @@ func TestCreate(t *testing.T) {
|
||||
|
||||
t.Run("CreateFromListWithSkip", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -90,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)
|
||||
@@ -103,7 +102,7 @@ func TestCreate(t *testing.T) {
|
||||
|
||||
t.Run("FromNothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -140,7 +139,7 @@ func TestCreate(t *testing.T) {
|
||||
|
||||
t.Run("WithParameter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
defaultValue := "something"
|
||||
@@ -181,7 +180,7 @@ func TestCreate(t *testing.T) {
|
||||
|
||||
t.Run("WithParameterFileContainingTheValue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
defaultValue := "something"
|
||||
@@ -194,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())
|
||||
@@ -219,12 +219,11 @@ func TestCreate(t *testing.T) {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
<-doneChan
|
||||
removeTmpDirUntilSuccess(t, tempDir)
|
||||
})
|
||||
|
||||
t.Run("WithParameterFileNotContainingTheValue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
defaultValue := "something"
|
||||
@@ -236,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())
|
||||
@@ -250,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{IncludeProvisionerDaemon: 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())
|
||||
|
||||
+30
-4
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -10,7 +11,8 @@ import (
|
||||
)
|
||||
|
||||
// nolint
|
||||
func delete() *cobra.Command {
|
||||
func deleteWorkspace() *cobra.Command {
|
||||
var orphan bool
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "delete <workspace>",
|
||||
@@ -21,12 +23,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
|
||||
}
|
||||
@@ -34,16 +37,39 @@ func delete() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var state []byte
|
||||
|
||||
if orphan {
|
||||
cliui.Warn(
|
||||
cmd.ErrOrStderr(),
|
||||
"Orphaning workspace requires template edit permission",
|
||||
)
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionDelete,
|
||||
Transition: codersdk.WorkspaceTransitionDelete,
|
||||
ProvisionerState: state,
|
||||
Orphan: orphan,
|
||||
})
|
||||
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
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&orphan, "orphan", false,
|
||||
`Delete a workspace without deleting its resources. This can delete a
|
||||
workspace in a broken state, but may also lead to unaccounted cloud resources.`,
|
||||
)
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+32
-2
@@ -15,9 +15,10 @@ 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})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -42,9 +43,38 @@ func TestDelete(t *testing.T) {
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("Orphan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
cmd, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan")
|
||||
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
// When running with the race detector on, we sometimes get an EOF.
|
||||
if err != nil {
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
}()
|
||||
pty.ExpectMatch("Cleaning Up")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("DifferentUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
adminUser := coderdtest.CreateFirstUser(t, adminClient)
|
||||
orgID := adminUser.OrganizationID
|
||||
client := coderdtest.CreateAnotherUser(t, adminClient, orgID)
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
package deployment
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
secretValue = "********"
|
||||
)
|
||||
|
||||
func Flags() *codersdk.DeploymentFlags {
|
||||
return &codersdk.DeploymentFlags{
|
||||
AccessURL: &codersdk.StringFlag{
|
||||
Name: "Access URL",
|
||||
Flag: "access-url",
|
||||
EnvVar: "CODER_ACCESS_URL",
|
||||
Description: "External URL to access your deployment. This must be accessible by all provisioned workspaces.",
|
||||
},
|
||||
WildcardAccessURL: &codersdk.StringFlag{
|
||||
Name: "Wildcard Address URL",
|
||||
Flag: "wildcard-access-url",
|
||||
EnvVar: "CODER_WILDCARD_ACCESS_URL",
|
||||
Description: `Specifies the wildcard hostname to use for workspace applications in the form "*.example.com".`,
|
||||
},
|
||||
Address: &codersdk.StringFlag{
|
||||
Name: "Bind Address",
|
||||
Flag: "address",
|
||||
EnvVar: "CODER_ADDRESS",
|
||||
Shorthand: "a",
|
||||
Description: "Bind address of the server.",
|
||||
Default: "127.0.0.1:3000",
|
||||
},
|
||||
AutobuildPollInterval: &codersdk.DurationFlag{
|
||||
Name: "Autobuild Poll Interval",
|
||||
Flag: "autobuild-poll-interval",
|
||||
EnvVar: "CODER_AUTOBUILD_POLL_INTERVAL",
|
||||
Description: "Interval to poll for scheduled workspace builds.",
|
||||
Hidden: true,
|
||||
Default: time.Minute,
|
||||
},
|
||||
DerpServerEnable: &codersdk.BoolFlag{
|
||||
Name: "DERP Server Enabled",
|
||||
Flag: "derp-server-enable",
|
||||
EnvVar: "CODER_DERP_SERVER_ENABLE",
|
||||
Description: "Whether to enable or disable the embedded DERP relay server.",
|
||||
Default: true,
|
||||
},
|
||||
DerpServerRegionID: &codersdk.IntFlag{
|
||||
Name: "DERP Server Region ID",
|
||||
Flag: "derp-server-region-id",
|
||||
EnvVar: "CODER_DERP_SERVER_REGION_ID",
|
||||
Description: "Region ID to use for the embedded DERP server.",
|
||||
Default: 999,
|
||||
},
|
||||
DerpServerRegionCode: &codersdk.StringFlag{
|
||||
Name: "DERP Server Region Code",
|
||||
Flag: "derp-server-region-code",
|
||||
EnvVar: "CODER_DERP_SERVER_REGION_CODE",
|
||||
Description: "Region code to use for the embedded DERP server.",
|
||||
Default: "coder",
|
||||
},
|
||||
DerpServerRegionName: &codersdk.StringFlag{
|
||||
Name: "DERP Server Region Name",
|
||||
Flag: "derp-server-region-name",
|
||||
EnvVar: "CODER_DERP_SERVER_REGION_NAME",
|
||||
Description: "Region name that for the embedded DERP server.",
|
||||
Default: "Coder Embedded Relay",
|
||||
},
|
||||
DerpServerSTUNAddresses: &codersdk.StringArrayFlag{
|
||||
Name: "DERP Server STUN Addresses",
|
||||
Flag: "derp-server-stun-addresses",
|
||||
EnvVar: "CODER_DERP_SERVER_STUN_ADDRESSES",
|
||||
Description: "Addresses for STUN servers to establish P2P connections. Set empty to disable P2P connections.",
|
||||
Default: []string{"stun.l.google.com:19302"},
|
||||
},
|
||||
DerpConfigURL: &codersdk.StringFlag{
|
||||
Name: "DERP Config URL",
|
||||
Flag: "derp-config-url",
|
||||
EnvVar: "CODER_DERP_CONFIG_URL",
|
||||
Description: "URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/",
|
||||
},
|
||||
DerpConfigPath: &codersdk.StringFlag{
|
||||
Name: "DERP Config Path",
|
||||
Flag: "derp-config-path",
|
||||
EnvVar: "CODER_DERP_CONFIG_PATH",
|
||||
Description: "Path to read a DERP mapping from. See: https://tailscale.com/kb/1118/custom-derp-servers/",
|
||||
},
|
||||
PromEnabled: &codersdk.BoolFlag{
|
||||
Name: "Prometheus Enabled",
|
||||
Flag: "prometheus-enable",
|
||||
EnvVar: "CODER_PROMETHEUS_ENABLE",
|
||||
Description: "Serve prometheus metrics on the address defined by `prometheus-address`.",
|
||||
},
|
||||
PromAddress: &codersdk.StringFlag{
|
||||
Name: "Prometheus Address",
|
||||
Flag: "prometheus-address",
|
||||
EnvVar: "CODER_PROMETHEUS_ADDRESS",
|
||||
Description: "The bind address to serve prometheus metrics.",
|
||||
Default: "127.0.0.1:2112",
|
||||
},
|
||||
PprofEnabled: &codersdk.BoolFlag{
|
||||
Name: "pprof Enabled",
|
||||
Flag: "pprof-enable",
|
||||
EnvVar: "CODER_PPROF_ENABLE",
|
||||
Description: "Serve pprof metrics on the address defined by `pprof-address`.",
|
||||
},
|
||||
PprofAddress: &codersdk.StringFlag{
|
||||
Name: "pprof Address",
|
||||
Flag: "pprof-address",
|
||||
EnvVar: "CODER_PPROF_ADDRESS",
|
||||
Description: "The bind address to serve pprof.",
|
||||
Default: "127.0.0.1:6060",
|
||||
},
|
||||
CacheDir: &codersdk.StringFlag{
|
||||
Name: "Cache Directory",
|
||||
Flag: "cache-dir",
|
||||
EnvVar: "CODER_CACHE_DIRECTORY",
|
||||
Description: "The directory to cache temporary files. If unspecified and $CACHE_DIRECTORY is set, it will be used for compatibility with systemd.",
|
||||
Default: defaultCacheDir(),
|
||||
},
|
||||
InMemoryDatabase: &codersdk.BoolFlag{
|
||||
Name: "In-Memory Database",
|
||||
Flag: "in-memory",
|
||||
EnvVar: "CODER_INMEMORY",
|
||||
Description: "Controls whether data will be stored in an in-memory database.",
|
||||
Hidden: true,
|
||||
},
|
||||
ProvisionerDaemonCount: &codersdk.IntFlag{
|
||||
Name: "Provisioner Daemons",
|
||||
Flag: "provisioner-daemons",
|
||||
EnvVar: "CODER_PROVISIONER_DAEMONS",
|
||||
Description: "Number of provisioner daemons to create on start. If builds are stuck in queued state for a long time, consider increasing this.",
|
||||
Default: 3,
|
||||
},
|
||||
PostgresURL: &codersdk.StringFlag{
|
||||
Name: "Postgres URL",
|
||||
Flag: "postgres-url",
|
||||
EnvVar: "CODER_PG_CONNECTION_URL",
|
||||
Description: "URL of a PostgreSQL database. If empty, PostgreSQL binaries will be downloaded from Maven (https://repo1.maven.org/maven2) and store all data in the config root. Access the built-in database with \"coder server postgres-builtin-url\"",
|
||||
Secret: true,
|
||||
},
|
||||
OAuth2GithubClientID: &codersdk.StringFlag{
|
||||
Name: "Oauth2 Github Client ID",
|
||||
Flag: "oauth2-github-client-id",
|
||||
EnvVar: "CODER_OAUTH2_GITHUB_CLIENT_ID",
|
||||
Description: "Client ID for Login with GitHub.",
|
||||
},
|
||||
OAuth2GithubClientSecret: &codersdk.StringFlag{
|
||||
Name: "Oauth2 Github Client Secret",
|
||||
Flag: "oauth2-github-client-secret",
|
||||
EnvVar: "CODER_OAUTH2_GITHUB_CLIENT_SECRET",
|
||||
Description: "Client secret for Login with GitHub.",
|
||||
Secret: true,
|
||||
},
|
||||
OAuth2GithubAllowedOrganizations: &codersdk.StringArrayFlag{
|
||||
Name: "Oauth2 Github Allowed Organizations",
|
||||
Flag: "oauth2-github-allowed-orgs",
|
||||
EnvVar: "CODER_OAUTH2_GITHUB_ALLOWED_ORGS",
|
||||
Description: "Organizations the user must be a member of to Login with GitHub.",
|
||||
Default: []string{},
|
||||
},
|
||||
OAuth2GithubAllowedTeams: &codersdk.StringArrayFlag{
|
||||
Name: "Oauth2 Github Allowed Teams",
|
||||
Flag: "oauth2-github-allowed-teams",
|
||||
EnvVar: "CODER_OAUTH2_GITHUB_ALLOWED_TEAMS",
|
||||
Description: "Teams inside organizations the user must be a member of to Login with GitHub. Structured as: <organization-name>/<team-slug>.",
|
||||
Default: []string{},
|
||||
},
|
||||
OAuth2GithubAllowSignups: &codersdk.BoolFlag{
|
||||
Name: "Oauth2 Github Allow Signups",
|
||||
Flag: "oauth2-github-allow-signups",
|
||||
EnvVar: "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS",
|
||||
Description: "Whether new users can sign up with GitHub.",
|
||||
},
|
||||
OAuth2GithubEnterpriseBaseURL: &codersdk.StringFlag{
|
||||
Name: "Oauth2 Github Enterprise Base URL",
|
||||
Flag: "oauth2-github-enterprise-base-url",
|
||||
EnvVar: "CODER_OAUTH2_GITHUB_ENTERPRISE_BASE_URL",
|
||||
Description: "Base URL of a GitHub Enterprise deployment to use for Login with GitHub.",
|
||||
},
|
||||
OIDCAllowSignups: &codersdk.BoolFlag{
|
||||
Name: "OIDC Allow Signups",
|
||||
Flag: "oidc-allow-signups",
|
||||
EnvVar: "CODER_OIDC_ALLOW_SIGNUPS",
|
||||
Description: "Whether new users can sign up with OIDC.",
|
||||
Default: true,
|
||||
},
|
||||
OIDCClientID: &codersdk.StringFlag{
|
||||
Name: "OIDC Client ID",
|
||||
Flag: "oidc-client-id",
|
||||
EnvVar: "CODER_OIDC_CLIENT_ID",
|
||||
Description: "Client ID to use for Login with OIDC.",
|
||||
},
|
||||
OIDCClientSecret: &codersdk.StringFlag{
|
||||
Name: "OIDC Client Secret",
|
||||
Flag: "oidc-client-secret",
|
||||
EnvVar: "CODER_OIDC_CLIENT_SECRET",
|
||||
Description: "Client secret to use for Login with OIDC.",
|
||||
Secret: true,
|
||||
},
|
||||
OIDCEmailDomain: &codersdk.StringFlag{
|
||||
Name: "OIDC Email Domain",
|
||||
Flag: "oidc-email-domain",
|
||||
EnvVar: "CODER_OIDC_EMAIL_DOMAIN",
|
||||
Description: "Email domain that clients logging in with OIDC must match.",
|
||||
},
|
||||
OIDCIssuerURL: &codersdk.StringFlag{
|
||||
Name: "OIDC Issuer URL",
|
||||
Flag: "oidc-issuer-url",
|
||||
EnvVar: "CODER_OIDC_ISSUER_URL",
|
||||
Description: "Issuer URL to use for Login with OIDC.",
|
||||
},
|
||||
OIDCScopes: &codersdk.StringArrayFlag{
|
||||
Name: "OIDC Scopes",
|
||||
Flag: "oidc-scopes",
|
||||
EnvVar: "CODER_OIDC_SCOPES",
|
||||
Description: "Scopes to grant when authenticating with OIDC.",
|
||||
Default: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
TelemetryEnable: &codersdk.BoolFlag{
|
||||
Name: "Telemetry Enabled",
|
||||
Flag: "telemetry",
|
||||
EnvVar: "CODER_TELEMETRY",
|
||||
Description: "Whether telemetry is enabled or not. Coder collects anonymized usage data to help improve our product.",
|
||||
Default: flag.Lookup("test.v") == nil,
|
||||
},
|
||||
TelemetryTraceEnable: &codersdk.BoolFlag{
|
||||
Name: "Trace Telemetry Enabled",
|
||||
Flag: "telemetry-trace",
|
||||
EnvVar: "CODER_TELEMETRY_TRACE",
|
||||
Shorthand: "",
|
||||
Description: "Whether Opentelemetry traces are sent to Coder. Coder collects anonymized application tracing to help improve our product. Disabling telemetry also disables this option.",
|
||||
Default: flag.Lookup("test.v") == nil,
|
||||
},
|
||||
TelemetryURL: &codersdk.StringFlag{
|
||||
Name: "Telemetry URL",
|
||||
Flag: "telemetry-url",
|
||||
EnvVar: "CODER_TELEMETRY_URL",
|
||||
Description: "URL to send telemetry.",
|
||||
Hidden: true,
|
||||
Default: "https://telemetry.coder.com",
|
||||
},
|
||||
TLSEnable: &codersdk.BoolFlag{
|
||||
Name: "TLS Enabled",
|
||||
Flag: "tls-enable",
|
||||
EnvVar: "CODER_TLS_ENABLE",
|
||||
Description: "Whether TLS will be enabled.",
|
||||
},
|
||||
TLSCertFiles: &codersdk.StringArrayFlag{
|
||||
Name: "TLS Cert Files",
|
||||
Flag: "tls-cert-file",
|
||||
EnvVar: "CODER_TLS_CERT_FILE",
|
||||
Description: "Path to each certificate for TLS. It requires a PEM-encoded file. " +
|
||||
"To configure the listener to use a CA certificate, concatenate the primary certificate " +
|
||||
"and the CA certificate together. The primary certificate should appear first in the combined file.",
|
||||
Default: []string{},
|
||||
},
|
||||
TLSClientCAFile: &codersdk.StringFlag{
|
||||
Name: "TLS Client CA File",
|
||||
Flag: "tls-client-ca-file",
|
||||
EnvVar: "CODER_TLS_CLIENT_CA_FILE",
|
||||
Description: "PEM-encoded Certificate Authority file used for checking the authenticity of client",
|
||||
},
|
||||
TLSClientAuth: &codersdk.StringFlag{
|
||||
Name: "TLS Client Auth",
|
||||
Flag: "tls-client-auth",
|
||||
EnvVar: "CODER_TLS_CLIENT_AUTH",
|
||||
Description: `Policy the server will follow for TLS Client Authentication. ` +
|
||||
`Accepted values are "none", "request", "require-any", "verify-if-given", or "require-and-verify"`,
|
||||
Default: "request",
|
||||
},
|
||||
TLSKeyFiles: &codersdk.StringArrayFlag{
|
||||
Name: "TLS Key Files",
|
||||
Flag: "tls-key-file",
|
||||
EnvVar: "CODER_TLS_KEY_FILE",
|
||||
Description: "Paths to the private keys for each of the certificates. It requires a PEM-encoded file",
|
||||
Default: []string{},
|
||||
},
|
||||
TLSMinVersion: &codersdk.StringFlag{
|
||||
Name: "TLS Min Version",
|
||||
Flag: "tls-min-version",
|
||||
EnvVar: "CODER_TLS_MIN_VERSION",
|
||||
Description: `Minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13"`,
|
||||
Default: "tls12",
|
||||
},
|
||||
TraceEnable: &codersdk.BoolFlag{
|
||||
Name: "Trace Enabled",
|
||||
Flag: "trace",
|
||||
EnvVar: "CODER_TRACE",
|
||||
Description: "Whether application tracing data is collected.",
|
||||
},
|
||||
SecureAuthCookie: &codersdk.BoolFlag{
|
||||
Name: "Secure Auth Cookie",
|
||||
Flag: "secure-auth-cookie",
|
||||
EnvVar: "CODER_SECURE_AUTH_COOKIE",
|
||||
Description: "Controls if the 'Secure' property is set on browser session cookies",
|
||||
},
|
||||
SSHKeygenAlgorithm: &codersdk.StringFlag{
|
||||
Name: "SSH Keygen Algorithm",
|
||||
Flag: "ssh-keygen-algorithm",
|
||||
EnvVar: "CODER_SSH_KEYGEN_ALGORITHM",
|
||||
Description: "The algorithm to use for generating ssh keys. " +
|
||||
`Accepted values are "ed25519", "ecdsa", or "rsa4096"`,
|
||||
Default: "ed25519",
|
||||
},
|
||||
AutoImportTemplates: &codersdk.StringArrayFlag{
|
||||
Name: "Auto Import Templates",
|
||||
Flag: "auto-import-template",
|
||||
EnvVar: "CODER_TEMPLATE_AUTOIMPORT",
|
||||
Description: "Templates to auto-import. Available auto-importable templates are: kubernetes",
|
||||
Hidden: true,
|
||||
Default: []string{},
|
||||
},
|
||||
MetricsCacheRefreshInterval: &codersdk.DurationFlag{
|
||||
Name: "Metrics Cache Refresh Interval",
|
||||
Flag: "metrics-cache-refresh-interval",
|
||||
EnvVar: "CODER_METRICS_CACHE_REFRESH_INTERVAL",
|
||||
Description: "How frequently metrics are refreshed",
|
||||
Hidden: true,
|
||||
Default: time.Hour,
|
||||
},
|
||||
AgentStatRefreshInterval: &codersdk.DurationFlag{
|
||||
Name: "Agent Stats Refresh Interval",
|
||||
Flag: "agent-stats-refresh-interval",
|
||||
EnvVar: "CODER_AGENT_STATS_REFRESH_INTERVAL",
|
||||
Description: "How frequently agent stats are recorded",
|
||||
Hidden: true,
|
||||
Default: 10 * time.Minute,
|
||||
},
|
||||
Verbose: &codersdk.BoolFlag{
|
||||
Name: "Verbose Logging",
|
||||
Flag: "verbose",
|
||||
EnvVar: "CODER_VERBOSE",
|
||||
Shorthand: "v",
|
||||
Description: "Enables verbose logging.",
|
||||
},
|
||||
AuditLogging: &codersdk.BoolFlag{
|
||||
Name: "Audit Logging",
|
||||
Flag: "audit-logging",
|
||||
EnvVar: "CODER_AUDIT_LOGGING",
|
||||
Description: "Specifies whether audit logging is enabled.",
|
||||
Default: true,
|
||||
Enterprise: true,
|
||||
},
|
||||
BrowserOnly: &codersdk.BoolFlag{
|
||||
Name: "Browser Only",
|
||||
Flag: "browser-only",
|
||||
EnvVar: "CODER_BROWSER_ONLY",
|
||||
Description: "Whether Coder only allows connections to workspaces via the browser.",
|
||||
Enterprise: true,
|
||||
},
|
||||
SCIMAuthHeader: &codersdk.StringFlag{
|
||||
Name: "SCIM Authentication Header",
|
||||
Flag: "scim-auth-header",
|
||||
EnvVar: "CODER_SCIM_API_KEY",
|
||||
Description: "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.",
|
||||
Secret: true,
|
||||
Enterprise: true,
|
||||
},
|
||||
UserWorkspaceQuota: &codersdk.IntFlag{
|
||||
Name: "User Workspace Quota",
|
||||
Flag: "user-workspace-quota",
|
||||
EnvVar: "CODER_USER_WORKSPACE_QUOTA",
|
||||
Description: "Enables and sets a limit on how many workspaces each user can create.",
|
||||
Default: 0,
|
||||
Enterprise: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveSensitiveValues(df codersdk.DeploymentFlags) codersdk.DeploymentFlags {
|
||||
v := reflect.ValueOf(&df).Elem()
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
fv := v.Field(i)
|
||||
if vp, ok := fv.Interface().(*codersdk.StringFlag); ok {
|
||||
if vp.Secret && vp.Value != "" {
|
||||
// Make a copy and remove the value.
|
||||
v := *vp
|
||||
v.Value = secretValue
|
||||
fv.Set(reflect.ValueOf(&v))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return df
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func AttachFlags(flagset *pflag.FlagSet, df *codersdk.DeploymentFlags, enterprise bool) {
|
||||
v := reflect.ValueOf(df).Elem()
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
fv := v.Field(i)
|
||||
fve := fv.Elem()
|
||||
e := fve.FieldByName("Enterprise").Bool()
|
||||
if e != enterprise {
|
||||
continue
|
||||
}
|
||||
if e {
|
||||
d := fve.FieldByName("Description").String()
|
||||
d += cliui.Styles.Keyword.Render(" This is an Enterprise feature. Contact sales@coder.com for licensing")
|
||||
fve.FieldByName("Description").SetString(d)
|
||||
}
|
||||
|
||||
switch v := fv.Interface().(type) {
|
||||
case *codersdk.StringFlag:
|
||||
StringFlag(flagset, v)
|
||||
case *codersdk.StringArrayFlag:
|
||||
StringArrayFlag(flagset, v)
|
||||
case *codersdk.IntFlag:
|
||||
IntFlag(flagset, v)
|
||||
case *codersdk.BoolFlag:
|
||||
BoolFlag(flagset, v)
|
||||
case *codersdk.DurationFlag:
|
||||
DurationFlag(flagset, v)
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown flag type: %T", v))
|
||||
}
|
||||
if fve.FieldByName("Hidden").Bool() {
|
||||
_ = flagset.MarkHidden(fve.FieldByName("Flag").String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func StringFlag(flagset *pflag.FlagSet, fl *codersdk.StringFlag) {
|
||||
cliflag.StringVarP(flagset,
|
||||
&fl.Value,
|
||||
fl.Flag,
|
||||
fl.Shorthand,
|
||||
fl.EnvVar,
|
||||
fl.Default,
|
||||
fl.Description,
|
||||
)
|
||||
}
|
||||
|
||||
func BoolFlag(flagset *pflag.FlagSet, fl *codersdk.BoolFlag) {
|
||||
cliflag.BoolVarP(flagset,
|
||||
&fl.Value,
|
||||
fl.Flag,
|
||||
fl.Shorthand,
|
||||
fl.EnvVar,
|
||||
fl.Default,
|
||||
fl.Description,
|
||||
)
|
||||
}
|
||||
|
||||
func IntFlag(flagset *pflag.FlagSet, fl *codersdk.IntFlag) {
|
||||
cliflag.IntVarP(flagset,
|
||||
&fl.Value,
|
||||
fl.Flag,
|
||||
fl.Shorthand,
|
||||
fl.EnvVar,
|
||||
fl.Default,
|
||||
fl.Description,
|
||||
)
|
||||
}
|
||||
|
||||
func DurationFlag(flagset *pflag.FlagSet, fl *codersdk.DurationFlag) {
|
||||
cliflag.DurationVarP(flagset,
|
||||
&fl.Value,
|
||||
fl.Flag,
|
||||
fl.Shorthand,
|
||||
fl.EnvVar,
|
||||
fl.Default,
|
||||
fl.Description,
|
||||
)
|
||||
}
|
||||
|
||||
func StringArrayFlag(flagset *pflag.FlagSet, fl *codersdk.StringArrayFlag) {
|
||||
cliflag.StringArrayVarP(flagset,
|
||||
&fl.Value,
|
||||
fl.Flag,
|
||||
fl.Shorthand,
|
||||
fl.EnvVar,
|
||||
fl.Default,
|
||||
fl.Description,
|
||||
)
|
||||
}
|
||||
|
||||
func defaultCacheDir() string {
|
||||
defaultCacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
defaultCacheDir = os.TempDir()
|
||||
}
|
||||
if dir := os.Getenv("CACHE_DIRECTORY"); dir != "" {
|
||||
// For compatibility with systemd.
|
||||
defaultCacheDir = dir
|
||||
}
|
||||
|
||||
return filepath.Join(defaultCacheDir, "coder")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package deployment_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/deployment"
|
||||
)
|
||||
|
||||
func TestFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
df := deployment.Flags()
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
deployment.AttachFlags(fs, df, false)
|
||||
|
||||
require.NotNil(t, fs.Lookup("access-url"))
|
||||
require.False(t, fs.Lookup("access-url").Hidden)
|
||||
require.True(t, fs.Lookup("telemetry-url").Hidden)
|
||||
require.NotEmpty(t, fs.Lookup("telemetry-url").DefValue)
|
||||
require.Nil(t, fs.Lookup("audit-logging"))
|
||||
|
||||
df = deployment.Flags()
|
||||
fs = pflag.NewFlagSet("test-enterprise", pflag.ContinueOnError)
|
||||
deployment.AttachFlags(fs, df, true)
|
||||
|
||||
require.Nil(t, fs.Lookup("access-url"))
|
||||
require.NotNil(t, fs.Lookup("audit-logging"))
|
||||
require.Contains(t, fs.Lookup("audit-logging").Usage, "This is an Enterprise feature")
|
||||
}
|
||||
+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: "Checkout and install a dotfiles repository from a Git URL",
|
||||
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"
|
||||
|
||||
+121
-4
@@ -1,9 +1,15 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -13,16 +19,30 @@ import (
|
||||
)
|
||||
|
||||
func gitssh() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &cobra.Command{
|
||||
Use: "gitssh",
|
||||
Hidden: true,
|
||||
Short: `Wraps the "ssh" command and uses the coder gitssh key for authentication`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
env := os.Environ()
|
||||
|
||||
// Catch interrupt signals to ensure the temporary private
|
||||
// key file is cleaned up on most cases.
|
||||
ctx, stop := signal.NotifyContext(ctx, interruptSignals...)
|
||||
defer stop()
|
||||
|
||||
// Early check so errors are reported immediately.
|
||||
identityFiles, err := parseIdentityFilesForHost(ctx, args, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := createAgentClient(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create agent client: %w", err)
|
||||
}
|
||||
key, err := client.AgentGitSSHKey(cmd.Context())
|
||||
key, err := client.AgentGitSSHKey(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get agent git ssh token: %w", err)
|
||||
}
|
||||
@@ -44,8 +64,23 @@ func gitssh() *cobra.Command {
|
||||
return xerrors.Errorf("close temp gitsshkey file: %w", err)
|
||||
}
|
||||
|
||||
args = append([]string{"-i", privateKeyFile.Name()}, args...)
|
||||
c := exec.CommandContext(cmd.Context(), "ssh", args...)
|
||||
// Append our key, giving precedence to user keys. Note that
|
||||
// OpenSSH server are typically configured with MaxAuthTries
|
||||
// set to the default value of 6. This means that only the 6
|
||||
// first keys can be tried. However, we will assume that if
|
||||
// a user has configured 6+ keys for a host, they know what
|
||||
// they're doing. This behavior is critical if a server has
|
||||
// been configured with MaxAuthTries set to 1.
|
||||
identityFiles = append(identityFiles, privateKeyFile.Name())
|
||||
|
||||
var identityArgs []string
|
||||
for _, id := range identityFiles {
|
||||
identityArgs = append(identityArgs, "-i", id)
|
||||
}
|
||||
|
||||
args = append(identityArgs, args...)
|
||||
c := exec.CommandContext(ctx, "ssh", args...)
|
||||
c.Env = append(c.Env, env...)
|
||||
c.Stderr = cmd.ErrOrStderr()
|
||||
c.Stdout = cmd.OutOrStdout()
|
||||
c.Stdin = cmd.InOrStdin()
|
||||
@@ -69,4 +104,86 @@ func gitssh() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// fallbackIdentityFiles is the list of identity files SSH tries when
|
||||
// none have been defined for a host.
|
||||
var fallbackIdentityFiles = strings.Join([]string{
|
||||
"identityfile ~/.ssh/id_rsa",
|
||||
"identityfile ~/.ssh/id_dsa",
|
||||
"identityfile ~/.ssh/id_ecdsa",
|
||||
"identityfile ~/.ssh/id_ecdsa_sk",
|
||||
"identityfile ~/.ssh/id_ed25519",
|
||||
"identityfile ~/.ssh/id_ed25519_sk",
|
||||
"identityfile ~/.ssh/id_xmss",
|
||||
}, "\n")
|
||||
|
||||
// parseIdentityFilesForHost uses ssh -G to discern what SSH keys have
|
||||
// been enabled for the host (via the users SSH config) and returns a
|
||||
// list of existing identity files.
|
||||
//
|
||||
// We do this because when no keys are defined for a host, SSH uses
|
||||
// fallback keys (see above). However, by passing `-i` to attach our
|
||||
// private key, we're effectively disabling the fallback keys.
|
||||
//
|
||||
// Example invocation:
|
||||
//
|
||||
// ssh -G -o SendEnv=GIT_PROTOCOL git@github.com git-upload-pack 'coder/coder'
|
||||
//
|
||||
// The extra arguments work without issue and lets us run the command
|
||||
// as-is without stripping out the excess (git-upload-pack 'coder/coder').
|
||||
func parseIdentityFilesForHost(ctx context.Context, args, env []string) (identityFiles []string, error error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get user home dir failed: %w", err)
|
||||
}
|
||||
|
||||
var outBuf bytes.Buffer
|
||||
var r io.Reader = &outBuf
|
||||
|
||||
args = append([]string{"-G"}, args...)
|
||||
cmd := exec.CommandContext(ctx, "ssh", args...)
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
cmd.Stdout = &outBuf
|
||||
cmd.Stderr = io.Discard
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
// If ssh -G failed, the SSH version is likely too old, fallback
|
||||
// to using the default identity files.
|
||||
r = strings.NewReader(fallbackIdentityFiles)
|
||||
}
|
||||
|
||||
s := bufio.NewScanner(r)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
if strings.HasPrefix(line, "identityfile ") {
|
||||
id := strings.TrimPrefix(line, "identityfile ")
|
||||
if strings.HasPrefix(id, "~/") {
|
||||
id = home + id[1:]
|
||||
}
|
||||
// OpenSSH on Windows is weird, it supports using (and does
|
||||
// use) mixed \ and / in paths.
|
||||
//
|
||||
// Example: C:\Users\ZeroCool/.ssh/known_hosts
|
||||
//
|
||||
// To check the file existence in Go, though, we want to use
|
||||
// proper Windows paths.
|
||||
// OpenSSH is amazing, this will work on Windows too:
|
||||
// C:\Users\ZeroCool/.ssh/id_rsa
|
||||
id = filepath.FromSlash(id)
|
||||
|
||||
// Only include the identity file if it exists.
|
||||
if _, err := os.Stat(id); err == nil {
|
||||
identityFiles = append(identityFiles, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
// This should never happen, the check is for completeness.
|
||||
return nil, xerrors.Errorf("scan ssh output: %w", err)
|
||||
}
|
||||
|
||||
return identityFiles, nil
|
||||
}
|
||||
|
||||
+225
-79
@@ -2,8 +2,16 @@ package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
@@ -17,98 +25,236 @@ import (
|
||||
"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 prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, string, gossh.PublicKey) {
|
||||
t.Helper()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer t.Cleanup(cancel) // Defer so that cancel is the first cleanup.
|
||||
|
||||
// get user public key
|
||||
keypair, err := client.GitSSHKey(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
//nolint:dogsled
|
||||
pubkey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keypair.PublicKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
// setup template
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: agentToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// start workspace agent
|
||||
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
agentClient := client
|
||||
clitest.SetupConfig(t, agentClient, root)
|
||||
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
t.Cleanup(func() { require.NoError(t, <-errC) })
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
return agentClient, agentToken, pubkey
|
||||
}
|
||||
|
||||
func serveSSHForGitSSH(t *testing.T, handler func(ssh.Session), pubkeys ...gossh.PublicKey) *net.TCPAddr {
|
||||
t.Helper()
|
||||
|
||||
// start ssh server
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = l.Close() })
|
||||
|
||||
serveOpts := []ssh.Option{
|
||||
ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||
for _, pubkey := range pubkeys {
|
||||
if ssh.KeysEqual(pubkey, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}),
|
||||
}
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
// as long as we get a successful session we don't care if the server errors
|
||||
errC <- ssh.Serve(l, handler, serveOpts...)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = l.Close() // Ensure server shutdown.
|
||||
<-errC
|
||||
})
|
||||
|
||||
// start ssh session
|
||||
addr, ok := l.Addr().(*net.TCPAddr)
|
||||
require.True(t, ok)
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
func writePrivateKeyToFile(t *testing.T, name string, key *ecdsa.PrivateKey) {
|
||||
t.Helper()
|
||||
|
||||
b, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
b = pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: b,
|
||||
})
|
||||
|
||||
err = os.WriteFile(name, b, 0o600)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestGitSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Dial", func(t *testing.T) {
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
t.Parallel()
|
||||
|
||||
// get user public key
|
||||
keypair, err := client.GitSSHKey(context.Background(), codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
publicKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keypair.PublicKey))
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// setup template
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "somename",
|
||||
Type: "someinstance",
|
||||
Agents: []*proto.Agent{{
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: agentToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// start workspace agent
|
||||
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
agentClient := client
|
||||
clitest.SetupConfig(t, agentClient, root)
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
agentErrC := make(chan error)
|
||||
go func() {
|
||||
agentErrC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
dialer, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
_, err = dialer.Ping()
|
||||
require.NoError(t, err)
|
||||
|
||||
// start ssh server
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
publicKeyOption := ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||
return ssh.KeysEqual(publicKey, key)
|
||||
})
|
||||
client, token, pubkey := prepareTestGitSSH(ctx, t)
|
||||
var inc int64
|
||||
sshErrC := make(chan error)
|
||||
go func() {
|
||||
// as long as we get a successful session we don't care if the server errors
|
||||
_ = ssh.Serve(l, func(s ssh.Session) {
|
||||
atomic.AddInt64(&inc, 1)
|
||||
t.Log("got authenticated session")
|
||||
sshErrC <- s.Exit(0)
|
||||
}, publicKeyOption)
|
||||
}()
|
||||
errC := make(chan error, 1)
|
||||
addr := serveSSHForGitSSH(t, func(s ssh.Session) {
|
||||
atomic.AddInt64(&inc, 1)
|
||||
t.Log("got authenticated session")
|
||||
select {
|
||||
case errC <- s.Exit(0):
|
||||
default:
|
||||
t.Error("error channel is full")
|
||||
}
|
||||
}, pubkey)
|
||||
|
||||
// start ssh session
|
||||
addr, ok := l.Addr().(*net.TCPAddr)
|
||||
require.True(t, ok)
|
||||
// set to agent config dir
|
||||
gitsshCmd, _ := clitest.New(t, "gitssh", "--agent-url", agentClient.URL.String(), "--agent-token", agentToken, "--", fmt.Sprintf("-p%d", addr.Port), "-o", "StrictHostKeyChecking=no", "-o", "IdentitiesOnly=yes", "127.0.0.1")
|
||||
err = gitsshCmd.ExecuteContext(context.Background())
|
||||
cmd, _ := clitest.New(t,
|
||||
"gitssh",
|
||||
"--agent-url", client.URL.String(),
|
||||
"--agent-token", token,
|
||||
"--",
|
||||
fmt.Sprintf("-p%d", addr.Port),
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"127.0.0.1",
|
||||
)
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, inc)
|
||||
|
||||
err = <-sshErrC
|
||||
require.NoError(t, err, "error in ssh session exit")
|
||||
|
||||
cancelFunc()
|
||||
err = <-agentErrC
|
||||
err = <-errC
|
||||
require.NoError(t, err, "error in agent execute")
|
||||
})
|
||||
|
||||
t.Run("Local SSH Keys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
home := t.TempDir()
|
||||
sshdir := filepath.Join(home, ".ssh")
|
||||
err := os.MkdirAll(sshdir, 0o700)
|
||||
require.NoError(t, err)
|
||||
|
||||
idFile := filepath.Join(sshdir, "id_ed25519")
|
||||
privkey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
localPubkey, err := gossh.NewPublicKey(&privkey.PublicKey)
|
||||
require.NoError(t, err)
|
||||
writePrivateKeyToFile(t, idFile, privkey)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
client, token, coderPubkey := prepareTestGitSSH(ctx, t)
|
||||
|
||||
authkey := make(chan gossh.PublicKey, 1)
|
||||
addr := serveSSHForGitSSH(t, func(s ssh.Session) {
|
||||
t.Logf("authenticated with: %s", gossh.MarshalAuthorizedKey(s.PublicKey()))
|
||||
select {
|
||||
case authkey <- s.PublicKey():
|
||||
default:
|
||||
t.Error("authkey channel is full")
|
||||
}
|
||||
}, localPubkey, coderPubkey)
|
||||
|
||||
// Create a new config which sets an identity file.
|
||||
config := filepath.Join(sshdir, "config")
|
||||
knownHosts := filepath.Join(sshdir, "known_hosts")
|
||||
err = os.WriteFile(config, []byte(strings.Join([]string{
|
||||
"Host mytest",
|
||||
" HostName 127.0.0.1",
|
||||
fmt.Sprintf(" Port %d", addr.Port),
|
||||
" StrictHostKeyChecking no",
|
||||
" UserKnownHostsFile=" + knownHosts,
|
||||
" IdentitiesOnly yes",
|
||||
" IdentityFile=" + idFile,
|
||||
}, "\n")), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
cmdArgs := []string{
|
||||
"gitssh",
|
||||
"--agent-url", client.URL.String(),
|
||||
"--agent-token", token,
|
||||
"--",
|
||||
"-F", config,
|
||||
"mytest",
|
||||
}
|
||||
// Test authentication via local private key.
|
||||
cmd, _ := clitest.New(t, cmdArgs...)
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
select {
|
||||
case key := <-authkey:
|
||||
require.Equal(t, localPubkey, key)
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for auth")
|
||||
}
|
||||
|
||||
// Delete the local private key.
|
||||
err = os.Remove(idFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
// With the local file deleted, the coder key should be used.
|
||||
cmd, _ = clitest.New(t, cmdArgs...)
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
select {
|
||||
case key := <-authkey:
|
||||
require.Equal(t, coderPubkey, key)
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for auth")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+93
-76
@@ -2,10 +2,10 @@ 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"
|
||||
@@ -14,29 +14,92 @@ 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
|
||||
all bool
|
||||
columns []string
|
||||
defaultQuery = "owner:me"
|
||||
searchQuery string
|
||||
me bool
|
||||
displayWorkspaces []workspaceListRow
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "list",
|
||||
Short: "List all workspaces",
|
||||
Short: "List 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 all && searchQuery == defaultQuery {
|
||||
filter.FilterQuery = ""
|
||||
}
|
||||
|
||||
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{})
|
||||
@@ -48,78 +111,32 @@ func list() *cobra.Command {
|
||||
usersByID[user.ID] = user
|
||||
}
|
||||
|
||||
tableWriter := cliui.Table()
|
||||
header := table.Row{"workspace", "template", "status", "last built", "outdated", "starts at", "stops after"}
|
||||
tableWriter.AppendHeader(header)
|
||||
tableWriter.SortBy([]table.SortBy{{
|
||||
Name: "workspace",
|
||||
}})
|
||||
tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, columns))
|
||||
|
||||
now := time.Now()
|
||||
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"
|
||||
}
|
||||
|
||||
lastBuilt := 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 = 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.After(now) && status == "Running" {
|
||||
remaining := time.Until(workspace.LatestBuild.Deadline)
|
||||
autostopDisplay = fmt.Sprintf("%s (%s)", autostopDisplay, relative(remaining))
|
||||
}
|
||||
}
|
||||
|
||||
user := usersByID[workspace.OwnerID]
|
||||
tableWriter.AppendRow(table.Row{
|
||||
user.Username + "/" + workspace.Name,
|
||||
workspace.TemplateName,
|
||||
status,
|
||||
durationDisplay(lastBuilt),
|
||||
workspace.Outdated,
|
||||
autostartDisplay,
|
||||
autostopDisplay,
|
||||
})
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
availColumns, err := cliui.TableHeaders(displayWorkspaces)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
columnString := strings.Join(availColumns[:], ", ")
|
||||
|
||||
cmd.Flags().BoolVarP(&all, "all", "a", false,
|
||||
"Specifies whether all workspaces will be listed or not.")
|
||||
cmd.Flags().StringArrayVarP(&columns, "column", "c", nil,
|
||||
"Specify a column to filter in the table.")
|
||||
fmt.Sprintf("Specify a column to filter in the table. Available columns are: %v", columnString))
|
||||
cmd.Flags().StringVar(&searchQuery, "search", "", "Search for a workspace with a query.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
+6
-5
@@ -3,22 +3,20 @@ package cli_test
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -30,6 +28,9 @@ func TestList(t *testing.T) {
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
done := make(chan any)
|
||||
go func() {
|
||||
errC := cmd.ExecuteContext(ctx)
|
||||
@@ -37,7 +38,7 @@ func TestList(t *testing.T) {
|
||||
close(done)
|
||||
}()
|
||||
pty.ExpectMatch(workspace.Name)
|
||||
pty.ExpectMatch("Running")
|
||||
pty.ExpectMatch("Started")
|
||||
cancelFunc()
|
||||
<-done
|
||||
})
|
||||
|
||||
+44
-27
@@ -45,7 +45,7 @@ func login() *cobra.Command {
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "login <url>",
|
||||
Short: "Authenticate with a Coder deployment",
|
||||
Short: "Authenticate with Coder deployment",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rawURL := args[0]
|
||||
@@ -66,10 +66,24 @@ func login() *cobra.Command {
|
||||
serverURL.Scheme = "https"
|
||||
}
|
||||
|
||||
client := codersdk.New(serverURL)
|
||||
client, err := createUnauthenticatedClient(cmd, serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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)
|
||||
return xerrors.Errorf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w", serverURL.String(), err)
|
||||
}
|
||||
if !hasInitialUser {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Your Coder deployment hasn't been set up!\n")
|
||||
@@ -80,7 +94,7 @@ func login() *cobra.Command {
|
||||
}
|
||||
_, err := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Would you like to create the first user?",
|
||||
Default: "yes",
|
||||
Default: cliui.ConfirmYes,
|
||||
IsConfirm: true,
|
||||
})
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
@@ -122,26 +136,29 @@ func login() *cobra.Command {
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
password, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Enter a " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: cliui.ValidateNotEmpty,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("specify password prompt: %w", err)
|
||||
}
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Confirm " + cliui.Styles.Field.Render("password") + ":",
|
||||
Secret: true,
|
||||
Validate: func(s string) error {
|
||||
if s != password {
|
||||
return xerrors.Errorf("Passwords do not match")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("confirm password prompt: %w", err)
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,9 +248,9 @@ 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.")
|
||||
cliflag.StringVarP(cmd.Flags(), &email, "first-user-email", "", "CODER_FIRST_USER_EMAIL", "", "Specifies an email address to use if creating the first user for the deployment.")
|
||||
cliflag.StringVarP(cmd.Flags(), &username, "first-user-username", "", "CODER_FIRST_USER_USERNAME", "", "Specifies a username to use if creating the first user for the deployment.")
|
||||
cliflag.StringVarP(cmd.Flags(), &password, "first-user-password", "", "CODER_FIRST_USER_PASSWORD", "", "Specifies a password to use if creating the first user for the deployment.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
+21
-4
@@ -2,12 +2,14 @@ package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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"
|
||||
)
|
||||
@@ -22,6 +24,15 @@ func TestLogin(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("InitialUserBadLoginURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
badLoginURL := "https://fcca2077f06e68aaf9"
|
||||
root, _ := clitest.New(t, "login", badLoginURL)
|
||||
err := root.Execute()
|
||||
errMsg := fmt.Sprintf("Failed to check server %q for first user, is the URL correct and is coder accessible from your browser?", badLoginURL)
|
||||
require.ErrorContains(t, err, errMsg)
|
||||
})
|
||||
|
||||
t.Run("InitialUserTTY", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
@@ -63,7 +74,7 @@ func TestLogin(t *testing.T) {
|
||||
// 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")
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "password")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
@@ -92,7 +103,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{
|
||||
@@ -108,9 +119,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
|
||||
})
|
||||
|
||||
|
||||
+4
-4
@@ -14,9 +14,9 @@ import (
|
||||
func logout() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Remove the local authenticated session",
|
||||
Short: "Unauthenticate your local 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
|
||||
})
|
||||
|
||||
+10
-33
@@ -1,18 +1,18 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func parameters() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Short: "List parameters for a given scope",
|
||||
Example: "coder parameters list workspace my-workspace",
|
||||
Use: "parameters",
|
||||
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.
|
||||
@@ -20,35 +20,12 @@ func parameters() *cobra.Command {
|
||||
// constructing curl requests.
|
||||
Hidden: true,
|
||||
Aliases: []string{"params"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(
|
||||
parameterList(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// displayParameters will return a table displaying all parameters passed in.
|
||||
// filterColumns must be a subset of the parameter fields and will determine which
|
||||
// columns to display
|
||||
func displayParameters(filterColumns []string, params ...codersdk.Parameter) string {
|
||||
tableWriter := cliui.Table()
|
||||
header := table.Row{"id", "scope", "scope id", "name", "source scheme", "destination scheme", "created at", "updated at"}
|
||||
tableWriter.AppendHeader(header)
|
||||
tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, filterColumns))
|
||||
tableWriter.SortBy([]table.SortBy{{
|
||||
Name: "name",
|
||||
}})
|
||||
for _, param := range params {
|
||||
tableWriter.AppendRow(table.Row{
|
||||
param.ID.String(),
|
||||
param.Scope,
|
||||
param.ScopeID.String(),
|
||||
param.Name,
|
||||
param.SourceScheme,
|
||||
param.DestinationScheme,
|
||||
param.CreatedAt,
|
||||
param.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return tableWriter.Render()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
@@ -21,7 +22,7 @@ func parameterList() *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
scope, name := args[0], args[1]
|
||||
|
||||
client, err := createClient(cmd)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -70,11 +71,16 @@ func parameterList() *cobra.Command {
|
||||
return xerrors.Errorf("fetch params: %w", err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), displayParameters(columns, params...))
|
||||
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", "source_scheme", "destination_scheme"},
|
||||
cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "scope", "destination scheme"},
|
||||
"Specify a column to filter in the table.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
+145
-140
@@ -6,55 +6,56 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/pion/udp"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
coderagent "github.com/coder/coder/agent"
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func portForward() *cobra.Command {
|
||||
var (
|
||||
tcpForwards []string // <port>:<port>
|
||||
udpForwards []string // <port>:<port>
|
||||
unixForwards []string // <path>:<path> OR <port>:<path>
|
||||
tcpForwards []string // <port>:<port>
|
||||
udpForwards []string // <port>:<port>
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "port-forward <workspace>",
|
||||
Short: "Forward ports from machine to a 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: "Port forward multiple TCP ports and a UDP port",
|
||||
Command: "coder port-forward <workspace> --tcp 8080:8080 --tcp 9000:3000 --udp 5353:53",
|
||||
},
|
||||
example{
|
||||
Description: "Port forward multiple ports (TCP or UDP) in condensed syntax",
|
||||
Command: "coder port-forward <workspace> --tcp 8080,9000:3000,9090-9092,10000-10002:10010-10012",
|
||||
},
|
||||
),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
specs, err := parsePortForwards(tcpForwards, udpForwards, unixForwards)
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
specs, err := parsePortForwards(tcpForwards, udpForwards)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse port-forward specs: %w", err)
|
||||
}
|
||||
@@ -66,12 +67,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, workspaceAgent, err := getWorkspaceAndAgent(ctx, cmd, client, codersdk.Me, args[0], false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -79,31 +80,30 @@ 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)
|
||||
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)
|
||||
conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dial workspace agent: %w", err)
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Start all listeners.
|
||||
var (
|
||||
ctx, cancel = context.WithCancel(cmd.Context())
|
||||
wg = new(sync.WaitGroup)
|
||||
listeners = make([]net.Listener, len(specs))
|
||||
closeAllListeners = func() {
|
||||
@@ -115,11 +115,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 +128,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)
|
||||
|
||||
@@ -144,20 +147,34 @@ func portForward() *cobra.Command {
|
||||
closeAllListeners()
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
_, err = conn.Ping()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
ticker.Stop()
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Ready!")
|
||||
wg.Wait()
|
||||
return closeErr
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringArrayVarP(&tcpForwards, "tcp", "p", []string{}, "Forward a TCP port from the workspace to the local machine")
|
||||
cmd.Flags().StringArrayVar(&udpForwards, "udp", []string{}, "Forward a UDP port from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols")
|
||||
cmd.Flags().StringArrayVar(&unixForwards, "unix", []string{}, "Forward a Unix socket in the workspace to a local Unix socket or TCP port")
|
||||
|
||||
cliflag.StringArrayVarP(cmd.Flags(), &tcpForwards, "tcp", "p", "CODER_PORT_FORWARD_TCP", nil, "Forward TCP port(s) from the workspace to the local machine")
|
||||
cliflag.StringArrayVarP(cmd.Flags(), &udpForwards, "udp", "", "CODER_PORT_FORWARD_UDP", nil, "Forward UDP port(s) from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *coderagent.Conn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) {
|
||||
func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersdk.AgentConn, wg *sync.WaitGroup, spec portForwardSpec) (net.Listener, error) {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Forwarding '%v://%v' locally to '%v://%v' in the workspace\n", spec.listenNetwork, spec.listenAddress, spec.dialNetwork, spec.dialAddress)
|
||||
|
||||
var (
|
||||
@@ -184,8 +201,6 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *coderag
|
||||
IP: net.ParseIP(host),
|
||||
Port: portInt,
|
||||
})
|
||||
case "unix":
|
||||
l, err = net.Listen(spec.listenNetwork, spec.listenAddress)
|
||||
default:
|
||||
return nil, xerrors.Errorf("unknown listen network %q", spec.listenNetwork)
|
||||
}
|
||||
@@ -213,7 +228,7 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *coderag
|
||||
}
|
||||
defer remoteConn.Close()
|
||||
|
||||
coderagent.Bicopy(ctx, netConn, remoteConn)
|
||||
agent.Bicopy(ctx, netConn, remoteConn)
|
||||
}(netConn)
|
||||
}
|
||||
}(spec)
|
||||
@@ -222,65 +237,50 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *coderag
|
||||
}
|
||||
|
||||
type portForwardSpec struct {
|
||||
listenNetwork string // tcp, udp, unix
|
||||
listenNetwork string // tcp, udp
|
||||
listenAddress string // <ip>:<port> or path
|
||||
|
||||
dialNetwork string // tcp, udp, unix
|
||||
dialNetwork string // tcp, udp
|
||||
dialAddress string // <ip>:<port> or path
|
||||
}
|
||||
|
||||
func parsePortForwards(tcpSpecs, udpSpecs, unixSpecs []string) ([]portForwardSpec, error) {
|
||||
func parsePortForwards(tcpSpecs, udpSpecs []string) ([]portForwardSpec, error) {
|
||||
specs := []portForwardSpec{}
|
||||
|
||||
for _, spec := range tcpSpecs {
|
||||
local, remote, err := parsePortPort(spec)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to parse TCP port-forward specification %q: %w", spec, err)
|
||||
}
|
||||
|
||||
specs = append(specs, portForwardSpec{
|
||||
listenNetwork: "tcp",
|
||||
listenAddress: fmt.Sprintf("127.0.0.1:%v", local),
|
||||
dialNetwork: "tcp",
|
||||
dialAddress: fmt.Sprintf("127.0.0.1:%v", remote),
|
||||
})
|
||||
}
|
||||
|
||||
for _, spec := range udpSpecs {
|
||||
local, remote, err := parsePortPort(spec)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to parse UDP port-forward specification %q: %w", spec, err)
|
||||
}
|
||||
|
||||
specs = append(specs, portForwardSpec{
|
||||
listenNetwork: "udp",
|
||||
listenAddress: fmt.Sprintf("127.0.0.1:%v", local),
|
||||
dialNetwork: "udp",
|
||||
dialAddress: fmt.Sprintf("127.0.0.1:%v", remote),
|
||||
})
|
||||
}
|
||||
|
||||
for _, specStr := range unixSpecs {
|
||||
localPath, localTCP, remotePath, err := parseUnixUnix(specStr)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to parse Unix port-forward specification %q: %w", specStr, err)
|
||||
}
|
||||
|
||||
spec := portForwardSpec{
|
||||
dialNetwork: "unix",
|
||||
dialAddress: remotePath,
|
||||
}
|
||||
if localPath == "" {
|
||||
spec.listenNetwork = "tcp"
|
||||
spec.listenAddress = fmt.Sprintf("127.0.0.1:%v", localTCP)
|
||||
} else {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil, xerrors.Errorf("Unix port-forwarding is not supported on Windows")
|
||||
for _, specEntry := range tcpSpecs {
|
||||
for _, spec := range strings.Split(specEntry, ",") {
|
||||
ports, err := parseSrcDestPorts(spec)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to parse TCP port-forward specification %q: %w", spec, err)
|
||||
}
|
||||
|
||||
for _, port := range ports {
|
||||
specs = append(specs, portForwardSpec{
|
||||
listenNetwork: "tcp",
|
||||
listenAddress: fmt.Sprintf("127.0.0.1:%v", port.local),
|
||||
dialNetwork: "tcp",
|
||||
dialAddress: fmt.Sprintf("127.0.0.1:%v", port.remote),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, specEntry := range udpSpecs {
|
||||
for _, spec := range strings.Split(specEntry, ",") {
|
||||
ports, err := parseSrcDestPorts(spec)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to parse UDP port-forward specification %q: %w", spec, err)
|
||||
}
|
||||
|
||||
for _, port := range ports {
|
||||
specs = append(specs, portForwardSpec{
|
||||
listenNetwork: "udp",
|
||||
listenAddress: fmt.Sprintf("127.0.0.1:%v", port.local),
|
||||
dialNetwork: "udp",
|
||||
dialAddress: fmt.Sprintf("127.0.0.1:%v", port.remote),
|
||||
})
|
||||
}
|
||||
spec.listenNetwork = "unix"
|
||||
spec.listenAddress = localPath
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
|
||||
// Check for duplicate entries.
|
||||
@@ -308,67 +308,72 @@ func parsePort(in string) (uint16, error) {
|
||||
return uint16(port), nil
|
||||
}
|
||||
|
||||
func parseUnixPath(in string) (string, error) {
|
||||
path, err := coderagent.ExpandRelativeHomePath(strings.TrimSpace(in))
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("tidy path %q: %w", in, err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
type parsedSrcDestPort struct {
|
||||
local, remote uint16
|
||||
}
|
||||
|
||||
func parsePortPort(in string) (local uint16, remote uint16, err error) {
|
||||
func parseSrcDestPorts(in string) ([]parsedSrcDestPort, error) {
|
||||
parts := strings.Split(in, ":")
|
||||
if len(parts) > 2 {
|
||||
return 0, 0, xerrors.Errorf("invalid port specification %q", in)
|
||||
return nil, xerrors.Errorf("invalid port specification %q", in)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
// Duplicate the single part
|
||||
parts = append(parts, parts[0])
|
||||
}
|
||||
if !strings.Contains(parts[0], "-") {
|
||||
local, err := parsePort(parts[0])
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse local port from %q: %w", in, err)
|
||||
}
|
||||
remote, err := parsePort(parts[1])
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse remote port from %q: %w", in, err)
|
||||
}
|
||||
|
||||
local, err = parsePort(parts[0])
|
||||
if err != nil {
|
||||
return 0, 0, xerrors.Errorf("parse local port from %q: %w", in, err)
|
||||
}
|
||||
remote, err = parsePort(parts[1])
|
||||
if err != nil {
|
||||
return 0, 0, xerrors.Errorf("parse remote port from %q: %w", in, err)
|
||||
return []parsedSrcDestPort{{local: local, remote: remote}}, nil
|
||||
}
|
||||
|
||||
return local, remote, nil
|
||||
local, err := parsePortRange(parts[0])
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse local port range from %q: %w", in, err)
|
||||
}
|
||||
remote, err := parsePortRange(parts[1])
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse remote port range from %q: %w", in, err)
|
||||
}
|
||||
if len(local) != len(remote) {
|
||||
return nil, xerrors.Errorf("port ranges must be the same length, got %d ports forwarded to %d ports", len(local), len(remote))
|
||||
}
|
||||
var out []parsedSrcDestPort
|
||||
for i := range local {
|
||||
out = append(out, parsedSrcDestPort{
|
||||
local: local[i],
|
||||
remote: remote[i],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parsePortOrUnixPath(in string) (string, uint16, error) {
|
||||
port, err := parsePort(in)
|
||||
if err == nil {
|
||||
return "", port, nil
|
||||
func parsePortRange(in string) ([]uint16, error) {
|
||||
parts := strings.Split(in, "-")
|
||||
if len(parts) != 2 {
|
||||
return nil, xerrors.Errorf("invalid port range specification %q", in)
|
||||
}
|
||||
|
||||
path, err := parseUnixPath(in)
|
||||
start, err := parsePort(parts[0])
|
||||
if err != nil {
|
||||
return "", 0, xerrors.Errorf("could not parse port or unix path %q: %w", in, err)
|
||||
return nil, xerrors.Errorf("parse range start port from %q: %w", in, err)
|
||||
}
|
||||
|
||||
return path, 0, nil
|
||||
}
|
||||
|
||||
func parseUnixUnix(in string) (string, uint16, string, error) {
|
||||
parts := strings.Split(in, ":")
|
||||
if len(parts) > 2 {
|
||||
return "", 0, "", xerrors.Errorf("invalid port-forward specification %q", in)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
// Duplicate the single part
|
||||
parts = append(parts, parts[0])
|
||||
}
|
||||
|
||||
localPath, localPort, err := parsePortOrUnixPath(parts[0])
|
||||
end, err := parsePort(parts[1])
|
||||
if err != nil {
|
||||
return "", 0, "", xerrors.Errorf("parse local part of spec %q: %w", in, err)
|
||||
return nil, xerrors.Errorf("parse range end port from %q: %w", in, err)
|
||||
}
|
||||
|
||||
// We don't really touch the remote path at all since it gets cleaned
|
||||
// up/expanded on the remote.
|
||||
return localPath, localPort, parts[1], nil
|
||||
if end < start {
|
||||
return nil, xerrors.Errorf("range end port %v is less than start port %v", end, start)
|
||||
}
|
||||
var ports []uint16
|
||||
for i := start; i <= end; i++ {
|
||||
ports = append(ports, i)
|
||||
}
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_parsePortForwards(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
portForwardSpecToString := func(v []portForwardSpec) (out []string) {
|
||||
for _, p := range v {
|
||||
require.Equal(t, p.listenNetwork, p.dialNetwork)
|
||||
out = append(out, fmt.Sprintf("%s:%s", strings.Replace(p.listenAddress, "127.0.0.1:", "", 1), strings.Replace(p.dialAddress, "127.0.0.1:", "", 1)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
type args struct {
|
||||
tcpSpecs []string
|
||||
udpSpecs []string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "TCP mixed ports and ranges",
|
||||
args: args{
|
||||
tcpSpecs: []string{
|
||||
"8000,8080:8081,9000-9002,9003-9004:9005-9006",
|
||||
"10000",
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
"8000:8000",
|
||||
"8080:8081",
|
||||
"9000:9000",
|
||||
"9001:9001",
|
||||
"9002:9002",
|
||||
"9003:9005",
|
||||
"9004:9006",
|
||||
"10000:10000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "UDP with port range",
|
||||
args: args{
|
||||
udpSpecs: []string{"8000,8080-8081"},
|
||||
},
|
||||
want: []string{
|
||||
"8000:8000",
|
||||
"8080:8080",
|
||||
"8081:8081",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Bad port range",
|
||||
args: args{
|
||||
tcpSpecs: []string{"8000-7000"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Bad dest port range",
|
||||
args: args{
|
||||
tcpSpecs: []string{"8080-8081:9080-9082"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parsePortForwards(tt.args.tcpSpecs, tt.args.udpSpecs)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("parsePortForwards() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
gotStrings := portForwardSpecToString(got)
|
||||
require.Equal(t, tt.want, gotStrings)
|
||||
})
|
||||
}
|
||||
}
|
||||
+67
-198
@@ -1,18 +1,12 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pion/udp"
|
||||
@@ -24,10 +18,13 @@ import (
|
||||
"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 TestPortForward(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Skip("These tests flake... a lot. It seems related to the Tailscale change, but all other tests pass...")
|
||||
|
||||
t.Run("None", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -37,15 +34,17 @@ func TestPortForward(t *testing.T) {
|
||||
|
||||
cmd, root := clitest.New(t, "port-forward", "blah")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(buf)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "no port-forwards")
|
||||
|
||||
// Check that the help was printed.
|
||||
require.Contains(t, buf.String(), "port-forward <workspace>")
|
||||
pty.ExpectMatch("port-forward <workspace>")
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
@@ -58,7 +57,7 @@ func TestPortForward(t *testing.T) {
|
||||
// setupRemote creates a "remote" listener to emulate a service in the
|
||||
// workspace.
|
||||
setupRemote func(t *testing.T) net.Listener
|
||||
// setupLocal returns an available port or Unix socket path that the
|
||||
// setupLocal returns an available port that the
|
||||
// port-forward command will listen on "locally". Returns the address
|
||||
// you pass to net.Dial, and the port/path you pass to `coder
|
||||
// port-forward`.
|
||||
@@ -110,53 +109,24 @@ func TestPortForward(t *testing.T) {
|
||||
return l.Addr().String(), port
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unix",
|
||||
network: "unix",
|
||||
flag: "--unix=%v:%v",
|
||||
setupRemote: func(t *testing.T) net.Listener {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix socket forwarding isn't supported on Windows")
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "coderd_agent_test_")
|
||||
require.NoError(t, err, "create temp dir for unix listener")
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
l, err := net.Listen("unix", filepath.Join(tmpDir, "test.sock"))
|
||||
require.NoError(t, err, "create UDP listener")
|
||||
return l
|
||||
},
|
||||
setupLocal: func(t *testing.T) (string, string) {
|
||||
tmpDir, err := os.MkdirTemp("", "coderd_agent_test_")
|
||||
require.NoError(t, err, "create temp dir for unix listener")
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
path := filepath.Join(tmpDir, "test.sock")
|
||||
return path, path
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Setup agent once to be shared between test-cases (avoid expensive
|
||||
// non-parallel setup).
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: 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)
|
||||
@@ -164,21 +134,25 @@ func TestPortForward(t *testing.T) {
|
||||
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listener.
|
||||
cmd, root := clitest.New(t, "port-forward", workspace.Name, flag)
|
||||
cmd, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(io.MultiWriter(buf, os.Stderr))
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
pty.ExpectMatch("Ready!")
|
||||
|
||||
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 +170,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.
|
||||
@@ -211,21 +182,25 @@ func TestPortForward(t *testing.T) {
|
||||
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listeners.
|
||||
cmd, root := clitest.New(t, "port-forward", workspace.Name, flag1, flag2)
|
||||
cmd, root := clitest.New(t, "-v", "port-forward", workspace.Name, flag1, flag2)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(io.MultiWriter(buf, os.Stderr))
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
pty.ExpectMatch("Ready!")
|
||||
|
||||
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()
|
||||
@@ -242,79 +217,16 @@ func TestPortForward(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// Test doing a TCP -> Unix forward.
|
||||
//nolint:paralleltest
|
||||
t.Run("TCP2Unix", func(t *testing.T) {
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
_, workspace = runAgent(t, client, user.UserID)
|
||||
|
||||
// Find the TCP and Unix cases so we can use their setupLocal and
|
||||
// setupRemote methods respectively.
|
||||
tcpCase = cases[0]
|
||||
unixCase = cases[2]
|
||||
|
||||
// Setup remote Unix listener.
|
||||
p1 = setupTestListener(t, unixCase.setupRemote(t))
|
||||
)
|
||||
|
||||
// Create a flag that forwards from local TCP to Unix listener 1.
|
||||
// Notably this is a --unix flag.
|
||||
localAddress, localFlag := tcpCase.setupLocal(t)
|
||||
flag := fmt.Sprintf(unixCase.flag, localFlag, p1)
|
||||
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listener.
|
||||
cmd, root := clitest.New(t, "port-forward", workspace.Name, flag)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(io.MultiWriter(buf, os.Stderr))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
|
||||
// Open two connections simultaneously and test them out of
|
||||
// sync.
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
c1, err := d.DialContext(ctx, tcpCase.network, localAddress)
|
||||
require.NoError(t, err, "open connection 1 to 'local' listener")
|
||||
defer c1.Close()
|
||||
c2, err := d.DialContext(ctx, tcpCase.network, localAddress)
|
||||
require.NoError(t, err, "open connection 2 to 'local' listener")
|
||||
defer c2.Close()
|
||||
testDial(t, c2)
|
||||
testDial(t, c1)
|
||||
|
||||
cancel()
|
||||
err = <-errC
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
})
|
||||
|
||||
// Test doing TCP, UDP and Unix at the same time.
|
||||
// Test doing TCP and UDP at the same time.
|
||||
//nolint:paralleltest
|
||||
t.Run("All", func(t *testing.T) {
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
_, workspace = runAgent(t, client, user.UserID)
|
||||
// These aren't fixed size because we exclude Unix on Windows.
|
||||
dials = []addr{}
|
||||
flags = []string{}
|
||||
)
|
||||
|
||||
// Start listeners and populate arrays with the cases.
|
||||
for _, c := range cases {
|
||||
if strings.HasPrefix(c.network, "unix") && runtime.GOOS == "windows" {
|
||||
// Unix isn't supported on Windows, but we can still
|
||||
// test other protocols together.
|
||||
continue
|
||||
}
|
||||
|
||||
p := setupTestListener(t, c.setupRemote(t))
|
||||
|
||||
localAddress, localFlag := c.setupLocal(t)
|
||||
@@ -327,21 +239,25 @@ func TestPortForward(t *testing.T) {
|
||||
|
||||
// Launch port-forward in a goroutine so we can start dialing
|
||||
// the "local" listeners.
|
||||
cmd, root := clitest.New(t, append([]string{"port-forward", workspace.Name}, flags...)...)
|
||||
cmd, root := clitest.New(t, append([]string{"-v", "port-forward", workspace.Name}, flags...)...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
buf := newThreadSafeBuffer()
|
||||
cmd.SetOut(io.MultiWriter(buf, os.Stderr))
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
waitForPortForwardReady(t, buf)
|
||||
pty.ExpectMatch("Ready!")
|
||||
|
||||
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 {
|
||||
@@ -366,7 +282,8 @@ func TestPortForward(t *testing.T) {
|
||||
|
||||
// runAgent creates a fake workspace and starts an agent locally for that
|
||||
// workspace. The agent will be cleaned up on test completion.
|
||||
func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]codersdk.WorkspaceResource, codersdk.Workspace) {
|
||||
// nolint:unused
|
||||
func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk.Workspace {
|
||||
ctx := context.Background()
|
||||
user, err := client.User(ctx, userID.String())
|
||||
require.NoError(t, err, "specified user does not exist")
|
||||
@@ -404,6 +321,10 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]coders
|
||||
// Start workspace agent in a goroutine
|
||||
cmd, root := clitest.New(t, "agent", "--agent-token", agentToken, "--agent-url", client.URL.String())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
errC := make(chan error)
|
||||
agentCtx, agentCancel := context.WithCancel(ctx)
|
||||
t.Cleanup(func() {
|
||||
@@ -415,16 +336,16 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]coders
|
||||
errC <- cmd.ExecuteContext(agentCtx)
|
||||
}()
|
||||
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(context.Background(), workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
return resources, workspace
|
||||
return workspace
|
||||
}
|
||||
|
||||
// setupTestListener starts accepting connections and echoing a single packet.
|
||||
// Returns the listener and the listen port or Unix path.
|
||||
// Returns the listener and the listen port.
|
||||
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 +361,7 @@ func setupTestListener(t *testing.T, l net.Listener) string {
|
||||
for {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
_ = l.Close()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -452,11 +374,9 @@ func setupTestListener(t *testing.T, l net.Listener) string {
|
||||
}()
|
||||
|
||||
addr := l.Addr().String()
|
||||
if !strings.HasPrefix(l.Addr().Network(), "unix") {
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
require.NoErrorf(t, err, "split non-Unix listen path %q", addr)
|
||||
addr = port
|
||||
}
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
require.NoErrorf(t, err, "split non-Unix listen path %q", addr)
|
||||
addr = port
|
||||
|
||||
return addr
|
||||
}
|
||||
@@ -479,6 +399,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,65 +408,13 @@ 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) {
|
||||
for i := 0; i < 100; i++ {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
data := output.String()
|
||||
if strings.Contains(data, "Ready!") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatal("port-forward command did not become ready in time")
|
||||
}
|
||||
|
||||
type addr struct {
|
||||
network string
|
||||
addr string
|
||||
}
|
||||
|
||||
type threadSafeBuffer struct {
|
||||
b *bytes.Buffer
|
||||
mut *sync.RWMutex
|
||||
}
|
||||
|
||||
func newThreadSafeBuffer() *threadSafeBuffer {
|
||||
return &threadSafeBuffer{
|
||||
b: bytes.NewBuffer(nil),
|
||||
mut: new(sync.RWMutex),
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ io.Reader = &threadSafeBuffer{}
|
||||
_ io.Writer = &threadSafeBuffer{}
|
||||
)
|
||||
|
||||
// Read implements io.Reader.
|
||||
func (b *threadSafeBuffer) Read(p []byte) (int, error) {
|
||||
b.mut.RLock()
|
||||
defer b.mut.RUnlock()
|
||||
|
||||
return b.b.Read(p)
|
||||
}
|
||||
|
||||
// Write implements io.Writer.
|
||||
func (b *threadSafeBuffer) Write(p []byte) (int, error) {
|
||||
b.mut.Lock()
|
||||
defer b.mut.Unlock()
|
||||
|
||||
return b.b.Write(p)
|
||||
}
|
||||
|
||||
func (b *threadSafeBuffer) String() string {
|
||||
b.mut.RLock()
|
||||
defer b.mut.RUnlock()
|
||||
|
||||
return b.b.String()
|
||||
}
|
||||
|
||||
+2
-2
@@ -18,9 +18,9 @@ func publickey() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "publickey",
|
||||
Aliases: []string{"pubkey"},
|
||||
Short: "Output your public key for Git operations",
|
||||
Short: "Output your Coder public key used 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{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
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"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/migrations"
|
||||
"github.com/coder/coder/coderd/userpassword"
|
||||
)
|
||||
|
||||
@@ -19,7 +21,7 @@ func resetPassword() *cobra.Command {
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "reset-password <username>",
|
||||
Short: "Reset a user's password by directly updating the database",
|
||||
Short: "Directly connect to the database to reset a user's password",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
username := args[0]
|
||||
@@ -34,7 +36,7 @@ func resetPassword() *cobra.Command {
|
||||
return xerrors.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
|
||||
err = database.EnsureClean(sqlDB)
|
||||
err = migrations.EnsureClean(sqlDB)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("database needs migration: %w", err)
|
||||
}
|
||||
@@ -80,6 +82,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
|
||||
},
|
||||
}
|
||||
|
||||
+17
-13
@@ -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,27 @@ 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",
|
||||
"--access-url", "example.com",
|
||||
"--postgres-url", connectionURL,
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
err = serverCmd.ExecuteContext(ctx)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
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,
|
||||
|
||||
+348
-57
@@ -1,14 +1,19 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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 +22,8 @@ import (
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/cli/deployment"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
@@ -26,94 +33,201 @@ var (
|
||||
// Applied as annotations to workspace commands
|
||||
// so they display in a separated "help" section.
|
||||
workspaceCommand = map[string]string{
|
||||
"workspaces": " ",
|
||||
"workspaces": "",
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
varURL = "url"
|
||||
varToken = "token"
|
||||
varAgentToken = "agent-token"
|
||||
varAgentURL = "agent-url"
|
||||
varGlobalConfig = "global-config"
|
||||
varNoOpen = "no-open"
|
||||
varForceTty = "force-tty"
|
||||
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
|
||||
varURL = "url"
|
||||
varToken = "token"
|
||||
varAgentToken = "agent-token"
|
||||
varAgentURL = "agent-url"
|
||||
varGlobalConfig = "global-config"
|
||||
varHeader = "header"
|
||||
varNoOpen = "no-open"
|
||||
varNoVersionCheck = "no-version-warning"
|
||||
varNoFeatureWarning = "no-feature-warning"
|
||||
varForceTty = "force-tty"
|
||||
varVerbose = "verbose"
|
||||
varExperimental = "experimental"
|
||||
notLoggedInMessage = "You are not logged in. Try logging in using 'coder login <url>'."
|
||||
|
||||
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
|
||||
envNoFeatureWarning = "CODER_NO_FEATURE_WARNING"
|
||||
envExperimental = "CODER_EXPERIMENTAL"
|
||||
envSessionToken = "CODER_SESSION_TOKEN"
|
||||
envURL = "CODER_URL"
|
||||
)
|
||||
|
||||
var (
|
||||
errUnauthenticated = xerrors.New(notLoggedInMessage)
|
||||
)
|
||||
|
||||
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 {
|
||||
cmd := &cobra.Command{
|
||||
Use: "coder",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
Long: `Coder — A tool for provisioning self-hosted development environments.
|
||||
`,
|
||||
Example: ` Start a Coder server.
|
||||
` + cliui.Styles.Code.Render("$ coder server") + `
|
||||
|
||||
Get started by creating a template from an example.
|
||||
` + cliui.Styles.Code.Render("$ coder templates init"),
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
func Core() []*cobra.Command {
|
||||
return []*cobra.Command{
|
||||
configSSH(),
|
||||
create(),
|
||||
delete(),
|
||||
deleteWorkspace(),
|
||||
dotfiles(),
|
||||
gitssh(),
|
||||
list(),
|
||||
login(),
|
||||
logout(),
|
||||
parameters(),
|
||||
portForward(),
|
||||
publickey(),
|
||||
resetPassword(),
|
||||
schedules(),
|
||||
server(),
|
||||
show(),
|
||||
ssh(),
|
||||
speedtest(),
|
||||
start(),
|
||||
state(),
|
||||
stop(),
|
||||
ssh(),
|
||||
rename(),
|
||||
templates(),
|
||||
update(),
|
||||
users(),
|
||||
portForward(),
|
||||
workspaceAgent(),
|
||||
versionCmd(),
|
||||
parameters(),
|
||||
)
|
||||
workspaceAgent(),
|
||||
tokens(),
|
||||
}
|
||||
}
|
||||
|
||||
func AGPL() []*cobra.Command {
|
||||
all := append(Core(), Server(deployment.Flags(), func(_ context.Context, o *coderd.Options) (*coderd.API, error) {
|
||||
return coderd.New(o), nil
|
||||
}))
|
||||
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 with Terraform.
|
||||
`,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
if cliflag.IsSetBool(cmd, varNoVersionCheck) &&
|
||||
cliflag.IsSetBool(cmd, varNoFeatureWarning) {
|
||||
return
|
||||
}
|
||||
|
||||
// login handles checking the versions itself since it has a handle
|
||||
// to an unauthenticated client.
|
||||
//
|
||||
// server is skipped for obvious reasons.
|
||||
//
|
||||
// agent is skipped because these checks use the global coder config
|
||||
// and not the agent URL and token from the environment.
|
||||
//
|
||||
// gitssh is skipped because it's usually not called by users
|
||||
// directly.
|
||||
if cmd.Name() == "login" || cmd.Name() == "server" || cmd.Name() == "agent" || cmd.Name() == "gitssh" {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
// If we are unable to create a client, presumably the subcommand will fail as well
|
||||
// so we can bail out here.
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = 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())
|
||||
}
|
||||
|
||||
err = checkWarnings(cmd, client)
|
||||
if err != nil {
|
||||
// Same as above
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
|
||||
cliui.Styles.Warn.Render("check entitlement warnings 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(subcommands...)
|
||||
fixUnknownSubcommandError(cmd.Commands())
|
||||
|
||||
cmd.SetUsageTemplate(usageTemplate())
|
||||
|
||||
cmd.PersistentFlags().String(varURL, "", "Specify the URL to your deployment.")
|
||||
cmd.PersistentFlags().String(varToken, "", "Specify an authentication token.")
|
||||
cliflag.String(cmd.PersistentFlags(), varAgentToken, "", "CODER_AGENT_TOKEN", "", "Specify an agent authentication token.")
|
||||
cliflag.String(cmd.PersistentFlags(), varURL, "", envURL, "", "URL to a deployment.")
|
||||
cliflag.Bool(cmd.PersistentFlags(), varNoVersionCheck, "", envNoVersionCheck, false, "Suppress warning when client and server versions do not match.")
|
||||
cliflag.Bool(cmd.PersistentFlags(), varNoFeatureWarning, "", envNoFeatureWarning, false, "Suppress warnings about unlicensed features.")
|
||||
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", "", "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.")
|
||||
cliflag.String(cmd.PersistentFlags(), varAgentURL, "", "CODER_AGENT_URL", "", "URL for an agent to access your deployment.")
|
||||
_ = cmd.PersistentFlags().MarkHidden(varAgentURL)
|
||||
cliflag.String(cmd.PersistentFlags(), varGlobalConfig, "", "CODER_CONFIG_DIR", configdir.LocalConfig("coderv2"), "Specify the path to the global `coder` config directory.")
|
||||
cliflag.String(cmd.PersistentFlags(), varGlobalConfig, "", "CODER_CONFIG_DIR", configdir.LocalConfig("coderv2"), "Path to the global `coder` config directory.")
|
||||
cliflag.StringArray(cmd.PersistentFlags(), varHeader, "", "CODER_HEADER", []string{}, "HTTP headers added to all requests. Provide as \"Key=Value\"")
|
||||
cmd.PersistentFlags().Bool(varForceTty, false, "Force the `coder` command to run as if connected to a TTY.")
|
||||
_ = cmd.PersistentFlags().MarkHidden(varForceTty)
|
||||
cmd.PersistentFlags().Bool(varNoOpen, false, "Block automatically opening URLs in the browser.")
|
||||
_ = cmd.PersistentFlags().MarkHidden(varNoOpen)
|
||||
cliflag.Bool(cmd.PersistentFlags(), varVerbose, "v", "CODER_VERBOSE", false, "Enable verbose output.")
|
||||
cliflag.Bool(cmd.PersistentFlags(), varExperimental, "", envExperimental, false, "Enable experimental features. Experimental features are not ready for production.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// fixUnknownSubcommandError modifies the provided commands so that the
|
||||
// ones with subcommands output the correct error message when an
|
||||
// unknown subcommand is invoked.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// unknown command "bad" for "coder templates"
|
||||
func fixUnknownSubcommandError(commands []*cobra.Command) {
|
||||
for _, sc := range commands {
|
||||
if sc.HasSubCommands() {
|
||||
if sc.Run == nil && sc.RunE == nil {
|
||||
if sc.Args != nil {
|
||||
// In case the developer does not know about this
|
||||
// behavior in Cobra they must verify correct
|
||||
// behavior. For instance, settings Args to
|
||||
// `cobra.ExactArgs(0)` will not give the same
|
||||
// message as `cobra.NoArgs`. Likewise, omitting the
|
||||
// run function will not give the wanted error.
|
||||
panic("developer error: subcommand has subcommands and Args but no Run or RunE")
|
||||
}
|
||||
sc.Args = cobra.NoArgs
|
||||
sc.Run = func(*cobra.Command, []string) {}
|
||||
}
|
||||
|
||||
fixUnknownSubcommandError(sc.Commands())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()))
|
||||
@@ -129,9 +243,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 == "" {
|
||||
@@ -139,7 +257,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
|
||||
}
|
||||
@@ -154,18 +272,42 @@ 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
|
||||
}
|
||||
}
|
||||
client, err := createUnauthenticatedClient(cmd, serverURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.SessionToken = token
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func createUnauthenticatedClient(cmd *cobra.Command, serverURL *url.URL) (*codersdk.Client, error) {
|
||||
client := codersdk.New(serverURL)
|
||||
client.SessionToken = strings.TrimSpace(token)
|
||||
headers, err := cmd.Flags().GetStringArray(varHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := &headerTransport{
|
||||
transport: http.DefaultTransport,
|
||||
headers: map[string]string{},
|
||||
}
|
||||
for _, header := range headers {
|
||||
parts := strings.SplitN(header, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
return nil, xerrors.Errorf("split header %q had less than two parts", header)
|
||||
}
|
||||
transport.headers[parts[0]] = parts[1]
|
||||
}
|
||||
client.HTTPClient.Transport = transport
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// createAgentClient returns a new client from the command context.
|
||||
// It works just like createClient, but uses the agent token and URL instead.
|
||||
// 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 {
|
||||
@@ -261,6 +403,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:"}}
|
||||
@@ -281,19 +447,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}}
|
||||
@@ -301,12 +469,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}}
|
||||
@@ -323,8 +491,131 @@ 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
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
clientVersion := buildinfo.Version()
|
||||
info, err := client.BuildInfo(ctx)
|
||||
// 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
|
||||
}
|
||||
|
||||
func checkWarnings(cmd *cobra.Command, client *codersdk.Client) error {
|
||||
if cliflag.IsSetBool(cmd, varNoFeatureWarning) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
entitlements, err := client.Entitlements(ctx)
|
||||
if err == nil {
|
||||
for _, w := range entitlements.Warnings {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), cliui.Styles.Warn.Render(w))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type headerTransport struct {
|
||||
transport http.RoundTripper
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
func (h *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
for k, v := range h.headers {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
return h.transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
// ExperimentalEnabled returns if the experimental feature flag is enabled.
|
||||
func ExperimentalEnabled(cmd *cobra.Command) bool {
|
||||
return cliflag.IsSetBool(cmd, varExperimental)
|
||||
}
|
||||
|
||||
// EnsureExperimental will ensure that the experimental feature flag is set if the given flag is set.
|
||||
func EnsureExperimental(cmd *cobra.Command, name string) error {
|
||||
_, set := cliflag.IsSet(cmd, name)
|
||||
if set && !ExperimentalEnabled(cmd) {
|
||||
return xerrors.Errorf("flag %s is set but requires flag --experimental or environment variable CODER_EXPERIMENTAL=true.", name)
|
||||
}
|
||||
|
||||
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"),
|
||||
)
|
||||
}
|
||||
+139
-6
@@ -2,25 +2,122 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/coder/buildinfo"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/cli"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"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) {
|
||||
@@ -36,4 +133,40 @@ func TestRoot(t *testing.T) {
|
||||
require.Contains(t, output, buildinfo.Version(), "has version")
|
||||
require.Contains(t, output, buildinfo.ExternalURL(), "has url")
|
||||
})
|
||||
|
||||
t.Run("Header", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
done := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "wow", r.Header.Get("X-Testing"))
|
||||
w.WriteHeader(http.StatusGone)
|
||||
select {
|
||||
case <-done:
|
||||
close(done)
|
||||
default:
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd, _ := clitest.New(t, "--header", "X-Testing=wow", "login", srv.URL)
|
||||
cmd.SetOut(buf)
|
||||
// This won't succeed, because we're using the login cmd to assert requests.
|
||||
_ = cmd.Execute()
|
||||
})
|
||||
|
||||
t.Run("Experimental", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, _ := clitest.New(t, "--experimental")
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
require.True(t, cli.ExperimentalEnabled(cmd))
|
||||
|
||||
cmd, _ = clitest.New(t, "help", "--verbose")
|
||||
_ = cmd.Execute()
|
||||
_, set := cliflag.IsSet(cmd, "verbose")
|
||||
require.True(t, set)
|
||||
require.ErrorContains(t, cli.EnsureExperimental(cmd, "verbose"), "--experimental")
|
||||
})
|
||||
}
|
||||
|
||||
+48
-41
@@ -17,12 +17,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
scheduleDescriptionLong = `Modify scheduled stop and start times for your workspace:
|
||||
* schedule show: show workspace schedule
|
||||
* schedule start: edit workspace start schedule
|
||||
* schedule stop: edit workspace stop schedule
|
||||
* schedule override-stop: edit stop time of active workspace
|
||||
`
|
||||
scheduleShowDescriptionLong = `Shows the following information for the given workspace:
|
||||
* The automatic start schedule
|
||||
* The next scheduled start time
|
||||
@@ -63,27 +57,30 @@ 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",
|
||||
Long: scheduleDescriptionLong,
|
||||
Short: "Schedule automated start and stop times for workspaces",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
scheduleCmd.AddCommand(scheduleShow())
|
||||
scheduleCmd.AddCommand(scheduleStart())
|
||||
scheduleCmd.AddCommand(scheduleStop())
|
||||
scheduleCmd.AddCommand(scheduleOverride())
|
||||
scheduleCmd.AddCommand(
|
||||
scheduleShow(),
|
||||
scheduleStart(),
|
||||
scheduleStop(),
|
||||
scheduleOverride(),
|
||||
)
|
||||
|
||||
return scheduleCmd
|
||||
}
|
||||
|
||||
func scheduleShow() *cobra.Command {
|
||||
showCmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "show <workspace-name>",
|
||||
Short: "Show workspace schedule",
|
||||
Long: scheduleShowDescriptionLong,
|
||||
Args: cobra.ExactArgs(1),
|
||||
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)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -101,14 +98,18 @@ func scheduleShow() *cobra.Command {
|
||||
|
||||
func scheduleStart() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "start <workspace-name> { <start-time> [day-of-week] [location] | manual }",
|
||||
Example: `start my-workspace 9:30AM Mon-Fri Europe/Dublin`,
|
||||
Short: "Edit workspace start schedule",
|
||||
Long: scheduleStartDescriptionLong,
|
||||
Args: cobra.RangeArgs(2, 4),
|
||||
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)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -148,14 +149,17 @@ func scheduleStart() *cobra.Command {
|
||||
|
||||
func scheduleStop() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Args: cobra.ExactArgs(2),
|
||||
Use: "stop <workspace-name> { <duration> | manual }",
|
||||
Example: `stop my-workspace 2h30m`,
|
||||
Short: "Edit workspace stop schedule",
|
||||
Long: scheduleStopDescriptionLong,
|
||||
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)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -191,19 +195,22 @@ func scheduleStop() *cobra.Command {
|
||||
|
||||
func scheduleOverride() *cobra.Command {
|
||||
overrideCmd := &cobra.Command{
|
||||
Args: cobra.ExactArgs(2),
|
||||
Annotations: workspaceCommand,
|
||||
Use: "override-stop <workspace-name> <duration from now>",
|
||||
Example: "override-stop my-workspace 90m",
|
||||
Short: "Edit stop time of active workspace",
|
||||
Long: scheduleOverrideDescriptionLong,
|
||||
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)
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create client: %w", err)
|
||||
}
|
||||
@@ -276,8 +283,8 @@ func displaySchedule(workspace codersdk.Workspace, out io.Writer) error {
|
||||
if workspace.LatestBuild.Transition != "start" {
|
||||
schedNextStop = "-"
|
||||
} else {
|
||||
schedNextStop = workspace.LatestBuild.Deadline.In(loc).Format(timeFormat + " on " + dateFormat)
|
||||
schedNextStop = fmt.Sprintf("%s (in %s)", schedNextStop, durationDisplay(time.Until(workspace.LatestBuild.Deadline)))
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-16
@@ -28,7 +28,7 @@ func TestScheduleShow(t *testing.T) {
|
||||
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})
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -61,22 +61,20 @@ func TestScheduleShow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
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
|
||||
cwr.TTLMillis = nil
|
||||
})
|
||||
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
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)
|
||||
@@ -96,7 +94,7 @@ func TestScheduleShow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -115,7 +113,7 @@ func TestScheduleStart(t *testing.T) {
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -168,7 +166,7 @@ func TestScheduleStop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -221,7 +219,7 @@ func TestScheduleOverride(t *testing.T) {
|
||||
var (
|
||||
err error
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -239,7 +237,7 @@ func TestScheduleOverride(t *testing.T) {
|
||||
|
||||
// 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)
|
||||
require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline.Time, time.Minute)
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
@@ -252,7 +250,7 @@ func TestScheduleOverride(t *testing.T) {
|
||||
// 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)
|
||||
require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline.Time, time.Minute)
|
||||
})
|
||||
|
||||
t.Run("InvalidDuration", func(t *testing.T) {
|
||||
@@ -262,7 +260,7 @@ func TestScheduleOverride(t *testing.T) {
|
||||
var (
|
||||
err error
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -279,7 +277,7 @@ func TestScheduleOverride(t *testing.T) {
|
||||
|
||||
// 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)
|
||||
require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline.Time, time.Minute)
|
||||
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
@@ -298,7 +296,7 @@ func TestScheduleOverride(t *testing.T) {
|
||||
var (
|
||||
err error
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -349,7 +347,7 @@ func TestScheduleOverride(t *testing.T) {
|
||||
func TestScheduleStartDefaults(t *testing.T) {
|
||||
t.Setenv("TZ", "Pacific/Tongatapu")
|
||||
var (
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
|
||||
+649
-362
File diff suppressed because it is too large
Load Diff
+457
-85
@@ -1,6 +1,7 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -17,23 +19,28 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"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) {
|
||||
if runtime.GOOS != "linux" || testing.Short() {
|
||||
@@ -45,22 +52,21 @@ 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",
|
||||
"--access-url", "example.com",
|
||||
"--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
|
||||
}, time.Minute, 50*time.Millisecond)
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
client := codersdk.New(accessURL)
|
||||
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: "some@one.com",
|
||||
Username: "example",
|
||||
@@ -69,7 +75,7 @@ func TestServer(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
t.Run("BuiltinPostgres", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -77,26 +83,104 @@ func TestServer(t *testing.T) {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
root, cfg := clitest.New(t, "server", "--address", ":0")
|
||||
errC := make(chan error)
|
||||
defer cancelFunc()
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--address", ":0",
|
||||
"--access-url", "example.com",
|
||||
"--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 {
|
||||
_, err := cfg.URL().Read()
|
||||
return err == nil
|
||||
}, time.Minute, 25*time.Millisecond)
|
||||
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)
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
t.Run("BuiltinPostgresURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
root, _ := clitest.New(t, "server", "postgres-builtin-url")
|
||||
var buf strings.Builder
|
||||
root.SetOutput(&buf)
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, buf.String(), "psql")
|
||||
|
||||
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()
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "localhost:3000/",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
// Just wait for startup
|
||||
_ = waitAccessURL(t, cfg)
|
||||
|
||||
pty.ExpectMatch("this may cause unexpected problems when creating workspaces")
|
||||
pty.ExpectMatch("View the Web UI: http://localhost:3000/")
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
// 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()
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "example.com",
|
||||
"--access-url", "foobarbaz.mydomain",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
// Just wait for startup
|
||||
_ = waitAccessURL(t, cfg)
|
||||
|
||||
pty.ExpectMatch("this may cause unexpected problems when creating workspaces")
|
||||
pty.ExpectMatch("View the Web UI: https://foobarbaz.mydomain")
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("NoWarningWithRemoteAccessURL", func(t *testing.T) {
|
||||
@@ -104,33 +188,45 @@ func TestServer(t *testing.T) {
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, cfg := clitest.New(t, "server", "--in-memory", "--address", ":0", "--access-url", "http://1.2.3.4:3000/")
|
||||
var buf strings.Builder
|
||||
errC := make(chan error)
|
||||
root.SetOutput(&buf)
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "example.com",
|
||||
"--access-url", "https://google.com",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
// Just wait for startup
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
_, err = cfg.URL().Read()
|
||||
return err == nil
|
||||
}, 15*time.Second, 25*time.Millisecond)
|
||||
_ = waitAccessURL(t, cfg)
|
||||
|
||||
pty.ExpectMatch("View the Web UI: https://google.com")
|
||||
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
|
||||
assert.NotContains(t, buf.String(), "Workspaces must be able to reach Coder from this URL")
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("TLSBadVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, _ := clitest.New(t, "server", "--in-memory", "--address", ":0",
|
||||
"--tls-enable", "--tls-min-version", "tls9")
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "example.com",
|
||||
"--tls-enable",
|
||||
"--tls-min-version", "tls9",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
})
|
||||
@@ -138,19 +234,78 @@ func TestServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, _ := clitest.New(t, "server", "--in-memory", "--address", ":0",
|
||||
"--tls-enable", "--tls-client-auth", "something")
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "example.com",
|
||||
"--tls-enable",
|
||||
"--tls-client-auth", "something",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("TLSNoCertFile", func(t *testing.T) {
|
||||
t.Run("TLSInvalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, _ := clitest.New(t, "server", "--in-memory", "--address", ":0",
|
||||
"--tls-enable")
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
|
||||
cert1Path, key1Path := generateTLSCertificate(t)
|
||||
cert2Path, key2Path := generateTLSCertificate(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "NoCertAndKey",
|
||||
args: []string{"--tls-enable"},
|
||||
errContains: "--tls-cert-file is required when tls is enabled",
|
||||
},
|
||||
{
|
||||
name: "NoCert",
|
||||
args: []string{"--tls-enable", "--tls-key-file", key1Path},
|
||||
errContains: "--tls-cert-file and --tls-key-file must be used the same amount of times",
|
||||
},
|
||||
{
|
||||
name: "NoKey",
|
||||
args: []string{"--tls-enable", "--tls-cert-file", cert1Path},
|
||||
errContains: "--tls-cert-file and --tls-key-file must be used the same amount of times",
|
||||
},
|
||||
{
|
||||
name: "MismatchedCount",
|
||||
args: []string{"--tls-enable", "--tls-cert-file", cert1Path, "--tls-key-file", key1Path, "--tls-cert-file", cert2Path},
|
||||
errContains: "--tls-cert-file and --tls-key-file must be used the same amount of times",
|
||||
},
|
||||
{
|
||||
name: "MismatchedCertAndKey",
|
||||
args: []string{"--tls-enable", "--tls-cert-file", cert1Path, "--tls-key-file", key2Path},
|
||||
errContains: "load TLS key pair",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
args := []string{
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "example.com",
|
||||
"--cache-dir", t.TempDir(),
|
||||
}
|
||||
args = append(args, c.args...)
|
||||
root, _ := clitest.New(t, args...)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, c.errContains)
|
||||
})
|
||||
}
|
||||
})
|
||||
t.Run("TLSValid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -158,22 +313,23 @@ func TestServer(t *testing.T) {
|
||||
defer cancelFunc()
|
||||
|
||||
certPath, keyPath := generateTLSCertificate(t)
|
||||
root, cfg := clitest.New(t, "server", "--in-memory", "--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",
|
||||
"--access-url", "example.com",
|
||||
"--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{
|
||||
@@ -184,11 +340,92 @@ func TestServer(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
_, err := client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
t.Run("TLSValidMultiple", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
cert1Path, key1Path := generateTLSCertificate(t, "alpaca.com")
|
||||
cert2Path, key2Path := generateTLSCertificate(t, "*.llama.com")
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "example.com",
|
||||
"--tls-enable",
|
||||
"--tls-cert-file", cert1Path,
|
||||
"--tls-key-file", key1Path,
|
||||
"--tls-cert-file", cert2Path,
|
||||
"--tls-key-file", key2Path,
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
require.Equal(t, "https", accessURL.Scheme)
|
||||
originalHost := accessURL.Host
|
||||
|
||||
var (
|
||||
expectAddr string
|
||||
dials int64
|
||||
)
|
||||
client := codersdk.New(accessURL)
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
atomic.AddInt64(&dials, 1)
|
||||
assert.Equal(t, expectAddr, addr)
|
||||
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Always connect to the accessURL ip:port regardless of
|
||||
// hostname.
|
||||
conn, err := tls.Dial(network, originalHost, &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: host,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// We can't call conn.VerifyHostname because it requires
|
||||
// that the certificates are valid, so we call
|
||||
// VerifyHostname on the first certificate instead.
|
||||
require.Len(t, conn.ConnectionState().PeerCertificates, 1)
|
||||
err = conn.ConnectionState().PeerCertificates[0].VerifyHostname(host)
|
||||
assert.NoError(t, err, "invalid cert common name")
|
||||
return conn, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Use the first certificate and hostname.
|
||||
client.URL.Host = "alpaca.com:443"
|
||||
expectAddr = "alpaca.com:443"
|
||||
_, err := client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, atomic.LoadInt64(&dials))
|
||||
|
||||
// Use the second certificate (wildcard) and hostname.
|
||||
client.URL.Host = "hi.llama.com:443"
|
||||
expectAddr = "hi.llama.com:443"
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, atomic.LoadInt64(&dials))
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
// This cannot be ran in parallel because it uses a signal.
|
||||
//nolint:paralleltest
|
||||
@@ -199,28 +436,26 @@ func TestServer(t *testing.T) {
|
||||
}
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, cfg := clitest.New(t, "server", "--in-memory", "--address", ":0", "--provisioner-daemons", "1")
|
||||
serverErr := make(chan error)
|
||||
go func() {
|
||||
err := root.ExecuteContext(ctx)
|
||||
serverErr <- err
|
||||
}()
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
_, err = cfg.URL().Read()
|
||||
return err == nil
|
||||
}, 15*time.Second, 25*time.Millisecond)
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "example.com",
|
||||
"--provisioner-daemons", "1",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
_ = 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)
|
||||
})
|
||||
@@ -228,13 +463,21 @@ func TestServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
root, _ := clitest.New(t, "server", "--in-memory", "--address", ":0", "--trace=true")
|
||||
errC := make(chan error)
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "example.com",
|
||||
"--trace=true",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
cancelFunc()
|
||||
require.ErrorIs(t, <-errC, context.Canceled)
|
||||
require.NoError(t, <-errC)
|
||||
require.Error(t, goleak.Find())
|
||||
})
|
||||
t.Run("Telemetry", func(t *testing.T) {
|
||||
@@ -257,29 +500,141 @@ func TestServer(t *testing.T) {
|
||||
snapshot <- ss
|
||||
})
|
||||
server := httptest.NewServer(r)
|
||||
t.Cleanup(server.Close)
|
||||
defer server.Close()
|
||||
|
||||
root, _ := clitest.New(t, "server", "--in-memory", "--address", ":0", "--telemetry", "--telemetry-url", server.URL)
|
||||
errC := make(chan error)
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "example.com",
|
||||
"--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",
|
||||
"--access-url", "example.com",
|
||||
"--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",
|
||||
"--access-url", "example.com",
|
||||
"--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) {
|
||||
func generateTLSCertificate(t testing.TB, commonName ...string) (certPath, keyPath string) {
|
||||
dir := t.TempDir()
|
||||
|
||||
commonNameStr := "localhost"
|
||||
if len(commonName) > 0 {
|
||||
commonNameStr = commonName[0]
|
||||
}
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Acme Co"},
|
||||
CommonName: commonNameStr,
|
||||
},
|
||||
DNSNames: []string{commonNameStr},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(time.Hour * 24 * 180),
|
||||
|
||||
@@ -288,6 +643,7 @@ func generateTLSCertificate(t testing.TB) (certPath, keyPath string) {
|
||||
BasicConstraintsValid: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
require.NoError(t, err)
|
||||
certFile, err := os.CreateTemp(dir, "")
|
||||
@@ -304,3 +660,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
|
||||
}
|
||||
|
||||
+9
-8
@@ -10,24 +10,25 @@ import (
|
||||
func show() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "show",
|
||||
Short: "Show details of a workspace's resources and agents",
|
||||
Use: "show <workspace>",
|
||||
Short: "Display 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
|
||||
}
|
||||
buildInfo, err := client.BuildInfo(cmd.Context())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get server version: %w", err)
|
||||
}
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace: %w", err)
|
||||
}
|
||||
resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace resources: %w", err)
|
||||
}
|
||||
return cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
||||
return cliui.WorkspaceResources(cmd.OutOrStdout(), workspace.LatestBuild.Resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: workspace.Name,
|
||||
ServerVersion: buildInfo.Version,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ func TestShow(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Exists", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,123 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
tsspeedtest "tailscale.com/net/speedtest"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func speedtest() *cobra.Command {
|
||||
var (
|
||||
direct bool
|
||||
duration time.Duration
|
||||
reverse bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "speedtest <workspace>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Run upload and download tests from your machine to a workspace",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create codersdk client: %w", err)
|
||||
}
|
||||
|
||||
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, cmd, client, codersdk.Me, args[0], false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = cliui.Agent(ctx, cmd.ErrOrStderr(), cliui.AgentOptions{
|
||||
WorkspaceName: workspace.Name,
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
return client.WorkspaceAgent(ctx, workspaceAgent.ID)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr()))
|
||||
if cliflag.IsSetBool(cmd, varVerbose) {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
conn, err := client.DialWorkspaceAgentTailnet(ctx, logger, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
dur, err := conn.Ping()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
status := conn.Status()
|
||||
if len(status.Peers()) != 1 {
|
||||
continue
|
||||
}
|
||||
peer := status.Peer[status.Peers()[0]]
|
||||
if peer.CurAddr == "" && direct {
|
||||
cmd.Printf("Waiting for a direct connection... (%dms via %s)\n", dur.Milliseconds(), peer.Relay)
|
||||
continue
|
||||
}
|
||||
via := peer.Relay
|
||||
if via == "" {
|
||||
via = "direct"
|
||||
}
|
||||
cmd.Printf("%dms via %s\n", dur.Milliseconds(), via)
|
||||
break
|
||||
}
|
||||
dir := tsspeedtest.Download
|
||||
if reverse {
|
||||
dir = tsspeedtest.Upload
|
||||
}
|
||||
cmd.Printf("Starting a %ds %s test...\n", int(duration.Seconds()), dir)
|
||||
results, err := conn.Speedtest(dir, duration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableWriter := cliui.Table()
|
||||
tableWriter.AppendHeader(table.Row{"Interval", "Transfer", "Bandwidth"})
|
||||
for _, r := range results {
|
||||
if r.Total {
|
||||
tableWriter.AppendSeparator()
|
||||
}
|
||||
tableWriter.AppendRow(table.Row{
|
||||
fmt.Sprintf("%.2f-%.2f sec", r.IntervalStart.Seconds(), r.IntervalEnd.Seconds()),
|
||||
fmt.Sprintf("%.4f MBits", r.MegaBits()),
|
||||
fmt.Sprintf("%.4f Mbits/sec", r.MBitsPerSecond()),
|
||||
})
|
||||
}
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), tableWriter.Render())
|
||||
return err
|
||||
},
|
||||
}
|
||||
cliflag.BoolVarP(cmd.Flags(), &direct, "direct", "d", "", false,
|
||||
"Specifies whether to wait for a direct connection before testing speed.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &reverse, "reverse", "r", "", false,
|
||||
"Specifies whether to run in reverse mode where the client receives and the server sends.")
|
||||
cmd.Flags().DurationVarP(&duration, "time", "t", tsspeedtest.DefaultDuration,
|
||||
"Specifies the duration to monitor traffic.")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestSpeedtest(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.Skip("This test takes a minimum of 5ms per a hardcoded value in Tailscale!")
|
||||
}
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer agentCloser.Close()
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "speedtest", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
<-cmdDone
|
||||
}
|
||||
+68
-34
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -19,6 +20,8 @@ import (
|
||||
"golang.org/x/term"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/autobuild/notify"
|
||||
@@ -27,8 +30,10 @@ import (
|
||||
"github.com/coder/coder/cryptorand"
|
||||
)
|
||||
|
||||
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 (
|
||||
@@ -41,10 +46,13 @@ func ssh() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "ssh <workspace>",
|
||||
Short: "SSH into a workspace",
|
||||
Short: "Start a shell 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,30 +69,30 @@ 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)
|
||||
conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
stopPolling := tryPollWorkspaceAutostop(cmd.Context(), client, workspace)
|
||||
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
|
||||
defer stopPolling()
|
||||
|
||||
if stdio {
|
||||
@@ -92,21 +100,33 @@ func ssh() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rawSSH.Close()
|
||||
|
||||
go func() {
|
||||
_, _ = io.Copy(cmd.OutOrStdout(), rawSSH)
|
||||
}()
|
||||
_, _ = io.Copy(rawSSH, cmd.InOrStdin())
|
||||
return nil
|
||||
}
|
||||
|
||||
sshClient, err := conn.SSHClient()
|
||||
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 +142,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 +177,31 @@ 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()
|
||||
|
||||
if validOut {
|
||||
// Set initial window size.
|
||||
width, height, err := term.GetSize(int(stdoutFile.Fd()))
|
||||
if err == nil {
|
||||
_ = sshSession.WindowChange(height, width)
|
||||
}
|
||||
}
|
||||
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,23 +214,20 @@ 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.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// getWorkspaceAgent returns the workspace and agent selected using either the
|
||||
// `<workspace>[.<agent>]` syntax via `in` or picks a random workspace and agent
|
||||
// if `shuffle` is true.
|
||||
func getWorkspaceAndAgent(cmd *cobra.Command, client *codersdk.Client, userID string, in string, shuffle bool) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { //nolint:revive
|
||||
ctx := cmd.Context()
|
||||
|
||||
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 {
|
||||
@@ -224,10 +261,7 @@ func getWorkspaceAndAgent(cmd *cobra.Command, client *codersdk.Client, userID st
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q is being deleted", workspace.Name)
|
||||
}
|
||||
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("fetch workspace resources: %w", err)
|
||||
}
|
||||
resources := workspace.LatestBuild.Resources
|
||||
|
||||
agents := make([]codersdk.WorkspaceAgent, 0)
|
||||
for _, resource := range resources {
|
||||
@@ -236,34 +270,34 @@ func getWorkspaceAndAgent(cmd *cobra.Command, client *codersdk.Client, userID st
|
||||
if len(agents) == 0 {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("workspace %q has no agents", workspace.Name)
|
||||
}
|
||||
var agent codersdk.WorkspaceAgent
|
||||
var workspaceAgent codersdk.WorkspaceAgent
|
||||
if len(workspaceParts) >= 2 {
|
||||
for _, otherAgent := range agents {
|
||||
if otherAgent.Name != workspaceParts[1] {
|
||||
continue
|
||||
}
|
||||
agent = otherAgent
|
||||
workspaceAgent = otherAgent
|
||||
break
|
||||
}
|
||||
if agent.ID == uuid.Nil {
|
||||
if workspaceAgent.ID == uuid.Nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.Errorf("agent not found by name %q", workspaceParts[1])
|
||||
}
|
||||
}
|
||||
if agent.ID == uuid.Nil {
|
||||
if workspaceAgent.ID == uuid.Nil {
|
||||
if len(agents) > 1 {
|
||||
if !shuffle {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("you must specify the name of an agent")
|
||||
}
|
||||
agent, err = cryptorand.Element(agents)
|
||||
workspaceAgent, err = cryptorand.Element(agents)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
}
|
||||
} else {
|
||||
agent = agents[0]
|
||||
workspaceAgent = agents[0]
|
||||
}
|
||||
}
|
||||
|
||||
return workspace, agent, nil
|
||||
return workspace, workspaceAgent, nil
|
||||
}
|
||||
|
||||
// Attempt to poll workspace autostop. We write a per-workspace lockfile to
|
||||
@@ -293,7 +327,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
|
||||
|
||||
+54
-34
@@ -19,7 +19,6 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
gosshagent "golang.org/x/crypto/ssh/agent"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
@@ -29,11 +28,12 @@ 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) {
|
||||
func setupWorkspaceForAgent(t *testing.T) (*codersdk.Client, codersdk.Workspace, string) {
|
||||
t.Helper()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
@@ -59,6 +59,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,27 +68,34 @@ func TestSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("ImmediateExit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, workspace, agentToken := setupWorkspaceForSSH(t)
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
cmd, root := clitest.New(t, "ssh", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetErr(pty.Output())
|
||||
cmd.SetOut(pty.Output())
|
||||
|
||||
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),
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
}()
|
||||
|
||||
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
|
||||
pty.WriteLine("exit")
|
||||
@@ -95,16 +103,16 @@ func TestSSH(t *testing.T) {
|
||||
})
|
||||
t.Run("Stdio", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, workspace, agentToken := setupWorkspaceForSSH(t)
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(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),
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
<-ctx.Done()
|
||||
_ = agentCloser.Close()
|
||||
@@ -112,6 +120,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 +135,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 +147,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"
|
||||
@@ -153,20 +173,16 @@ func TestSSH(t *testing.T) {
|
||||
|
||||
t.Parallel()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForSSH(t)
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(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(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer agentCloser.Close()
|
||||
|
||||
// Generate private key.
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
@@ -187,18 +203,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 +229,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 +243,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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user